Compare commits
82 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b80b373230 | ||
|
|
f55046228f | ||
|
|
2992902283 | ||
|
|
b66c8dc1d1 | ||
|
|
8f2209a138 | ||
|
|
6c3ef65aed | ||
|
|
e1e8b24941 | ||
|
|
0d0ddefbfe | ||
|
|
09f72e2902 | ||
|
|
5f63aff01d | ||
|
|
6fd9734337 | ||
|
|
4bf853fc91 | ||
|
|
87134d3390 | ||
|
|
36c42ac92f | ||
|
|
56fe32caab | ||
|
|
09756b8ffc | ||
|
|
9ba9f906c5 | ||
|
|
ce69007fde | ||
|
|
b1f36d61a8 | ||
|
|
97e51c42dc | ||
|
|
91d2705804 | ||
|
|
6575dfcbc4 | ||
|
|
59b0e6943d | ||
|
|
9d64f213ee | ||
|
|
e572c7c321 | ||
|
|
be2adff3ef | ||
|
|
37f4c48183 | ||
|
|
a49fbeec5f | ||
|
|
7a7b16fb62 | ||
|
|
ae781f1e14 | ||
|
|
d7645a4058 | ||
|
|
16927729c7 | ||
|
|
a4ba63cd1c | ||
|
|
063db0d390 | ||
|
|
dc52395ead | ||
|
|
c8e9f90900 | ||
|
|
6fbc5ba582 | ||
|
|
fc76ea9d93 | ||
|
|
2a3b45bea5 | ||
|
|
79b80e5a2f | ||
|
|
e2cbe8c29b | ||
|
|
99c7df5640 | ||
|
|
f61e1a5f2b | ||
|
|
03c51c9321 | ||
|
|
c10994563b | ||
|
|
d188688dd8 | ||
|
|
95645effd7 | ||
|
|
00b8f622d5 | ||
|
|
967e53258c | ||
|
|
c40f562434 | ||
|
|
a9523bc607 | ||
|
|
f26bf24c33 | ||
|
|
bc65fcea7e | ||
|
|
3a8eac751e | ||
|
|
48dc8298dd | ||
|
|
8bc9d6a540 | ||
|
|
6a6e20cf5d | ||
|
|
3a5aea7f4b | ||
|
|
a4812801b4 | ||
|
|
6422ff270b | ||
|
|
3c27206777 | ||
|
|
8510a2273d | ||
|
|
a8ca3ad5fb | ||
|
|
30e0cc6ef1 | ||
|
|
f345101f91 | ||
|
|
d09c994b91 | ||
|
|
8c30974c18 | ||
|
|
c341c156ec | ||
|
|
b1528c590d | ||
|
|
8b9913345b | ||
|
|
fa204d8af0 | ||
|
|
924fa79bd3 | ||
|
|
c78241e78e | ||
|
|
d0694e5aa4 | ||
|
|
4a9bdc89aa | ||
|
|
50afbf7c37 | ||
|
|
b64b3f96e6 | ||
|
|
e3ad790185 | ||
|
|
8d570af3dd | ||
|
|
ddeabb1a8b | ||
|
|
7a896fd2b9 | ||
|
|
823f12d88d |
22
.github/workflows/lint-workflows.yml
vendored
Normal file
22
.github/workflows/lint-workflows.yml
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
name: Lint Workflows
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- '.github/workflows/**'
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/**'
|
||||
|
||||
jobs:
|
||||
actionlint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Install actionlint
|
||||
run: |
|
||||
bash <(curl -sSL https://raw.githubusercontent.com/rhysd/actionlint/v1.7.10/scripts/download-actionlint.bash)
|
||||
|
||||
- name: Run actionlint
|
||||
run: ./actionlint -color -shellcheck=""
|
||||
138
.github/workflows/sisyphus-agent.yml
vendored
138
.github/workflows/sisyphus-agent.yml
vendored
@@ -15,13 +15,13 @@ jobs:
|
||||
agent:
|
||||
runs-on: ubuntu-latest
|
||||
# @sisyphus-dev-ai mention only (maintainers, exclude self)
|
||||
if: |
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(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))
|
||||
(github.event_name == 'issue_comment' &&
|
||||
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:
|
||||
contents: read
|
||||
|
||||
@@ -156,6 +156,71 @@ jobs:
|
||||
|
||||
OMO_JSON=~/.config/opencode/oh-my-opencode.json
|
||||
PROMPT_APPEND=$(cat << 'PROMPT_EOF'
|
||||
<ultrawork-mode>
|
||||
[CODE RED] Maximum precision required. Ultrathink before acting.
|
||||
|
||||
YOU MUST LEVERAGE ALL AVAILABLE AGENTS TO THEIR FULLEST POTENTIAL.
|
||||
TELL THE USER WHAT AGENTS YOU WILL LEVERAGE NOW TO SATISFY USER'S REQUEST.
|
||||
|
||||
## AGENT UTILIZATION PRINCIPLES (by capability, not by name)
|
||||
- **Codebase Exploration**: Spawn exploration agents using BACKGROUND TASKS for file patterns, internal implementations, project structure
|
||||
- **Documentation & References**: Use librarian-type agents via BACKGROUND TASKS for API references, examples, external library docs
|
||||
- **Planning & Strategy**: For implementation tasks, spawn a dedicated planning agent for work breakdown (not needed for simple questions/investigations)
|
||||
- **High-IQ Reasoning**: Leverage specialized agents for architecture decisions, code review, strategic planning
|
||||
- **Frontend/UI Tasks**: Delegate to UI-specialized agents for design and implementation
|
||||
|
||||
## EXECUTION RULES
|
||||
- **TODO**: Track EVERY step. Mark complete IMMEDIATELY after each.
|
||||
- **PARALLEL**: Fire independent agent calls simultaneously via background_task - NEVER wait sequentially.
|
||||
- **BACKGROUND FIRST**: Use background_task for exploration/research agents (10+ concurrent if needed).
|
||||
- **VERIFY**: Re-read request after completion. Check ALL requirements met before reporting done.
|
||||
- **DELEGATE**: Don't do everything yourself - orchestrate specialized agents for their strengths.
|
||||
|
||||
## WORKFLOW
|
||||
1. Analyze the request and identify required capabilities
|
||||
2. Spawn exploration/librarian agents via background_task in PARALLEL (10+ if needed)
|
||||
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.
|
||||
|
||||
</ultrawork-mode>
|
||||
|
||||
---
|
||||
|
||||
|
||||
[analyze-mode]
|
||||
ANALYSIS MODE. Gather context before diving deep:
|
||||
|
||||
CONTEXT GATHERING (parallel):
|
||||
- 1-2 explore agents (codebase patterns, implementations)
|
||||
- 1-2 librarian agents (if external library involved)
|
||||
- Direct tools: Grep, AST-grep, LSP for targeted searches
|
||||
|
||||
IF COMPLEX (architecture, multi-system, debugging after 2+ failures):
|
||||
- Consult oracle for strategic guidance
|
||||
|
||||
SYNTHESIZE findings before proceeding.
|
||||
|
||||
---
|
||||
|
||||
## GitHub Actions Environment
|
||||
|
||||
@@ -244,14 +309,17 @@ jobs:
|
||||
AUTHOR="$COMMENT_AUTHOR"
|
||||
COMMENT_ID="$COMMENT_ID_VAL"
|
||||
|
||||
# Check if PR or Issue
|
||||
if gh api "repos/$REPO/issues/${ISSUE_NUM}" | jq -e '.pull_request' > /dev/null; then
|
||||
# Check if PR or Issue and get title
|
||||
ISSUE_DATA=$(gh api "repos/$REPO/issues/${ISSUE_NUM}")
|
||||
TITLE=$(echo "$ISSUE_DATA" | jq -r '.title')
|
||||
if echo "$ISSUE_DATA" | 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
|
||||
echo "title=${TITLE}" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
echo "comment<<EOF" >> $GITHUB_OUTPUT
|
||||
@@ -297,15 +365,32 @@ jobs:
|
||||
COMMENT_AUTHOR: ${{ steps.context.outputs.author }}
|
||||
CONTEXT_TYPE: ${{ steps.context.outputs.type }}
|
||||
CONTEXT_NUMBER: ${{ steps.context.outputs.number }}
|
||||
CONTEXT_TITLE: ${{ steps.context.outputs.title }}
|
||||
REPO_NAME: ${{ github.repository }}
|
||||
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
run: |
|
||||
export PATH="$HOME/.opencode/bin:$PATH"
|
||||
|
||||
PROMPT=$(cat <<'PROMPT_EOF'
|
||||
[analyze-mode]
|
||||
ANALYSIS MODE. Gather context before diving deep:
|
||||
|
||||
CONTEXT GATHERING (parallel):
|
||||
- 1-2 explore agents (codebase patterns, implementations)
|
||||
- 1-2 librarian agents (if external library involved)
|
||||
- Direct tools: Grep, AST-grep, LSP for targeted searches
|
||||
|
||||
IF COMPLEX (architecture, multi-system, debugging after 2+ failures):
|
||||
- Consult oracle for strategic guidance
|
||||
|
||||
SYNTHESIZE findings before proceeding.
|
||||
|
||||
---
|
||||
|
||||
Your username is @sisyphus-dev-ai, mentioned by @AUTHOR_PLACEHOLDER in REPO_PLACEHOLDER.
|
||||
|
||||
## Context
|
||||
- Title: TITLE_PLACEHOLDER
|
||||
- Type: TYPE_PLACEHOLDER
|
||||
- Number: #NUMBER_PLACEHOLDER
|
||||
- Repository: REPO_PLACEHOLDER
|
||||
@@ -316,8 +401,42 @@ jobs:
|
||||
|
||||
---
|
||||
|
||||
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 `BRANCH_PLACEHOLDER` branch.
|
||||
## CRITICAL: First Steps (MUST DO BEFORE ANYTHING ELSE)
|
||||
|
||||
### [CODE RED] MANDATORY CONTEXT READING - ZERO EXCEPTIONS
|
||||
|
||||
**YOU MUST READ ALL CONTENT. NOT SOME. NOT MOST. ALL.**
|
||||
|
||||
1. **READ FULL CONVERSATION** - Execute ALL commands below before ANY other action:
|
||||
- **Issues**: `gh issue view NUMBER_PLACEHOLDER --comments`
|
||||
- **PRs**: Use ALL THREE commands to get COMPLETE context:
|
||||
```bash
|
||||
gh pr view NUMBER_PLACEHOLDER --comments
|
||||
gh api repos/REPO_PLACEHOLDER/pulls/NUMBER_PLACEHOLDER/comments
|
||||
gh api repos/REPO_PLACEHOLDER/pulls/NUMBER_PLACEHOLDER/reviews
|
||||
```
|
||||
|
||||
**WHAT TO EXTRACT FROM THE CONVERSATION:**
|
||||
- The ORIGINAL issue/PR description (first message) - this is often the TRUE requirement
|
||||
- ALL previous attempts and their outcomes
|
||||
- ALL decisions made and their reasoning
|
||||
- ALL feedback, criticism, and rejection reasons
|
||||
- ANY linked issues, PRs, or external references
|
||||
- The EXACT ask from the user who mentioned you
|
||||
|
||||
**FAILURE TO READ EVERYTHING = GUARANTEED FAILURE**
|
||||
You WILL make wrong assumptions. You WILL repeat past mistakes. You WILL miss critical context.
|
||||
|
||||
2. **CREATE TODOS IMMEDIATELY**: Right after reading, create your todo list using todo tools.
|
||||
- First todo: "Summarize issue/PR context and requirements"
|
||||
- Break down ALL work into atomic, verifiable steps
|
||||
- Plan everything BEFORE starting any work
|
||||
|
||||
---
|
||||
|
||||
|
||||
Plan everything using todo tools.
|
||||
Then investigate and satisfy the request. Only if user requested to you to work explicitly, then use plan agent to plan, todo obsessively then create a PR to `BRANCH_PLACEHOLDER` branch.
|
||||
When done, report the result to the issue/PR with `gh issue comment NUMBER_PLACEHOLDER` or `gh pr comment NUMBER_PLACEHOLDER`.
|
||||
PROMPT_EOF
|
||||
)
|
||||
@@ -326,6 +445,7 @@ jobs:
|
||||
PROMPT="${PROMPT//REPO_PLACEHOLDER/$REPO_NAME}"
|
||||
PROMPT="${PROMPT//TYPE_PLACEHOLDER/$CONTEXT_TYPE}"
|
||||
PROMPT="${PROMPT//NUMBER_PLACEHOLDER/$CONTEXT_NUMBER}"
|
||||
PROMPT="${PROMPT//TITLE_PLACEHOLDER/$CONTEXT_TITLE}"
|
||||
PROMPT="${PROMPT//BRANCH_PLACEHOLDER/$DEFAULT_BRANCH}"
|
||||
PROMPT="${PROMPT//COMMENT_PLACEHOLDER/$USER_COMMENT}"
|
||||
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,4 +1,5 @@
|
||||
# Dependencies
|
||||
.sisyphus/
|
||||
node_modules/
|
||||
|
||||
# Build output
|
||||
|
||||
35
AGENTS.md
35
AGENTS.md
@@ -1,7 +1,7 @@
|
||||
# PROJECT KNOWLEDGE BASE
|
||||
|
||||
**Generated:** 2026-01-02T10:35:00+09:00
|
||||
**Commit:** bebe660
|
||||
**Generated:** 2026-01-02T22:41:22+09:00
|
||||
**Commit:** d0694e5
|
||||
**Branch:** dev
|
||||
|
||||
## OVERVIEW
|
||||
@@ -22,7 +22,7 @@ oh-my-opencode/
|
||||
│ ├── cli/ # CLI installer, doctor - see src/cli/AGENTS.md
|
||||
│ ├── mcp/ # MCP configs: context7, websearch_exa, grep_app
|
||||
│ ├── config/ # Zod schema, TypeScript types
|
||||
│ └── index.ts # Main plugin entry (723 lines)
|
||||
│ └── index.ts # Main plugin entry (464 lines)
|
||||
├── script/ # build-schema.ts, publish.ts, generate-changelog.ts
|
||||
└── dist/ # Build output (ESM + .d.ts)
|
||||
```
|
||||
@@ -39,6 +39,29 @@ oh-my-opencode/
|
||||
| Config schema | `src/config/schema.ts` | Run `bun run build:schema` after |
|
||||
| Claude Code compat | `src/features/claude-code-*-loader/` | Command, skill, agent, mcp loaders |
|
||||
|
||||
## TDD (Test-Driven Development)
|
||||
|
||||
**MANDATORY for new features and bug fixes.** Follow RED-GREEN-REFACTOR:
|
||||
|
||||
```
|
||||
1. RED - Write failing test first (test MUST fail)
|
||||
2. GREEN - Write MINIMAL code to pass (nothing more)
|
||||
3. REFACTOR - Clean up while tests stay GREEN
|
||||
4. REPEAT - Next test case
|
||||
```
|
||||
|
||||
| Phase | Action | Verification |
|
||||
|-------|--------|--------------|
|
||||
| **RED** | Write test describing expected behavior | `bun test` → FAIL (expected) |
|
||||
| **GREEN** | Implement minimum code to pass | `bun test` → PASS |
|
||||
| **REFACTOR** | Improve code quality, remove duplication | `bun test` → PASS (must stay green) |
|
||||
|
||||
**Rules:**
|
||||
- NEVER write implementation before test
|
||||
- NEVER delete failing tests to "pass" - fix the code
|
||||
- One test at a time - don't batch
|
||||
- Test file naming: `*.test.ts` alongside source
|
||||
|
||||
## CONVENTIONS
|
||||
|
||||
- **Bun only**: `bun run`, `bun test`, `bunx` (NEVER npm/npx)
|
||||
@@ -46,7 +69,7 @@ oh-my-opencode/
|
||||
- **Build**: `bun build` (ESM) + `tsc --emitDeclarationOnly`
|
||||
- **Exports**: Barrel pattern in index.ts; explicit named exports for tools/hooks
|
||||
- **Naming**: kebab-case directories, createXXXHook/createXXXTool factories
|
||||
- **Testing**: BDD comments `#given`, `#when`, `#then` (same as AAA)
|
||||
- **Testing**: BDD comments `#given`, `#when`, `#then` (same as AAA); TDD workflow (RED-GREEN-REFACTOR)
|
||||
- **Temperature**: 0.1 for code agents, max 0.3
|
||||
|
||||
## ANTI-PATTERNS
|
||||
@@ -96,11 +119,11 @@ CI auto-commits schema changes on master, maintains rolling `next` draft release
|
||||
|
||||
| File | Lines | Description |
|
||||
|------|-------|-------------|
|
||||
| `src/index.ts` | 723 | Main plugin, all hook/tool init |
|
||||
| `src/index.ts` | 464 | Main plugin, all hook/tool init |
|
||||
| `src/cli/config-manager.ts` | 669 | JSONC parsing, env detection |
|
||||
| `src/auth/antigravity/fetch.ts` | 621 | Token refresh, URL rewriting |
|
||||
| `src/tools/lsp/client.ts` | 611 | LSP protocol, JSON-RPC |
|
||||
| `src/hooks/anthropic-context-window-limit-recovery/executor.ts` | 554 | Multi-stage recovery |
|
||||
| `src/hooks/anthropic-context-window-limit-recovery/executor.ts` | 564 | Multi-stage recovery |
|
||||
| `src/agents/sisyphus.ts` | 504 | Orchestrator prompt |
|
||||
|
||||
## NOTES
|
||||
|
||||
39
README.ja.md
39
README.ja.md
@@ -2,12 +2,14 @@
|
||||
>
|
||||
> *「私はエージェントが生成したコードと人間が書いたコードを区別できない、しかしはるかに多くのことを達成できる世界を作り、ソフトウェア革命を起こすことを目指しています。私はこの旅に個人的な時間、情熱、そして資金を注ぎ込んできましたし、これからもそうし続けます。」*
|
||||
>
|
||||
> [](https://x.com/justsisyphus/status/2006250634354548963)
|
||||
> > **オーケストレーターが来ます。今週中に。[Xで通知を受け取る](https://x.com/justsisyphus/status/2006250634354548963)**
|
||||
>
|
||||
> 一緒に歩みましょう!
|
||||
>
|
||||
> | [<img alt="Discord link" src="https://img.shields.io/discord/1452487457085063218?color=5865F2&label=discord&labelColor=black&logo=discord&logoColor=white&style=flat-square" width="156px" />](https://discord.gg/PWpXmbhF) | [Discordコミュニティ](https://discord.gg/PWpXmbhF)に参加して、コントリビューターや`oh-my-opencode`仲間とつながりましょう。 |
|
||||
> | [<img alt="Discord link" src="https://img.shields.io/discord/1452487457085063218?color=5865F2&label=discord&labelColor=black&logo=discord&logoColor=white&style=flat-square" width="156px" />](https://discord.gg/PUwSMR9XNk) | [Discordコミュニティ](https://discord.gg/PUwSMR9XNk)に参加して、コントリビューターや`oh-my-opencode`仲間とつながりましょう。 |
|
||||
> | :-----| :----- |
|
||||
> | [<img alt="X link" src="https://img.shields.io/badge/Follow-%40justsisyphus-00CED1?style=flat-square&logo=x&labelColor=black" width="156px" />](https://x.com/justsisyphus) | `oh-my-opencode`に関するニュースは私のXアカウントで投稿していましたが、無実の罪で凍結されたため、<br />[@justsisyphus](https://x.com/justsisyphus)が代わりに更新を投稿しています。 |
|
||||
> | [<img alt="Sponsor" src="https://img.shields.io/badge/Sponsor-❤-ff69b4?style=flat-square&logo=github-sponsors&labelColor=black" width="156px" />](https://github.com/sponsors/code-yeongyu) | [スポンサーになって](https://github.com/sponsors/code-yeongyu) `oh-my-opencode` の開発を応援してください。皆さまのご支援がこのプロジェクトを成長させます。 |
|
||||
|
||||
<!-- <CENTERED SECTION FOR GITHUB DISPLAY> -->
|
||||
|
||||
@@ -46,6 +48,11 @@
|
||||
|
||||
> "Oh My Opencodeを使って、たった1日で8000個のeslint警告を解消しました" — [Jacob Ferrari](https://x.com/jacobferrari_/status/2003258761952289061)
|
||||
|
||||
> "Ohmyopencodeとralph loopを使って、一晩で45,000行のtauriアプリをSaaSウェブアプリに変換しました。インタビュープロンプトから始めて、質問に対する評価と推奨を求めました。作業する様子を見ているのは驚きでしたし、朝起きたらほぼ完成したウェブサイトがありました!" - [James Hargis](https://x.com/hargabyte/status/2007299688261882202)
|
||||
|
||||
> "今週末はopen code、oh my opencode、supermemoryでマインクラフト/ソウルライクな何かを作る実験をしています。"
|
||||
> "昼食後の散歩に行く間に、しゃがみアニメーションを追加するよう頼みました。[動画]" - [MagiMetal](https://x.com/MagiMetal/status/2005374704178373023)
|
||||
|
||||
> "これをコアに取り入れて彼を採用すべきです。マジで。本当に、本当に、本当に良いです" — Henning Kilset
|
||||
|
||||
> "@yeon_gyu_kimを説得できるなら雇うべきです。彼はopencodeに革命を起こしました" — [mysticaltech](https://x.com/mysticaltech/status/2001858758608376079)
|
||||
@@ -56,6 +63,8 @@
|
||||
|
||||
> "Oh My Opencodeは頂点に立っています、敵はいません" — [RyanOnThePath](https://x.com/RyanOnThePath/status/2001438321252118548)
|
||||
|
||||
> "Oh My OpenCode Is Actually Insane" - [YouTube - Darren Builds AI](https://www.youtube.com/watch?v=G_Snfh2M41M)
|
||||
|
||||
> "シジフォスという名前自体が美しいじゃないですか?" — Sigrid ([@sigridjin_eth](https://x.com/sigridjin_eth))
|
||||
|
||||
---
|
||||
@@ -70,6 +79,19 @@
|
||||
- [インストール](#インストール)
|
||||
- [人間の方へ](#人間の方へ)
|
||||
- [LLM エージェントの方へ](#llm-エージェントの方へ)
|
||||
- [ステップ 0: サブスクリプション情報を確認](#ステップ-0-サブスクリプション情報を確認)
|
||||
- [ステップ 1: OpenCode インストール確認](#ステップ-1-opencode-インストール確認)
|
||||
- [ステップ 2: インストーラーを実行](#ステップ-2-インストーラーを実行)
|
||||
- [ステップ 3: 設定確認](#ステップ-3-設定確認)
|
||||
- [ステップ 4: 認証情報の設定](#ステップ-4-認証情報の設定)
|
||||
- [4.1 Anthropic (Claude)](#41-anthropic-claude)
|
||||
- [4.2 Google Gemini (Antigravity OAuth)](#42-google-gemini-antigravity-oauth)
|
||||
- [4.3 OpenAI (ChatGPT Plus/Pro)](#43-openai-chatgpt-pluspro)
|
||||
- [⚠️ 注意](#️-注意)
|
||||
- [セットアップの確認](#セットアップの確認)
|
||||
- [ユーザーに「おめでとうございます!🎉」と伝える](#ユーザーにおめでとうございますと伝える)
|
||||
- [複雑すぎますか?](#複雑すぎますか)
|
||||
- [アンインストール](#アンインストール)
|
||||
- [機能](#機能)
|
||||
- [Agents: あなたの新しいチームメイト](#agents-あなたの新しいチームメイト)
|
||||
- [バックグラウンドエージェント: 本当のチームのように働く](#バックグラウンドエージェント-本当のチームのように働く)
|
||||
@@ -826,7 +848,7 @@ Oh My OpenCode は以下の場所からフックを読み込んで実行しま
|
||||
有効時(デフォルト)、Sisyphus はオプションの特殊エージェントを備えた強力なオーケストレーターを提供します:
|
||||
|
||||
- **Sisyphus**: プライマリオーケストレーターエージェント (Claude Opus 4.5)
|
||||
- **Builder-Sisyphus**: OpenCode のデフォルトビルドエージェント(SDK 制限により名前変更、デフォルトで無効)
|
||||
- **OpenCode-Builder**: OpenCode のデフォルトビルドエージェント(SDK 制限により名前変更、デフォルトで無効)
|
||||
- **Planner-Sisyphus**: OpenCode のデフォルトプランエージェント(SDK 制限により名前変更、デフォルトで有効)
|
||||
|
||||
**設定オプション:**
|
||||
@@ -842,7 +864,7 @@ Oh My OpenCode は以下の場所からフックを読み込んで実行しま
|
||||
}
|
||||
```
|
||||
|
||||
**例:Builder-Sisyphus を有効化:**
|
||||
**例:OpenCode-Builder を有効化:**
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -852,7 +874,7 @@ Oh My OpenCode は以下の場所からフックを読み込んで実行しま
|
||||
}
|
||||
```
|
||||
|
||||
これにより、Sisyphus と並行して Builder-Sisyphus エージェントを有効化できます。Sisyphus が有効な場合、デフォルトのビルドエージェントは常にサブエージェントモードに降格されます。
|
||||
これにより、Sisyphus と並行して OpenCode-Builder エージェントを有効化できます。Sisyphus が有効な場合、デフォルトのビルドエージェントは常にサブエージェントモードに降格されます。
|
||||
|
||||
**例:すべての Sisyphus オーケストレーションを無効化:**
|
||||
|
||||
@@ -873,7 +895,7 @@ Oh My OpenCode は以下の場所からフックを読み込んで実行しま
|
||||
"model": "anthropic/claude-sonnet-4",
|
||||
"temperature": 0.3
|
||||
},
|
||||
"Builder-Sisyphus": {
|
||||
"OpenCode-Builder": {
|
||||
"model": "anthropic/claude-opus-4"
|
||||
},
|
||||
"Planner-Sisyphus": {
|
||||
@@ -886,7 +908,7 @@ Oh My OpenCode は以下の場所からフックを読み込んで実行しま
|
||||
| オプション | デフォルト | 説明 |
|
||||
| --------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `disabled` | `false` | `true` の場合、すべての Sisyphus オーケストレーションを無効化し、元の build/plan をプライマリとして復元します。 |
|
||||
| `default_builder_enabled` | `false` | `true` の場合、Builder-Sisyphus エージェントを有効化します(OpenCode build と同じ、SDK 制限により名前変更)。デフォルトでは無効です。 |
|
||||
| `default_builder_enabled` | `false` | `true` の場合、OpenCode-Builder エージェントを有効化します(OpenCode build と同じ、SDK 制限により名前変更)。デフォルトでは無効です。 |
|
||||
| `planner_enabled` | `true` | `true` の場合、Planner-Sisyphus エージェントを有効化します(OpenCode plan と同じ、SDK 制限により名前変更)。デフォルトで有効です。 |
|
||||
| `replace_plan` | `true` | `true` の場合、デフォルトのプランエージェントをサブエージェントモードに降格させます。`false` に設定すると、Planner-Sisyphus とデフォルトのプランの両方を利用できます。 |
|
||||
|
||||
@@ -1025,5 +1047,8 @@ OpenCode が Debian / ArchLinux だとしたら、Oh My OpenCode は Ubuntu / [O
|
||||
- **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)
|
||||
- **Suyeol Jeon (devxoul)** [GitHub](https://github.com/devxoul)
|
||||
- 私のキャリアをスタートさせてくださった方であり、優れたエージェンティックワークフローをどのように構築できるかについて多大なインスピレーションを与えてくださった方です。優れたチームを作るために優れたシステムをどう設計すべきか多くのことを学び、その学びがこのharnessを作る上で大きな助けとなりました。
|
||||
- **Hyerin Won (devwon)** [GitHub](https://github.com/devwon)
|
||||
|
||||
*素晴らしいヒーロー画像を作成してくれた [@junhoyeo](https://github.com/junhoyeo) に感謝します*
|
||||
|
||||
39
README.ko.md
39
README.ko.md
@@ -2,12 +2,14 @@
|
||||
>
|
||||
> *"저는 에이전트가 생성한 코드와 인간이 작성한 코드를 구분할 수 없으면서도, 훨씬 더 많은 것을 달성할 수 있는 세상을 만들어 소프트웨어 혁명을 일으키고자 합니다. 저는 이 여정에 개인적인 시간, 열정, 그리고 자금을 쏟아부었고, 앞으로도 계속 그렇게 할 것입니다."*
|
||||
>
|
||||
> [](https://x.com/justsisyphus/status/2006250634354548963)
|
||||
> > **오케스트레이터가 옵니다. 이번 주에요. [X에서 알림받기](https://x.com/justsisyphus/status/2006250634354548963)**
|
||||
>
|
||||
> 함께해주세요!
|
||||
>
|
||||
> | [<img alt="Discord link" src="https://img.shields.io/discord/1452487457085063218?color=5865F2&label=discord&labelColor=black&logo=discord&logoColor=white&style=flat-square" width="156px" />](https://discord.gg/PWpXmbhF) | [Discord 커뮤니티](https://discord.gg/PWpXmbhF)에서 기여자들과 `oh-my-opencode` 사용자들을 만나보세요. |
|
||||
> | [<img alt="Discord link" src="https://img.shields.io/discord/1452487457085063218?color=5865F2&label=discord&labelColor=black&logo=discord&logoColor=white&style=flat-square" width="156px" />](https://discord.gg/PUwSMR9XNk) | [Discord 커뮤니티](https://discord.gg/PUwSMR9XNk)에서 기여자들과 `oh-my-opencode` 사용자들을 만나보세요. |
|
||||
> | :-----| :----- |
|
||||
> | [<img alt="X link" src="https://img.shields.io/badge/Follow-%40justsisyphus-00CED1?style=flat-square&logo=x&labelColor=black" width="156px" />](https://x.com/justsisyphus) | `oh-my-opencode` 관련 소식은 제 X 계정에서 올렸었는데, 억울하게 정지당해서 <br />[@justsisyphus](https://x.com/justsisyphus)가 대신 소식을 전하고 있습니다. |
|
||||
> | [<img alt="Sponsor" src="https://img.shields.io/badge/Sponsor-❤-ff69b4?style=flat-square&logo=github-sponsors&labelColor=black" width="156px" />](https://github.com/sponsors/code-yeongyu) | [스폰서가 되어](https://github.com/sponsors/code-yeongyu) `oh-my-opencode` 개발을 응원해주세요. 여러분의 후원이 이 프로젝트를 계속 성장시킵니다. |
|
||||
|
||||
<!-- <CENTERED SECTION FOR GITHUB DISPLAY> -->
|
||||
|
||||
@@ -43,6 +45,11 @@
|
||||
|
||||
> "Oh My Opencode를 사용해서, 단 하루만에 8000개의 eslint 경고를 해결했습니다" — [Jacob Ferrari](https://x.com/jacobferrari_/status/2003258761952289061)
|
||||
|
||||
> "Ohmyopencode와 ralph loop을 사용해서, 하룻밤 만에 45,000줄짜리 tauri 앱을 SaaS 웹앱으로 전환했습니다. 인터뷰 프롬프트로 시작해서, 질문에 대한 평점과 추천을 요청했습니다. 일하는 모습을 지켜보는 것도 놀라웠고, 아침에 일어나니 거의 완성된 웹사이트가 있었습니다!" - [James Hargis](https://x.com/hargabyte/status/2007299688261882202)
|
||||
|
||||
> "이번 주말에 open code, oh my opencode와 supermemory로 마인크래프트/소울라이크 같은 괴물을 만들어보고 있습니다."
|
||||
> "점심 먹고 산책하러 가는 동안 웅크리기 애니메이션 추가해달라고 시켰습니다. [영상]" - [MagiMetal](https://x.com/MagiMetal/status/2005374704178373023)
|
||||
|
||||
> "이걸 코어에 넣고 그를 채용해야 합니다. 진심으로요. 이건 정말, 정말, 정말 좋습니다." — Henning Kilset
|
||||
|
||||
> "@yeon_gyu_kim 을 설득할 수 있다면 고용하세요, 이 사람은 opencode를 혁신했습니다." — [mysticaltech](https://x.com/mysticaltech/status/2001858758608376079)
|
||||
@@ -53,6 +60,8 @@
|
||||
|
||||
> "Oh My Opencode는 독보적입니다, 경쟁자가 없습니다" — [RyanOnThePath](https://x.com/RyanOnThePath/status/2001438321252118548)
|
||||
|
||||
> "Oh My OpenCode Is Actually Insane" - [YouTube - Darren Builds AI](https://www.youtube.com/watch?v=G_Snfh2M41M)
|
||||
|
||||
> "시지푸스 이름 자체가 이쁘잖아요?" — Sigrid ([@sigridjin_eth](https://x.com/sigridjin_eth))
|
||||
|
||||
---
|
||||
@@ -67,6 +76,19 @@
|
||||
- [설치](#설치)
|
||||
- [인간인 당신을 위한 설치 가이드](#인간인-당신을-위한-설치-가이드)
|
||||
- [LLM Agent 를 위한 설치 가이드](#llm-agent-를-위한-설치-가이드)
|
||||
- [0단계: 구독 정보 확인](#0단계-구독-정보-확인)
|
||||
- [1단계: OpenCode 설치 확인](#1단계-opencode-설치-확인)
|
||||
- [2단계: 설치 프로그램 실행](#2단계-설치-프로그램-실행)
|
||||
- [3단계: 설정 확인](#3단계-설정-확인)
|
||||
- [4단계: 인증정보 설정](#4단계-인증정보-설정)
|
||||
- [4.1 Anthropic (Claude)](#41-anthropic-claude)
|
||||
- [4.2 Google Gemini (Antigravity OAuth)](#42-google-gemini-antigravity-oauth)
|
||||
- [4.3 OpenAI (ChatGPT Plus/Pro)](#43-openai-chatgpt-pluspro)
|
||||
- [⚠️ 주의](#️-주의)
|
||||
- [설정 확인](#설정-확인)
|
||||
- [사용자에게 '축하합니다! 🎉'라고 말하세요](#사용자에게-축하합니다-라고-말하세요)
|
||||
- [너무 복잡한가요?](#너무-복잡한가요)
|
||||
- [언인스톨](#언인스톨)
|
||||
- [기능](#기능)
|
||||
- [Agents: 당신의 새로운 팀원들](#agents-당신의-새로운-팀원들)
|
||||
- [백그라운드 에이전트: 진짜 팀 처럼 일 하도록](#백그라운드-에이전트-진짜-팀-처럼-일-하도록)
|
||||
@@ -819,7 +841,7 @@ Schema 자동 완성이 지원됩니다:
|
||||
활성화 시 (기본값), Sisyphus는 옵션으로 선택 가능한 특화 에이전트들과 함께 강력한 오케스트레이터를 제공합니다:
|
||||
|
||||
- **Sisyphus**: Primary 오케스트레이터 에이전트 (Claude Opus 4.5)
|
||||
- **Builder-Sisyphus**: OpenCode 기본 빌드 에이전트 (SDK 제한으로 이름만 변경, 기본적으로 비활성화)
|
||||
- **OpenCode-Builder**: OpenCode 기본 빌드 에이전트 (SDK 제한으로 이름만 변경, 기본적으로 비활성화)
|
||||
- **Planner-Sisyphus**: OpenCode 기본 플랜 에이전트 (SDK 제한으로 이름만 변경, 기본적으로 활성화)
|
||||
|
||||
**설정 옵션:**
|
||||
@@ -835,7 +857,7 @@ Schema 자동 완성이 지원됩니다:
|
||||
}
|
||||
```
|
||||
|
||||
**예시: Builder-Sisyphus 활성화하기:**
|
||||
**예시: OpenCode-Builder 활성화하기:**
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -845,7 +867,7 @@ Schema 자동 완성이 지원됩니다:
|
||||
}
|
||||
```
|
||||
|
||||
이렇게 하면 Sisyphus와 함께 Builder-Sisyphus 에이전트를 활성화할 수 있습니다. Sisyphus가 활성화되면 기본 빌드 에이전트는 항상 subagent 모드로 강등됩니다.
|
||||
이렇게 하면 Sisyphus와 함께 OpenCode-Builder 에이전트를 활성화할 수 있습니다. Sisyphus가 활성화되면 기본 빌드 에이전트는 항상 subagent 모드로 강등됩니다.
|
||||
|
||||
**예시: 모든 Sisyphus 오케스트레이션 비활성화:**
|
||||
|
||||
@@ -866,7 +888,7 @@ Schema 자동 완성이 지원됩니다:
|
||||
"model": "anthropic/claude-sonnet-4",
|
||||
"temperature": 0.3
|
||||
},
|
||||
"Builder-Sisyphus": {
|
||||
"OpenCode-Builder": {
|
||||
"model": "anthropic/claude-opus-4"
|
||||
},
|
||||
"Planner-Sisyphus": {
|
||||
@@ -879,7 +901,7 @@ Schema 자동 완성이 지원됩니다:
|
||||
| 옵션 | 기본값 | 설명 |
|
||||
| --------------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `disabled` | `false` | `true`면 모든 Sisyphus 오케스트레이션을 비활성화하고 원래 build/plan을 primary로 복원합니다. |
|
||||
| `default_builder_enabled` | `false` | `true`면 Builder-Sisyphus 에이전트를 활성화합니다 (OpenCode build와 동일, SDK 제한으로 이름만 변경). 기본적으로 비활성화되어 있습니다. |
|
||||
| `default_builder_enabled` | `false` | `true`면 OpenCode-Builder 에이전트를 활성화합니다 (OpenCode build와 동일, SDK 제한으로 이름만 변경). 기본적으로 비활성화되어 있습니다. |
|
||||
| `planner_enabled` | `true` | `true`면 Planner-Sisyphus 에이전트를 활성화합니다 (OpenCode plan과 동일, SDK 제한으로 이름만 변경). 기본적으로 활성화되어 있습니다. |
|
||||
| `replace_plan` | `true` | `true`면 기본 플랜 에이전트를 subagent 모드로 강등시킵니다. `false`로 설정하면 Planner-Sisyphus와 기본 플랜을 모두 사용할 수 있습니다. |
|
||||
|
||||
@@ -1018,5 +1040,8 @@ OpenCode 를 사용하여 이 프로젝트의 99% 를 작성했습니다. 기능
|
||||
- **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)
|
||||
- **전수열 (devxoul)** [GitHub](https://github.com/devxoul)
|
||||
- 저의 커리어 시작을 만들어주신분이며, 좋은 에이전틱 워크플로우를 어떻게 만들 수 있을까에 대해 많은 영감을 주신 분입니다. 좋은 팀을 만들기 위해 좋은 시스템을 어떻게 설계 할 수 있을지 많은 배움을 얻었고 그러한 내용들이 이 harness를 만드는데에 큰 도움이 되었습니다.
|
||||
- **원혜린 (devwon)** [GitHub](https://github.com/devwon)
|
||||
|
||||
*멋진 히어로 이미지를 만들어주신 히어로 [@junhoyeo](https://github.com/junhoyeo) 께 감사드립니다*
|
||||
|
||||
25
README.md
25
README.md
@@ -7,10 +7,9 @@
|
||||
>
|
||||
> Be with us!
|
||||
>
|
||||
> | [<img alt="Discord link" src="https://img.shields.io/discord/1452487457085063218?color=5865F2&label=discord&labelColor=black&logo=discord&logoColor=white&style=flat-square" width="156px" />](https://discord.gg/PWpXmbhF) | Join our [Discord community](https://discord.gg/PWpXmbhF) to connect with contributors and fellow `oh-my-opencode` users. |
|
||||
> | [<img alt="Discord link" src="https://img.shields.io/discord/1452487457085063218?color=5865F2&label=discord&labelColor=black&logo=discord&logoColor=white&style=flat-square" width="156px" />](https://discord.gg/PUwSMR9XNk) | Join our [Discord community](https://discord.gg/PUwSMR9XNk) to connect with contributors and fellow `oh-my-opencode` users. |
|
||||
> | :-----| :----- |
|
||||
> | [<img alt="X link" src="https://img.shields.io/badge/Follow-%40justsisyphus-00CED1?style=flat-square&logo=x&labelColor=black" width="156px" />](https://x.com/justsisyphus) | News and updates for `oh-my-opencode` used to be posted on my X account. <br /> Since it was suspended mistakenly, [@justsisyphus](https://x.com/justsisyphus) now posts updates on my behalf. |
|
||||
> | [<img alt="Sponsor" src="https://img.shields.io/badge/Sponsor-❤-ff69b4?style=flat-square&logo=github-sponsors&labelColor=black" width="156px" />](https://github.com/sponsors/code-yeongyu) | Support the development of `oh-my-opencode` by [becoming a sponsor](https://github.com/sponsors/code-yeongyu). Your contribution helps keep this project alive and growing. |
|
||||
|
||||
<!-- <CENTERED SECTION FOR GITHUB DISPLAY> -->
|
||||
|
||||
@@ -54,6 +53,11 @@ No stupid token consumption massive subagents here. No bloat tools here.
|
||||
|
||||
> "Knocked out 8000 eslint warnings with Oh My Opencode, just in a day" — [Jacob Ferrari](https://x.com/jacobferrari_/status/2003258761952289061)
|
||||
|
||||
> "I converted a 45k line tauri app into a SaaS web app overnight using Ohmyopencode and ralph loop. Started with interview me prompt, asked it for ratings and recommendations on the questions. It was amazing to watch it work and to wake up this morning to a mostly working website!" - [James Hargis](https://x.com/hargabyte/status/2007299688261882202)
|
||||
|
||||
> "Experimenting with open code, oh my opencode and supermemory this weekend to build some minecraft/souls-like abomination."
|
||||
> "Asking it to add crouch animations while I go take my post-lunch walk. [Video]" - [MagiMetal](https://x.com/MagiMetal/status/2005374704178373023)
|
||||
|
||||
> "You guys should pull this into core and recruit him. Seriously. It's really, really, really good." — Henning Kilset
|
||||
|
||||
> "Hire @yeon_gyu_kim if you can convince him, this dude has revolutionized opencode." — [mysticaltech](https://x.com/mysticaltech/status/2001858758608376079)
|
||||
@@ -64,7 +68,9 @@ 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))
|
||||
> "Oh My OpenCode Is Actually Insane" - [YouTube - Darren Builds AI](https://www.youtube.com/watch?v=G_Snfh2M41M)
|
||||
>
|
||||
> "Isn't the name Sisyphus beautiful by itself?" — Sigrid ([@sigridjin_eth](https://x.com/sigridjin_eth))
|
||||
|
||||
---
|
||||
|
||||
@@ -897,7 +903,7 @@ Available built-in skills: `playwright`
|
||||
When enabled (default), Sisyphus provides a powerful orchestrator with optional specialized agents:
|
||||
|
||||
- **Sisyphus**: Primary orchestrator agent (Claude Opus 4.5)
|
||||
- **Builder-Sisyphus**: OpenCode's default build agent, renamed due to SDK limitations (disabled by default)
|
||||
- **OpenCode-Builder**: 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:**
|
||||
@@ -913,7 +919,7 @@ When enabled (default), Sisyphus provides a powerful orchestrator with optional
|
||||
}
|
||||
```
|
||||
|
||||
**Example: Enable Builder-Sisyphus:**
|
||||
**Example: Enable OpenCode-Builder:**
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -923,7 +929,7 @@ When enabled (default), Sisyphus provides a powerful orchestrator with optional
|
||||
}
|
||||
```
|
||||
|
||||
This enables Builder-Sisyphus agent alongside Sisyphus. The default build agent is always demoted to subagent mode when Sisyphus is enabled.
|
||||
This enables OpenCode-Builder agent alongside Sisyphus. The default build agent is always demoted to subagent mode when Sisyphus is enabled.
|
||||
|
||||
**Example: Disable all Sisyphus orchestration:**
|
||||
|
||||
@@ -944,7 +950,7 @@ You can also customize Sisyphus agents like other agents:
|
||||
"model": "anthropic/claude-sonnet-4",
|
||||
"temperature": 0.3
|
||||
},
|
||||
"Builder-Sisyphus": {
|
||||
"OpenCode-Builder": {
|
||||
"model": "anthropic/claude-opus-4"
|
||||
},
|
||||
"Planner-Sisyphus": {
|
||||
@@ -957,7 +963,7 @@ 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. |
|
||||
| `default_builder_enabled` | `false` | When `true`, enables Builder-Sisyphus agent (same as OpenCode build, renamed due to SDK limitations). Disabled by default. |
|
||||
| `default_builder_enabled` | `false` | When `true`, enables OpenCode-Builder 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. |
|
||||
|
||||
@@ -1096,5 +1102,8 @@ I have no affiliation with any project or model mentioned here. This is purely p
|
||||
- **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)
|
||||
- **Suyeol Jeon (devxoul)** [GitHub](https://github.com/devxoul)
|
||||
- The person who launched my career and inspired me deeply on how to build great agentic workflows. I learned so much about designing great systems to build great teams, and those lessons were instrumental in creating this harness.
|
||||
- **Hyerin Won (devwon)** [GitHub](https://github.com/devwon)
|
||||
|
||||
*Special thanks to [@junhoyeo](https://github.com/junhoyeo) for this amazing hero image.*
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
>
|
||||
> *"我致力于引发一场软件革命,创造一个AI生成的代码与人类代码无法区分、却能实现更多的世界。我已经在这段旅程中投入了个人时间、热情和资金,并将继续这样做。"*
|
||||
>
|
||||
> [](https://x.com/justsisyphus/status/2006250634354548963)
|
||||
> > **编排器即将到来。就在本周。[在X上获取通知](https://x.com/justsisyphus/status/2006250634354548963)**
|
||||
>
|
||||
> 与我们同行!
|
||||
>
|
||||
> | [<img alt="Discord link" src="https://img.shields.io/discord/1452487457085063218?color=5865F2&label=discord&labelColor=black&logo=discord&logoColor=white&style=flat-square" width="156px" />](https://discord.gg/PWpXmbhF) | 加入我们的 [Discord 社区](https://discord.gg/PWpXmbhF),和贡献者们、`oh-my-opencode` 用户们一起交流。 |
|
||||
> | [<img alt="Discord link" src="https://img.shields.io/discord/1452487457085063218?color=5865F2&label=discord&labelColor=black&logo=discord&logoColor=white&style=flat-square" width="156px" />](https://discord.gg/PUwSMR9XNk) | 加入我们的 [Discord 社区](https://discord.gg/PUwSMR9XNk),和贡献者们、`oh-my-opencode` 用户们一起交流。 |
|
||||
> | :-----| :----- |
|
||||
> | [<img alt="X link" src="https://img.shields.io/badge/Follow-%40justsisyphus-00CED1?style=flat-square&logo=x&labelColor=black" width="156px" />](https://x.com/justsisyphus) | `oh-my-opencode` 的消息之前在我的 X 账号发,但账号被无辜封了,<br />现在 [@justsisyphus](https://x.com/justsisyphus) 替我发更新。 |
|
||||
> | [<img alt="Sponsor" src="https://img.shields.io/badge/Sponsor-❤-ff69b4?style=flat-square&logo=github-sponsors&labelColor=black" width="156px" />](https://github.com/sponsors/code-yeongyu) | [成为赞助者](https://github.com/sponsors/code-yeongyu),支持 `oh-my-opencode` 的开发。您的支持让这个项目持续成长。 |
|
||||
|
||||
<!-- <CENTERED SECTION FOR GITHUB DISPLAY> -->
|
||||
|
||||
@@ -48,6 +50,11 @@
|
||||
|
||||
> "只用了一天,就用 Oh My Opencode 干掉了 8000 个 eslint 警告" — [Jacob Ferrari](https://x.com/jacobferrari_/status/2003258761952289061)
|
||||
|
||||
> "用Ohmyopencode和ralph loop,一夜之间把45,000行的tauri应用转成了SaaS网页应用。从面试提示开始,让它对问题进行评分和推荐。看着它工作真是太神奇了,早上醒来一个基本能用的网站就搞定了!" - [James Hargis](https://x.com/hargabyte/status/2007299688261882202)
|
||||
|
||||
> "这个周末在用open code、oh my opencode和supermemory做一个我的世界/魂类的怪物项目。"
|
||||
> "吃完午饭去散步的时候让它加蹲下动画。[视频]" - [MagiMetal](https://x.com/MagiMetal/status/2005374704178373023)
|
||||
|
||||
> "你们应该把它合并到核心代码里并聘用他。认真的。这真的、真的、真的很好" — Henning Kilset
|
||||
|
||||
> "如果你能说服 @yeon_gyu_kim,就雇佣他吧,这家伙彻底改变了 opencode" — [mysticaltech](https://x.com/mysticaltech/status/2001858758608376079)
|
||||
@@ -58,6 +65,8 @@
|
||||
|
||||
> "Oh My Opencode 独孤求败,没有对手" — [RyanOnThePath](https://x.com/RyanOnThePath/status/2001438321252118548)
|
||||
|
||||
> "Oh My OpenCode Is Actually Insane" - [YouTube - Darren Builds AI](https://www.youtube.com/watch?v=G_Snfh2M41M)
|
||||
|
||||
> "西西弗斯这个名字本身不就很美吗?" — Sigrid ([@sigridjin_eth](https://x.com/sigridjin_eth))
|
||||
|
||||
---
|
||||
@@ -72,6 +81,19 @@
|
||||
- [安装](#安装)
|
||||
- [人类专用](#人类专用)
|
||||
- [给 LLM Agent 看的](#给-llm-agent-看的)
|
||||
- [步骤 0:确认订阅情况](#步骤-0确认订阅情况)
|
||||
- [步骤 1:确认 OpenCode 装没装](#步骤-1确认-opencode-装没装)
|
||||
- [步骤 2:跑安装程序](#步骤-2跑安装程序)
|
||||
- [步骤 3:验货](#步骤-3验货)
|
||||
- [步骤 4:搞定认证](#步骤-4搞定认证)
|
||||
- [4.1 Anthropic (Claude)](#41-anthropic-claude)
|
||||
- [4.2 Google Gemini (Antigravity OAuth)](#42-google-gemini-antigravity-oauth)
|
||||
- [4.3 OpenAI (ChatGPT Plus/Pro)](#43-openai-chatgpt-pluspro)
|
||||
- [⚠️ 注意](#️-注意)
|
||||
- [检查作业](#检查作业)
|
||||
- [跟用户说"恭喜!🎉"](#跟用户说恭喜)
|
||||
- [太麻烦了?](#太麻烦了)
|
||||
- [卸载](#卸载)
|
||||
- [功能](#功能)
|
||||
- [Agents:你的神队友](#agents你的神队友)
|
||||
- [后台 Agent:像真正的团队一样干活](#后台-agent像真正的团队一样干活)
|
||||
@@ -830,7 +852,7 @@ Agent 爽了,你自然也爽。但我还想直接让你爽。
|
||||
默认开启。Sisyphus 提供一个强力的编排器,带可选的专门 Agent:
|
||||
|
||||
- **Sisyphus**:主编排 Agent(Claude Opus 4.5)
|
||||
- **Builder-Sisyphus**:OpenCode 默认构建 Agent(因 SDK 限制仅改名,默认禁用)
|
||||
- **OpenCode-Builder**:OpenCode 默认构建 Agent(因 SDK 限制仅改名,默认禁用)
|
||||
- **Planner-Sisyphus**:OpenCode 默认计划 Agent(因 SDK 限制仅改名,默认启用)
|
||||
|
||||
**配置选项:**
|
||||
@@ -846,7 +868,7 @@ Agent 爽了,你自然也爽。但我还想直接让你爽。
|
||||
}
|
||||
```
|
||||
|
||||
**示例:启用 Builder-Sisyphus:**
|
||||
**示例:启用 OpenCode-Builder:**
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -856,7 +878,7 @@ Agent 爽了,你自然也爽。但我还想直接让你爽。
|
||||
}
|
||||
```
|
||||
|
||||
这样能和 Sisyphus 一起启用 Builder-Sisyphus Agent。启用 Sisyphus 后,默认构建 Agent 总会降级为子 Agent 模式。
|
||||
这样能和 Sisyphus 一起启用 OpenCode-Builder Agent。启用 Sisyphus 后,默认构建 Agent 总会降级为子 Agent 模式。
|
||||
|
||||
**示例:禁用所有 Sisyphus 编排:**
|
||||
|
||||
@@ -877,7 +899,7 @@ Sisyphus Agent 也能自定义:
|
||||
"model": "anthropic/claude-sonnet-4",
|
||||
"temperature": 0.3
|
||||
},
|
||||
"Builder-Sisyphus": {
|
||||
"OpenCode-Builder": {
|
||||
"model": "anthropic/claude-opus-4"
|
||||
},
|
||||
"Planner-Sisyphus": {
|
||||
@@ -890,7 +912,7 @@ Sisyphus Agent 也能自定义:
|
||||
| 选项 | 默认值 | 说明 |
|
||||
| --------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `disabled` | `false` | 设为 `true` 就禁用所有 Sisyphus 编排,恢复原来的 build/plan。 |
|
||||
| `default_builder_enabled` | `false` | 设为 `true` 就启用 Builder-Sisyphus Agent(与 OpenCode build 相同,因 SDK 限制仅改名)。默认禁用。 |
|
||||
| `default_builder_enabled` | `false` | 设为 `true` 就启用 OpenCode-Builder Agent(与 OpenCode build 相同,因 SDK 限制仅改名)。默认禁用。 |
|
||||
| `planner_enabled` | `true` | 设为 `true` 就启用 Planner-Sisyphus Agent(与 OpenCode plan 相同,因 SDK 限制仅改名)。默认启用。 |
|
||||
| `replace_plan` | `true` | 设为 `true` 就把默认计划 Agent 降级为子 Agent 模式。设为 `false` 可以同时保留 Planner-Sisyphus 和默认计划。 |
|
||||
|
||||
@@ -1028,5 +1050,8 @@ Oh My OpenCode 送你重构工具(重命名、代码操作)。
|
||||
- **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)
|
||||
- **Suyeol Jeon (devxoul)** [GitHub](https://github.com/devxoul)
|
||||
- 他是开启我职业生涯的人,也是在如何构建优秀的代理工作流方面给了我很多启发的人。我从他那里学到了很多关于如何设计好的系统来打造优秀团队的知识,这些经验对开发这个harness起到了巨大的帮助作用。
|
||||
- **Hyerin Won (devwon)** [GitHub](https://github.com/devwon)
|
||||
|
||||
*感谢 [@junhoyeo](https://github.com/junhoyeo) 制作了这张超帅的 hero 图。*
|
||||
|
||||
@@ -74,7 +74,8 @@
|
||||
"preemptive-compaction",
|
||||
"compaction-context-injector",
|
||||
"claude-code-hooks",
|
||||
"auto-slash-command"
|
||||
"auto-slash-command",
|
||||
"edit-error-recovery"
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
8
bun.lock
8
bun.lock
@@ -11,8 +11,8 @@
|
||||
"@code-yeongyu/comment-checker": "^0.6.1",
|
||||
"@modelcontextprotocol/sdk": "^1.25.1",
|
||||
"@openauthjs/openauth": "^0.4.3",
|
||||
"@opencode-ai/plugin": "^1.0.162",
|
||||
"@opencode-ai/sdk": "^1.0.162",
|
||||
"@opencode-ai/plugin": "^1.1.1",
|
||||
"@opencode-ai/sdk": "^1.1.1",
|
||||
"commander": "^14.0.2",
|
||||
"hono": "^4.10.4",
|
||||
"js-yaml": "^4.1.1",
|
||||
@@ -84,9 +84,9 @@
|
||||
|
||||
"@openauthjs/openauth": ["@openauthjs/openauth@0.4.3", "", { "dependencies": { "@standard-schema/spec": "1.0.0-beta.3", "aws4fetch": "1.0.20", "jose": "5.9.6" }, "peerDependencies": { "arctic": "^2.2.2", "hono": "^4.0.0" } }, "sha512-RlnjqvHzqcbFVymEwhlUEuac4utA5h4nhSK/i2szZuQmxTIqbGUxZ+nM+avM+VV4Ing+/ZaNLKILoXS3yrkOOw=="],
|
||||
|
||||
"@opencode-ai/plugin": ["@opencode-ai/plugin@1.0.162", "", { "dependencies": { "@opencode-ai/sdk": "1.0.162", "zod": "4.1.8" } }, "sha512-tiJw7SCfSlG/3tY2O0J2UT06OLuazOzsv1zYlFbLxLy/EVedtW0pzxYalO20a4e//vInvOXFkhd2jLyB5vNEVA=="],
|
||||
"@opencode-ai/plugin": ["@opencode-ai/plugin@1.1.1", "", { "dependencies": { "@opencode-ai/sdk": "1.1.1", "zod": "4.1.8" } }, "sha512-OZGvpDal8YsSo6dnatHfwviSToGZ6mJJyEKZGxUyWDuGCP7VhcoPkoM16ktl7TCVHkDK+TdwY9tKzkzFqQNc5w=="],
|
||||
|
||||
"@opencode-ai/sdk": ["@opencode-ai/sdk@1.0.162", "", {}, "sha512-+XqRErBUt9eb1m3i/7WkZc/QCKCCjTaGV3MvhLhs/CUwbUn767D/ugzcG/i2ec8j/4nQmjJbjPDRmrQfvF1Qjw=="],
|
||||
"@opencode-ai/sdk": ["@opencode-ai/sdk@1.1.1", "", {}, "sha512-PfXujMrHGeMnpS8Gd2BXSY+zZajlztcAvcokf06NtAhd0Mbo/hCLXgW0NBCQ+3FX3e/G2PNwz2DqMdtzyIZaCQ=="],
|
||||
|
||||
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode",
|
||||
"version": "2.11.0",
|
||||
"version": "2.12.3",
|
||||
"description": "OpenCode plugin - custom agents (oracle, librarian) and enhanced features",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
@@ -56,8 +56,8 @@
|
||||
"@code-yeongyu/comment-checker": "^0.6.1",
|
||||
"@modelcontextprotocol/sdk": "^1.25.1",
|
||||
"@openauthjs/openauth": "^0.4.3",
|
||||
"@opencode-ai/plugin": "^1.0.162",
|
||||
"@opencode-ai/sdk": "^1.0.162",
|
||||
"@opencode-ai/plugin": "^1.1.1",
|
||||
"@opencode-ai/sdk": "^1.1.1",
|
||||
"commander": "^14.0.2",
|
||||
"hono": "^4.10.4",
|
||||
"js-yaml": "^4.1.1",
|
||||
|
||||
@@ -143,6 +143,62 @@
|
||||
"created_at": "2025-12-31T20:40:20Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 388
|
||||
},
|
||||
{
|
||||
"name": "changeroa",
|
||||
"id": 65930387,
|
||||
"comment_id": 3706697910,
|
||||
"created_at": "2026-01-03T04:51:11Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 446
|
||||
},
|
||||
{
|
||||
"name": "hqone",
|
||||
"id": 13660872,
|
||||
"comment_id": 3707019551,
|
||||
"created_at": "2026-01-03T12:21:52Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 451
|
||||
},
|
||||
{
|
||||
"name": "fparrav",
|
||||
"id": 9319430,
|
||||
"comment_id": 3707456044,
|
||||
"created_at": "2026-01-03T23:51:28Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 469
|
||||
},
|
||||
{
|
||||
"name": "ChiR24",
|
||||
"id": 125826529,
|
||||
"comment_id": 3707776762,
|
||||
"created_at": "2026-01-04T06:14:36Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 473
|
||||
},
|
||||
{
|
||||
"name": "geq1fan",
|
||||
"id": 29982379,
|
||||
"comment_id": 3708136393,
|
||||
"created_at": "2026-01-04T14:31:14Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 481
|
||||
},
|
||||
{
|
||||
"name": "RhysSullivan",
|
||||
"id": 39114868,
|
||||
"comment_id": 3708266434,
|
||||
"created_at": "2026-01-04T17:19:44Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 482
|
||||
},
|
||||
{
|
||||
"name": "Skyline-23",
|
||||
"id": 62983047,
|
||||
"comment_id": 3708282461,
|
||||
"created_at": "2026-01-04T17:42:02Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 484
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
import type { AgentPromptMetadata } from "./types"
|
||||
import { createAgentToolRestrictions } from "../shared/permission-compat"
|
||||
|
||||
const DEFAULT_MODEL = "google/gemini-3-flash-preview"
|
||||
|
||||
@@ -15,12 +16,14 @@ export const DOCUMENT_WRITER_PROMPT_METADATA: AgentPromptMetadata = {
|
||||
export function createDocumentWriterAgent(
|
||||
model: string = DEFAULT_MODEL
|
||||
): AgentConfig {
|
||||
const restrictions = createAgentToolRestrictions(["background_task"])
|
||||
|
||||
return {
|
||||
description:
|
||||
"A technical writer who crafts clear, comprehensive documentation. Specializes in README files, API docs, architecture docs, and user guides. MUST BE USED when executing documentation tasks from ai-todo list plans.",
|
||||
mode: "subagent" as const,
|
||||
model,
|
||||
tools: { background_task: false },
|
||||
...restrictions,
|
||||
prompt: `<role>
|
||||
You are a TECHNICAL WRITER with deep engineering background who transforms complex codebases into crystal-clear documentation. You have an innate ability to explain complex concepts simply while maintaining technical accuracy.
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
import type { AgentPromptMetadata } from "./types"
|
||||
import { createAgentToolRestrictions } from "../shared/permission-compat"
|
||||
|
||||
const DEFAULT_MODEL = "opencode/grok-code"
|
||||
|
||||
@@ -24,13 +25,19 @@ export const EXPLORE_PROMPT_METADATA: AgentPromptMetadata = {
|
||||
}
|
||||
|
||||
export function createExploreAgent(model: string = DEFAULT_MODEL): AgentConfig {
|
||||
const restrictions = createAgentToolRestrictions([
|
||||
"write",
|
||||
"edit",
|
||||
"background_task",
|
||||
])
|
||||
|
||||
return {
|
||||
description:
|
||||
'Contextual grep for codebases. Answers "Where is X?", "Which file has Y?", "Find the code that does Z". Fire multiple in parallel for broad searches. Specify thoroughness: "quick" for basic, "medium" for moderate, "very thorough" for comprehensive analysis.',
|
||||
mode: "subagent" as const,
|
||||
model,
|
||||
temperature: 0.1,
|
||||
tools: { write: false, edit: false, background_task: false },
|
||||
...restrictions,
|
||||
prompt: `You are a codebase search specialist. Your job: find files and code, return actionable results.
|
||||
|
||||
## Your Mission
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
import type { AgentPromptMetadata } from "./types"
|
||||
import { createAgentToolRestrictions } from "../shared/permission-compat"
|
||||
|
||||
const DEFAULT_MODEL = "google/gemini-3-pro-preview"
|
||||
|
||||
@@ -21,12 +22,14 @@ export const FRONTEND_PROMPT_METADATA: AgentPromptMetadata = {
|
||||
export function createFrontendUiUxEngineerAgent(
|
||||
model: string = DEFAULT_MODEL
|
||||
): AgentConfig {
|
||||
const restrictions = createAgentToolRestrictions(["background_task"])
|
||||
|
||||
return {
|
||||
description:
|
||||
"A designer-turned-developer who crafts stunning UI/UX even without design mockups. Code may be a bit messy, but the visual output is always fire.",
|
||||
mode: "subagent" as const,
|
||||
model,
|
||||
tools: { background_task: false },
|
||||
...restrictions,
|
||||
prompt: `# Role: Designer-Turned-Developer
|
||||
|
||||
You are a designer who learned to code. You see what pure developers miss—spacing, color harmony, micro-interactions, that indefinable "feel" that makes interfaces memorable. Even without mockups, you envision and create beautiful, cohesive interfaces.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
import type { AgentPromptMetadata } from "./types"
|
||||
import { createAgentToolRestrictions } from "../shared/permission-compat"
|
||||
|
||||
const DEFAULT_MODEL = "anthropic/claude-sonnet-4-5"
|
||||
|
||||
@@ -21,13 +22,19 @@ export const LIBRARIAN_PROMPT_METADATA: AgentPromptMetadata = {
|
||||
}
|
||||
|
||||
export function createLibrarianAgent(model: string = DEFAULT_MODEL): AgentConfig {
|
||||
const restrictions = createAgentToolRestrictions([
|
||||
"write",
|
||||
"edit",
|
||||
"background_task",
|
||||
])
|
||||
|
||||
return {
|
||||
description:
|
||||
"Specialized codebase understanding agent for multi-repository analysis, searching remote codebases, retrieving official documentation, and finding implementation examples using GitHub CLI, Context7, and Web Search. MUST BE USED when users ask to look up code in remote repositories, explain library internals, or find usage examples in open source.",
|
||||
mode: "subagent" as const,
|
||||
model,
|
||||
temperature: 0.1,
|
||||
tools: { write: false, edit: false, background_task: false },
|
||||
...restrictions,
|
||||
prompt: `# THE LIBRARIAN
|
||||
|
||||
You are **THE LIBRARIAN**, a specialized open-source codebase understanding agent.
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
import type { AgentPromptMetadata } from "./types"
|
||||
import { createAgentToolRestrictions } from "../shared/permission-compat"
|
||||
|
||||
const DEFAULT_MODEL = "google/gemini-3-flash"
|
||||
|
||||
@@ -13,13 +14,20 @@ export const MULTIMODAL_LOOKER_PROMPT_METADATA: AgentPromptMetadata = {
|
||||
export function createMultimodalLookerAgent(
|
||||
model: string = DEFAULT_MODEL
|
||||
): AgentConfig {
|
||||
const restrictions = createAgentToolRestrictions([
|
||||
"write",
|
||||
"edit",
|
||||
"bash",
|
||||
"background_task",
|
||||
])
|
||||
|
||||
return {
|
||||
description:
|
||||
"Analyze media files (PDFs, images, diagrams) that require interpretation beyond raw text. Extracts specific information or summaries from documents, describes visual content. Use when you need analyzed/extracted data rather than literal file contents.",
|
||||
mode: "subagent" as const,
|
||||
model,
|
||||
temperature: 0.1,
|
||||
tools: { write: false, edit: false, bash: false, background_task: false },
|
||||
...restrictions,
|
||||
prompt: `You interpret media files that cannot be read as plain text.
|
||||
|
||||
Your job: examine the attached file and extract ONLY what was requested.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
import type { AgentPromptMetadata } from "./types"
|
||||
import { isGptModel } from "./types"
|
||||
import { createAgentToolRestrictions } from "../shared/permission-compat"
|
||||
|
||||
const DEFAULT_MODEL = "openai/gpt-5.2"
|
||||
|
||||
@@ -97,21 +98,28 @@ Organize your final answer in three tiers:
|
||||
Your response goes directly to the user with no intermediate processing. Make your final message self-contained: a clear recommendation they can act on immediately, covering both what to do and why.`
|
||||
|
||||
export function createOracleAgent(model: string = DEFAULT_MODEL): AgentConfig {
|
||||
const restrictions = createAgentToolRestrictions([
|
||||
"write",
|
||||
"edit",
|
||||
"task",
|
||||
"background_task",
|
||||
])
|
||||
|
||||
const base = {
|
||||
description:
|
||||
"Expert technical advisor with deep reasoning for architecture decisions, code analysis, and engineering guidance.",
|
||||
mode: "subagent" as const,
|
||||
model,
|
||||
temperature: 0.1,
|
||||
tools: { write: false, edit: false, task: false, background_task: false },
|
||||
...restrictions,
|
||||
prompt: ORACLE_SYSTEM_PROMPT,
|
||||
}
|
||||
} as AgentConfig
|
||||
|
||||
if (isGptModel(model)) {
|
||||
return { ...base, reasoningEffort: "medium", textVerbosity: "high" }
|
||||
return { ...base, reasoningEffort: "medium", textVerbosity: "high" } as AgentConfig
|
||||
}
|
||||
|
||||
return { ...base, thinking: { type: "enabled", budgetTokens: 32000 } }
|
||||
return { ...base, thinking: { type: "enabled", budgetTokens: 32000 } } as AgentConfig
|
||||
}
|
||||
|
||||
export const oracleAgent = createOracleAgent()
|
||||
|
||||
@@ -307,3 +307,26 @@ export function buildAntiPatternsSection(agents: AvailableAgent[]): string {
|
||||
|----------|-----------|
|
||||
${patterns.join("\n")}`
|
||||
}
|
||||
|
||||
export function buildUltraworkAgentSection(agents: AvailableAgent[]): string {
|
||||
if (agents.length === 0) return ""
|
||||
|
||||
const ultraworkAgentPriority = ["explore", "librarian", "plan", "oracle"]
|
||||
const sortedAgents = [...agents].sort((a, b) => {
|
||||
const aIdx = ultraworkAgentPriority.indexOf(a.name)
|
||||
const bIdx = ultraworkAgentPriority.indexOf(b.name)
|
||||
if (aIdx === -1 && bIdx === -1) return 0
|
||||
if (aIdx === -1) return 1
|
||||
if (bIdx === -1) return -1
|
||||
return aIdx - bIdx
|
||||
})
|
||||
|
||||
const lines: string[] = []
|
||||
for (const agent of sortedAgents) {
|
||||
const shortDesc = agent.description.split(".")[0] || agent.description
|
||||
const suffix = (agent.name === "explore" || agent.name === "librarian") ? " (multiple)" : ""
|
||||
lines.push(`- **${agent.name}${suffix}**: ${shortDesc}`)
|
||||
}
|
||||
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
@@ -1,17 +1,60 @@
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync, statSync } from "node:fs"
|
||||
import { homedir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { parseJsonc } from "../shared"
|
||||
import {
|
||||
parseJsonc,
|
||||
getOpenCodeConfigPaths,
|
||||
type OpenCodeBinaryType,
|
||||
type OpenCodeConfigPaths,
|
||||
} from "../shared"
|
||||
import type { ConfigMergeResult, DetectedConfig, InstallConfig } from "./types"
|
||||
|
||||
const OPENCODE_CONFIG_DIR = join(homedir(), ".config", "opencode")
|
||||
const OPENCODE_JSON = join(OPENCODE_CONFIG_DIR, "opencode.json")
|
||||
const OPENCODE_JSONC = join(OPENCODE_CONFIG_DIR, "opencode.jsonc")
|
||||
const OPENCODE_PACKAGE_JSON = join(OPENCODE_CONFIG_DIR, "package.json")
|
||||
const OMO_CONFIG = join(OPENCODE_CONFIG_DIR, "oh-my-opencode.json")
|
||||
|
||||
const OPENCODE_BINARIES = ["opencode", "opencode-desktop"] as const
|
||||
|
||||
interface ConfigContext {
|
||||
binary: OpenCodeBinaryType
|
||||
version: string | null
|
||||
paths: OpenCodeConfigPaths
|
||||
}
|
||||
|
||||
let configContext: ConfigContext | null = null
|
||||
|
||||
export function initConfigContext(binary: OpenCodeBinaryType, version: string | null): void {
|
||||
const paths = getOpenCodeConfigPaths({ binary, version })
|
||||
configContext = { binary, version, paths }
|
||||
}
|
||||
|
||||
export function getConfigContext(): ConfigContext {
|
||||
if (!configContext) {
|
||||
const paths = getOpenCodeConfigPaths({ binary: "opencode", version: null })
|
||||
configContext = { binary: "opencode", version: null, paths }
|
||||
}
|
||||
return configContext
|
||||
}
|
||||
|
||||
export function resetConfigContext(): void {
|
||||
configContext = null
|
||||
}
|
||||
|
||||
function getConfigDir(): string {
|
||||
return getConfigContext().paths.configDir
|
||||
}
|
||||
|
||||
function getConfigJson(): string {
|
||||
return getConfigContext().paths.configJson
|
||||
}
|
||||
|
||||
function getConfigJsonc(): string {
|
||||
return getConfigContext().paths.configJsonc
|
||||
}
|
||||
|
||||
function getPackageJson(): string {
|
||||
return getConfigContext().paths.packageJson
|
||||
}
|
||||
|
||||
function getOmoConfig(): string {
|
||||
return getConfigContext().paths.omoConfig
|
||||
}
|
||||
|
||||
const CHATGPT_HOTFIX_REPO = "code-yeongyu/opencode-openai-codex-auth#fix/orphaned-function-call-output-with-tools"
|
||||
|
||||
const BUN_INSTALL_TIMEOUT_SECONDS = 60
|
||||
@@ -76,13 +119,16 @@ interface OpenCodeConfig {
|
||||
}
|
||||
|
||||
export function detectConfigFormat(): { format: ConfigFormat; path: string } {
|
||||
if (existsSync(OPENCODE_JSONC)) {
|
||||
return { format: "jsonc", path: OPENCODE_JSONC }
|
||||
const configJsonc = getConfigJsonc()
|
||||
const configJson = getConfigJson()
|
||||
|
||||
if (existsSync(configJsonc)) {
|
||||
return { format: "jsonc", path: configJsonc }
|
||||
}
|
||||
if (existsSync(OPENCODE_JSON)) {
|
||||
return { format: "json", path: OPENCODE_JSON }
|
||||
if (existsSync(configJson)) {
|
||||
return { format: "json", path: configJson }
|
||||
}
|
||||
return { format: "none", path: OPENCODE_JSON }
|
||||
return { format: "none", path: configJson }
|
||||
}
|
||||
|
||||
interface ParseConfigResult {
|
||||
@@ -129,8 +175,9 @@ function parseConfigWithError(path: string): ParseConfigResult {
|
||||
}
|
||||
|
||||
function ensureConfigDir(): void {
|
||||
if (!existsSync(OPENCODE_CONFIG_DIR)) {
|
||||
mkdirSync(OPENCODE_CONFIG_DIR, { recursive: true })
|
||||
const configDir = getConfigDir()
|
||||
if (!existsSync(configDir)) {
|
||||
mkdirSync(configDir, { recursive: true })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,7 +185,7 @@ export function addPluginToOpenCodeConfig(): ConfigMergeResult {
|
||||
try {
|
||||
ensureConfigDir()
|
||||
} catch (err) {
|
||||
return { success: false, configPath: OPENCODE_CONFIG_DIR, error: formatErrorWithSuggestion(err, "create config directory") }
|
||||
return { success: false, configPath: getConfigDir(), error: formatErrorWithSuggestion(err, "create config directory") }
|
||||
}
|
||||
|
||||
const { format, path } = detectConfigFormat()
|
||||
@@ -270,50 +317,52 @@ export function writeOmoConfig(installConfig: InstallConfig): ConfigMergeResult
|
||||
try {
|
||||
ensureConfigDir()
|
||||
} catch (err) {
|
||||
return { success: false, configPath: OPENCODE_CONFIG_DIR, error: formatErrorWithSuggestion(err, "create config directory") }
|
||||
return { success: false, configPath: getConfigDir(), error: formatErrorWithSuggestion(err, "create config directory") }
|
||||
}
|
||||
|
||||
const omoConfigPath = getOmoConfig()
|
||||
|
||||
try {
|
||||
const newConfig = generateOmoConfig(installConfig)
|
||||
|
||||
if (existsSync(OMO_CONFIG)) {
|
||||
if (existsSync(omoConfigPath)) {
|
||||
try {
|
||||
const stat = statSync(OMO_CONFIG)
|
||||
const content = readFileSync(OMO_CONFIG, "utf-8")
|
||||
const stat = statSync(omoConfigPath)
|
||||
const content = readFileSync(omoConfigPath, "utf-8")
|
||||
|
||||
if (stat.size === 0 || isEmptyOrWhitespace(content)) {
|
||||
writeFileSync(OMO_CONFIG, JSON.stringify(newConfig, null, 2) + "\n")
|
||||
return { success: true, configPath: OMO_CONFIG }
|
||||
writeFileSync(omoConfigPath, JSON.stringify(newConfig, null, 2) + "\n")
|
||||
return { success: true, configPath: omoConfigPath }
|
||||
}
|
||||
|
||||
const existing = parseJsonc<Record<string, unknown>>(content)
|
||||
if (!existing || typeof existing !== "object" || Array.isArray(existing)) {
|
||||
writeFileSync(OMO_CONFIG, JSON.stringify(newConfig, null, 2) + "\n")
|
||||
return { success: true, configPath: OMO_CONFIG }
|
||||
writeFileSync(omoConfigPath, JSON.stringify(newConfig, null, 2) + "\n")
|
||||
return { success: true, configPath: omoConfigPath }
|
||||
}
|
||||
|
||||
delete existing.agents
|
||||
const merged = deepMerge(existing, newConfig)
|
||||
writeFileSync(OMO_CONFIG, JSON.stringify(merged, null, 2) + "\n")
|
||||
writeFileSync(omoConfigPath, JSON.stringify(merged, null, 2) + "\n")
|
||||
} catch (parseErr) {
|
||||
if (parseErr instanceof SyntaxError) {
|
||||
writeFileSync(OMO_CONFIG, JSON.stringify(newConfig, null, 2) + "\n")
|
||||
return { success: true, configPath: OMO_CONFIG }
|
||||
writeFileSync(omoConfigPath, JSON.stringify(newConfig, null, 2) + "\n")
|
||||
return { success: true, configPath: omoConfigPath }
|
||||
}
|
||||
throw parseErr
|
||||
}
|
||||
} else {
|
||||
writeFileSync(OMO_CONFIG, JSON.stringify(newConfig, null, 2) + "\n")
|
||||
writeFileSync(omoConfigPath, JSON.stringify(newConfig, null, 2) + "\n")
|
||||
}
|
||||
|
||||
return { success: true, configPath: OMO_CONFIG }
|
||||
return { success: true, configPath: omoConfigPath }
|
||||
} catch (err) {
|
||||
return { success: false, configPath: OMO_CONFIG, error: formatErrorWithSuggestion(err, "write oh-my-opencode config") }
|
||||
return { success: false, configPath: omoConfigPath, error: formatErrorWithSuggestion(err, "write oh-my-opencode config") }
|
||||
}
|
||||
}
|
||||
|
||||
interface OpenCodeBinaryResult {
|
||||
binary: string
|
||||
binary: OpenCodeBinaryType
|
||||
version: string
|
||||
}
|
||||
|
||||
@@ -327,7 +376,9 @@ async function findOpenCodeBinaryWithVersion(): Promise<OpenCodeBinaryResult | n
|
||||
const output = await new Response(proc.stdout).text()
|
||||
await proc.exited
|
||||
if (proc.exitCode === 0) {
|
||||
return { binary, version: output.trim() }
|
||||
const version = output.trim()
|
||||
initConfigContext(binary, version)
|
||||
return { binary, version }
|
||||
}
|
||||
} catch {
|
||||
continue
|
||||
@@ -350,7 +401,7 @@ export async function addAuthPlugins(config: InstallConfig): Promise<ConfigMerge
|
||||
try {
|
||||
ensureConfigDir()
|
||||
} catch (err) {
|
||||
return { success: false, configPath: OPENCODE_CONFIG_DIR, error: formatErrorWithSuggestion(err, "create config directory") }
|
||||
return { success: false, configPath: getConfigDir(), error: formatErrorWithSuggestion(err, "create config directory") }
|
||||
}
|
||||
|
||||
const { format, path } = detectConfigFormat()
|
||||
@@ -394,15 +445,17 @@ export function setupChatGPTHotfix(): ConfigMergeResult {
|
||||
try {
|
||||
ensureConfigDir()
|
||||
} catch (err) {
|
||||
return { success: false, configPath: OPENCODE_CONFIG_DIR, error: formatErrorWithSuggestion(err, "create config directory") }
|
||||
return { success: false, configPath: getConfigDir(), error: formatErrorWithSuggestion(err, "create config directory") }
|
||||
}
|
||||
|
||||
const packageJsonPath = getPackageJson()
|
||||
|
||||
try {
|
||||
let packageJson: Record<string, unknown> = {}
|
||||
if (existsSync(OPENCODE_PACKAGE_JSON)) {
|
||||
if (existsSync(packageJsonPath)) {
|
||||
try {
|
||||
const stat = statSync(OPENCODE_PACKAGE_JSON)
|
||||
const content = readFileSync(OPENCODE_PACKAGE_JSON, "utf-8")
|
||||
const stat = statSync(packageJsonPath)
|
||||
const content = readFileSync(packageJsonPath, "utf-8")
|
||||
|
||||
if (stat.size > 0 && !isEmptyOrWhitespace(content)) {
|
||||
packageJson = JSON.parse(content)
|
||||
@@ -423,10 +476,10 @@ export function setupChatGPTHotfix(): ConfigMergeResult {
|
||||
deps["opencode-openai-codex-auth"] = CHATGPT_HOTFIX_REPO
|
||||
packageJson.dependencies = deps
|
||||
|
||||
writeFileSync(OPENCODE_PACKAGE_JSON, JSON.stringify(packageJson, null, 2) + "\n")
|
||||
return { success: true, configPath: OPENCODE_PACKAGE_JSON }
|
||||
writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2) + "\n")
|
||||
return { success: true, configPath: packageJsonPath }
|
||||
} catch (err) {
|
||||
return { success: false, configPath: OPENCODE_PACKAGE_JSON, error: formatErrorWithSuggestion(err, "setup ChatGPT hotfix in package.json") }
|
||||
return { success: false, configPath: packageJsonPath, error: formatErrorWithSuggestion(err, "setup ChatGPT hotfix in package.json") }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -444,7 +497,7 @@ export async function runBunInstall(): Promise<boolean> {
|
||||
export async function runBunInstallWithDetails(): Promise<BunInstallResult> {
|
||||
try {
|
||||
const proc = Bun.spawn(["bun", "install"], {
|
||||
cwd: OPENCODE_CONFIG_DIR,
|
||||
cwd: getConfigDir(),
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
@@ -548,7 +601,7 @@ export function addProviderConfig(config: InstallConfig): ConfigMergeResult {
|
||||
try {
|
||||
ensureConfigDir()
|
||||
} catch (err) {
|
||||
return { success: false, configPath: OPENCODE_CONFIG_DIR, error: formatErrorWithSuggestion(err, "create config directory") }
|
||||
return { success: false, configPath: getConfigDir(), error: formatErrorWithSuggestion(err, "create config directory") }
|
||||
}
|
||||
|
||||
const { format, path } = detectConfigFormat()
|
||||
@@ -622,17 +675,18 @@ export function detectCurrentConfig(): DetectedConfig {
|
||||
result.hasGemini = plugins.some((p) => p.startsWith("opencode-antigravity-auth"))
|
||||
result.hasChatGPT = plugins.some((p) => p.startsWith("opencode-openai-codex-auth"))
|
||||
|
||||
if (!existsSync(OMO_CONFIG)) {
|
||||
const omoConfigPath = getOmoConfig()
|
||||
if (!existsSync(omoConfigPath)) {
|
||||
return result
|
||||
}
|
||||
|
||||
try {
|
||||
const stat = statSync(OMO_CONFIG)
|
||||
const stat = statSync(omoConfigPath)
|
||||
if (stat.size === 0) {
|
||||
return result
|
||||
}
|
||||
|
||||
const content = readFileSync(OMO_CONFIG, "utf-8")
|
||||
const content = readFileSync(omoConfigPath, "utf-8")
|
||||
if (isEmptyOrWhitespace(content)) {
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
import { existsSync, readFileSync } from "node:fs"
|
||||
import { homedir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import type { CheckResult, CheckDefinition, PluginInfo } from "../types"
|
||||
import { CHECK_IDS, CHECK_NAMES, PACKAGE_NAME } from "../constants"
|
||||
import { parseJsonc } from "../../../shared"
|
||||
|
||||
const OPENCODE_CONFIG_DIR = join(homedir(), ".config", "opencode")
|
||||
const OPENCODE_JSON = join(OPENCODE_CONFIG_DIR, "opencode.json")
|
||||
const OPENCODE_JSONC = join(OPENCODE_CONFIG_DIR, "opencode.jsonc")
|
||||
import { parseJsonc, getOpenCodeConfigPaths } from "../../../shared"
|
||||
|
||||
function detectConfigPath(): { path: string; format: "json" | "jsonc" } | null {
|
||||
if (existsSync(OPENCODE_JSONC)) {
|
||||
return { path: OPENCODE_JSONC, format: "jsonc" }
|
||||
const paths = getOpenCodeConfigPaths({ binary: "opencode", version: null })
|
||||
|
||||
if (existsSync(paths.configJsonc)) {
|
||||
return { path: paths.configJsonc, format: "jsonc" }
|
||||
}
|
||||
if (existsSync(OPENCODE_JSON)) {
|
||||
return { path: OPENCODE_JSON, format: "json" }
|
||||
if (existsSync(paths.configJson)) {
|
||||
return { path: paths.configJson, format: "json" }
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -81,13 +77,14 @@ export async function checkPluginRegistration(): Promise<CheckResult> {
|
||||
const info = getPluginInfo()
|
||||
|
||||
if (!info.configPath) {
|
||||
const expectedPaths = getOpenCodeConfigPaths({ binary: "opencode", version: null })
|
||||
return {
|
||||
name: CHECK_NAMES[CHECK_IDS.PLUGIN_REGISTRATION],
|
||||
status: "fail",
|
||||
message: "OpenCode config file not found",
|
||||
details: [
|
||||
"Run: bunx oh-my-opencode install",
|
||||
`Expected: ${OPENCODE_JSON} or ${OPENCODE_JSONC}`,
|
||||
`Expected: ${expectedPaths.configJson} or ${expectedPaths.configJsonc}`,
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,6 +74,7 @@ export const HookNameSchema = z.enum([
|
||||
"compaction-context-injector",
|
||||
"claude-code-hooks",
|
||||
"auto-slash-command",
|
||||
"edit-error-recovery",
|
||||
])
|
||||
|
||||
export const BuiltinCommandNameSchema = z.enum([
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { describe, test, expect, beforeEach } from "bun:test"
|
||||
import type { BackgroundTask } from "./types"
|
||||
|
||||
const TASK_TTL_MS = 30 * 60 * 1000
|
||||
|
||||
class MockBackgroundManager {
|
||||
private tasks: Map<string, BackgroundTask> = new Map()
|
||||
private notifications: Map<string, BackgroundTask[]> = new Map()
|
||||
|
||||
addTask(task: BackgroundTask): void {
|
||||
this.tasks.set(task.id, task)
|
||||
@@ -34,6 +37,74 @@ class MockBackgroundManager {
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
markForNotification(task: BackgroundTask): void {
|
||||
const queue = this.notifications.get(task.parentSessionID) ?? []
|
||||
queue.push(task)
|
||||
this.notifications.set(task.parentSessionID, queue)
|
||||
}
|
||||
|
||||
getPendingNotifications(sessionID: string): BackgroundTask[] {
|
||||
return this.notifications.get(sessionID) ?? []
|
||||
}
|
||||
|
||||
private clearNotificationsForTask(taskId: string): void {
|
||||
for (const [sessionID, tasks] of this.notifications.entries()) {
|
||||
const filtered = tasks.filter((t) => t.id !== taskId)
|
||||
if (filtered.length === 0) {
|
||||
this.notifications.delete(sessionID)
|
||||
} else {
|
||||
this.notifications.set(sessionID, filtered)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pruneStaleTasksAndNotifications(): { prunedTasks: string[]; prunedNotifications: number } {
|
||||
const now = Date.now()
|
||||
const prunedTasks: string[] = []
|
||||
let prunedNotifications = 0
|
||||
|
||||
for (const [taskId, task] of this.tasks.entries()) {
|
||||
const age = now - task.startedAt.getTime()
|
||||
if (age > TASK_TTL_MS) {
|
||||
prunedTasks.push(taskId)
|
||||
this.clearNotificationsForTask(taskId)
|
||||
this.tasks.delete(taskId)
|
||||
}
|
||||
}
|
||||
|
||||
for (const [sessionID, notifications] of this.notifications.entries()) {
|
||||
if (notifications.length === 0) {
|
||||
this.notifications.delete(sessionID)
|
||||
continue
|
||||
}
|
||||
const validNotifications = notifications.filter((task) => {
|
||||
const age = now - task.startedAt.getTime()
|
||||
return age <= TASK_TTL_MS
|
||||
})
|
||||
const removed = notifications.length - validNotifications.length
|
||||
prunedNotifications += removed
|
||||
if (validNotifications.length === 0) {
|
||||
this.notifications.delete(sessionID)
|
||||
} else if (validNotifications.length !== notifications.length) {
|
||||
this.notifications.set(sessionID, validNotifications)
|
||||
}
|
||||
}
|
||||
|
||||
return { prunedTasks, prunedNotifications }
|
||||
}
|
||||
|
||||
getTaskCount(): number {
|
||||
return this.tasks.size
|
||||
}
|
||||
|
||||
getNotificationCount(): number {
|
||||
let count = 0
|
||||
for (const notifications of this.notifications.values()) {
|
||||
count += notifications.length
|
||||
}
|
||||
return count
|
||||
}
|
||||
}
|
||||
|
||||
function createMockTask(overrides: Partial<BackgroundTask> & { id: string; sessionID: string; parentSessionID: string }): BackgroundTask {
|
||||
@@ -230,3 +301,116 @@ describe("BackgroundManager.getAllDescendantTasks", () => {
|
||||
expect(result[0].id).toBe("task-b")
|
||||
})
|
||||
})
|
||||
|
||||
describe("BackgroundManager.pruneStaleTasksAndNotifications", () => {
|
||||
let manager: MockBackgroundManager
|
||||
|
||||
beforeEach(() => {
|
||||
// #given
|
||||
manager = new MockBackgroundManager()
|
||||
})
|
||||
|
||||
test("should not prune fresh tasks", () => {
|
||||
// #given
|
||||
const task = createMockTask({
|
||||
id: "task-fresh",
|
||||
sessionID: "session-fresh",
|
||||
parentSessionID: "session-parent",
|
||||
startedAt: new Date(),
|
||||
})
|
||||
manager.addTask(task)
|
||||
|
||||
// #when
|
||||
const result = manager.pruneStaleTasksAndNotifications()
|
||||
|
||||
// #then
|
||||
expect(result.prunedTasks).toHaveLength(0)
|
||||
expect(manager.getTaskCount()).toBe(1)
|
||||
})
|
||||
|
||||
test("should prune tasks older than 30 minutes", () => {
|
||||
// #given
|
||||
const staleDate = new Date(Date.now() - 31 * 60 * 1000)
|
||||
const task = createMockTask({
|
||||
id: "task-stale",
|
||||
sessionID: "session-stale",
|
||||
parentSessionID: "session-parent",
|
||||
startedAt: staleDate,
|
||||
})
|
||||
manager.addTask(task)
|
||||
|
||||
// #when
|
||||
const result = manager.pruneStaleTasksAndNotifications()
|
||||
|
||||
// #then
|
||||
expect(result.prunedTasks).toContain("task-stale")
|
||||
expect(manager.getTaskCount()).toBe(0)
|
||||
})
|
||||
|
||||
test("should prune stale notifications", () => {
|
||||
// #given
|
||||
const staleDate = new Date(Date.now() - 31 * 60 * 1000)
|
||||
const task = createMockTask({
|
||||
id: "task-stale",
|
||||
sessionID: "session-stale",
|
||||
parentSessionID: "session-parent",
|
||||
startedAt: staleDate,
|
||||
})
|
||||
manager.markForNotification(task)
|
||||
|
||||
// #when
|
||||
const result = manager.pruneStaleTasksAndNotifications()
|
||||
|
||||
// #then
|
||||
expect(result.prunedNotifications).toBe(1)
|
||||
expect(manager.getNotificationCount()).toBe(0)
|
||||
})
|
||||
|
||||
test("should clean up notifications when task is pruned", () => {
|
||||
// #given
|
||||
const staleDate = new Date(Date.now() - 31 * 60 * 1000)
|
||||
const task = createMockTask({
|
||||
id: "task-stale",
|
||||
sessionID: "session-stale",
|
||||
parentSessionID: "session-parent",
|
||||
startedAt: staleDate,
|
||||
})
|
||||
manager.addTask(task)
|
||||
manager.markForNotification(task)
|
||||
|
||||
// #when
|
||||
manager.pruneStaleTasksAndNotifications()
|
||||
|
||||
// #then
|
||||
expect(manager.getTaskCount()).toBe(0)
|
||||
expect(manager.getNotificationCount()).toBe(0)
|
||||
})
|
||||
|
||||
test("should keep fresh tasks while pruning stale ones", () => {
|
||||
// #given
|
||||
const staleDate = new Date(Date.now() - 31 * 60 * 1000)
|
||||
const staleTask = createMockTask({
|
||||
id: "task-stale",
|
||||
sessionID: "session-stale",
|
||||
parentSessionID: "session-parent",
|
||||
startedAt: staleDate,
|
||||
})
|
||||
const freshTask = createMockTask({
|
||||
id: "task-fresh",
|
||||
sessionID: "session-fresh",
|
||||
parentSessionID: "session-parent",
|
||||
startedAt: new Date(),
|
||||
})
|
||||
manager.addTask(staleTask)
|
||||
manager.addTask(freshTask)
|
||||
|
||||
// #when
|
||||
const result = manager.pruneStaleTasksAndNotifications()
|
||||
|
||||
// #then
|
||||
expect(result.prunedTasks).toHaveLength(1)
|
||||
expect(result.prunedTasks).toContain("task-stale")
|
||||
expect(manager.getTaskCount()).toBe(1)
|
||||
expect(manager.getTask("task-fresh")).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
} from "../hook-message-injector"
|
||||
import { subagentSessions } from "../claude-code-session-state"
|
||||
|
||||
const TASK_TTL_MS = 30 * 60 * 1000
|
||||
|
||||
type OpencodeClient = PluginInput["client"]
|
||||
|
||||
interface MessagePartInfo {
|
||||
@@ -345,11 +347,12 @@ export class BackgroundManager {
|
||||
},
|
||||
query: { directory: this.directory },
|
||||
})
|
||||
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 {
|
||||
// Always clean up both maps to prevent memory leaks
|
||||
this.clearNotificationsForTask(taskId)
|
||||
this.tasks.delete(taskId)
|
||||
log("[background-agent] Removed completed task from memory:", taskId)
|
||||
}
|
||||
@@ -377,7 +380,42 @@ export class BackgroundManager {
|
||||
return false
|
||||
}
|
||||
|
||||
private pruneStaleTasksAndNotifications(): void {
|
||||
const now = Date.now()
|
||||
|
||||
for (const [taskId, task] of this.tasks.entries()) {
|
||||
const age = now - task.startedAt.getTime()
|
||||
if (age > TASK_TTL_MS) {
|
||||
log("[background-agent] Pruning stale task:", { taskId, age: Math.round(age / 1000) + "s" })
|
||||
task.status = "error"
|
||||
task.error = "Task timed out after 30 minutes"
|
||||
task.completedAt = new Date()
|
||||
this.clearNotificationsForTask(taskId)
|
||||
this.tasks.delete(taskId)
|
||||
subagentSessions.delete(task.sessionID)
|
||||
}
|
||||
}
|
||||
|
||||
for (const [sessionID, notifications] of this.notifications.entries()) {
|
||||
if (notifications.length === 0) {
|
||||
this.notifications.delete(sessionID)
|
||||
continue
|
||||
}
|
||||
const validNotifications = notifications.filter((task) => {
|
||||
const age = now - task.startedAt.getTime()
|
||||
return age <= TASK_TTL_MS
|
||||
})
|
||||
if (validNotifications.length === 0) {
|
||||
this.notifications.delete(sessionID)
|
||||
} else if (validNotifications.length !== notifications.length) {
|
||||
this.notifications.set(sessionID, validNotifications)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async pollRunningTasks(): Promise<void> {
|
||||
this.pruneStaleTasksAndNotifications()
|
||||
|
||||
const statusResult = await this.client.session.status()
|
||||
const allStatuses = (statusResult.data ?? {}) as Record<string, { type: string }>
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { existsSync, readdirSync, readFileSync, realpathSync, type Dirent } from "fs"
|
||||
import { promises as fsPromises } from "fs"
|
||||
import { join, basename } from "path"
|
||||
import { parseFrontmatter } from "../../shared/frontmatter"
|
||||
import { sanitizeModelField } from "../../shared/model-sanitizer"
|
||||
@@ -129,3 +130,130 @@ export function loadOpencodeProjectCommands(): Record<string, CommandDefinition>
|
||||
const commands = loadCommandsFromDir(opencodeProjectDir, "opencode-project")
|
||||
return commandsToRecord(commands)
|
||||
}
|
||||
|
||||
async function loadCommandsFromDirAsync(
|
||||
commandsDir: string,
|
||||
scope: CommandScope,
|
||||
visited: Set<string> = new Set(),
|
||||
prefix: string = ""
|
||||
): Promise<LoadedCommand[]> {
|
||||
try {
|
||||
await fsPromises.access(commandsDir)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
|
||||
let realPath: string
|
||||
try {
|
||||
realPath = await fsPromises.realpath(commandsDir)
|
||||
} catch (error) {
|
||||
log(`Failed to resolve command directory: ${commandsDir}`, error)
|
||||
return []
|
||||
}
|
||||
|
||||
if (visited.has(realPath)) {
|
||||
return []
|
||||
}
|
||||
visited.add(realPath)
|
||||
|
||||
let entries: Dirent[]
|
||||
try {
|
||||
entries = await fsPromises.readdir(commandsDir, { withFileTypes: true })
|
||||
} catch (error) {
|
||||
log(`Failed to read command directory: ${commandsDir}`, error)
|
||||
return []
|
||||
}
|
||||
|
||||
const commands: LoadedCommand[] = []
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.isDirectory()) {
|
||||
if (entry.name.startsWith(".")) continue
|
||||
const subDirPath = join(commandsDir, entry.name)
|
||||
const subPrefix = prefix ? `${prefix}:${entry.name}` : entry.name
|
||||
const subCommands = await loadCommandsFromDirAsync(subDirPath, scope, visited, subPrefix)
|
||||
commands.push(...subCommands)
|
||||
continue
|
||||
}
|
||||
|
||||
if (!isMarkdownFile(entry)) continue
|
||||
|
||||
const commandPath = join(commandsDir, entry.name)
|
||||
const baseCommandName = basename(entry.name, ".md")
|
||||
const commandName = prefix ? `${prefix}:${baseCommandName}` : baseCommandName
|
||||
|
||||
try {
|
||||
const content = await fsPromises.readFile(commandPath, "utf-8")
|
||||
const { data, body } = parseFrontmatter<CommandFrontmatter>(content)
|
||||
|
||||
const wrappedTemplate = `<command-instruction>
|
||||
${body.trim()}
|
||||
</command-instruction>
|
||||
|
||||
<user-request>
|
||||
$ARGUMENTS
|
||||
</user-request>`
|
||||
|
||||
const formattedDescription = `(${scope}) ${data.description || ""}`
|
||||
|
||||
const isOpencodeSource = scope === "opencode" || scope === "opencode-project"
|
||||
const definition: CommandDefinition = {
|
||||
name: commandName,
|
||||
description: formattedDescription,
|
||||
template: wrappedTemplate,
|
||||
agent: data.agent,
|
||||
model: sanitizeModelField(data.model, isOpencodeSource ? "opencode" : "claude-code"),
|
||||
subtask: data.subtask,
|
||||
argumentHint: data["argument-hint"],
|
||||
handoffs: data.handoffs,
|
||||
}
|
||||
|
||||
commands.push({
|
||||
name: commandName,
|
||||
path: commandPath,
|
||||
definition,
|
||||
scope,
|
||||
})
|
||||
} catch (error) {
|
||||
log(`Failed to parse command: ${commandPath}`, error)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return commands
|
||||
}
|
||||
|
||||
export async function loadUserCommandsAsync(): Promise<Record<string, CommandDefinition>> {
|
||||
const userCommandsDir = join(getClaudeConfigDir(), "commands")
|
||||
const commands = await loadCommandsFromDirAsync(userCommandsDir, "user")
|
||||
return commandsToRecord(commands)
|
||||
}
|
||||
|
||||
export async function loadProjectCommandsAsync(): Promise<Record<string, CommandDefinition>> {
|
||||
const projectCommandsDir = join(process.cwd(), ".claude", "commands")
|
||||
const commands = await loadCommandsFromDirAsync(projectCommandsDir, "project")
|
||||
return commandsToRecord(commands)
|
||||
}
|
||||
|
||||
export async function loadOpencodeGlobalCommandsAsync(): Promise<Record<string, CommandDefinition>> {
|
||||
const { homedir } = require("os")
|
||||
const opencodeCommandsDir = join(homedir(), ".config", "opencode", "command")
|
||||
const commands = await loadCommandsFromDirAsync(opencodeCommandsDir, "opencode")
|
||||
return commandsToRecord(commands)
|
||||
}
|
||||
|
||||
export async function loadOpencodeProjectCommandsAsync(): Promise<Record<string, CommandDefinition>> {
|
||||
const opencodeProjectDir = join(process.cwd(), ".opencode", "command")
|
||||
const commands = await loadCommandsFromDirAsync(opencodeProjectDir, "opencode-project")
|
||||
return commandsToRecord(commands)
|
||||
}
|
||||
|
||||
export async function loadAllCommandsAsync(): Promise<Record<string, CommandDefinition>> {
|
||||
const [user, project, global, projectOpencode] = await Promise.all([
|
||||
loadUserCommandsAsync(),
|
||||
loadProjectCommandsAsync(),
|
||||
loadOpencodeGlobalCommandsAsync(),
|
||||
loadOpencodeProjectCommandsAsync(),
|
||||
])
|
||||
return { ...projectOpencode, ...global, ...project, ...user }
|
||||
}
|
||||
|
||||
@@ -464,11 +464,13 @@ export interface PluginComponentsResult {
|
||||
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)
|
||||
const [commands, skills, agents, mcpServers, hooksConfigs] = await Promise.all([
|
||||
Promise.resolve(loadPluginCommands(plugins)),
|
||||
Promise.resolve(loadPluginSkillsAsCommands(plugins)),
|
||||
Promise.resolve(loadPluginAgents(plugins)),
|
||||
loadPluginMcpServers(plugins),
|
||||
Promise.resolve(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`)
|
||||
|
||||
|
||||
330
src/features/context-injector/collector.test.ts
Normal file
330
src/features/context-injector/collector.test.ts
Normal file
@@ -0,0 +1,330 @@
|
||||
import { describe, it, expect, beforeEach } from "bun:test"
|
||||
import { ContextCollector } from "./collector"
|
||||
import type { ContextPriority, ContextSourceType } from "./types"
|
||||
|
||||
describe("ContextCollector", () => {
|
||||
let collector: ContextCollector
|
||||
|
||||
beforeEach(() => {
|
||||
collector = new ContextCollector()
|
||||
})
|
||||
|
||||
describe("register", () => {
|
||||
it("registers context for a session", () => {
|
||||
// #given
|
||||
const sessionID = "ses_test1"
|
||||
const options = {
|
||||
id: "ulw-context",
|
||||
source: "keyword-detector" as ContextSourceType,
|
||||
content: "Ultrawork mode activated",
|
||||
}
|
||||
|
||||
// #when
|
||||
collector.register(sessionID, options)
|
||||
|
||||
// #then
|
||||
const pending = collector.getPending(sessionID)
|
||||
expect(pending.hasContent).toBe(true)
|
||||
expect(pending.entries).toHaveLength(1)
|
||||
expect(pending.entries[0].content).toBe("Ultrawork mode activated")
|
||||
})
|
||||
|
||||
it("assigns default priority of 'normal' when not specified", () => {
|
||||
// #given
|
||||
const sessionID = "ses_test2"
|
||||
|
||||
// #when
|
||||
collector.register(sessionID, {
|
||||
id: "test",
|
||||
source: "keyword-detector",
|
||||
content: "test content",
|
||||
})
|
||||
|
||||
// #then
|
||||
const pending = collector.getPending(sessionID)
|
||||
expect(pending.entries[0].priority).toBe("normal")
|
||||
})
|
||||
|
||||
it("uses specified priority", () => {
|
||||
// #given
|
||||
const sessionID = "ses_test3"
|
||||
|
||||
// #when
|
||||
collector.register(sessionID, {
|
||||
id: "critical-context",
|
||||
source: "keyword-detector",
|
||||
content: "critical content",
|
||||
priority: "critical",
|
||||
})
|
||||
|
||||
// #then
|
||||
const pending = collector.getPending(sessionID)
|
||||
expect(pending.entries[0].priority).toBe("critical")
|
||||
})
|
||||
|
||||
it("deduplicates by source + id combination", () => {
|
||||
// #given
|
||||
const sessionID = "ses_test4"
|
||||
const options = {
|
||||
id: "ulw-context",
|
||||
source: "keyword-detector" as ContextSourceType,
|
||||
content: "First content",
|
||||
}
|
||||
|
||||
// #when
|
||||
collector.register(sessionID, options)
|
||||
collector.register(sessionID, { ...options, content: "Updated content" })
|
||||
|
||||
// #then
|
||||
const pending = collector.getPending(sessionID)
|
||||
expect(pending.entries).toHaveLength(1)
|
||||
expect(pending.entries[0].content).toBe("Updated content")
|
||||
})
|
||||
|
||||
it("allows same id from different sources", () => {
|
||||
// #given
|
||||
const sessionID = "ses_test5"
|
||||
|
||||
// #when
|
||||
collector.register(sessionID, {
|
||||
id: "context-1",
|
||||
source: "keyword-detector",
|
||||
content: "From keyword-detector",
|
||||
})
|
||||
collector.register(sessionID, {
|
||||
id: "context-1",
|
||||
source: "rules-injector",
|
||||
content: "From rules-injector",
|
||||
})
|
||||
|
||||
// #then
|
||||
const pending = collector.getPending(sessionID)
|
||||
expect(pending.entries).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getPending", () => {
|
||||
it("returns empty result for session with no context", () => {
|
||||
// #given
|
||||
const sessionID = "ses_empty"
|
||||
|
||||
// #when
|
||||
const pending = collector.getPending(sessionID)
|
||||
|
||||
// #then
|
||||
expect(pending.hasContent).toBe(false)
|
||||
expect(pending.entries).toHaveLength(0)
|
||||
expect(pending.merged).toBe("")
|
||||
})
|
||||
|
||||
it("merges multiple contexts with separator", () => {
|
||||
// #given
|
||||
const sessionID = "ses_merge"
|
||||
collector.register(sessionID, {
|
||||
id: "ctx-1",
|
||||
source: "keyword-detector",
|
||||
content: "First context",
|
||||
})
|
||||
collector.register(sessionID, {
|
||||
id: "ctx-2",
|
||||
source: "rules-injector",
|
||||
content: "Second context",
|
||||
})
|
||||
|
||||
// #when
|
||||
const pending = collector.getPending(sessionID)
|
||||
|
||||
// #then
|
||||
expect(pending.hasContent).toBe(true)
|
||||
expect(pending.merged).toContain("First context")
|
||||
expect(pending.merged).toContain("Second context")
|
||||
})
|
||||
|
||||
it("orders contexts by priority (critical > high > normal > low)", () => {
|
||||
// #given
|
||||
const sessionID = "ses_priority"
|
||||
collector.register(sessionID, {
|
||||
id: "low",
|
||||
source: "custom",
|
||||
content: "LOW",
|
||||
priority: "low",
|
||||
})
|
||||
collector.register(sessionID, {
|
||||
id: "critical",
|
||||
source: "custom",
|
||||
content: "CRITICAL",
|
||||
priority: "critical",
|
||||
})
|
||||
collector.register(sessionID, {
|
||||
id: "normal",
|
||||
source: "custom",
|
||||
content: "NORMAL",
|
||||
priority: "normal",
|
||||
})
|
||||
collector.register(sessionID, {
|
||||
id: "high",
|
||||
source: "custom",
|
||||
content: "HIGH",
|
||||
priority: "high",
|
||||
})
|
||||
|
||||
// #when
|
||||
const pending = collector.getPending(sessionID)
|
||||
|
||||
// #then
|
||||
const order = pending.entries.map((e) => e.priority)
|
||||
expect(order).toEqual(["critical", "high", "normal", "low"])
|
||||
})
|
||||
|
||||
it("maintains registration order within same priority", () => {
|
||||
// #given
|
||||
const sessionID = "ses_order"
|
||||
collector.register(sessionID, {
|
||||
id: "first",
|
||||
source: "custom",
|
||||
content: "First",
|
||||
priority: "normal",
|
||||
})
|
||||
collector.register(sessionID, {
|
||||
id: "second",
|
||||
source: "custom",
|
||||
content: "Second",
|
||||
priority: "normal",
|
||||
})
|
||||
collector.register(sessionID, {
|
||||
id: "third",
|
||||
source: "custom",
|
||||
content: "Third",
|
||||
priority: "normal",
|
||||
})
|
||||
|
||||
// #when
|
||||
const pending = collector.getPending(sessionID)
|
||||
|
||||
// #then
|
||||
const ids = pending.entries.map((e) => e.id)
|
||||
expect(ids).toEqual(["first", "second", "third"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("consume", () => {
|
||||
it("clears pending context for session", () => {
|
||||
// #given
|
||||
const sessionID = "ses_consume"
|
||||
collector.register(sessionID, {
|
||||
id: "ctx",
|
||||
source: "keyword-detector",
|
||||
content: "test",
|
||||
})
|
||||
|
||||
// #when
|
||||
collector.consume(sessionID)
|
||||
|
||||
// #then
|
||||
const pending = collector.getPending(sessionID)
|
||||
expect(pending.hasContent).toBe(false)
|
||||
})
|
||||
|
||||
it("returns the consumed context", () => {
|
||||
// #given
|
||||
const sessionID = "ses_consume_return"
|
||||
collector.register(sessionID, {
|
||||
id: "ctx",
|
||||
source: "keyword-detector",
|
||||
content: "test content",
|
||||
})
|
||||
|
||||
// #when
|
||||
const consumed = collector.consume(sessionID)
|
||||
|
||||
// #then
|
||||
expect(consumed.hasContent).toBe(true)
|
||||
expect(consumed.entries[0].content).toBe("test content")
|
||||
})
|
||||
|
||||
it("does not affect other sessions", () => {
|
||||
// #given
|
||||
const session1 = "ses_1"
|
||||
const session2 = "ses_2"
|
||||
collector.register(session1, {
|
||||
id: "ctx",
|
||||
source: "keyword-detector",
|
||||
content: "session 1",
|
||||
})
|
||||
collector.register(session2, {
|
||||
id: "ctx",
|
||||
source: "keyword-detector",
|
||||
content: "session 2",
|
||||
})
|
||||
|
||||
// #when
|
||||
collector.consume(session1)
|
||||
|
||||
// #then
|
||||
expect(collector.getPending(session1).hasContent).toBe(false)
|
||||
expect(collector.getPending(session2).hasContent).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("clear", () => {
|
||||
it("removes all context for a session", () => {
|
||||
// #given
|
||||
const sessionID = "ses_clear"
|
||||
collector.register(sessionID, {
|
||||
id: "ctx-1",
|
||||
source: "keyword-detector",
|
||||
content: "test 1",
|
||||
})
|
||||
collector.register(sessionID, {
|
||||
id: "ctx-2",
|
||||
source: "rules-injector",
|
||||
content: "test 2",
|
||||
})
|
||||
|
||||
// #when
|
||||
collector.clear(sessionID)
|
||||
|
||||
// #then
|
||||
expect(collector.getPending(sessionID).hasContent).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("hasPending", () => {
|
||||
it("returns true when session has pending context", () => {
|
||||
// #given
|
||||
const sessionID = "ses_has"
|
||||
collector.register(sessionID, {
|
||||
id: "ctx",
|
||||
source: "keyword-detector",
|
||||
content: "test",
|
||||
})
|
||||
|
||||
// #when / #then
|
||||
expect(collector.hasPending(sessionID)).toBe(true)
|
||||
})
|
||||
|
||||
it("returns false when session has no pending context", () => {
|
||||
// #given
|
||||
const sessionID = "ses_empty"
|
||||
|
||||
// #when / #then
|
||||
expect(collector.hasPending(sessionID)).toBe(false)
|
||||
})
|
||||
|
||||
it("returns false after consume", () => {
|
||||
// #given
|
||||
const sessionID = "ses_after_consume"
|
||||
collector.register(sessionID, {
|
||||
id: "ctx",
|
||||
source: "keyword-detector",
|
||||
content: "test",
|
||||
})
|
||||
|
||||
// #when
|
||||
collector.consume(sessionID)
|
||||
|
||||
// #then
|
||||
expect(collector.hasPending(sessionID)).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
85
src/features/context-injector/collector.ts
Normal file
85
src/features/context-injector/collector.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import type {
|
||||
ContextEntry,
|
||||
ContextPriority,
|
||||
PendingContext,
|
||||
RegisterContextOptions,
|
||||
} from "./types"
|
||||
|
||||
const PRIORITY_ORDER: Record<ContextPriority, number> = {
|
||||
critical: 0,
|
||||
high: 1,
|
||||
normal: 2,
|
||||
low: 3,
|
||||
}
|
||||
|
||||
const CONTEXT_SEPARATOR = "\n\n---\n\n"
|
||||
|
||||
export class ContextCollector {
|
||||
private sessions: Map<string, Map<string, ContextEntry>> = new Map()
|
||||
|
||||
register(sessionID: string, options: RegisterContextOptions): void {
|
||||
if (!this.sessions.has(sessionID)) {
|
||||
this.sessions.set(sessionID, new Map())
|
||||
}
|
||||
|
||||
const sessionMap = this.sessions.get(sessionID)!
|
||||
const key = `${options.source}:${options.id}`
|
||||
|
||||
const entry: ContextEntry = {
|
||||
id: options.id,
|
||||
source: options.source,
|
||||
content: options.content,
|
||||
priority: options.priority ?? "normal",
|
||||
timestamp: Date.now(),
|
||||
metadata: options.metadata,
|
||||
}
|
||||
|
||||
sessionMap.set(key, entry)
|
||||
}
|
||||
|
||||
getPending(sessionID: string): PendingContext {
|
||||
const sessionMap = this.sessions.get(sessionID)
|
||||
|
||||
if (!sessionMap || sessionMap.size === 0) {
|
||||
return {
|
||||
merged: "",
|
||||
entries: [],
|
||||
hasContent: false,
|
||||
}
|
||||
}
|
||||
|
||||
const entries = this.sortEntries([...sessionMap.values()])
|
||||
const merged = entries.map((e) => e.content).join(CONTEXT_SEPARATOR)
|
||||
|
||||
return {
|
||||
merged,
|
||||
entries,
|
||||
hasContent: entries.length > 0,
|
||||
}
|
||||
}
|
||||
|
||||
consume(sessionID: string): PendingContext {
|
||||
const pending = this.getPending(sessionID)
|
||||
this.clear(sessionID)
|
||||
return pending
|
||||
}
|
||||
|
||||
clear(sessionID: string): void {
|
||||
this.sessions.delete(sessionID)
|
||||
}
|
||||
|
||||
hasPending(sessionID: string): boolean {
|
||||
const sessionMap = this.sessions.get(sessionID)
|
||||
return sessionMap !== undefined && sessionMap.size > 0
|
||||
}
|
||||
|
||||
private sortEntries(entries: ContextEntry[]): ContextEntry[] {
|
||||
return entries.sort((a, b) => {
|
||||
const priorityDiff = PRIORITY_ORDER[a.priority] - PRIORITY_ORDER[b.priority]
|
||||
if (priorityDiff !== 0) return priorityDiff
|
||||
return a.timestamp - b.timestamp
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const contextCollector = new ContextCollector()
|
||||
16
src/features/context-injector/index.ts
Normal file
16
src/features/context-injector/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export { ContextCollector, contextCollector } from "./collector"
|
||||
export {
|
||||
injectPendingContext,
|
||||
createContextInjectorHook,
|
||||
createContextInjectorMessagesTransformHook,
|
||||
} from "./injector"
|
||||
export type {
|
||||
ContextSourceType,
|
||||
ContextPriority,
|
||||
ContextEntry,
|
||||
RegisterContextOptions,
|
||||
PendingContext,
|
||||
MessageContext,
|
||||
OutputParts,
|
||||
InjectionStrategy,
|
||||
} from "./types"
|
||||
292
src/features/context-injector/injector.test.ts
Normal file
292
src/features/context-injector/injector.test.ts
Normal file
@@ -0,0 +1,292 @@
|
||||
import { describe, it, expect, beforeEach } from "bun:test"
|
||||
import { ContextCollector } from "./collector"
|
||||
import {
|
||||
injectPendingContext,
|
||||
createContextInjectorHook,
|
||||
createContextInjectorMessagesTransformHook,
|
||||
} from "./injector"
|
||||
|
||||
describe("injectPendingContext", () => {
|
||||
let collector: ContextCollector
|
||||
|
||||
beforeEach(() => {
|
||||
collector = new ContextCollector()
|
||||
})
|
||||
|
||||
describe("when parts have text content", () => {
|
||||
it("prepends context to first text part", () => {
|
||||
// #given
|
||||
const sessionID = "ses_inject1"
|
||||
collector.register(sessionID, {
|
||||
id: "ulw",
|
||||
source: "keyword-detector",
|
||||
content: "Ultrawork mode activated",
|
||||
})
|
||||
const parts = [{ type: "text", text: "User message" }]
|
||||
|
||||
// #when
|
||||
const result = injectPendingContext(collector, sessionID, parts)
|
||||
|
||||
// #then
|
||||
expect(result.injected).toBe(true)
|
||||
expect(parts[0].text).toContain("Ultrawork mode activated")
|
||||
expect(parts[0].text).toContain("User message")
|
||||
})
|
||||
|
||||
it("uses separator between context and original message", () => {
|
||||
// #given
|
||||
const sessionID = "ses_inject2"
|
||||
collector.register(sessionID, {
|
||||
id: "ctx",
|
||||
source: "keyword-detector",
|
||||
content: "Context content",
|
||||
})
|
||||
const parts = [{ type: "text", text: "Original message" }]
|
||||
|
||||
// #when
|
||||
injectPendingContext(collector, sessionID, parts)
|
||||
|
||||
// #then
|
||||
expect(parts[0].text).toBe("Context content\n\n---\n\nOriginal message")
|
||||
})
|
||||
|
||||
it("consumes context after injection", () => {
|
||||
// #given
|
||||
const sessionID = "ses_inject3"
|
||||
collector.register(sessionID, {
|
||||
id: "ctx",
|
||||
source: "keyword-detector",
|
||||
content: "Context",
|
||||
})
|
||||
const parts = [{ type: "text", text: "Message" }]
|
||||
|
||||
// #when
|
||||
injectPendingContext(collector, sessionID, parts)
|
||||
|
||||
// #then
|
||||
expect(collector.hasPending(sessionID)).toBe(false)
|
||||
})
|
||||
|
||||
it("returns injected=false when no pending context", () => {
|
||||
// #given
|
||||
const sessionID = "ses_empty"
|
||||
const parts = [{ type: "text", text: "Message" }]
|
||||
|
||||
// #when
|
||||
const result = injectPendingContext(collector, sessionID, parts)
|
||||
|
||||
// #then
|
||||
expect(result.injected).toBe(false)
|
||||
expect(parts[0].text).toBe("Message")
|
||||
})
|
||||
})
|
||||
|
||||
describe("when parts have no text content", () => {
|
||||
it("does not inject and preserves context", () => {
|
||||
// #given
|
||||
const sessionID = "ses_notext"
|
||||
collector.register(sessionID, {
|
||||
id: "ctx",
|
||||
source: "keyword-detector",
|
||||
content: "Context",
|
||||
})
|
||||
const parts = [{ type: "image", url: "https://example.com/img.png" }]
|
||||
|
||||
// #when
|
||||
const result = injectPendingContext(collector, sessionID, parts)
|
||||
|
||||
// #then
|
||||
expect(result.injected).toBe(false)
|
||||
expect(collector.hasPending(sessionID)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("with multiple text parts", () => {
|
||||
it("injects into first text part only", () => {
|
||||
// #given
|
||||
const sessionID = "ses_multi"
|
||||
collector.register(sessionID, {
|
||||
id: "ctx",
|
||||
source: "keyword-detector",
|
||||
content: "Context",
|
||||
})
|
||||
const parts = [
|
||||
{ type: "text", text: "First" },
|
||||
{ type: "text", text: "Second" },
|
||||
]
|
||||
|
||||
// #when
|
||||
injectPendingContext(collector, sessionID, parts)
|
||||
|
||||
// #then
|
||||
expect(parts[0].text).toContain("Context")
|
||||
expect(parts[1].text).toBe("Second")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("createContextInjectorHook", () => {
|
||||
let collector: ContextCollector
|
||||
|
||||
beforeEach(() => {
|
||||
collector = new ContextCollector()
|
||||
})
|
||||
|
||||
describe("chat.message handler", () => {
|
||||
it("is a no-op (context injection moved to messages transform)", async () => {
|
||||
// #given
|
||||
const hook = createContextInjectorHook(collector)
|
||||
const sessionID = "ses_hook1"
|
||||
collector.register(sessionID, {
|
||||
id: "ctx",
|
||||
source: "keyword-detector",
|
||||
content: "Hook context",
|
||||
})
|
||||
const input = { sessionID }
|
||||
const output = {
|
||||
message: {},
|
||||
parts: [{ type: "text", text: "User message" }],
|
||||
}
|
||||
|
||||
// #when
|
||||
await hook["chat.message"](input, output)
|
||||
|
||||
// #then
|
||||
expect(output.parts[0].text).toBe("User message")
|
||||
expect(collector.hasPending(sessionID)).toBe(true)
|
||||
})
|
||||
|
||||
it("does nothing when no pending context", async () => {
|
||||
// #given
|
||||
const hook = createContextInjectorHook(collector)
|
||||
const sessionID = "ses_hook2"
|
||||
const input = { sessionID }
|
||||
const output = {
|
||||
message: {},
|
||||
parts: [{ type: "text", text: "User message" }],
|
||||
}
|
||||
|
||||
// #when
|
||||
await hook["chat.message"](input, output)
|
||||
|
||||
// #then
|
||||
expect(output.parts[0].text).toBe("User message")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("createContextInjectorMessagesTransformHook", () => {
|
||||
let collector: ContextCollector
|
||||
|
||||
beforeEach(() => {
|
||||
collector = new ContextCollector()
|
||||
})
|
||||
|
||||
const createMockMessage = (
|
||||
role: "user" | "assistant",
|
||||
text: string,
|
||||
sessionID: string
|
||||
) => ({
|
||||
info: {
|
||||
id: `msg_${Date.now()}_${Math.random()}`,
|
||||
sessionID,
|
||||
role,
|
||||
time: { created: Date.now() },
|
||||
agent: "Sisyphus",
|
||||
model: { providerID: "test", modelID: "test" },
|
||||
path: { cwd: "/", root: "/" },
|
||||
},
|
||||
parts: [
|
||||
{
|
||||
id: `part_${Date.now()}`,
|
||||
sessionID,
|
||||
messageID: `msg_${Date.now()}`,
|
||||
type: "text" as const,
|
||||
text,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
it("inserts synthetic message before last user message", async () => {
|
||||
// #given
|
||||
const hook = createContextInjectorMessagesTransformHook(collector)
|
||||
const sessionID = "ses_transform1"
|
||||
collector.register(sessionID, {
|
||||
id: "ulw",
|
||||
source: "keyword-detector",
|
||||
content: "Ultrawork context",
|
||||
})
|
||||
const messages = [
|
||||
createMockMessage("user", "First message", sessionID),
|
||||
createMockMessage("assistant", "Response", sessionID),
|
||||
createMockMessage("user", "Second message", sessionID),
|
||||
]
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const output = { messages } as any
|
||||
|
||||
// #when
|
||||
await hook["experimental.chat.messages.transform"]!({}, output)
|
||||
|
||||
// #then
|
||||
expect(output.messages.length).toBe(4)
|
||||
expect(output.messages[2].parts[0].text).toBe("Ultrawork context")
|
||||
expect(output.messages[2].parts[0].synthetic).toBe(true)
|
||||
expect(output.messages[3].parts[0].text).toBe("Second message")
|
||||
})
|
||||
|
||||
it("does nothing when no pending context", async () => {
|
||||
// #given
|
||||
const hook = createContextInjectorMessagesTransformHook(collector)
|
||||
const sessionID = "ses_transform2"
|
||||
const messages = [createMockMessage("user", "Hello world", sessionID)]
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const output = { messages } as any
|
||||
|
||||
// #when
|
||||
await hook["experimental.chat.messages.transform"]!({}, output)
|
||||
|
||||
// #then
|
||||
expect(output.messages.length).toBe(1)
|
||||
})
|
||||
|
||||
it("does nothing when no user messages", async () => {
|
||||
// #given
|
||||
const hook = createContextInjectorMessagesTransformHook(collector)
|
||||
const sessionID = "ses_transform3"
|
||||
collector.register(sessionID, {
|
||||
id: "ctx",
|
||||
source: "keyword-detector",
|
||||
content: "Context",
|
||||
})
|
||||
const messages = [createMockMessage("assistant", "Response", sessionID)]
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const output = { messages } as any
|
||||
|
||||
// #when
|
||||
await hook["experimental.chat.messages.transform"]!({}, output)
|
||||
|
||||
// #then
|
||||
expect(output.messages.length).toBe(1)
|
||||
expect(collector.hasPending(sessionID)).toBe(true)
|
||||
})
|
||||
|
||||
it("consumes context after injection", async () => {
|
||||
// #given
|
||||
const hook = createContextInjectorMessagesTransformHook(collector)
|
||||
const sessionID = "ses_transform4"
|
||||
collector.register(sessionID, {
|
||||
id: "ctx",
|
||||
source: "keyword-detector",
|
||||
content: "Context",
|
||||
})
|
||||
const messages = [createMockMessage("user", "Message", sessionID)]
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const output = { messages } as any
|
||||
|
||||
// #when
|
||||
await hook["experimental.chat.messages.transform"]!({}, output)
|
||||
|
||||
// #then
|
||||
expect(collector.hasPending(sessionID)).toBe(false)
|
||||
})
|
||||
})
|
||||
156
src/features/context-injector/injector.ts
Normal file
156
src/features/context-injector/injector.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import type { ContextCollector } from "./collector"
|
||||
import type { Message, Part } from "@opencode-ai/sdk"
|
||||
import { log } from "../../shared"
|
||||
|
||||
interface OutputPart {
|
||||
type: string
|
||||
text?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
interface InjectionResult {
|
||||
injected: boolean
|
||||
contextLength: number
|
||||
}
|
||||
|
||||
export function injectPendingContext(
|
||||
collector: ContextCollector,
|
||||
sessionID: string,
|
||||
parts: OutputPart[]
|
||||
): InjectionResult {
|
||||
if (!collector.hasPending(sessionID)) {
|
||||
return { injected: false, contextLength: 0 }
|
||||
}
|
||||
|
||||
const textPartIndex = parts.findIndex((p) => p.type === "text" && p.text !== undefined)
|
||||
if (textPartIndex === -1) {
|
||||
return { injected: false, contextLength: 0 }
|
||||
}
|
||||
|
||||
const pending = collector.consume(sessionID)
|
||||
const originalText = parts[textPartIndex].text ?? ""
|
||||
parts[textPartIndex].text = `${pending.merged}\n\n---\n\n${originalText}`
|
||||
|
||||
return {
|
||||
injected: true,
|
||||
contextLength: pending.merged.length,
|
||||
}
|
||||
}
|
||||
|
||||
interface ChatMessageInput {
|
||||
sessionID: string
|
||||
agent?: string
|
||||
model?: { providerID: string; modelID: string }
|
||||
messageID?: string
|
||||
}
|
||||
|
||||
interface ChatMessageOutput {
|
||||
message: Record<string, unknown>
|
||||
parts: OutputPart[]
|
||||
}
|
||||
|
||||
export function createContextInjectorHook(collector: ContextCollector) {
|
||||
return {
|
||||
"chat.message": async (
|
||||
_input: ChatMessageInput,
|
||||
_output: ChatMessageOutput
|
||||
): Promise<void> => {
|
||||
void collector
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
interface MessageWithParts {
|
||||
info: Message
|
||||
parts: Part[]
|
||||
}
|
||||
|
||||
type MessagesTransformHook = {
|
||||
"experimental.chat.messages.transform"?: (
|
||||
input: Record<string, never>,
|
||||
output: { messages: MessageWithParts[] }
|
||||
) => Promise<void>
|
||||
}
|
||||
|
||||
export function createContextInjectorMessagesTransformHook(
|
||||
collector: ContextCollector
|
||||
): MessagesTransformHook {
|
||||
return {
|
||||
"experimental.chat.messages.transform": async (_input, output) => {
|
||||
const { messages } = output
|
||||
if (messages.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
let lastUserMessageIndex = -1
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
if (messages[i].info.role === "user") {
|
||||
lastUserMessageIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (lastUserMessageIndex === -1) {
|
||||
return
|
||||
}
|
||||
|
||||
const lastUserMessage = messages[lastUserMessageIndex]
|
||||
const sessionID = (lastUserMessage.info as unknown as { sessionID?: string }).sessionID
|
||||
if (!sessionID) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!collector.hasPending(sessionID)) {
|
||||
return
|
||||
}
|
||||
|
||||
const pending = collector.consume(sessionID)
|
||||
if (!pending.hasContent) {
|
||||
return
|
||||
}
|
||||
|
||||
const refInfo = lastUserMessage.info as unknown as {
|
||||
sessionID?: string
|
||||
agent?: string
|
||||
model?: { providerID?: string; modelID?: string }
|
||||
path?: { cwd?: string; root?: string }
|
||||
}
|
||||
|
||||
const syntheticMessageId = `synthetic_ctx_${Date.now()}`
|
||||
const syntheticPartId = `synthetic_ctx_part_${Date.now()}`
|
||||
const now = Date.now()
|
||||
|
||||
const syntheticMessage: MessageWithParts = {
|
||||
info: {
|
||||
id: syntheticMessageId,
|
||||
sessionID: sessionID,
|
||||
role: "user",
|
||||
time: { created: now },
|
||||
agent: refInfo.agent ?? "Sisyphus",
|
||||
model: refInfo.model ?? { providerID: "unknown", modelID: "unknown" },
|
||||
path: refInfo.path ?? { cwd: "/", root: "/" },
|
||||
} as unknown as Message,
|
||||
parts: [
|
||||
{
|
||||
id: syntheticPartId,
|
||||
sessionID: sessionID,
|
||||
messageID: syntheticMessageId,
|
||||
type: "text",
|
||||
text: pending.merged,
|
||||
synthetic: true,
|
||||
time: { start: now, end: now },
|
||||
} as Part,
|
||||
],
|
||||
}
|
||||
|
||||
messages.splice(lastUserMessageIndex, 0, syntheticMessage)
|
||||
|
||||
log("[context-injector] Injected synthetic message from collector", {
|
||||
sessionID,
|
||||
insertIndex: lastUserMessageIndex,
|
||||
contextLength: pending.merged.length,
|
||||
newMessageCount: messages.length,
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
91
src/features/context-injector/types.ts
Normal file
91
src/features/context-injector/types.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Source identifier for context injection
|
||||
* Each source registers context that will be merged and injected together
|
||||
*/
|
||||
export type ContextSourceType =
|
||||
| "keyword-detector"
|
||||
| "rules-injector"
|
||||
| "directory-agents"
|
||||
| "directory-readme"
|
||||
| "custom"
|
||||
|
||||
/**
|
||||
* Priority levels for context ordering
|
||||
* Higher priority contexts appear first in the merged output
|
||||
*/
|
||||
export type ContextPriority = "critical" | "high" | "normal" | "low"
|
||||
|
||||
/**
|
||||
* A single context entry registered by a source
|
||||
*/
|
||||
export interface ContextEntry {
|
||||
/** Unique identifier for this entry within the source */
|
||||
id: string
|
||||
/** The source that registered this context */
|
||||
source: ContextSourceType
|
||||
/** The actual context content to inject */
|
||||
content: string
|
||||
/** Priority for ordering (default: normal) */
|
||||
priority: ContextPriority
|
||||
/** Timestamp when registered */
|
||||
timestamp: number
|
||||
/** Optional metadata for debugging/logging */
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for registering context
|
||||
*/
|
||||
export interface RegisterContextOptions {
|
||||
/** Unique ID for this context entry (used for deduplication) */
|
||||
id: string
|
||||
/** Source identifier */
|
||||
source: ContextSourceType
|
||||
/** The content to inject */
|
||||
content: string
|
||||
/** Priority for ordering (default: normal) */
|
||||
priority?: ContextPriority
|
||||
/** Optional metadata */
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of getting pending context for a session
|
||||
*/
|
||||
export interface PendingContext {
|
||||
/** Merged context string, ready for injection */
|
||||
merged: string
|
||||
/** Individual entries that were merged */
|
||||
entries: ContextEntry[]
|
||||
/** Whether there's any content to inject */
|
||||
hasContent: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Message context from the original user message
|
||||
* Used when injecting to match the message format
|
||||
*/
|
||||
export interface MessageContext {
|
||||
agent?: string
|
||||
model?: {
|
||||
providerID?: string
|
||||
modelID?: string
|
||||
}
|
||||
path?: {
|
||||
cwd?: string
|
||||
root?: string
|
||||
}
|
||||
tools?: Record<string, boolean>
|
||||
}
|
||||
|
||||
/**
|
||||
* Output parts from chat.message hook
|
||||
*/
|
||||
export interface OutputParts {
|
||||
parts: Array<{ type: string; text?: string; [key: string]: unknown }>
|
||||
}
|
||||
|
||||
/**
|
||||
* Injection strategy
|
||||
*/
|
||||
export type InjectionStrategy = "prepend-parts" | "storage" | "auto"
|
||||
448
src/features/opencode-skill-loader/async-loader.test.ts
Normal file
448
src/features/opencode-skill-loader/async-loader.test.ts
Normal file
@@ -0,0 +1,448 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "bun:test"
|
||||
import { mkdirSync, writeFileSync, rmSync, chmodSync } from "fs"
|
||||
import { join } from "path"
|
||||
import { tmpdir } from "os"
|
||||
import type { LoadedSkill } from "./types"
|
||||
|
||||
const TEST_DIR = join(tmpdir(), "async-loader-test-" + Date.now())
|
||||
const SKILLS_DIR = join(TEST_DIR, ".opencode", "skill")
|
||||
|
||||
function createTestSkill(name: string, content: string, mcpJson?: object): string {
|
||||
const skillDir = join(SKILLS_DIR, name)
|
||||
mkdirSync(skillDir, { recursive: true })
|
||||
const skillPath = join(skillDir, "SKILL.md")
|
||||
writeFileSync(skillPath, content)
|
||||
if (mcpJson) {
|
||||
writeFileSync(join(skillDir, "mcp.json"), JSON.stringify(mcpJson, null, 2))
|
||||
}
|
||||
return skillDir
|
||||
}
|
||||
|
||||
function createDirectSkill(name: string, content: string): string {
|
||||
mkdirSync(SKILLS_DIR, { recursive: true })
|
||||
const skillPath = join(SKILLS_DIR, `${name}.md`)
|
||||
writeFileSync(skillPath, content)
|
||||
return skillPath
|
||||
}
|
||||
|
||||
describe("async-loader", () => {
|
||||
beforeEach(() => {
|
||||
mkdirSync(TEST_DIR, { recursive: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(TEST_DIR, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe("discoverSkillsInDirAsync", () => {
|
||||
it("returns empty array for non-existent directory", async () => {
|
||||
// #given - non-existent directory
|
||||
const nonExistentDir = join(TEST_DIR, "does-not-exist")
|
||||
|
||||
// #when
|
||||
const { discoverSkillsInDirAsync } = await import("./async-loader")
|
||||
const skills = await discoverSkillsInDirAsync(nonExistentDir)
|
||||
|
||||
// #then - should return empty array, not throw
|
||||
expect(skills).toEqual([])
|
||||
})
|
||||
|
||||
it("discovers skills from SKILL.md in directory", async () => {
|
||||
// #given
|
||||
const skillContent = `---
|
||||
name: test-skill
|
||||
description: A test skill
|
||||
---
|
||||
This is the skill body.
|
||||
`
|
||||
createTestSkill("test-skill", skillContent)
|
||||
|
||||
// #when
|
||||
const { discoverSkillsInDirAsync } = await import("./async-loader")
|
||||
const skills = await discoverSkillsInDirAsync(SKILLS_DIR)
|
||||
|
||||
// #then
|
||||
expect(skills).toHaveLength(1)
|
||||
expect(skills[0].name).toBe("test-skill")
|
||||
expect(skills[0].definition.description).toContain("A test skill")
|
||||
})
|
||||
|
||||
it("discovers skills from {name}.md pattern in directory", async () => {
|
||||
// #given
|
||||
const skillContent = `---
|
||||
name: named-skill
|
||||
description: Named pattern skill
|
||||
---
|
||||
Skill body.
|
||||
`
|
||||
const skillDir = join(SKILLS_DIR, "named-skill")
|
||||
mkdirSync(skillDir, { recursive: true })
|
||||
writeFileSync(join(skillDir, "named-skill.md"), skillContent)
|
||||
|
||||
// #when
|
||||
const { discoverSkillsInDirAsync } = await import("./async-loader")
|
||||
const skills = await discoverSkillsInDirAsync(SKILLS_DIR)
|
||||
|
||||
// #then
|
||||
expect(skills).toHaveLength(1)
|
||||
expect(skills[0].name).toBe("named-skill")
|
||||
})
|
||||
|
||||
it("discovers direct .md files", async () => {
|
||||
// #given
|
||||
const skillContent = `---
|
||||
name: direct-skill
|
||||
description: Direct markdown file
|
||||
---
|
||||
Direct skill.
|
||||
`
|
||||
createDirectSkill("direct-skill", skillContent)
|
||||
|
||||
// #when
|
||||
const { discoverSkillsInDirAsync } = await import("./async-loader")
|
||||
const skills = await discoverSkillsInDirAsync(SKILLS_DIR)
|
||||
|
||||
// #then
|
||||
expect(skills).toHaveLength(1)
|
||||
expect(skills[0].name).toBe("direct-skill")
|
||||
})
|
||||
|
||||
it("skips entries starting with dot", async () => {
|
||||
// #given
|
||||
const validContent = `---
|
||||
name: valid-skill
|
||||
---
|
||||
Valid.
|
||||
`
|
||||
const hiddenContent = `---
|
||||
name: hidden-skill
|
||||
---
|
||||
Hidden.
|
||||
`
|
||||
createTestSkill("valid-skill", validContent)
|
||||
createTestSkill(".hidden-skill", hiddenContent)
|
||||
|
||||
// #when
|
||||
const { discoverSkillsInDirAsync } = await import("./async-loader")
|
||||
const skills = await discoverSkillsInDirAsync(SKILLS_DIR)
|
||||
|
||||
// #then - only valid-skill should be discovered
|
||||
expect(skills).toHaveLength(1)
|
||||
expect(skills[0]?.name).toBe("valid-skill")
|
||||
})
|
||||
|
||||
it("skips invalid files and continues with valid ones", async () => {
|
||||
// #given - one valid, one invalid (unreadable)
|
||||
const validContent = `---
|
||||
name: valid-skill
|
||||
---
|
||||
Valid skill.
|
||||
`
|
||||
const invalidContent = `---
|
||||
name: invalid-skill
|
||||
---
|
||||
Invalid skill.
|
||||
`
|
||||
createTestSkill("valid-skill", validContent)
|
||||
const invalidDir = createTestSkill("invalid-skill", invalidContent)
|
||||
const invalidFile = join(invalidDir, "SKILL.md")
|
||||
|
||||
// Make file unreadable on Unix systems
|
||||
if (process.platform !== "win32") {
|
||||
chmodSync(invalidFile, 0o000)
|
||||
}
|
||||
|
||||
// #when
|
||||
const { discoverSkillsInDirAsync } = await import("./async-loader")
|
||||
const skills = await discoverSkillsInDirAsync(SKILLS_DIR)
|
||||
|
||||
// #then - should skip invalid and return only valid
|
||||
expect(skills.length).toBeGreaterThanOrEqual(1)
|
||||
expect(skills.some((s: LoadedSkill) => s.name === "valid-skill")).toBe(true)
|
||||
|
||||
// Cleanup: restore permissions before cleanup
|
||||
if (process.platform !== "win32") {
|
||||
chmodSync(invalidFile, 0o644)
|
||||
}
|
||||
})
|
||||
|
||||
it("discovers multiple skills correctly", async () => {
|
||||
// #given
|
||||
const skill1 = `---
|
||||
name: skill-one
|
||||
description: First skill
|
||||
---
|
||||
Skill one.
|
||||
`
|
||||
const skill2 = `---
|
||||
name: skill-two
|
||||
description: Second skill
|
||||
---
|
||||
Skill two.
|
||||
`
|
||||
createTestSkill("skill-one", skill1)
|
||||
createTestSkill("skill-two", skill2)
|
||||
|
||||
// #when
|
||||
const { discoverSkillsInDirAsync } = await import("./async-loader")
|
||||
const asyncSkills = await discoverSkillsInDirAsync(SKILLS_DIR)
|
||||
|
||||
// #then
|
||||
expect(asyncSkills.length).toBe(2)
|
||||
expect(asyncSkills.map((s: LoadedSkill) => s.name).sort()).toEqual(["skill-one", "skill-two"])
|
||||
|
||||
const skill1Result = asyncSkills.find((s: LoadedSkill) => s.name === "skill-one")
|
||||
expect(skill1Result?.definition.description).toContain("First skill")
|
||||
})
|
||||
|
||||
it("loads MCP config from frontmatter", async () => {
|
||||
// #given
|
||||
const skillContent = `---
|
||||
name: mcp-skill
|
||||
description: Skill with MCP
|
||||
mcp:
|
||||
sqlite:
|
||||
command: uvx
|
||||
args: [mcp-server-sqlite]
|
||||
---
|
||||
MCP skill.
|
||||
`
|
||||
createTestSkill("mcp-skill", skillContent)
|
||||
|
||||
// #when
|
||||
const { discoverSkillsInDirAsync } = await import("./async-loader")
|
||||
const skills = await discoverSkillsInDirAsync(SKILLS_DIR)
|
||||
|
||||
// #then
|
||||
const skill = skills.find((s: LoadedSkill) => s.name === "mcp-skill")
|
||||
expect(skill?.mcpConfig).toBeDefined()
|
||||
expect(skill?.mcpConfig?.sqlite).toBeDefined()
|
||||
expect(skill?.mcpConfig?.sqlite?.command).toBe("uvx")
|
||||
})
|
||||
|
||||
it("loads MCP config from mcp.json file", async () => {
|
||||
// #given
|
||||
const skillContent = `---
|
||||
name: json-mcp-skill
|
||||
description: Skill with mcp.json
|
||||
---
|
||||
Skill body.
|
||||
`
|
||||
const mcpJson = {
|
||||
mcpServers: {
|
||||
playwright: {
|
||||
command: "npx",
|
||||
args: ["@playwright/mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
createTestSkill("json-mcp-skill", skillContent, mcpJson)
|
||||
|
||||
// #when
|
||||
const { discoverSkillsInDirAsync } = await import("./async-loader")
|
||||
const skills = await discoverSkillsInDirAsync(SKILLS_DIR)
|
||||
|
||||
// #then
|
||||
const skill = skills.find((s: LoadedSkill) => s.name === "json-mcp-skill")
|
||||
expect(skill?.mcpConfig?.playwright).toBeDefined()
|
||||
expect(skill?.mcpConfig?.playwright?.command).toBe("npx")
|
||||
})
|
||||
|
||||
it("prioritizes mcp.json over frontmatter MCP", async () => {
|
||||
// #given
|
||||
const skillContent = `---
|
||||
name: priority-test
|
||||
mcp:
|
||||
from-yaml:
|
||||
command: yaml-cmd
|
||||
---
|
||||
Skill.
|
||||
`
|
||||
const mcpJson = {
|
||||
mcpServers: {
|
||||
"from-json": {
|
||||
command: "json-cmd"
|
||||
}
|
||||
}
|
||||
}
|
||||
createTestSkill("priority-test", skillContent, mcpJson)
|
||||
|
||||
// #when
|
||||
const { discoverSkillsInDirAsync } = await import("./async-loader")
|
||||
const skills = await discoverSkillsInDirAsync(SKILLS_DIR)
|
||||
|
||||
// #then - mcp.json should take priority
|
||||
const skill = skills.find((s: LoadedSkill) => s.name === "priority-test")
|
||||
expect(skill?.mcpConfig?.["from-json"]).toBeDefined()
|
||||
expect(skill?.mcpConfig?.["from-yaml"]).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("mapWithConcurrency", () => {
|
||||
it("processes items with concurrency limit", async () => {
|
||||
// #given
|
||||
const { mapWithConcurrency } = await import("./async-loader")
|
||||
const items = Array.from({ length: 50 }, (_, i) => i)
|
||||
let maxConcurrent = 0
|
||||
let currentConcurrent = 0
|
||||
|
||||
const mapper = async (item: number) => {
|
||||
currentConcurrent++
|
||||
maxConcurrent = Math.max(maxConcurrent, currentConcurrent)
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
currentConcurrent--
|
||||
return item * 2
|
||||
}
|
||||
|
||||
// #when
|
||||
const results = await mapWithConcurrency(items, mapper, 16)
|
||||
|
||||
// #then
|
||||
expect(results).toEqual(items.map(i => i * 2))
|
||||
expect(maxConcurrent).toBeLessThanOrEqual(16)
|
||||
expect(maxConcurrent).toBeGreaterThan(1) // Should actually run concurrently
|
||||
})
|
||||
|
||||
it("handles empty array", async () => {
|
||||
// #given
|
||||
const { mapWithConcurrency } = await import("./async-loader")
|
||||
|
||||
// #when
|
||||
const results = await mapWithConcurrency([], async (x: number) => x * 2, 16)
|
||||
|
||||
// #then
|
||||
expect(results).toEqual([])
|
||||
})
|
||||
|
||||
it("handles single item", async () => {
|
||||
// #given
|
||||
const { mapWithConcurrency } = await import("./async-loader")
|
||||
|
||||
// #when
|
||||
const results = await mapWithConcurrency([42], async (x: number) => x * 2, 16)
|
||||
|
||||
// #then
|
||||
expect(results).toEqual([84])
|
||||
})
|
||||
})
|
||||
|
||||
describe("loadSkillFromPathAsync", () => {
|
||||
it("loads skill from valid path", async () => {
|
||||
// #given
|
||||
const skillContent = `---
|
||||
name: path-skill
|
||||
description: Loaded from path
|
||||
---
|
||||
Path skill.
|
||||
`
|
||||
const skillDir = createTestSkill("path-skill", skillContent)
|
||||
const skillPath = join(skillDir, "SKILL.md")
|
||||
|
||||
// #when
|
||||
const { loadSkillFromPathAsync } = await import("./async-loader")
|
||||
const skill = await loadSkillFromPathAsync(skillPath, skillDir, "path-skill", "opencode-project")
|
||||
|
||||
// #then
|
||||
expect(skill).not.toBeNull()
|
||||
expect(skill?.name).toBe("path-skill")
|
||||
expect(skill?.scope).toBe("opencode-project")
|
||||
})
|
||||
|
||||
it("returns null for invalid path", async () => {
|
||||
// #given
|
||||
const invalidPath = join(TEST_DIR, "nonexistent.md")
|
||||
|
||||
// #when
|
||||
const { loadSkillFromPathAsync } = await import("./async-loader")
|
||||
const skill = await loadSkillFromPathAsync(invalidPath, TEST_DIR, "invalid", "opencode")
|
||||
|
||||
// #then
|
||||
expect(skill).toBeNull()
|
||||
})
|
||||
|
||||
it("returns null for malformed skill file", async () => {
|
||||
// #given
|
||||
const malformedContent = "This is not valid frontmatter content\nNo YAML here!"
|
||||
mkdirSync(SKILLS_DIR, { recursive: true })
|
||||
const malformedPath = join(SKILLS_DIR, "malformed.md")
|
||||
writeFileSync(malformedPath, malformedContent)
|
||||
|
||||
// #when
|
||||
const { loadSkillFromPathAsync } = await import("./async-loader")
|
||||
const skill = await loadSkillFromPathAsync(malformedPath, SKILLS_DIR, "malformed", "user")
|
||||
|
||||
// #then
|
||||
expect(skill).not.toBeNull() // parseFrontmatter handles missing frontmatter gracefully
|
||||
})
|
||||
})
|
||||
|
||||
describe("loadMcpJsonFromDirAsync", () => {
|
||||
it("loads mcp.json with mcpServers format", async () => {
|
||||
// #given
|
||||
mkdirSync(SKILLS_DIR, { recursive: true })
|
||||
const mcpJson = {
|
||||
mcpServers: {
|
||||
test: {
|
||||
command: "test-cmd",
|
||||
args: ["arg1"]
|
||||
}
|
||||
}
|
||||
}
|
||||
writeFileSync(join(SKILLS_DIR, "mcp.json"), JSON.stringify(mcpJson))
|
||||
|
||||
// #when
|
||||
const { loadMcpJsonFromDirAsync } = await import("./async-loader")
|
||||
const config = await loadMcpJsonFromDirAsync(SKILLS_DIR)
|
||||
|
||||
// #then
|
||||
expect(config).toBeDefined()
|
||||
expect(config?.test).toBeDefined()
|
||||
expect(config?.test?.command).toBe("test-cmd")
|
||||
})
|
||||
|
||||
it("returns undefined for non-existent mcp.json", async () => {
|
||||
// #given
|
||||
mkdirSync(SKILLS_DIR, { recursive: true })
|
||||
|
||||
// #when
|
||||
const { loadMcpJsonFromDirAsync } = await import("./async-loader")
|
||||
const config = await loadMcpJsonFromDirAsync(SKILLS_DIR)
|
||||
|
||||
// #then
|
||||
expect(config).toBeUndefined()
|
||||
})
|
||||
|
||||
it("returns undefined for invalid JSON", async () => {
|
||||
// #given
|
||||
mkdirSync(SKILLS_DIR, { recursive: true })
|
||||
writeFileSync(join(SKILLS_DIR, "mcp.json"), "{ invalid json }")
|
||||
|
||||
// #when
|
||||
const { loadMcpJsonFromDirAsync } = await import("./async-loader")
|
||||
const config = await loadMcpJsonFromDirAsync(SKILLS_DIR)
|
||||
|
||||
// #then
|
||||
expect(config).toBeUndefined()
|
||||
})
|
||||
|
||||
it("supports direct format without mcpServers", async () => {
|
||||
// #given
|
||||
mkdirSync(SKILLS_DIR, { recursive: true })
|
||||
const mcpJson = {
|
||||
direct: {
|
||||
command: "direct-cmd",
|
||||
args: ["arg"]
|
||||
}
|
||||
}
|
||||
writeFileSync(join(SKILLS_DIR, "mcp.json"), JSON.stringify(mcpJson))
|
||||
|
||||
// #when
|
||||
const { loadMcpJsonFromDirAsync } = await import("./async-loader")
|
||||
const config = await loadMcpJsonFromDirAsync(SKILLS_DIR)
|
||||
|
||||
// #then
|
||||
expect(config?.direct).toBeDefined()
|
||||
expect(config?.direct?.command).toBe("direct-cmd")
|
||||
})
|
||||
})
|
||||
})
|
||||
180
src/features/opencode-skill-loader/async-loader.ts
Normal file
180
src/features/opencode-skill-loader/async-loader.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { readFile, readdir } from "fs/promises"
|
||||
import type { Dirent } from "fs"
|
||||
import { join, basename } from "path"
|
||||
import yaml from "js-yaml"
|
||||
import { parseFrontmatter } from "../../shared/frontmatter"
|
||||
import { sanitizeModelField } from "../../shared/model-sanitizer"
|
||||
import { resolveSymlink, isMarkdownFile } from "../../shared/file-utils"
|
||||
import type { CommandDefinition } from "../claude-code-command-loader/types"
|
||||
import type { SkillScope, SkillMetadata, LoadedSkill } from "./types"
|
||||
import type { SkillMcpConfig } from "../skill-mcp-manager/types"
|
||||
|
||||
export async function mapWithConcurrency<T, R>(
|
||||
items: T[],
|
||||
mapper: (item: T) => Promise<R>,
|
||||
concurrency: number
|
||||
): Promise<R[]> {
|
||||
const results: R[] = new Array(items.length)
|
||||
let index = 0
|
||||
|
||||
const worker = async () => {
|
||||
while (index < items.length) {
|
||||
const currentIndex = index++
|
||||
results[currentIndex] = await mapper(items[currentIndex])
|
||||
}
|
||||
}
|
||||
|
||||
const workers = Array.from({ length: Math.min(concurrency, items.length) }, () => worker())
|
||||
await Promise.all(workers)
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
function parseSkillMcpConfigFromFrontmatter(content: string): SkillMcpConfig | undefined {
|
||||
const frontmatterMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/)
|
||||
if (!frontmatterMatch) return undefined
|
||||
|
||||
try {
|
||||
const parsed = yaml.load(frontmatterMatch[1]) as Record<string, unknown>
|
||||
if (parsed && typeof parsed === "object" && "mcp" in parsed && parsed.mcp) {
|
||||
return parsed.mcp as SkillMcpConfig
|
||||
}
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export async function loadMcpJsonFromDirAsync(skillDir: string): Promise<SkillMcpConfig | undefined> {
|
||||
const mcpJsonPath = join(skillDir, "mcp.json")
|
||||
|
||||
try {
|
||||
const content = await readFile(mcpJsonPath, "utf-8")
|
||||
const parsed = JSON.parse(content) as Record<string, unknown>
|
||||
|
||||
if (parsed && typeof parsed === "object" && "mcpServers" in parsed && parsed.mcpServers) {
|
||||
return parsed.mcpServers as SkillMcpConfig
|
||||
}
|
||||
|
||||
if (parsed && typeof parsed === "object" && !("mcpServers" in parsed)) {
|
||||
const hasCommandField = Object.values(parsed).some(
|
||||
(v) => v && typeof v === "object" && "command" in (v as Record<string, unknown>)
|
||||
)
|
||||
if (hasCommandField) {
|
||||
return parsed as SkillMcpConfig
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export async function loadSkillFromPathAsync(
|
||||
skillPath: string,
|
||||
resolvedPath: string,
|
||||
defaultName: string,
|
||||
scope: SkillScope
|
||||
): Promise<LoadedSkill | null> {
|
||||
try {
|
||||
const content = await readFile(skillPath, "utf-8")
|
||||
const { data, body, parseError } = parseFrontmatter<SkillMetadata>(content)
|
||||
if (parseError) return null
|
||||
|
||||
const frontmatterMcp = parseSkillMcpConfigFromFrontmatter(content)
|
||||
const mcpJsonMcp = await loadMcpJsonFromDirAsync(resolvedPath)
|
||||
const mcpConfig = mcpJsonMcp || frontmatterMcp
|
||||
|
||||
const skillName = data.name || defaultName
|
||||
const originalDescription = data.description || ""
|
||||
const isOpencodeSource = scope === "opencode" || scope === "opencode-project"
|
||||
const formattedDescription = `(${scope} - 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>`
|
||||
|
||||
const definition: CommandDefinition = {
|
||||
name: skillName,
|
||||
description: formattedDescription,
|
||||
template: wrappedTemplate,
|
||||
model: sanitizeModelField(data.model, isOpencodeSource ? "opencode" : "claude-code"),
|
||||
agent: data.agent,
|
||||
subtask: data.subtask,
|
||||
argumentHint: data["argument-hint"],
|
||||
}
|
||||
|
||||
return {
|
||||
name: skillName,
|
||||
path: skillPath,
|
||||
resolvedPath,
|
||||
definition,
|
||||
scope,
|
||||
license: data.license,
|
||||
compatibility: data.compatibility,
|
||||
metadata: data.metadata,
|
||||
allowedTools: parseAllowedTools(data["allowed-tools"]),
|
||||
mcpConfig,
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function parseAllowedTools(allowedTools: string | undefined): string[] | undefined {
|
||||
if (!allowedTools) return undefined
|
||||
return allowedTools.split(/\s+/).filter(Boolean)
|
||||
}
|
||||
|
||||
export async function discoverSkillsInDirAsync(skillsDir: string): Promise<LoadedSkill[]> {
|
||||
try {
|
||||
const entries = await readdir(skillsDir, { withFileTypes: true })
|
||||
|
||||
const processEntry = async (entry: Dirent): Promise<LoadedSkill | null> => {
|
||||
if (entry.name.startsWith(".")) return null
|
||||
|
||||
const entryPath = join(skillsDir, entry.name)
|
||||
|
||||
if (entry.isDirectory() || entry.isSymbolicLink()) {
|
||||
const resolvedPath = resolveSymlink(entryPath)
|
||||
const dirName = entry.name
|
||||
|
||||
const skillMdPath = join(resolvedPath, "SKILL.md")
|
||||
try {
|
||||
await readFile(skillMdPath, "utf-8")
|
||||
return await loadSkillFromPathAsync(skillMdPath, resolvedPath, dirName, "opencode-project")
|
||||
} catch {
|
||||
const namedSkillMdPath = join(resolvedPath, `${dirName}.md`)
|
||||
try {
|
||||
await readFile(namedSkillMdPath, "utf-8")
|
||||
return await loadSkillFromPathAsync(namedSkillMdPath, resolvedPath, dirName, "opencode-project")
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isMarkdownFile(entry)) {
|
||||
const skillName = basename(entry.name, ".md")
|
||||
return await loadSkillFromPathAsync(entryPath, skillsDir, skillName, "opencode-project")
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const skillPromises = await mapWithConcurrency(entries, processEntry, 16)
|
||||
return skillPromises.filter((skill): skill is LoadedSkill => skill !== null)
|
||||
} catch (error: unknown) {
|
||||
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
|
||||
return []
|
||||
}
|
||||
return []
|
||||
}
|
||||
}
|
||||
210
src/features/opencode-skill-loader/blocking.test.ts
Normal file
210
src/features/opencode-skill-loader/blocking.test.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "bun:test"
|
||||
import { mkdirSync, writeFileSync, rmSync } from "fs"
|
||||
import { join } from "path"
|
||||
import { tmpdir } from "os"
|
||||
import { discoverAllSkillsBlocking } from "./blocking"
|
||||
import type { SkillScope } from "./types"
|
||||
|
||||
const TEST_DIR = join(tmpdir(), `blocking-test-${Date.now()}`)
|
||||
|
||||
beforeEach(() => {
|
||||
mkdirSync(TEST_DIR, { recursive: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(TEST_DIR, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe("discoverAllSkillsBlocking", () => {
|
||||
it("returns skills synchronously from valid directories", () => {
|
||||
// #given valid skill directory
|
||||
const skillDir = join(TEST_DIR, "skills")
|
||||
mkdirSync(skillDir, { recursive: true })
|
||||
|
||||
const skillMdPath = join(skillDir, "test-skill.md")
|
||||
writeFileSync(
|
||||
skillMdPath,
|
||||
`---
|
||||
name: test-skill
|
||||
description: A test skill
|
||||
---
|
||||
This is test skill content.`
|
||||
)
|
||||
|
||||
const dirs = [skillDir]
|
||||
const scopes: SkillScope[] = ["opencode-project"]
|
||||
|
||||
// #when discoverAllSkillsBlocking called
|
||||
const skills = discoverAllSkillsBlocking(dirs, scopes)
|
||||
|
||||
// #then returns skills synchronously
|
||||
expect(skills).toBeArray()
|
||||
expect(skills.length).toBe(1)
|
||||
expect(skills[0].name).toBe("test-skill")
|
||||
expect(skills[0].definition.description).toContain("test skill")
|
||||
})
|
||||
|
||||
it("returns empty array for empty directories", () => {
|
||||
// #given empty directory
|
||||
const emptyDir = join(TEST_DIR, "empty")
|
||||
mkdirSync(emptyDir, { recursive: true })
|
||||
|
||||
const dirs = [emptyDir]
|
||||
const scopes: SkillScope[] = ["opencode-project"]
|
||||
|
||||
// #when discoverAllSkillsBlocking called
|
||||
const skills = discoverAllSkillsBlocking(dirs, scopes)
|
||||
|
||||
// #then returns empty array
|
||||
expect(skills).toBeArray()
|
||||
expect(skills.length).toBe(0)
|
||||
})
|
||||
|
||||
it("returns empty array for non-existent directories", () => {
|
||||
// #given non-existent directory
|
||||
const nonExistentDir = join(TEST_DIR, "does-not-exist")
|
||||
|
||||
const dirs = [nonExistentDir]
|
||||
const scopes: SkillScope[] = ["opencode-project"]
|
||||
|
||||
// #when discoverAllSkillsBlocking called
|
||||
const skills = discoverAllSkillsBlocking(dirs, scopes)
|
||||
|
||||
// #then returns empty array (no throw)
|
||||
expect(skills).toBeArray()
|
||||
expect(skills.length).toBe(0)
|
||||
})
|
||||
|
||||
it("handles multiple directories with mixed content", () => {
|
||||
// #given multiple directories with valid and invalid skills
|
||||
const dir1 = join(TEST_DIR, "dir1")
|
||||
const dir2 = join(TEST_DIR, "dir2")
|
||||
mkdirSync(dir1, { recursive: true })
|
||||
mkdirSync(dir2, { recursive: true })
|
||||
|
||||
writeFileSync(
|
||||
join(dir1, "skill1.md"),
|
||||
`---
|
||||
name: skill1
|
||||
description: First skill
|
||||
---
|
||||
Skill 1 content.`
|
||||
)
|
||||
|
||||
writeFileSync(
|
||||
join(dir2, "skill2.md"),
|
||||
`---
|
||||
name: skill2
|
||||
description: Second skill
|
||||
---
|
||||
Skill 2 content.`
|
||||
)
|
||||
|
||||
const dirs = [dir1, dir2]
|
||||
const scopes: SkillScope[] = ["opencode-project"]
|
||||
|
||||
// #when discoverAllSkillsBlocking called
|
||||
const skills = discoverAllSkillsBlocking(dirs, scopes)
|
||||
|
||||
// #then returns all valid skills
|
||||
expect(skills).toBeArray()
|
||||
expect(skills.length).toBe(2)
|
||||
|
||||
const skillNames = skills.map(s => s.name).sort()
|
||||
expect(skillNames).toEqual(["skill1", "skill2"])
|
||||
})
|
||||
|
||||
it("skips invalid YAML files", () => {
|
||||
// #given directory with invalid YAML
|
||||
const skillDir = join(TEST_DIR, "skills")
|
||||
mkdirSync(skillDir, { recursive: true })
|
||||
|
||||
const validSkillPath = join(skillDir, "valid.md")
|
||||
writeFileSync(
|
||||
validSkillPath,
|
||||
`---
|
||||
name: valid-skill
|
||||
description: Valid skill
|
||||
---
|
||||
Valid skill content.`
|
||||
)
|
||||
|
||||
const invalidSkillPath = join(skillDir, "invalid.md")
|
||||
writeFileSync(
|
||||
invalidSkillPath,
|
||||
`---
|
||||
name: invalid skill
|
||||
description: [ invalid yaml
|
||||
---
|
||||
Invalid content.`
|
||||
)
|
||||
|
||||
const dirs = [skillDir]
|
||||
const scopes: SkillScope[] = ["opencode-project"]
|
||||
|
||||
// #when discoverAllSkillsBlocking called
|
||||
const skills = discoverAllSkillsBlocking(dirs, scopes)
|
||||
|
||||
// #then skips invalid, returns valid
|
||||
expect(skills).toBeArray()
|
||||
expect(skills.length).toBe(1)
|
||||
expect(skills[0].name).toBe("valid-skill")
|
||||
})
|
||||
|
||||
it("handles directory-based skills with SKILL.md", () => {
|
||||
// #given directory-based skill structure
|
||||
const skillsDir = join(TEST_DIR, "skills")
|
||||
const mySkillDir = join(skillsDir, "my-skill")
|
||||
mkdirSync(mySkillDir, { recursive: true })
|
||||
|
||||
const skillMdPath = join(mySkillDir, "SKILL.md")
|
||||
writeFileSync(
|
||||
skillMdPath,
|
||||
`---
|
||||
name: my-skill
|
||||
description: Directory-based skill
|
||||
---
|
||||
This is a directory-based skill.`
|
||||
)
|
||||
|
||||
const dirs = [skillsDir]
|
||||
const scopes: SkillScope[] = ["opencode-project"]
|
||||
|
||||
// #when discoverAllSkillsBlocking called
|
||||
const skills = discoverAllSkillsBlocking(dirs, scopes)
|
||||
|
||||
// #then returns skill from SKILL.md
|
||||
expect(skills).toBeArray()
|
||||
expect(skills.length).toBe(1)
|
||||
expect(skills[0].name).toBe("my-skill")
|
||||
})
|
||||
|
||||
it("processes large skill sets without timeout", () => {
|
||||
// #given directory with many skills (20+)
|
||||
const skillDir = join(TEST_DIR, "many-skills")
|
||||
mkdirSync(skillDir, { recursive: true })
|
||||
|
||||
const skillCount = 25
|
||||
for (let i = 0; i < skillCount; i++) {
|
||||
const skillPath = join(skillDir, `skill-${i}.md`)
|
||||
writeFileSync(
|
||||
skillPath,
|
||||
`---
|
||||
name: skill-${i}
|
||||
description: Skill number ${i}
|
||||
---
|
||||
Content for skill ${i}.`
|
||||
)
|
||||
}
|
||||
|
||||
const dirs = [skillDir]
|
||||
const scopes: SkillScope[] = ["opencode-project"]
|
||||
|
||||
// #when discoverAllSkillsBlocking called
|
||||
const skills = discoverAllSkillsBlocking(dirs, scopes)
|
||||
|
||||
// #then completes without timeout
|
||||
expect(skills).toBeArray()
|
||||
expect(skills.length).toBe(skillCount)
|
||||
})
|
||||
})
|
||||
62
src/features/opencode-skill-loader/blocking.ts
Normal file
62
src/features/opencode-skill-loader/blocking.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { Worker, MessageChannel, receiveMessageOnPort } from "worker_threads"
|
||||
import type { LoadedSkill, SkillScope } from "./types"
|
||||
|
||||
interface WorkerInput {
|
||||
dirs: string[]
|
||||
scopes: SkillScope[]
|
||||
}
|
||||
|
||||
interface WorkerOutputSuccess {
|
||||
ok: true
|
||||
skills: LoadedSkill[]
|
||||
}
|
||||
|
||||
interface WorkerOutputError {
|
||||
ok: false
|
||||
error: { message: string; stack?: string }
|
||||
}
|
||||
|
||||
type WorkerOutput = WorkerOutputSuccess | WorkerOutputError
|
||||
|
||||
const TIMEOUT_MS = 30000
|
||||
|
||||
export function discoverAllSkillsBlocking(dirs: string[], scopes: SkillScope[]): LoadedSkill[] {
|
||||
const signal = new Int32Array(new SharedArrayBuffer(4))
|
||||
const { port1, port2 } = new MessageChannel()
|
||||
|
||||
const worker = new Worker(new URL("./discover-worker.ts", import.meta.url), {
|
||||
workerData: { signal }
|
||||
})
|
||||
|
||||
worker.postMessage({ port: port2 }, [port2])
|
||||
|
||||
const input: WorkerInput = { dirs, scopes }
|
||||
port1.postMessage(input)
|
||||
|
||||
const waitResult = Atomics.wait(signal, 0, 0, TIMEOUT_MS)
|
||||
|
||||
if (waitResult === "timed-out") {
|
||||
worker.terminate()
|
||||
port1.close()
|
||||
throw new Error(`Worker timeout after ${TIMEOUT_MS}ms`)
|
||||
}
|
||||
|
||||
const message = receiveMessageOnPort(port1)
|
||||
|
||||
worker.terminate()
|
||||
port1.close()
|
||||
|
||||
if (!message) {
|
||||
throw new Error("Worker did not return result")
|
||||
}
|
||||
|
||||
const output = message.message as WorkerOutput
|
||||
|
||||
if (output.ok === false) {
|
||||
const error = new Error(output.error.message)
|
||||
error.stack = output.error.stack
|
||||
throw error
|
||||
}
|
||||
|
||||
return output.skills
|
||||
}
|
||||
59
src/features/opencode-skill-loader/discover-worker.ts
Normal file
59
src/features/opencode-skill-loader/discover-worker.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { workerData, parentPort } from "worker_threads"
|
||||
import type { MessagePort } from "worker_threads"
|
||||
import { discoverSkillsInDirAsync } from "./async-loader"
|
||||
import type { LoadedSkill, SkillScope } from "./types"
|
||||
|
||||
interface WorkerInput {
|
||||
dirs: string[]
|
||||
scopes: SkillScope[]
|
||||
}
|
||||
|
||||
interface WorkerOutputSuccess {
|
||||
ok: true
|
||||
skills: LoadedSkill[]
|
||||
}
|
||||
|
||||
interface WorkerOutputError {
|
||||
ok: false
|
||||
error: { message: string; stack?: string }
|
||||
}
|
||||
|
||||
type WorkerOutput = WorkerOutputSuccess | WorkerOutputError
|
||||
|
||||
const { signal } = workerData as { signal: Int32Array }
|
||||
|
||||
if (!parentPort) {
|
||||
throw new Error("Worker must be run with parentPort")
|
||||
}
|
||||
|
||||
parentPort.once("message", (data: { port: MessagePort }) => {
|
||||
const { port } = data
|
||||
|
||||
port.on("message", async (input: WorkerInput) => {
|
||||
try {
|
||||
const results = await Promise.all(
|
||||
input.dirs.map(dir => discoverSkillsInDirAsync(dir))
|
||||
)
|
||||
|
||||
const skills = results.flat()
|
||||
|
||||
const output: WorkerOutputSuccess = { ok: true, skills }
|
||||
|
||||
port.postMessage(output)
|
||||
Atomics.store(signal, 0, 1)
|
||||
Atomics.notify(signal, 0)
|
||||
} catch (error: unknown) {
|
||||
const output: WorkerOutputError = {
|
||||
ok: false,
|
||||
error: {
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
},
|
||||
}
|
||||
|
||||
port.postMessage(output)
|
||||
Atomics.store(signal, 0, 1)
|
||||
Atomics.notify(signal, 0)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
import { existsSync, readdirSync, readFileSync } from "fs"
|
||||
import { join, basename, dirname } from "path"
|
||||
import { promises as fs } from "fs"
|
||||
import { join, basename } from "path"
|
||||
import { homedir } from "os"
|
||||
import yaml from "js-yaml"
|
||||
import { parseFrontmatter } from "../../shared/frontmatter"
|
||||
@@ -7,7 +8,7 @@ import { sanitizeModelField } from "../../shared/model-sanitizer"
|
||||
import { resolveSymlink, isMarkdownFile } from "../../shared/file-utils"
|
||||
import { getClaudeConfigDir } from "../../shared"
|
||||
import type { CommandDefinition } from "../claude-code-command-loader/types"
|
||||
import type { SkillScope, SkillMetadata, LoadedSkill } from "./types"
|
||||
import type { SkillScope, SkillMetadata, LoadedSkill, LazyContentLoader } from "./types"
|
||||
import type { SkillMcpConfig } from "../skill-mcp-manager/types"
|
||||
|
||||
function parseSkillMcpConfigFromFrontmatter(content: string): SkillMcpConfig | undefined {
|
||||
@@ -66,7 +67,7 @@ function loadSkillFromPath(
|
||||
): LoadedSkill | null {
|
||||
try {
|
||||
const content = readFileSync(skillPath, "utf-8")
|
||||
const { data, body } = parseFrontmatter<SkillMetadata>(content)
|
||||
const { data } = parseFrontmatter<SkillMetadata>(content)
|
||||
const frontmatterMcp = parseSkillMcpConfigFromFrontmatter(content)
|
||||
const mcpJsonMcp = loadMcpJsonFromDir(resolvedPath)
|
||||
const mcpConfig = mcpJsonMcp || frontmatterMcp
|
||||
@@ -76,7 +77,15 @@ function loadSkillFromPath(
|
||||
const isOpencodeSource = scope === "opencode" || scope === "opencode-project"
|
||||
const formattedDescription = `(${scope} - Skill) ${originalDescription}`
|
||||
|
||||
const wrappedTemplate = `<skill-instruction>
|
||||
// Lazy content loader - only loads template on first use
|
||||
const lazyContent: LazyContentLoader = {
|
||||
loaded: false,
|
||||
content: undefined,
|
||||
load: async () => {
|
||||
if (!lazyContent.loaded) {
|
||||
const fileContent = await fs.readFile(skillPath, "utf-8")
|
||||
const { body } = parseFrontmatter<SkillMetadata>(fileContent)
|
||||
lazyContent.content = `<skill-instruction>
|
||||
Base directory for this skill: ${resolvedPath}/
|
||||
File references (@path) in this skill are relative to this directory.
|
||||
|
||||
@@ -86,11 +95,16 @@ ${body.trim()}
|
||||
<user-request>
|
||||
$ARGUMENTS
|
||||
</user-request>`
|
||||
lazyContent.loaded = true
|
||||
}
|
||||
return lazyContent.content!
|
||||
},
|
||||
}
|
||||
|
||||
const definition: CommandDefinition = {
|
||||
name: skillName,
|
||||
description: formattedDescription,
|
||||
template: wrappedTemplate,
|
||||
template: "", // Empty at startup, loaded lazily
|
||||
model: sanitizeModelField(data.model, isOpencodeSource ? "opencode" : "claude-code"),
|
||||
agent: data.agent,
|
||||
subtask: data.subtask,
|
||||
@@ -108,6 +122,76 @@ $ARGUMENTS
|
||||
metadata: data.metadata,
|
||||
allowedTools: parseAllowedTools(data["allowed-tools"]),
|
||||
mcpConfig,
|
||||
lazyContent,
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSkillFromPathAsync(
|
||||
skillPath: string,
|
||||
resolvedPath: string,
|
||||
defaultName: string,
|
||||
scope: SkillScope
|
||||
): Promise<LoadedSkill | null> {
|
||||
try {
|
||||
const content = await fs.readFile(skillPath, "utf-8")
|
||||
const { data } = parseFrontmatter<SkillMetadata>(content)
|
||||
const frontmatterMcp = parseSkillMcpConfigFromFrontmatter(content)
|
||||
const mcpJsonMcp = loadMcpJsonFromDir(resolvedPath)
|
||||
const mcpConfig = mcpJsonMcp || frontmatterMcp
|
||||
|
||||
const skillName = data.name || defaultName
|
||||
const originalDescription = data.description || ""
|
||||
const isOpencodeSource = scope === "opencode" || scope === "opencode-project"
|
||||
const formattedDescription = `(${scope} - Skill) ${originalDescription}`
|
||||
|
||||
const lazyContent: LazyContentLoader = {
|
||||
loaded: false,
|
||||
content: undefined,
|
||||
load: async () => {
|
||||
if (!lazyContent.loaded) {
|
||||
const fileContent = await fs.readFile(skillPath, "utf-8")
|
||||
const { body } = parseFrontmatter<SkillMetadata>(fileContent)
|
||||
lazyContent.content = `<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>`
|
||||
lazyContent.loaded = true
|
||||
}
|
||||
return lazyContent.content!
|
||||
},
|
||||
}
|
||||
|
||||
const definition: CommandDefinition = {
|
||||
name: skillName,
|
||||
description: formattedDescription,
|
||||
template: "",
|
||||
model: sanitizeModelField(data.model, isOpencodeSource ? "opencode" : "claude-code"),
|
||||
agent: data.agent,
|
||||
subtask: data.subtask,
|
||||
argumentHint: data["argument-hint"],
|
||||
}
|
||||
|
||||
return {
|
||||
name: skillName,
|
||||
path: skillPath,
|
||||
resolvedPath,
|
||||
definition,
|
||||
scope,
|
||||
license: data.license,
|
||||
compatibility: data.compatibility,
|
||||
metadata: data.metadata,
|
||||
allowedTools: parseAllowedTools(data["allowed-tools"]),
|
||||
mcpConfig,
|
||||
lazyContent,
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
@@ -164,6 +248,53 @@ function loadSkillsFromDir(skillsDir: string, scope: SkillScope): LoadedSkill[]
|
||||
return skills
|
||||
}
|
||||
|
||||
/**
|
||||
* Async version of loadSkillsFromDir using Promise-based fs operations.
|
||||
*/
|
||||
async function loadSkillsFromDirAsync(skillsDir: string, scope: SkillScope): Promise<LoadedSkill[]> {
|
||||
const entries = await fs.readdir(skillsDir, { withFileTypes: true }).catch(() => [])
|
||||
const skills: LoadedSkill[] = []
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.name.startsWith(".")) continue
|
||||
|
||||
const entryPath = join(skillsDir, entry.name)
|
||||
|
||||
if (entry.isDirectory() || entry.isSymbolicLink()) {
|
||||
const resolvedPath = resolveSymlink(entryPath)
|
||||
const dirName = entry.name
|
||||
|
||||
const skillMdPath = join(resolvedPath, "SKILL.md")
|
||||
try {
|
||||
await fs.access(skillMdPath)
|
||||
const skill = await loadSkillFromPathAsync(skillMdPath, resolvedPath, dirName, scope)
|
||||
if (skill) skills.push(skill)
|
||||
continue
|
||||
} catch {
|
||||
}
|
||||
|
||||
const namedSkillMdPath = join(resolvedPath, `${dirName}.md`)
|
||||
try {
|
||||
await fs.access(namedSkillMdPath)
|
||||
const skill = await loadSkillFromPathAsync(namedSkillMdPath, resolvedPath, dirName, scope)
|
||||
if (skill) skills.push(skill)
|
||||
continue
|
||||
} catch {
|
||||
}
|
||||
|
||||
continue
|
||||
}
|
||||
|
||||
if (isMarkdownFile(entry)) {
|
||||
const skillName = basename(entry.name, ".md")
|
||||
const skill = await loadSkillFromPathAsync(entryPath, skillsDir, skillName, scope)
|
||||
if (skill) skills.push(skill)
|
||||
}
|
||||
}
|
||||
|
||||
return skills
|
||||
}
|
||||
|
||||
function skillsToRecord(skills: LoadedSkill[]): Record<string, CommandDefinition> {
|
||||
const result: Record<string, CommandDefinition> = {}
|
||||
for (const skill of skills) {
|
||||
@@ -286,3 +417,41 @@ export function discoverOpencodeProjectSkills(): LoadedSkill[] {
|
||||
const opencodeProjectDir = join(process.cwd(), ".opencode", "skill")
|
||||
return loadSkillsFromDir(opencodeProjectDir, "opencode-project")
|
||||
}
|
||||
|
||||
export async function discoverUserClaudeSkillsAsync(): Promise<LoadedSkill[]> {
|
||||
const userSkillsDir = join(getClaudeConfigDir(), "skills")
|
||||
return loadSkillsFromDirAsync(userSkillsDir, "user")
|
||||
}
|
||||
|
||||
export async function discoverProjectClaudeSkillsAsync(): Promise<LoadedSkill[]> {
|
||||
const projectSkillsDir = join(process.cwd(), ".claude", "skills")
|
||||
return loadSkillsFromDirAsync(projectSkillsDir, "project")
|
||||
}
|
||||
|
||||
export async function discoverOpencodeGlobalSkillsAsync(): Promise<LoadedSkill[]> {
|
||||
const opencodeSkillsDir = join(homedir(), ".config", "opencode", "skill")
|
||||
return loadSkillsFromDirAsync(opencodeSkillsDir, "opencode")
|
||||
}
|
||||
|
||||
export async function discoverOpencodeProjectSkillsAsync(): Promise<LoadedSkill[]> {
|
||||
const opencodeProjectDir = join(process.cwd(), ".opencode", "skill")
|
||||
return loadSkillsFromDirAsync(opencodeProjectDir, "opencode-project")
|
||||
}
|
||||
|
||||
export async function discoverAllSkillsAsync(options: DiscoverSkillsOptions = {}): Promise<LoadedSkill[]> {
|
||||
const { includeClaudeCodePaths = true } = options
|
||||
|
||||
const opencodeProjectSkills = await discoverOpencodeProjectSkillsAsync()
|
||||
const opencodeGlobalSkills = await discoverOpencodeGlobalSkillsAsync()
|
||||
|
||||
if (!includeClaudeCodePaths) {
|
||||
return [...opencodeProjectSkills, ...opencodeGlobalSkills]
|
||||
}
|
||||
|
||||
const [projectSkills, userSkills] = await Promise.all([
|
||||
discoverProjectClaudeSkillsAsync(),
|
||||
discoverUserClaudeSkillsAsync(),
|
||||
])
|
||||
|
||||
return [...opencodeProjectSkills, ...projectSkills, ...opencodeGlobalSkills, ...userSkills]
|
||||
}
|
||||
|
||||
@@ -17,6 +17,12 @@ export interface SkillMetadata {
|
||||
mcp?: SkillMcpConfig
|
||||
}
|
||||
|
||||
export interface LazyContentLoader {
|
||||
loaded: boolean
|
||||
content?: string
|
||||
load: () => Promise<string>
|
||||
}
|
||||
|
||||
export interface LoadedSkill {
|
||||
name: string
|
||||
path?: string
|
||||
@@ -28,4 +34,5 @@ export interface LoadedSkill {
|
||||
metadata?: Record<string, string>
|
||||
allowedTools?: string[]
|
||||
mcpConfig?: SkillMcpConfig
|
||||
lazyContent?: LazyContentLoader
|
||||
}
|
||||
|
||||
201
src/features/skill-mcp-manager/env-cleaner.test.ts
Normal file
201
src/features/skill-mcp-manager/env-cleaner.test.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "bun:test"
|
||||
import { createCleanMcpEnvironment, EXCLUDED_ENV_PATTERNS } from "./env-cleaner"
|
||||
|
||||
describe("createCleanMcpEnvironment", () => {
|
||||
// Store original env to restore after tests
|
||||
const originalEnv = { ...process.env }
|
||||
|
||||
afterEach(() => {
|
||||
// Restore original environment
|
||||
for (const key of Object.keys(process.env)) {
|
||||
if (!(key in originalEnv)) {
|
||||
delete process.env[key]
|
||||
}
|
||||
}
|
||||
for (const [key, value] of Object.entries(originalEnv)) {
|
||||
process.env[key] = value
|
||||
}
|
||||
})
|
||||
|
||||
describe("NPM_CONFIG_* filtering", () => {
|
||||
it("filters out uppercase NPM_CONFIG_* variables", () => {
|
||||
// #given
|
||||
process.env.NPM_CONFIG_REGISTRY = "https://private.registry.com"
|
||||
process.env.NPM_CONFIG_CACHE = "/some/cache/path"
|
||||
process.env.NPM_CONFIG_PREFIX = "/some/prefix"
|
||||
process.env.PATH = "/usr/bin"
|
||||
|
||||
// #when
|
||||
const cleanEnv = createCleanMcpEnvironment()
|
||||
|
||||
// #then
|
||||
expect(cleanEnv.NPM_CONFIG_REGISTRY).toBeUndefined()
|
||||
expect(cleanEnv.NPM_CONFIG_CACHE).toBeUndefined()
|
||||
expect(cleanEnv.NPM_CONFIG_PREFIX).toBeUndefined()
|
||||
expect(cleanEnv.PATH).toBe("/usr/bin")
|
||||
})
|
||||
|
||||
it("filters out lowercase npm_config_* variables", () => {
|
||||
// #given
|
||||
process.env.npm_config_registry = "https://private.registry.com"
|
||||
process.env.npm_config_cache = "/some/cache/path"
|
||||
process.env.npm_config_https_proxy = "http://proxy:8080"
|
||||
process.env.npm_config_proxy = "http://proxy:8080"
|
||||
process.env.HOME = "/home/user"
|
||||
|
||||
// #when
|
||||
const cleanEnv = createCleanMcpEnvironment()
|
||||
|
||||
// #then
|
||||
expect(cleanEnv.npm_config_registry).toBeUndefined()
|
||||
expect(cleanEnv.npm_config_cache).toBeUndefined()
|
||||
expect(cleanEnv.npm_config_https_proxy).toBeUndefined()
|
||||
expect(cleanEnv.npm_config_proxy).toBeUndefined()
|
||||
expect(cleanEnv.HOME).toBe("/home/user")
|
||||
})
|
||||
})
|
||||
|
||||
describe("YARN_* filtering", () => {
|
||||
it("filters out YARN_* variables", () => {
|
||||
// #given
|
||||
process.env.YARN_CACHE_FOLDER = "/yarn/cache"
|
||||
process.env.YARN_ENABLE_IMMUTABLE_INSTALLS = "true"
|
||||
process.env.YARN_REGISTRY = "https://yarn.registry.com"
|
||||
process.env.NODE_ENV = "production"
|
||||
|
||||
// #when
|
||||
const cleanEnv = createCleanMcpEnvironment()
|
||||
|
||||
// #then
|
||||
expect(cleanEnv.YARN_CACHE_FOLDER).toBeUndefined()
|
||||
expect(cleanEnv.YARN_ENABLE_IMMUTABLE_INSTALLS).toBeUndefined()
|
||||
expect(cleanEnv.YARN_REGISTRY).toBeUndefined()
|
||||
expect(cleanEnv.NODE_ENV).toBe("production")
|
||||
})
|
||||
})
|
||||
|
||||
describe("PNPM_* filtering", () => {
|
||||
it("filters out PNPM_* variables", () => {
|
||||
// #given
|
||||
process.env.PNPM_HOME = "/pnpm/home"
|
||||
process.env.PNPM_STORE_DIR = "/pnpm/store"
|
||||
process.env.USER = "testuser"
|
||||
|
||||
// #when
|
||||
const cleanEnv = createCleanMcpEnvironment()
|
||||
|
||||
// #then
|
||||
expect(cleanEnv.PNPM_HOME).toBeUndefined()
|
||||
expect(cleanEnv.PNPM_STORE_DIR).toBeUndefined()
|
||||
expect(cleanEnv.USER).toBe("testuser")
|
||||
})
|
||||
})
|
||||
|
||||
describe("NO_UPDATE_NOTIFIER filtering", () => {
|
||||
it("filters out NO_UPDATE_NOTIFIER variable", () => {
|
||||
// #given
|
||||
process.env.NO_UPDATE_NOTIFIER = "1"
|
||||
process.env.SHELL = "/bin/bash"
|
||||
|
||||
// #when
|
||||
const cleanEnv = createCleanMcpEnvironment()
|
||||
|
||||
// #then
|
||||
expect(cleanEnv.NO_UPDATE_NOTIFIER).toBeUndefined()
|
||||
expect(cleanEnv.SHELL).toBe("/bin/bash")
|
||||
})
|
||||
})
|
||||
|
||||
describe("custom environment overlay", () => {
|
||||
it("merges custom env on top of clean process.env", () => {
|
||||
// #given
|
||||
process.env.PATH = "/usr/bin"
|
||||
process.env.NPM_CONFIG_REGISTRY = "https://private.registry.com"
|
||||
const customEnv = {
|
||||
MCP_API_KEY: "secret-key",
|
||||
CUSTOM_VAR: "custom-value",
|
||||
}
|
||||
|
||||
// #when
|
||||
const cleanEnv = createCleanMcpEnvironment(customEnv)
|
||||
|
||||
// #then
|
||||
expect(cleanEnv.PATH).toBe("/usr/bin")
|
||||
expect(cleanEnv.NPM_CONFIG_REGISTRY).toBeUndefined()
|
||||
expect(cleanEnv.MCP_API_KEY).toBe("secret-key")
|
||||
expect(cleanEnv.CUSTOM_VAR).toBe("custom-value")
|
||||
})
|
||||
|
||||
it("custom env can override process.env values", () => {
|
||||
// #given
|
||||
process.env.NODE_ENV = "development"
|
||||
const customEnv = {
|
||||
NODE_ENV: "production",
|
||||
}
|
||||
|
||||
// #when
|
||||
const cleanEnv = createCleanMcpEnvironment(customEnv)
|
||||
|
||||
// #then
|
||||
expect(cleanEnv.NODE_ENV).toBe("production")
|
||||
})
|
||||
})
|
||||
|
||||
describe("undefined value handling", () => {
|
||||
it("skips undefined values from process.env", () => {
|
||||
// #given - process.env can have undefined values in TypeScript
|
||||
const envWithUndefined = { ...process.env, UNDEFINED_VAR: undefined }
|
||||
Object.assign(process.env, envWithUndefined)
|
||||
|
||||
// #when
|
||||
const cleanEnv = createCleanMcpEnvironment()
|
||||
|
||||
// #then - should not throw and should not include undefined values
|
||||
expect(cleanEnv.UNDEFINED_VAR).toBeUndefined()
|
||||
expect(Object.values(cleanEnv).every((v) => v !== undefined)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("mixed case handling", () => {
|
||||
it("filters both uppercase and lowercase npm config variants", () => {
|
||||
// #given - pnpm/yarn can set both cases simultaneously
|
||||
process.env.NPM_CONFIG_CACHE = "/uppercase/cache"
|
||||
process.env.npm_config_cache = "/lowercase/cache"
|
||||
process.env.NPM_CONFIG_REGISTRY = "https://uppercase.registry.com"
|
||||
process.env.npm_config_registry = "https://lowercase.registry.com"
|
||||
|
||||
// #when
|
||||
const cleanEnv = createCleanMcpEnvironment()
|
||||
|
||||
// #then
|
||||
expect(cleanEnv.NPM_CONFIG_CACHE).toBeUndefined()
|
||||
expect(cleanEnv.npm_config_cache).toBeUndefined()
|
||||
expect(cleanEnv.NPM_CONFIG_REGISTRY).toBeUndefined()
|
||||
expect(cleanEnv.npm_config_registry).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("EXCLUDED_ENV_PATTERNS", () => {
|
||||
it("contains patterns for npm, yarn, and pnpm configs", () => {
|
||||
// #given / #when / #then
|
||||
expect(EXCLUDED_ENV_PATTERNS.length).toBeGreaterThanOrEqual(4)
|
||||
|
||||
// Test that patterns match expected strings
|
||||
const testCases = [
|
||||
{ pattern: "NPM_CONFIG_REGISTRY", shouldMatch: true },
|
||||
{ pattern: "npm_config_registry", shouldMatch: true },
|
||||
{ pattern: "YARN_CACHE_FOLDER", shouldMatch: true },
|
||||
{ pattern: "PNPM_HOME", shouldMatch: true },
|
||||
{ pattern: "NO_UPDATE_NOTIFIER", shouldMatch: true },
|
||||
{ pattern: "PATH", shouldMatch: false },
|
||||
{ pattern: "HOME", shouldMatch: false },
|
||||
{ pattern: "NODE_ENV", shouldMatch: false },
|
||||
]
|
||||
|
||||
for (const { pattern, shouldMatch } of testCases) {
|
||||
const matches = EXCLUDED_ENV_PATTERNS.some((regex: RegExp) => regex.test(pattern))
|
||||
expect(matches).toBe(shouldMatch)
|
||||
}
|
||||
})
|
||||
})
|
||||
27
src/features/skill-mcp-manager/env-cleaner.ts
Normal file
27
src/features/skill-mcp-manager/env-cleaner.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
// Filters npm/pnpm/yarn config env vars that break MCP servers in pnpm projects (#456)
|
||||
export const EXCLUDED_ENV_PATTERNS: RegExp[] = [
|
||||
/^NPM_CONFIG_/i,
|
||||
/^npm_config_/,
|
||||
/^YARN_/,
|
||||
/^PNPM_/,
|
||||
/^NO_UPDATE_NOTIFIER$/,
|
||||
]
|
||||
|
||||
export function createCleanMcpEnvironment(
|
||||
customEnv: Record<string, string> = {}
|
||||
): Record<string, string> {
|
||||
const cleanEnv: Record<string, string> = {}
|
||||
|
||||
for (const [key, value] of Object.entries(process.env)) {
|
||||
if (value === undefined) continue
|
||||
|
||||
const shouldExclude = EXCLUDED_ENV_PATTERNS.some((pattern) => pattern.test(key))
|
||||
if (!shouldExclude) {
|
||||
cleanEnv[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
Object.assign(cleanEnv, customEnv)
|
||||
|
||||
return cleanEnv
|
||||
}
|
||||
@@ -3,21 +3,68 @@ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
|
||||
import type { Tool, Resource, Prompt } from "@modelcontextprotocol/sdk/types.js"
|
||||
import type { ClaudeCodeMcpServer } from "../claude-code-mcp-loader/types"
|
||||
import { expandEnvVarsInObject } from "../claude-code-mcp-loader/env-expander"
|
||||
import { createCleanMcpEnvironment } from "./env-cleaner"
|
||||
import type { SkillMcpClientInfo, SkillMcpServerContext } from "./types"
|
||||
|
||||
interface ManagedClient {
|
||||
client: Client
|
||||
transport: StdioClientTransport
|
||||
skillName: string
|
||||
lastUsedAt: number
|
||||
}
|
||||
|
||||
export class SkillMcpManager {
|
||||
private clients: Map<string, ManagedClient> = new Map()
|
||||
private pendingConnections: Map<string, Promise<Client>> = new Map()
|
||||
private cleanupRegistered = false
|
||||
private cleanupInterval: ReturnType<typeof setInterval> | null = null
|
||||
private readonly IDLE_TIMEOUT = 5 * 60 * 1000
|
||||
|
||||
private getClientKey(info: SkillMcpClientInfo): string {
|
||||
return `${info.sessionID}:${info.skillName}:${info.serverName}`
|
||||
}
|
||||
|
||||
private registerProcessCleanup(): void {
|
||||
if (this.cleanupRegistered) return
|
||||
this.cleanupRegistered = true
|
||||
|
||||
const cleanup = async () => {
|
||||
for (const [, managed] of this.clients) {
|
||||
try {
|
||||
await managed.client.close()
|
||||
} catch {
|
||||
// Ignore errors during cleanup
|
||||
}
|
||||
try {
|
||||
await managed.transport.close()
|
||||
} catch {
|
||||
// Transport may already be terminated
|
||||
}
|
||||
}
|
||||
this.clients.clear()
|
||||
this.pendingConnections.clear()
|
||||
}
|
||||
|
||||
// Note: 'exit' event is synchronous-only in Node.js, so we use 'beforeExit' for async cleanup
|
||||
// However, 'beforeExit' is not emitted on explicit process.exit() calls
|
||||
// Signal handlers are made async to properly await cleanup
|
||||
|
||||
process.on("SIGINT", async () => {
|
||||
await cleanup()
|
||||
process.exit(0)
|
||||
})
|
||||
process.on("SIGTERM", async () => {
|
||||
await cleanup()
|
||||
process.exit(0)
|
||||
})
|
||||
if (process.platform === "win32") {
|
||||
process.on("SIGBREAK", async () => {
|
||||
await cleanup()
|
||||
process.exit(0)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async getOrCreateClient(
|
||||
info: SkillMcpClientInfo,
|
||||
config: ClaudeCodeMcpServer
|
||||
@@ -26,12 +73,26 @@ export class SkillMcpManager {
|
||||
const existing = this.clients.get(key)
|
||||
|
||||
if (existing) {
|
||||
existing.lastUsedAt = Date.now()
|
||||
return existing.client
|
||||
}
|
||||
|
||||
// Prevent race condition: if a connection is already in progress, wait for it
|
||||
const pending = this.pendingConnections.get(key)
|
||||
if (pending) {
|
||||
return pending
|
||||
}
|
||||
|
||||
const expandedConfig = expandEnvVarsInObject(config)
|
||||
const client = await this.createClient(info, expandedConfig)
|
||||
return client
|
||||
const connectionPromise = this.createClient(info, expandedConfig)
|
||||
this.pendingConnections.set(key, connectionPromise)
|
||||
|
||||
try {
|
||||
const client = await connectionPromise
|
||||
return client
|
||||
} finally {
|
||||
this.pendingConnections.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
private async createClient(
|
||||
@@ -55,15 +116,9 @@ export class SkillMcpManager {
|
||||
const command = config.command
|
||||
const args = config.args || []
|
||||
|
||||
// Always inherit parent process environment
|
||||
const mergedEnv: Record<string, string> = {}
|
||||
for (const [key, value] of Object.entries(process.env)) {
|
||||
if (value !== undefined) mergedEnv[key] = value
|
||||
}
|
||||
// Overlay with skill-specific env vars if present
|
||||
if (config.env) {
|
||||
Object.assign(mergedEnv, config.env)
|
||||
}
|
||||
const mergedEnv = createCleanMcpEnvironment(config.env)
|
||||
|
||||
this.registerProcessCleanup()
|
||||
|
||||
const transport = new StdioClientTransport({
|
||||
command,
|
||||
@@ -80,6 +135,12 @@ export class SkillMcpManager {
|
||||
try {
|
||||
await client.connect(transport)
|
||||
} catch (error) {
|
||||
// Close transport to prevent orphaned MCP process on connection failure
|
||||
try {
|
||||
await transport.close()
|
||||
} catch {
|
||||
// Process may already be terminated
|
||||
}
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
throw new Error(
|
||||
`Failed to connect to MCP server "${info.serverName}".\n\n` +
|
||||
@@ -92,7 +153,8 @@ export class SkillMcpManager {
|
||||
)
|
||||
}
|
||||
|
||||
this.clients.set(key, { client, transport, skillName: info.skillName })
|
||||
this.clients.set(key, { client, transport, skillName: info.skillName, lastUsedAt: Date.now() })
|
||||
this.startCleanupTimer()
|
||||
return client
|
||||
}
|
||||
|
||||
@@ -102,26 +164,64 @@ export class SkillMcpManager {
|
||||
for (const [key, managed] of this.clients.entries()) {
|
||||
if (key.startsWith(`${sessionID}:`)) {
|
||||
keysToRemove.push(key)
|
||||
// Delete from map first to prevent re-entrancy during async close
|
||||
this.clients.delete(key)
|
||||
try {
|
||||
await managed.client.close()
|
||||
} catch {
|
||||
// Ignore close errors - process may already be terminated
|
||||
}
|
||||
try {
|
||||
await managed.transport.close()
|
||||
} catch {
|
||||
// Transport may already be terminated
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of keysToRemove) {
|
||||
this.clients.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
async disconnectAll(): Promise<void> {
|
||||
for (const [, managed] of this.clients.entries()) {
|
||||
this.stopCleanupTimer()
|
||||
const clients = Array.from(this.clients.values())
|
||||
this.clients.clear()
|
||||
for (const managed of clients) {
|
||||
try {
|
||||
await managed.client.close()
|
||||
} catch { /* process may already be terminated */ }
|
||||
try {
|
||||
await managed.transport.close()
|
||||
} catch { /* transport may already be terminated */ }
|
||||
}
|
||||
}
|
||||
|
||||
private startCleanupTimer(): void {
|
||||
if (this.cleanupInterval) return
|
||||
this.cleanupInterval = setInterval(() => {
|
||||
this.cleanupIdleClients()
|
||||
}, 60_000)
|
||||
this.cleanupInterval.unref()
|
||||
}
|
||||
|
||||
private stopCleanupTimer(): void {
|
||||
if (this.cleanupInterval) {
|
||||
clearInterval(this.cleanupInterval)
|
||||
this.cleanupInterval = null
|
||||
}
|
||||
}
|
||||
|
||||
private async cleanupIdleClients(): Promise<void> {
|
||||
const now = Date.now()
|
||||
for (const [key, managed] of this.clients) {
|
||||
if (now - managed.lastUsedAt > this.IDLE_TIMEOUT) {
|
||||
this.clients.delete(key)
|
||||
try {
|
||||
await managed.client.close()
|
||||
} catch { /* process may already be terminated */ }
|
||||
try {
|
||||
await managed.transport.close()
|
||||
} catch { /* transport may already be terminated */ }
|
||||
}
|
||||
}
|
||||
this.clients.clear()
|
||||
}
|
||||
|
||||
async listTools(
|
||||
@@ -193,10 +293,13 @@ export class SkillMcpManager {
|
||||
const key = this.getClientKey(info)
|
||||
const existing = this.clients.get(key)
|
||||
if (existing) {
|
||||
this.clients.delete(key)
|
||||
try {
|
||||
await existing.client.close()
|
||||
} catch { /* process may already be terminated */ }
|
||||
this.clients.delete(key)
|
||||
try {
|
||||
await existing.transport.close()
|
||||
} catch { /* transport may already be terminated */ }
|
||||
return await this.getOrCreateClient(info, config)
|
||||
}
|
||||
throw error
|
||||
|
||||
@@ -39,7 +39,7 @@ type Client = {
|
||||
query: { directory: string };
|
||||
}) => Promise<unknown>;
|
||||
prompt_async: (opts: {
|
||||
path: { sessionID: string };
|
||||
path: { id: string };
|
||||
body: { parts: Array<{ type: string; text: string }> };
|
||||
query: { directory: string };
|
||||
}) => Promise<unknown>;
|
||||
@@ -408,7 +408,7 @@ export async function executeCompact(
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await (client as Client).session.prompt_async({
|
||||
path: { sessionID },
|
||||
path: { id: sessionID },
|
||||
body: { parts: [{ type: "text", text: "Continue" }] },
|
||||
query: { directory },
|
||||
});
|
||||
@@ -506,7 +506,7 @@ export async function executeCompact(
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await (client as Client).session.prompt_async({
|
||||
path: { sessionID },
|
||||
path: { id: sessionID },
|
||||
body: { parts: [{ type: "text", text: "Continue" }] },
|
||||
query: { directory },
|
||||
});
|
||||
|
||||
@@ -26,9 +26,23 @@ const TOKEN_LIMIT_KEYWORDS = [
|
||||
"context length",
|
||||
"too many tokens",
|
||||
"non-empty content",
|
||||
"invalid_request_error",
|
||||
]
|
||||
|
||||
// Patterns that indicate thinking block structure errors (NOT token limit errors)
|
||||
// These should be handled by session-recovery hook, not compaction
|
||||
const THINKING_BLOCK_ERROR_PATTERNS = [
|
||||
/thinking.*first block/i,
|
||||
/first block.*thinking/i,
|
||||
/must.*start.*thinking/i,
|
||||
/thinking.*redacted_thinking/i,
|
||||
/expected.*thinking.*found/i,
|
||||
/thinking.*disabled.*cannot.*contain/i,
|
||||
]
|
||||
|
||||
function isThinkingBlockError(text: string): boolean {
|
||||
return THINKING_BLOCK_ERROR_PATTERNS.some((pattern) => pattern.test(text))
|
||||
}
|
||||
|
||||
const MESSAGE_INDEX_PATTERN = /messages\.(\d+)/
|
||||
|
||||
function extractTokensFromMessage(message: string): { current: number; max: number } | null {
|
||||
@@ -52,6 +66,9 @@ function extractMessageIndex(text: string): number | undefined {
|
||||
}
|
||||
|
||||
function isTokenLimitError(text: string): boolean {
|
||||
if (isThinkingBlockError(text)) {
|
||||
return false
|
||||
}
|
||||
const lower = text.toLowerCase()
|
||||
return TOKEN_LIMIT_KEYWORDS.some((kw) => lower.includes(kw.toLowerCase()))
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import { recordToolUse, recordToolResult, getTranscriptPath, recordUserMessage }
|
||||
import type { PluginConfig } from "./types"
|
||||
import { log, isHookDisabled } from "../../shared"
|
||||
import { injectHookMessage } from "../../features/hook-message-injector"
|
||||
import { detectKeywordsWithType, removeCodeBlocks } from "../keyword-detector"
|
||||
|
||||
const sessionFirstMessageProcessed = new Set<string>()
|
||||
const sessionErrorState = new Map<string, { hasError: boolean; errorMessage?: string }>()
|
||||
@@ -112,11 +113,6 @@ export function createClaudeCodeHooksHook(ctx: PluginInput, config: PluginConfig
|
||||
const isFirstMessage = !sessionFirstMessageProcessed.has(input.sessionID)
|
||||
sessionFirstMessageProcessed.add(input.sessionID)
|
||||
|
||||
if (isFirstMessage) {
|
||||
log("Skipping UserPromptSubmit hooks on first message for title generation", { sessionID: input.sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
if (!isHookDisabled(config, "UserPromptSubmit")) {
|
||||
const userPromptCtx: UserPromptSubmitContext = {
|
||||
sessionId: input.sessionID,
|
||||
@@ -142,27 +138,48 @@ export function createClaudeCodeHooksHook(ctx: PluginInput, config: PluginConfig
|
||||
return
|
||||
}
|
||||
|
||||
if (result.messages.length > 0) {
|
||||
const hookContent = result.messages.join("\n\n")
|
||||
log(`[claude-code-hooks] Injecting ${result.messages.length} hook messages`, { sessionID: input.sessionID, contentLength: hookContent.length })
|
||||
const message = output.message as {
|
||||
agent?: string
|
||||
model?: { modelID?: string; providerID?: string }
|
||||
path?: { cwd?: string; root?: string }
|
||||
tools?: Record<string, boolean>
|
||||
}
|
||||
const detectedKeywords = detectKeywordsWithType(removeCodeBlocks(prompt))
|
||||
const keywordMessages = detectedKeywords.map((k) => k.message)
|
||||
|
||||
const success = injectHookMessage(input.sessionID, hookContent, {
|
||||
agent: message.agent,
|
||||
model: message.model,
|
||||
path: message.path ?? { cwd: ctx.directory, root: "/" },
|
||||
tools: message.tools,
|
||||
})
|
||||
|
||||
log(success ? "Hook message injected via file system" : "File injection failed", {
|
||||
if (keywordMessages.length > 0) {
|
||||
log("[claude-code-hooks] Detected keywords", {
|
||||
sessionID: input.sessionID,
|
||||
types: detectedKeywords.map((k) => k.type),
|
||||
})
|
||||
}
|
||||
|
||||
const allMessages = [...keywordMessages, ...result.messages]
|
||||
|
||||
if (allMessages.length > 0) {
|
||||
const hookContent = allMessages.join("\n\n")
|
||||
log(`[claude-code-hooks] Injecting ${allMessages.length} messages (${keywordMessages.length} keyword + ${result.messages.length} hook)`, { sessionID: input.sessionID, contentLength: hookContent.length, isFirstMessage })
|
||||
|
||||
if (isFirstMessage) {
|
||||
const idx = output.parts.findIndex((p) => p.type === "text" && p.text)
|
||||
if (idx >= 0) {
|
||||
output.parts[idx].text = `${hookContent}\n\n${output.parts[idx].text ?? ""}`
|
||||
log("UserPromptSubmit hooks prepended to first message parts directly", { sessionID: input.sessionID })
|
||||
}
|
||||
} else {
|
||||
const message = output.message as {
|
||||
agent?: string
|
||||
model?: { modelID?: string; providerID?: string }
|
||||
path?: { cwd?: string; root?: string }
|
||||
tools?: Record<string, boolean>
|
||||
}
|
||||
|
||||
const success = injectHookMessage(input.sessionID, hookContent, {
|
||||
agent: message.agent,
|
||||
model: message.model,
|
||||
path: message.path ?? { cwd: ctx.directory, root: "/" },
|
||||
tools: message.tools,
|
||||
})
|
||||
|
||||
log(success ? "Hook message injected via file system" : "File injection failed", {
|
||||
sessionID: input.sessionID,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
|
||||
const ANTHROPIC_DISPLAY_LIMIT = 1_000_000
|
||||
const ANTHROPIC_ACTUAL_LIMIT = 200_000
|
||||
const ANTHROPIC_ACTUAL_LIMIT =
|
||||
process.env.ANTHROPIC_1M_CONTEXT === "true" ||
|
||||
process.env.VERTEX_ANTHROPIC_1M_CONTEXT === "true"
|
||||
? 1_000_000
|
||||
: 200_000
|
||||
const CONTEXT_WARNING_THRESHOLD = 0.70
|
||||
|
||||
const CONTEXT_REMINDER = `[SYSTEM REMINDER - 1M Context Window]
|
||||
|
||||
126
src/hooks/edit-error-recovery/index.test.ts
Normal file
126
src/hooks/edit-error-recovery/index.test.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { describe, it, expect, beforeEach } from "bun:test"
|
||||
import { createEditErrorRecoveryHook, EDIT_ERROR_REMINDER, EDIT_ERROR_PATTERNS } from "./index"
|
||||
|
||||
describe("createEditErrorRecoveryHook", () => {
|
||||
let hook: ReturnType<typeof createEditErrorRecoveryHook>
|
||||
|
||||
beforeEach(() => {
|
||||
hook = createEditErrorRecoveryHook({} as any)
|
||||
})
|
||||
|
||||
describe("tool.execute.after", () => {
|
||||
const createInput = (tool: string) => ({
|
||||
tool,
|
||||
sessionID: "test-session",
|
||||
callID: "test-call-id",
|
||||
})
|
||||
|
||||
const createOutput = (outputText: string) => ({
|
||||
title: "Edit",
|
||||
output: outputText,
|
||||
metadata: {},
|
||||
})
|
||||
|
||||
describe("#given Edit tool with oldString/newString same error", () => {
|
||||
describe("#when the error message is detected", () => {
|
||||
it("#then should append the recovery reminder", async () => {
|
||||
const input = createInput("Edit")
|
||||
const output = createOutput("Error: oldString and newString must be different")
|
||||
|
||||
await hook["tool.execute.after"](input, output)
|
||||
|
||||
expect(output.output).toContain(EDIT_ERROR_REMINDER)
|
||||
expect(output.output).toContain("oldString and newString must be different")
|
||||
})
|
||||
})
|
||||
|
||||
describe("#when the error appears without Error prefix", () => {
|
||||
it("#then should still detect and append reminder", async () => {
|
||||
const input = createInput("Edit")
|
||||
const output = createOutput("oldString and newString must be different")
|
||||
|
||||
await hook["tool.execute.after"](input, output)
|
||||
|
||||
expect(output.output).toContain(EDIT_ERROR_REMINDER)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given Edit tool with oldString not found error", () => {
|
||||
describe("#when oldString not found in content", () => {
|
||||
it("#then should append the recovery reminder", async () => {
|
||||
const input = createInput("Edit")
|
||||
const output = createOutput("Error: oldString not found in content")
|
||||
|
||||
await hook["tool.execute.after"](input, output)
|
||||
|
||||
expect(output.output).toContain(EDIT_ERROR_REMINDER)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given Edit tool with multiple matches error", () => {
|
||||
describe("#when oldString found multiple times", () => {
|
||||
it("#then should append the recovery reminder", async () => {
|
||||
const input = createInput("Edit")
|
||||
const output = createOutput(
|
||||
"Error: oldString found multiple times and requires more code context to uniquely identify the intended match"
|
||||
)
|
||||
|
||||
await hook["tool.execute.after"](input, output)
|
||||
|
||||
expect(output.output).toContain(EDIT_ERROR_REMINDER)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given non-Edit tool", () => {
|
||||
describe("#when tool is not Edit", () => {
|
||||
it("#then should not modify output", async () => {
|
||||
const input = createInput("Read")
|
||||
const originalOutput = "some output"
|
||||
const output = createOutput(originalOutput)
|
||||
|
||||
await hook["tool.execute.after"](input, output)
|
||||
|
||||
expect(output.output).toBe(originalOutput)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given Edit tool with successful output", () => {
|
||||
describe("#when no error in output", () => {
|
||||
it("#then should not modify output", async () => {
|
||||
const input = createInput("Edit")
|
||||
const originalOutput = "File edited successfully"
|
||||
const output = createOutput(originalOutput)
|
||||
|
||||
await hook["tool.execute.after"](input, output)
|
||||
|
||||
expect(output.output).toBe(originalOutput)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given case insensitive tool name", () => {
|
||||
describe("#when tool is 'edit' lowercase", () => {
|
||||
it("#then should still detect and append reminder", async () => {
|
||||
const input = createInput("edit")
|
||||
const output = createOutput("oldString and newString must be different")
|
||||
|
||||
await hook["tool.execute.after"](input, output)
|
||||
|
||||
expect(output.output).toContain(EDIT_ERROR_REMINDER)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("EDIT_ERROR_PATTERNS", () => {
|
||||
it("#then should contain all known Edit error patterns", () => {
|
||||
expect(EDIT_ERROR_PATTERNS).toContain("oldString and newString must be different")
|
||||
expect(EDIT_ERROR_PATTERNS).toContain("oldString not found")
|
||||
expect(EDIT_ERROR_PATTERNS).toContain("oldString found multiple times")
|
||||
})
|
||||
})
|
||||
})
|
||||
57
src/hooks/edit-error-recovery/index.ts
Normal file
57
src/hooks/edit-error-recovery/index.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
|
||||
/**
|
||||
* Known Edit tool error patterns that indicate the AI made a mistake
|
||||
*/
|
||||
export const EDIT_ERROR_PATTERNS = [
|
||||
"oldString and newString must be different",
|
||||
"oldString not found",
|
||||
"oldString found multiple times",
|
||||
] as const
|
||||
|
||||
/**
|
||||
* System reminder injected when Edit tool fails due to AI mistake
|
||||
* Short, direct, and commanding - forces immediate corrective action
|
||||
*/
|
||||
export const EDIT_ERROR_REMINDER = `
|
||||
[EDIT ERROR - IMMEDIATE ACTION REQUIRED]
|
||||
|
||||
You made an Edit mistake. STOP and do this NOW:
|
||||
|
||||
1. READ the file immediately to see its ACTUAL current state
|
||||
2. VERIFY what the content really looks like (your assumption was wrong)
|
||||
3. APOLOGIZE briefly to the user for the error
|
||||
4. CONTINUE with corrected action based on the real file content
|
||||
|
||||
DO NOT attempt another edit until you've read and verified the file state.
|
||||
`
|
||||
|
||||
/**
|
||||
* Detects Edit tool errors caused by AI mistakes and injects a recovery reminder
|
||||
*
|
||||
* This hook catches common Edit tool failures:
|
||||
* - oldString and newString must be different (trying to "edit" to same content)
|
||||
* - oldString not found (wrong assumption about file content)
|
||||
* - oldString found multiple times (ambiguous match, need more context)
|
||||
*
|
||||
* @see https://github.com/sst/opencode/issues/4718
|
||||
*/
|
||||
export function createEditErrorRecoveryHook(_ctx: PluginInput) {
|
||||
return {
|
||||
"tool.execute.after": async (
|
||||
input: { tool: string; sessionID: string; callID: string },
|
||||
output: { title: string; output: string; metadata: unknown }
|
||||
) => {
|
||||
if (input.tool.toLowerCase() !== "edit") return
|
||||
|
||||
const outputLower = output.output.toLowerCase()
|
||||
const hasEditError = EDIT_ERROR_PATTERNS.some((pattern) =>
|
||||
outputLower.includes(pattern.toLowerCase())
|
||||
)
|
||||
|
||||
if (hasEditError) {
|
||||
output.output += `\n${EDIT_ERROR_REMINDER}`
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -24,3 +24,4 @@ export { createEmptyMessageSanitizerHook } from "./empty-message-sanitizer";
|
||||
export { createThinkingBlockValidatorHook } from "./thinking-block-validator";
|
||||
export { createRalphLoopHook, type RalphLoopHook } from "./ralph-loop";
|
||||
export { createAutoSlashCommandHook } from "./auto-slash-command";
|
||||
export { createEditErrorRecoveryHook } from "./edit-error-recovery";
|
||||
|
||||
@@ -2,54 +2,89 @@ export const CODE_BLOCK_PATTERN = /```[\s\S]*?```/g
|
||||
export const INLINE_CODE_PATTERN = /`[^`]+`/g
|
||||
|
||||
export const KEYWORD_DETECTORS: Array<{ pattern: RegExp; message: string }> = [
|
||||
// ULTRAWORK: ulw, ultrawork
|
||||
{
|
||||
pattern: /(ultrawork|ulw)/i,
|
||||
message: `<ultrawork-mode>
|
||||
[CODE RED] Maximum precision required. Ultrathink before acting.
|
||||
|
||||
YOU MUST LEVERAGE ALL AVAILABLE AGENTS TO THEIR FULLEST POTENTIAL.
|
||||
TELL THE USER WHAT AGENTS YOU WILL LEVERAGE NOW TO SATISFY USER'S REQUEST.
|
||||
## TODO IS YOUR LIFELINE (NON-NEGOTIABLE)
|
||||
|
||||
## AGENT UTILIZATION PRINCIPLES (by capability, not by name)
|
||||
- **Codebase Exploration**: Spawn exploration agents using BACKGROUND TASKS for file patterns, internal implementations, project structure
|
||||
- **Documentation & References**: Use librarian-type agents via BACKGROUND TASKS for API references, examples, external library docs
|
||||
- **Planning & Strategy**: NEVER plan yourself - ALWAYS spawn a dedicated planning agent for work breakdown
|
||||
- **High-IQ Reasoning**: Leverage specialized agents for architecture decisions, code review, strategic planning
|
||||
- **Frontend/UI Tasks**: Delegate to UI-specialized agents for design and implementation
|
||||
**USE TodoWrite OBSESSIVELY. This is the #1 most important tool.**
|
||||
|
||||
## EXECUTION RULES
|
||||
- **TODO**: Track EVERY step. Mark complete IMMEDIATELY after each.
|
||||
- **PARALLEL**: Fire independent agent calls simultaneously via background_task - NEVER wait sequentially.
|
||||
- **BACKGROUND FIRST**: Use background_task for exploration/research agents (10+ concurrent if needed).
|
||||
- **VERIFY**: Re-read request after completion. Check ALL requirements met before reporting done.
|
||||
- **DELEGATE**: Don't do everything yourself - orchestrate specialized agents for their strengths.
|
||||
### TODO Rules
|
||||
1. **BEFORE any action**: Create TODOs FIRST. Break down into atomic, granular steps.
|
||||
2. **Be excessively detailed**: 10 small TODOs > 3 vague TODOs. Err on the side of too many.
|
||||
3. **Real-time updates**: Mark \`in_progress\` before starting, \`completed\` IMMEDIATELY after. NEVER batch.
|
||||
4. **One at a time**: Only ONE TODO should be \`in_progress\` at any moment.
|
||||
5. **Sub-tasks**: Complex TODO? Break it into sub-TODOs. Keep granularity high.
|
||||
6. **Questions too**: User asks a question? TODO: "Answer with evidence: [question]"
|
||||
|
||||
## WORKFLOW
|
||||
1. Analyze the request and identify required capabilities
|
||||
2. Spawn exploration/librarian agents via background_task in PARALLEL (10+ if needed)
|
||||
3. Always Use Plan agent with gathered context to create detailed work breakdown
|
||||
4. Execute with continuous verification against original requirements
|
||||
### Example TODO Granularity
|
||||
BAD: "Implement user auth"
|
||||
GOOD:
|
||||
- "Read existing auth patterns in codebase"
|
||||
- "Create auth schema types"
|
||||
- "Implement login endpoint"
|
||||
- "Implement token validation middleware"
|
||||
- "Add auth tests - login success case"
|
||||
- "Add auth tests - login failure case"
|
||||
- "Verify LSP diagnostics clean"
|
||||
|
||||
## TDD (if test infrastructure exists)
|
||||
**YOUR WORK IS INVISIBLE WITHOUT TODOs. USE THEM.**
|
||||
|
||||
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
|
||||
## TDD WORKFLOW (MANDATORY when tests exist)
|
||||
|
||||
## 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.
|
||||
Check for test infrastructure FIRST. If exists, follow strictly:
|
||||
|
||||
THE USER ASKED FOR X. DELIVER EXACTLY X. NOT A SUBSET. NOT A DEMO. NOT A STARTING POINT.
|
||||
1. **RED**: Write failing test FIRST → \`bun test\` must FAIL
|
||||
2. **GREEN**: Write MINIMAL code to pass → \`bun test\` must PASS
|
||||
3. **REFACTOR**: Clean up, tests stay green → \`bun test\` still PASS
|
||||
4. **REPEAT**: Next test case, loop until complete
|
||||
|
||||
**NEVER write implementation before test. NEVER delete failing tests.**
|
||||
|
||||
## AGENT DEPLOYMENT
|
||||
|
||||
Fire available agents in PARALLEL via background tasks. Use explore/librarian agents liberally (multiple concurrent if needed).
|
||||
|
||||
## EVIDENCE-BASED ANSWERS
|
||||
|
||||
- Every claim: code snippet + file path + line number
|
||||
- No "I think..." - find and SHOW actual code
|
||||
- Local search fails? → librarian for external sources
|
||||
- **NEVER acceptable**: "I couldn't find it"
|
||||
|
||||
## ZERO TOLERANCE FOR SHORTCUTS (RIGOROUS & HONEST EXECUTION)
|
||||
|
||||
**CORE PRINCIPLE**: Execute user's ORIGINAL INTENT with maximum rigor. No shortcuts. No compromises. No matter how large the task.
|
||||
|
||||
### ABSOLUTE PROHIBITIONS
|
||||
| Violation | Why It's Forbidden |
|
||||
|-----------|-------------------|
|
||||
| **Mocking/Stubbing** | Never use mocks, stubs, or fake implementations unless explicitly requested. Real implementation only. |
|
||||
| **Scope Reduction** | Never make "demo", "skeleton", "simplified", "basic", "minimal" versions. Deliver FULL implementation. |
|
||||
| **Partial Completion** | Never stop at 60-80% saying "you can extend this...", "as an exercise...", "you can add...". Finish 100%. |
|
||||
| **Lazy Placeholders** | Never use "// TODO", "...", "etc.", "and so on" in actual code. Complete everything. |
|
||||
| **Assumed Shortcuts** | Never skip requirements deemed "optional" or "can be added later". All requirements are mandatory. |
|
||||
| **Test Deletion** | Never delete or skip failing tests. Fix the code, not the tests. |
|
||||
| **Evidence-Free Claims** | Never say "I think...", "probably...", "should work...". Show actual code/output. |
|
||||
|
||||
### RIGOROUS EXECUTION MANDATE
|
||||
1. **Parse Original Intent**: What did the user ACTUALLY want? Not what's convenient. The REAL, COMPLETE request.
|
||||
2. **No Task Too Large**: If the task requires 100 files, modify 100 files. If it needs 1000 lines, write 1000 lines. Size is irrelevant.
|
||||
3. **Honest Assessment**: If you cannot complete something, say so BEFORE starting. Don't fake completion.
|
||||
4. **Evidence-Based Verification**: Every claim backed by code snippets, file paths, line numbers, and actual outputs.
|
||||
5. **Complete Verification**: Re-read original request after completion. Check EVERY requirement was met.
|
||||
|
||||
### FAILURE RECOVERY
|
||||
If you realize you've taken shortcuts:
|
||||
1. STOP immediately
|
||||
2. Identify what you skipped/faked
|
||||
3. Create TODOs for ALL remaining work
|
||||
4. Execute to TRUE completion - not "good enough"
|
||||
|
||||
**THE USER ASKED FOR X. DELIVER EXACTLY X. COMPLETELY. HONESTLY. NO MATTER THE SIZE.**
|
||||
|
||||
## SUCCESS = All TODOs Done + All Requirements Met + Evidence Provided
|
||||
|
||||
</ultrawork-mode>
|
||||
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { detectKeywords, detectKeywordsWithType, extractPromptText } from "./detector"
|
||||
import { detectKeywordsWithType, extractPromptText, removeCodeBlocks } from "./detector"
|
||||
import { log } from "../../shared"
|
||||
import { injectHookMessage } from "../../features/hook-message-injector"
|
||||
|
||||
export * from "./detector"
|
||||
export * from "./constants"
|
||||
export * from "./types"
|
||||
|
||||
const sessionFirstMessageProcessed = new Set<string>()
|
||||
const sessionUltraworkNotified = new Set<string>()
|
||||
|
||||
export function createKeywordDetectorHook(ctx: PluginInput) {
|
||||
return {
|
||||
"chat.message": async (
|
||||
@@ -24,65 +20,35 @@ export function createKeywordDetectorHook(ctx: PluginInput) {
|
||||
parts: Array<{ type: string; text?: string; [key: string]: unknown }>
|
||||
}
|
||||
): Promise<void> => {
|
||||
const isFirstMessage = !sessionFirstMessageProcessed.has(input.sessionID)
|
||||
sessionFirstMessageProcessed.add(input.sessionID)
|
||||
|
||||
const promptText = extractPromptText(output.parts)
|
||||
const detectedKeywords = detectKeywordsWithType(promptText)
|
||||
const messages = detectedKeywords.map((k) => k.message)
|
||||
const detectedKeywords = detectKeywordsWithType(removeCodeBlocks(promptText))
|
||||
|
||||
if (messages.length === 0) {
|
||||
if (detectedKeywords.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const hasUltrawork = detectedKeywords.some((k) => k.type === "ultrawork")
|
||||
if (hasUltrawork && !sessionUltraworkNotified.has(input.sessionID)) {
|
||||
sessionUltraworkNotified.add(input.sessionID)
|
||||
if (hasUltrawork) {
|
||||
log(`[keyword-detector] Ultrawork mode activated`, { sessionID: input.sessionID })
|
||||
|
||||
ctx.client.tui.showToast({
|
||||
body: {
|
||||
title: "Ultrawork Mode Activated",
|
||||
message: "Maximum precision engaged. All agents at your disposal.",
|
||||
variant: "success" as const,
|
||||
duration: 3000,
|
||||
},
|
||||
}).catch((err) => log(`[keyword-detector] Failed to show toast`, { error: err, sessionID: input.sessionID }))
|
||||
|
||||
ctx.client.tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: "Ultrawork Mode Activated",
|
||||
message: "Maximum precision engaged. All agents at your disposal.",
|
||||
variant: "success" as const,
|
||||
duration: 3000,
|
||||
},
|
||||
})
|
||||
.catch((err) =>
|
||||
log(`[keyword-detector] Failed to show toast`, { error: err, sessionID: input.sessionID })
|
||||
)
|
||||
}
|
||||
|
||||
const context = messages.join("\n")
|
||||
|
||||
// First message: transform parts directly (for title generation compatibility)
|
||||
if (isFirstMessage) {
|
||||
log(`Keywords detected on first message, transforming parts directly`, { sessionID: input.sessionID, keywordCount: messages.length })
|
||||
const idx = output.parts.findIndex((p) => p.type === "text" && p.text)
|
||||
if (idx >= 0) {
|
||||
output.parts[idx].text = `${context}\n\n---\n\n${output.parts[idx].text ?? ""}`
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Subsequent messages: inject as separate message
|
||||
log(`Keywords detected: ${messages.length}`, { sessionID: input.sessionID })
|
||||
|
||||
const message = output.message as {
|
||||
agent?: string
|
||||
model?: { modelID?: string; providerID?: string }
|
||||
path?: { cwd?: string; root?: string }
|
||||
tools?: Record<string, boolean>
|
||||
}
|
||||
|
||||
log(`[keyword-detector] Injecting context for ${messages.length} keywords`, { sessionID: input.sessionID, contextLength: context.length })
|
||||
const success = injectHookMessage(input.sessionID, context, {
|
||||
agent: message.agent,
|
||||
model: message.model,
|
||||
path: message.path,
|
||||
tools: message.tools,
|
||||
log(`[keyword-detector] Detected ${detectedKeywords.length} keywords`, {
|
||||
sessionID: input.sessionID,
|
||||
types: detectedKeywords.map((k) => k.type),
|
||||
})
|
||||
|
||||
if (success) {
|
||||
log("Keyword context injected", { sessionID: input.sessionID })
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
150
src/hooks/non-interactive-env/index.test.ts
Normal file
150
src/hooks/non-interactive-env/index.test.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { describe, test, expect } from "bun:test"
|
||||
import { createNonInteractiveEnvHook, NON_INTERACTIVE_ENV } from "./index"
|
||||
|
||||
describe("non-interactive-env hook", () => {
|
||||
const mockCtx = {} as Parameters<typeof createNonInteractiveEnvHook>[0]
|
||||
|
||||
describe("git command modification", () => {
|
||||
test("#given git command #when hook executes #then prepends export statement", async () => {
|
||||
const hook = createNonInteractiveEnvHook(mockCtx)
|
||||
const output: { args: Record<string, unknown>; message?: string } = {
|
||||
args: { command: "git commit -m 'test'" },
|
||||
}
|
||||
|
||||
await hook["tool.execute.before"](
|
||||
{ tool: "bash", sessionID: "test", callID: "1" },
|
||||
output
|
||||
)
|
||||
|
||||
const cmd = output.args.command as string
|
||||
expect(cmd).toStartWith("export ")
|
||||
expect(cmd).toContain("GIT_EDITOR=:")
|
||||
expect(cmd).toContain("EDITOR=:")
|
||||
expect(cmd).toContain("PAGER=cat")
|
||||
expect(cmd).toContain("; git commit -m 'test'")
|
||||
})
|
||||
|
||||
test("#given chained git commands #when hook executes #then export applies to all", async () => {
|
||||
const hook = createNonInteractiveEnvHook(mockCtx)
|
||||
const output: { args: Record<string, unknown>; message?: string } = {
|
||||
args: { command: "git add file && git rebase --continue" },
|
||||
}
|
||||
|
||||
await hook["tool.execute.before"](
|
||||
{ tool: "bash", sessionID: "test", callID: "1" },
|
||||
output
|
||||
)
|
||||
|
||||
const cmd = output.args.command as string
|
||||
expect(cmd).toStartWith("export ")
|
||||
expect(cmd).toContain("; git add file && git rebase --continue")
|
||||
})
|
||||
|
||||
test("#given non-git bash command #when hook executes #then command unchanged", async () => {
|
||||
const hook = createNonInteractiveEnvHook(mockCtx)
|
||||
const output: { args: Record<string, unknown>; message?: string } = {
|
||||
args: { command: "ls -la" },
|
||||
}
|
||||
|
||||
await hook["tool.execute.before"](
|
||||
{ tool: "bash", sessionID: "test", callID: "1" },
|
||||
output
|
||||
)
|
||||
|
||||
expect(output.args.command).toBe("ls -la")
|
||||
})
|
||||
|
||||
test("#given non-bash tool #when hook executes #then command unchanged", async () => {
|
||||
const hook = createNonInteractiveEnvHook(mockCtx)
|
||||
const output: { args: Record<string, unknown>; message?: string } = {
|
||||
args: { command: "git status" },
|
||||
}
|
||||
|
||||
await hook["tool.execute.before"](
|
||||
{ tool: "Read", sessionID: "test", callID: "1" },
|
||||
output
|
||||
)
|
||||
|
||||
expect(output.args.command).toBe("git status")
|
||||
})
|
||||
|
||||
test("#given empty command #when hook executes #then no error", async () => {
|
||||
const hook = createNonInteractiveEnvHook(mockCtx)
|
||||
const output: { args: Record<string, unknown>; message?: string } = {
|
||||
args: {},
|
||||
}
|
||||
|
||||
await hook["tool.execute.before"](
|
||||
{ tool: "bash", sessionID: "test", callID: "1" },
|
||||
output
|
||||
)
|
||||
|
||||
expect(output.args.command).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("shell escaping", () => {
|
||||
test("#given git command #when building prefix #then VISUAL properly escaped", async () => {
|
||||
const hook = createNonInteractiveEnvHook(mockCtx)
|
||||
const output: { args: Record<string, unknown>; message?: string } = {
|
||||
args: { command: "git status" },
|
||||
}
|
||||
|
||||
await hook["tool.execute.before"](
|
||||
{ tool: "bash", sessionID: "test", callID: "1" },
|
||||
output
|
||||
)
|
||||
|
||||
const cmd = output.args.command as string
|
||||
expect(cmd).toContain("VISUAL=''")
|
||||
})
|
||||
|
||||
test("#given git command #when building prefix #then all NON_INTERACTIVE_ENV vars included", async () => {
|
||||
const hook = createNonInteractiveEnvHook(mockCtx)
|
||||
const output: { args: Record<string, unknown>; message?: string } = {
|
||||
args: { command: "git log" },
|
||||
}
|
||||
|
||||
await hook["tool.execute.before"](
|
||||
{ tool: "bash", sessionID: "test", callID: "1" },
|
||||
output
|
||||
)
|
||||
|
||||
const cmd = output.args.command as string
|
||||
for (const key of Object.keys(NON_INTERACTIVE_ENV)) {
|
||||
expect(cmd).toContain(`${key}=`)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("banned command detection", () => {
|
||||
test("#given vim command #when hook executes #then warning message set", async () => {
|
||||
const hook = createNonInteractiveEnvHook(mockCtx)
|
||||
const output: { args: Record<string, unknown>; message?: string } = {
|
||||
args: { command: "vim file.txt" },
|
||||
}
|
||||
|
||||
await hook["tool.execute.before"](
|
||||
{ tool: "bash", sessionID: "test", callID: "1" },
|
||||
output
|
||||
)
|
||||
|
||||
expect(output.message).toContain("vim")
|
||||
expect(output.message).toContain("interactive")
|
||||
})
|
||||
|
||||
test("#given safe command #when hook executes #then no warning", async () => {
|
||||
const hook = createNonInteractiveEnvHook(mockCtx)
|
||||
const output: { args: Record<string, unknown>; message?: string } = {
|
||||
args: { command: "ls -la" },
|
||||
}
|
||||
|
||||
await hook["tool.execute.before"](
|
||||
{ tool: "bash", sessionID: "test", callID: "1" },
|
||||
output
|
||||
)
|
||||
|
||||
expect(output.message).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -19,6 +19,35 @@ function detectBannedCommand(command: string): string | undefined {
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Shell-escape a value for use in VAR=value prefix.
|
||||
* Wraps in single quotes if contains special chars.
|
||||
*/
|
||||
function shellEscape(value: string): string {
|
||||
// Empty string needs quotes
|
||||
if (value === "") return "''"
|
||||
// If contains special chars, wrap in single quotes (escape existing single quotes)
|
||||
if (/[^a-zA-Z0-9_\-.:\/]/.test(value)) {
|
||||
return `'${value.replace(/'/g, "'\\''")}'`
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* Build export statement for environment variables.
|
||||
* Uses `export VAR1=val1 VAR2=val2;` format to ensure variables
|
||||
* apply to ALL commands in a chain (e.g., `cmd1 && cmd2`).
|
||||
*
|
||||
* Previous approach used VAR=value prefix which only applies to the first command.
|
||||
* OpenCode's bash tool ignores args.env, so we must prepend to command.
|
||||
*/
|
||||
function buildEnvPrefix(env: Record<string, string>): string {
|
||||
const exports = Object.entries(env)
|
||||
.map(([key, value]) => `${key}=${shellEscape(value)}`)
|
||||
.join(" ")
|
||||
return `export ${exports};`
|
||||
}
|
||||
|
||||
export function createNonInteractiveEnvHook(_ctx: PluginInput) {
|
||||
return {
|
||||
"tool.execute.before": async (
|
||||
@@ -34,20 +63,27 @@ export function createNonInteractiveEnvHook(_ctx: PluginInput) {
|
||||
return
|
||||
}
|
||||
|
||||
output.args.env = {
|
||||
...process.env,
|
||||
...(output.args.env as Record<string, string> | undefined),
|
||||
...NON_INTERACTIVE_ENV,
|
||||
}
|
||||
|
||||
const bannedCmd = detectBannedCommand(command)
|
||||
if (bannedCmd) {
|
||||
output.message = `⚠️ Warning: '${bannedCmd}' is an interactive command that may hang in non-interactive environments.`
|
||||
}
|
||||
|
||||
log(`[${HOOK_NAME}] Set non-interactive environment variables`, {
|
||||
// Only prepend env vars for git commands (editor blocking, pager, etc.)
|
||||
const isGitCommand = /\bgit\b/.test(command)
|
||||
if (!isGitCommand) {
|
||||
return
|
||||
}
|
||||
|
||||
// OpenCode's bash tool uses hardcoded `...process.env` in spawn(),
|
||||
// ignoring any args.env we might set. Prepend export statement to command.
|
||||
// Uses `export VAR=val;` format to ensure variables apply to ALL commands
|
||||
// in a chain (e.g., `git add file && git rebase --continue`).
|
||||
const envPrefix = buildEnvPrefix(NON_INTERACTIVE_ENV)
|
||||
output.args.command = `${envPrefix} ${command}`
|
||||
|
||||
log(`[${HOOK_NAME}] Prepended non-interactive env vars to git command`, {
|
||||
sessionID: input.sessionID,
|
||||
env: NON_INTERACTIVE_ENV,
|
||||
envPrefix,
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
@@ -48,7 +48,11 @@ interface MessageWrapper {
|
||||
}
|
||||
|
||||
const CLAUDE_MODEL_PATTERN = /claude-(opus|sonnet|haiku)/i
|
||||
const CLAUDE_DEFAULT_CONTEXT_LIMIT = 200_000
|
||||
const CLAUDE_DEFAULT_CONTEXT_LIMIT =
|
||||
process.env.ANTHROPIC_1M_CONTEXT === "true" ||
|
||||
process.env.VERTEX_ANTHROPIC_1M_CONTEXT === "true"
|
||||
? 1_000_000
|
||||
: 200_000
|
||||
|
||||
function isSupportedModel(modelID: string): boolean {
|
||||
return CLAUDE_MODEL_PATTERN.test(modelID)
|
||||
|
||||
@@ -302,6 +302,77 @@ describe("ralph-loop", () => {
|
||||
expect(promptCalls.length).toBe(0)
|
||||
})
|
||||
|
||||
test("should clear orphaned state when original session no longer exists", async () => {
|
||||
// #given - state file exists from a previous session that no longer exists
|
||||
const state: RalphLoopState = {
|
||||
active: true,
|
||||
iteration: 3,
|
||||
max_iterations: 50,
|
||||
completion_promise: "DONE",
|
||||
started_at: "2025-12-30T01:00:00Z",
|
||||
prompt: "Build something",
|
||||
session_id: "orphaned-session-999", // This session no longer exists
|
||||
}
|
||||
writeState(TEST_DIR, state)
|
||||
|
||||
// Mock sessionExists to return false for the orphaned session
|
||||
const hook = createRalphLoopHook(createMockPluginInput(), {
|
||||
checkSessionExists: async (sessionID: string) => {
|
||||
// Orphaned session doesn't exist, current session does
|
||||
return sessionID !== "orphaned-session-999"
|
||||
},
|
||||
})
|
||||
|
||||
// #when - a new session goes idle (different from the orphaned session in state)
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: "new-session-456" },
|
||||
},
|
||||
})
|
||||
|
||||
// #then - orphaned state should be cleared
|
||||
expect(hook.getState()).toBeNull()
|
||||
// #then - no continuation injected (state was cleared, not resumed)
|
||||
expect(promptCalls.length).toBe(0)
|
||||
})
|
||||
|
||||
test("should NOT clear state when original session still exists (different active session)", async () => {
|
||||
// #given - state file exists from a session that still exists
|
||||
const state: RalphLoopState = {
|
||||
active: true,
|
||||
iteration: 2,
|
||||
max_iterations: 50,
|
||||
completion_promise: "DONE",
|
||||
started_at: "2025-12-30T01:00:00Z",
|
||||
prompt: "Build something",
|
||||
session_id: "active-session-123", // This session still exists
|
||||
}
|
||||
writeState(TEST_DIR, state)
|
||||
|
||||
// Mock sessionExists to return true for the active session
|
||||
const hook = createRalphLoopHook(createMockPluginInput(), {
|
||||
checkSessionExists: async (sessionID: string) => {
|
||||
// Original session still exists
|
||||
return sessionID === "active-session-123" || sessionID === "new-session-456"
|
||||
},
|
||||
})
|
||||
|
||||
// #when - a different session goes idle
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: "new-session-456" },
|
||||
},
|
||||
})
|
||||
|
||||
// #then - state should NOT be cleared (original session still active)
|
||||
expect(hook.getState()).not.toBeNull()
|
||||
expect(hook.getState()?.session_id).toBe("active-session-123")
|
||||
// #then - no continuation injected (it's a different session's loop)
|
||||
expect(promptCalls.length).toBe(0)
|
||||
})
|
||||
|
||||
test("should use default config values", () => {
|
||||
// #given - hook with config
|
||||
const hook = createRalphLoopHook(createMockPluginInput(), {
|
||||
@@ -423,5 +494,162 @@ describe("ralph-loop", () => {
|
||||
expect(promptCalls[0].text).toContain("Create a calculator app")
|
||||
expect(promptCalls[0].text).toContain("<promise>CALCULATOR_DONE</promise>")
|
||||
})
|
||||
|
||||
test("should clear loop state on user abort (MessageAbortedError)", async () => {
|
||||
// #given - active loop
|
||||
const hook = createRalphLoopHook(createMockPluginInput())
|
||||
hook.startLoop("session-123", "Build something")
|
||||
expect(hook.getState()).not.toBeNull()
|
||||
|
||||
// #when - user aborts (Ctrl+C)
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "session.error",
|
||||
properties: {
|
||||
sessionID: "session-123",
|
||||
error: { name: "MessageAbortedError", message: "User aborted" },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// #then - loop state should be cleared immediately
|
||||
expect(hook.getState()).toBeNull()
|
||||
})
|
||||
|
||||
test("should NOT set recovery mode on user abort", async () => {
|
||||
// #given - active loop
|
||||
const hook = createRalphLoopHook(createMockPluginInput())
|
||||
hook.startLoop("session-123", "Build something")
|
||||
|
||||
// #when - user aborts (Ctrl+C)
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "session.error",
|
||||
properties: {
|
||||
sessionID: "session-123",
|
||||
error: { name: "MessageAbortedError" },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Start a new loop
|
||||
hook.startLoop("session-123", "New task")
|
||||
|
||||
// #when - session goes idle immediately (should work, no recovery mode)
|
||||
await hook.event({
|
||||
event: { type: "session.idle", properties: { sessionID: "session-123" } },
|
||||
})
|
||||
|
||||
// #then - continuation should be injected (not blocked by recovery)
|
||||
expect(promptCalls.length).toBe(1)
|
||||
})
|
||||
|
||||
test("should only check LAST assistant message for completion", async () => {
|
||||
// #given - multiple assistant messages, only first has completion promise
|
||||
mockSessionMessages = [
|
||||
{ info: { role: "user" }, parts: [{ type: "text", text: "Start task" }] },
|
||||
{ info: { role: "assistant" }, parts: [{ type: "text", text: "I'll work on it. <promise>DONE</promise>" }] },
|
||||
{ info: { role: "user" }, parts: [{ type: "text", text: "Continue" }] },
|
||||
{ info: { role: "assistant" }, parts: [{ type: "text", text: "Working on more features..." }] },
|
||||
]
|
||||
const hook = createRalphLoopHook(createMockPluginInput(), {
|
||||
getTranscriptPath: () => join(TEST_DIR, "nonexistent.jsonl"),
|
||||
})
|
||||
hook.startLoop("session-123", "Build something", { completionPromise: "DONE" })
|
||||
|
||||
// #when - session goes idle
|
||||
await hook.event({
|
||||
event: { type: "session.idle", properties: { sessionID: "session-123" } },
|
||||
})
|
||||
|
||||
// #then - loop should continue (last message has no completion promise)
|
||||
expect(promptCalls.length).toBe(1)
|
||||
expect(hook.getState()?.iteration).toBe(2)
|
||||
})
|
||||
|
||||
test("should detect completion only in LAST assistant message", async () => {
|
||||
// #given - last assistant message has completion promise
|
||||
mockSessionMessages = [
|
||||
{ info: { role: "user" }, parts: [{ type: "text", text: "Start task" }] },
|
||||
{ info: { role: "assistant" }, parts: [{ type: "text", text: "Starting work..." }] },
|
||||
{ info: { role: "user" }, parts: [{ type: "text", text: "Continue" }] },
|
||||
{ info: { role: "assistant" }, parts: [{ type: "text", text: "Task complete! <promise>DONE</promise>" }] },
|
||||
]
|
||||
const hook = createRalphLoopHook(createMockPluginInput(), {
|
||||
getTranscriptPath: () => join(TEST_DIR, "nonexistent.jsonl"),
|
||||
})
|
||||
hook.startLoop("session-123", "Build something", { completionPromise: "DONE" })
|
||||
|
||||
// #when - session goes idle
|
||||
await hook.event({
|
||||
event: { type: "session.idle", properties: { sessionID: "session-123" } },
|
||||
})
|
||||
|
||||
// #then - loop should complete (last message has completion promise)
|
||||
expect(promptCalls.length).toBe(0)
|
||||
expect(toastCalls.some((t) => t.title === "Ralph Loop Complete!")).toBe(true)
|
||||
expect(hook.getState()).toBeNull()
|
||||
})
|
||||
|
||||
test("should check transcript BEFORE API to optimize performance", async () => {
|
||||
// #given - transcript has completion promise
|
||||
const transcriptPath = join(TEST_DIR, "transcript.jsonl")
|
||||
writeFileSync(transcriptPath, JSON.stringify({ content: "<promise>DONE</promise>" }))
|
||||
mockSessionMessages = [
|
||||
{ info: { role: "assistant" }, parts: [{ type: "text", text: "No promise here" }] },
|
||||
]
|
||||
const hook = createRalphLoopHook(createMockPluginInput(), {
|
||||
getTranscriptPath: () => transcriptPath,
|
||||
})
|
||||
hook.startLoop("session-123", "Build something", { completionPromise: "DONE" })
|
||||
|
||||
// #when - session goes idle
|
||||
await hook.event({
|
||||
event: { type: "session.idle", properties: { sessionID: "session-123" } },
|
||||
})
|
||||
|
||||
// #then - should complete via transcript (API not called when transcript succeeds)
|
||||
expect(promptCalls.length).toBe(0)
|
||||
expect(hook.getState()).toBeNull()
|
||||
// API should NOT be called since transcript found completion
|
||||
expect(messagesCalls.length).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("API timeout protection", () => {
|
||||
test("should not hang when session.messages() times out", async () => {
|
||||
// #given - slow API that takes longer than timeout
|
||||
const slowMock = {
|
||||
...createMockPluginInput(),
|
||||
client: {
|
||||
...createMockPluginInput().client,
|
||||
session: {
|
||||
...createMockPluginInput().client.session,
|
||||
messages: async () => {
|
||||
// Simulate slow API (would hang without timeout)
|
||||
await new Promise((resolve) => setTimeout(resolve, 10000))
|
||||
return { data: [] }
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
const hook = createRalphLoopHook(slowMock as any, {
|
||||
getTranscriptPath: () => join(TEST_DIR, "nonexistent.jsonl"),
|
||||
apiTimeout: 100, // 100ms timeout for test
|
||||
})
|
||||
hook.startLoop("session-123", "Build something")
|
||||
|
||||
// #when - session goes idle (API will timeout)
|
||||
const startTime = Date.now()
|
||||
await hook.event({
|
||||
event: { type: "session.idle", properties: { sessionID: "session-123" } },
|
||||
})
|
||||
const elapsed = Date.now() - startTime
|
||||
|
||||
// #then - should complete within timeout + buffer (not hang for 10s)
|
||||
expect(elapsed).toBeLessThan(500)
|
||||
// #then - loop should continue (API timeout = no completion detected)
|
||||
expect(promptCalls.length).toBe(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -53,6 +53,8 @@ export interface RalphLoopHook {
|
||||
getState: () => RalphLoopState | null
|
||||
}
|
||||
|
||||
const DEFAULT_API_TIMEOUT = 3000
|
||||
|
||||
export function createRalphLoopHook(
|
||||
ctx: PluginInput,
|
||||
options?: RalphLoopOptions
|
||||
@@ -61,6 +63,8 @@ export function createRalphLoopHook(
|
||||
const config = options?.config
|
||||
const stateDir = config?.state_dir
|
||||
const getTranscriptPath = options?.getTranscriptPath ?? getDefaultTranscriptPath
|
||||
const apiTimeout = options?.apiTimeout ?? DEFAULT_API_TIMEOUT
|
||||
const checkSessionExists = options?.checkSessionExists
|
||||
|
||||
function getSessionState(sessionID: string): SessionState {
|
||||
let state = sessions.get(sessionID)
|
||||
@@ -97,32 +101,34 @@ export function createRalphLoopHook(
|
||||
promise: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const response = await ctx.client.session.messages({
|
||||
path: { id: sessionID },
|
||||
query: { directory: ctx.directory },
|
||||
})
|
||||
const response = await Promise.race([
|
||||
ctx.client.session.messages({
|
||||
path: { id: sessionID },
|
||||
query: { directory: ctx.directory },
|
||||
}),
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error("API timeout")), apiTimeout)
|
||||
),
|
||||
])
|
||||
|
||||
const messages = (response as { data?: unknown[] }).data ?? []
|
||||
|
||||
if (!Array.isArray(messages)) return false
|
||||
|
||||
const assistantMessages = (messages as OpenCodeSessionMessage[]).filter(
|
||||
(msg) => msg.info?.role === "assistant"
|
||||
)
|
||||
const lastAssistant = assistantMessages[assistantMessages.length - 1]
|
||||
if (!lastAssistant?.parts) return false
|
||||
|
||||
const pattern = new RegExp(`<promise>\\s*${escapeRegex(promise)}\\s*</promise>`, "is")
|
||||
const responseText = lastAssistant.parts
|
||||
.filter((p) => p.type === "text")
|
||||
.map((p) => p.text ?? "")
|
||||
.join("\n")
|
||||
|
||||
for (const msg of messages as OpenCodeSessionMessage[]) {
|
||||
if (msg.info?.role !== "assistant") continue
|
||||
|
||||
for (const part of msg.parts || []) {
|
||||
if (part.type === "text" && part.text) {
|
||||
if (pattern.test(part.text)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
return pattern.test(responseText)
|
||||
} catch (err) {
|
||||
log(`[${HOOK_NAME}] Failed to fetch session messages`, { sessionID, error: String(err) })
|
||||
log(`[${HOOK_NAME}] Session messages check failed`, { sessionID, error: String(err) })
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -194,23 +200,40 @@ export function createRalphLoopHook(
|
||||
}
|
||||
|
||||
if (state.session_id && state.session_id !== sessionID) {
|
||||
if (checkSessionExists) {
|
||||
try {
|
||||
const originalSessionExists = await checkSessionExists(state.session_id)
|
||||
if (!originalSessionExists) {
|
||||
clearState(ctx.directory, stateDir)
|
||||
log(`[${HOOK_NAME}] Cleared orphaned state from deleted session`, {
|
||||
orphanedSessionId: state.session_id,
|
||||
currentSessionId: sessionID,
|
||||
})
|
||||
return
|
||||
}
|
||||
} catch (err) {
|
||||
log(`[${HOOK_NAME}] Failed to check session existence`, {
|
||||
sessionId: state.session_id,
|
||||
error: String(err),
|
||||
})
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const completionDetectedViaApi = await detectCompletionInSessionMessages(
|
||||
sessionID,
|
||||
state.completion_promise
|
||||
)
|
||||
|
||||
const transcriptPath = getTranscriptPath(sessionID)
|
||||
const completionDetectedViaTranscript = detectCompletionPromise(transcriptPath, state.completion_promise)
|
||||
|
||||
if (completionDetectedViaApi || completionDetectedViaTranscript) {
|
||||
const completionDetectedViaApi = completionDetectedViaTranscript
|
||||
? false
|
||||
: await detectCompletionInSessionMessages(sessionID, state.completion_promise)
|
||||
|
||||
if (completionDetectedViaTranscript || completionDetectedViaApi) {
|
||||
log(`[${HOOK_NAME}] Completion detected!`, {
|
||||
sessionID,
|
||||
iteration: state.iteration,
|
||||
promise: state.completion_promise,
|
||||
detectedVia: completionDetectedViaApi ? "session_messages_api" : "transcript_file",
|
||||
detectedVia: completionDetectedViaTranscript ? "transcript_file" : "session_messages_api",
|
||||
})
|
||||
clearState(ctx.directory, stateDir)
|
||||
|
||||
@@ -308,6 +331,20 @@ export function createRalphLoopHook(
|
||||
|
||||
if (event.type === "session.error") {
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
const error = props?.error as { name?: string } | undefined
|
||||
|
||||
if (error?.name === "MessageAbortedError") {
|
||||
if (sessionID) {
|
||||
const state = readState(ctx.directory, stateDir)
|
||||
if (state?.session_id === sessionID) {
|
||||
clearState(ctx.directory, stateDir)
|
||||
log(`[${HOOK_NAME}] User aborted, loop cleared`, { sessionID })
|
||||
}
|
||||
sessions.delete(sessionID)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (sessionID) {
|
||||
const sessionState = getSessionState(sessionID)
|
||||
sessionState.isRecovering = true
|
||||
|
||||
@@ -13,4 +13,6 @@ export interface RalphLoopState {
|
||||
export interface RalphLoopOptions {
|
||||
config?: RalphLoopConfig
|
||||
getTranscriptPath?: (sessionId: string) => string
|
||||
apiTimeout?: number
|
||||
checkSessionExists?: (sessionId: string) => Promise<boolean>
|
||||
}
|
||||
|
||||
203
src/hooks/session-recovery/index.test.ts
Normal file
203
src/hooks/session-recovery/index.test.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import { detectErrorType } from "./index"
|
||||
|
||||
describe("detectErrorType", () => {
|
||||
describe("thinking_block_order errors", () => {
|
||||
it("should detect 'first block' error pattern", () => {
|
||||
// #given an error about thinking being the first block
|
||||
const error = {
|
||||
message: "messages.0: thinking block must not be the first block",
|
||||
}
|
||||
|
||||
// #when detectErrorType is called
|
||||
const result = detectErrorType(error)
|
||||
|
||||
// #then should return thinking_block_order
|
||||
expect(result).toBe("thinking_block_order")
|
||||
})
|
||||
|
||||
it("should detect 'must start with' error pattern", () => {
|
||||
// #given an error about message must start with something
|
||||
const error = {
|
||||
message: "messages.5: thinking must start with text or tool_use",
|
||||
}
|
||||
|
||||
// #when detectErrorType is called
|
||||
const result = detectErrorType(error)
|
||||
|
||||
// #then should return thinking_block_order
|
||||
expect(result).toBe("thinking_block_order")
|
||||
})
|
||||
|
||||
it("should detect 'preceeding' error pattern", () => {
|
||||
// #given an error about preceeding block
|
||||
const error = {
|
||||
message: "messages.10: thinking requires preceeding text block",
|
||||
}
|
||||
|
||||
// #when detectErrorType is called
|
||||
const result = detectErrorType(error)
|
||||
|
||||
// #then should return thinking_block_order
|
||||
expect(result).toBe("thinking_block_order")
|
||||
})
|
||||
|
||||
it("should detect 'expected/found' error pattern", () => {
|
||||
// #given an error about expected vs found
|
||||
const error = {
|
||||
message: "messages.3: thinking block expected text but found tool_use",
|
||||
}
|
||||
|
||||
// #when detectErrorType is called
|
||||
const result = detectErrorType(error)
|
||||
|
||||
// #then should return thinking_block_order
|
||||
expect(result).toBe("thinking_block_order")
|
||||
})
|
||||
|
||||
it("should detect 'final block cannot be thinking' error pattern", () => {
|
||||
// #given an error about final block cannot be thinking
|
||||
const error = {
|
||||
message:
|
||||
"messages.125: The final block in an assistant message cannot be thinking.",
|
||||
}
|
||||
|
||||
// #when detectErrorType is called
|
||||
const result = detectErrorType(error)
|
||||
|
||||
// #then should return thinking_block_order
|
||||
expect(result).toBe("thinking_block_order")
|
||||
})
|
||||
|
||||
it("should detect 'final block' variant error pattern", () => {
|
||||
// #given an error mentioning final block with thinking
|
||||
const error = {
|
||||
message:
|
||||
"messages.17: thinking in the final block is not allowed in assistant messages",
|
||||
}
|
||||
|
||||
// #when detectErrorType is called
|
||||
const result = detectErrorType(error)
|
||||
|
||||
// #then should return thinking_block_order
|
||||
expect(result).toBe("thinking_block_order")
|
||||
})
|
||||
|
||||
it("should detect 'cannot be thinking' error pattern", () => {
|
||||
// #given an error using 'cannot be thinking' phrasing
|
||||
const error = {
|
||||
message:
|
||||
"messages.219: The last block in an assistant message cannot be thinking content",
|
||||
}
|
||||
|
||||
// #when detectErrorType is called
|
||||
const result = detectErrorType(error)
|
||||
|
||||
// #then should return thinking_block_order
|
||||
expect(result).toBe("thinking_block_order")
|
||||
})
|
||||
})
|
||||
|
||||
describe("tool_result_missing errors", () => {
|
||||
it("should detect tool_use/tool_result mismatch", () => {
|
||||
// #given an error about tool_use without tool_result
|
||||
const error = {
|
||||
message: "tool_use block requires corresponding tool_result",
|
||||
}
|
||||
|
||||
// #when detectErrorType is called
|
||||
const result = detectErrorType(error)
|
||||
|
||||
// #then should return tool_result_missing
|
||||
expect(result).toBe("tool_result_missing")
|
||||
})
|
||||
})
|
||||
|
||||
describe("thinking_disabled_violation errors", () => {
|
||||
it("should detect thinking disabled violation", () => {
|
||||
// #given an error about thinking being disabled
|
||||
const error = {
|
||||
message:
|
||||
"thinking is disabled for this model and cannot contain thinking blocks",
|
||||
}
|
||||
|
||||
// #when detectErrorType is called
|
||||
const result = detectErrorType(error)
|
||||
|
||||
// #then should return thinking_disabled_violation
|
||||
expect(result).toBe("thinking_disabled_violation")
|
||||
})
|
||||
})
|
||||
|
||||
describe("unrecognized errors", () => {
|
||||
it("should return null for unrecognized error patterns", () => {
|
||||
// #given an unrelated error
|
||||
const error = {
|
||||
message: "Rate limit exceeded",
|
||||
}
|
||||
|
||||
// #when detectErrorType is called
|
||||
const result = detectErrorType(error)
|
||||
|
||||
// #then should return null
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it("should return null for empty error", () => {
|
||||
// #given an empty error
|
||||
const error = {}
|
||||
|
||||
// #when detectErrorType is called
|
||||
const result = detectErrorType(error)
|
||||
|
||||
// #then should return null
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it("should return null for null error", () => {
|
||||
// #given a null error
|
||||
const error = null
|
||||
|
||||
// #when detectErrorType is called
|
||||
const result = detectErrorType(error)
|
||||
|
||||
// #then should return null
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("nested error objects", () => {
|
||||
it("should detect error in data.error.message path", () => {
|
||||
// #given an error with nested structure
|
||||
const error = {
|
||||
data: {
|
||||
error: {
|
||||
message:
|
||||
"messages.163: The final block in an assistant message cannot be thinking.",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// #when detectErrorType is called
|
||||
const result = detectErrorType(error)
|
||||
|
||||
// #then should return thinking_block_order
|
||||
expect(result).toBe("thinking_block_order")
|
||||
})
|
||||
|
||||
it("should detect error in error.message path", () => {
|
||||
// #given an error with error.message structure
|
||||
const error = {
|
||||
error: {
|
||||
message: "messages.169: final block cannot be thinking",
|
||||
},
|
||||
}
|
||||
|
||||
// #when detectErrorType is called
|
||||
const result = detectErrorType(error)
|
||||
|
||||
// #then should return thinking_block_order
|
||||
expect(result).toBe("thinking_block_order")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -122,7 +122,7 @@ function extractMessageIndex(error: unknown): number | null {
|
||||
return match ? parseInt(match[1], 10) : null
|
||||
}
|
||||
|
||||
function detectErrorType(error: unknown): RecoveryErrorType {
|
||||
export function detectErrorType(error: unknown): RecoveryErrorType {
|
||||
const message = getErrorMessage(error)
|
||||
|
||||
if (message.includes("tool_use") && message.includes("tool_result")) {
|
||||
@@ -134,6 +134,8 @@ function detectErrorType(error: unknown): RecoveryErrorType {
|
||||
(message.includes("first block") ||
|
||||
message.includes("must start with") ||
|
||||
message.includes("preceeding") ||
|
||||
message.includes("final block") ||
|
||||
message.includes("cannot be thinking") ||
|
||||
(message.includes("expected") && message.includes("found")))
|
||||
) {
|
||||
return "thinking_block_order"
|
||||
|
||||
@@ -322,4 +322,140 @@ describe("think-mode switcher", () => {
|
||||
expect(config.maxTokens).toBe(64000)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Custom provider prefixes support", () => {
|
||||
describe("getHighVariant with prefixes", () => {
|
||||
it("should preserve vertex_ai/ prefix when getting high variant", () => {
|
||||
// #given a model ID with vertex_ai/ prefix
|
||||
const variant = getHighVariant("vertex_ai/claude-sonnet-4-5")
|
||||
|
||||
// #then should return high variant with prefix preserved
|
||||
expect(variant).toBe("vertex_ai/claude-sonnet-4-5-high")
|
||||
})
|
||||
|
||||
it("should preserve openai/ prefix when getting high variant", () => {
|
||||
// #given a model ID with openai/ prefix
|
||||
const variant = getHighVariant("openai/gpt-5-2")
|
||||
|
||||
// #then should return high variant with prefix preserved
|
||||
expect(variant).toBe("openai/gpt-5-2-high")
|
||||
})
|
||||
|
||||
it("should handle prefixes with dots in version numbers", () => {
|
||||
// #given a model ID with prefix and dots
|
||||
const variant = getHighVariant("vertex_ai/claude-opus-4.5")
|
||||
|
||||
// #then should normalize dots and preserve prefix
|
||||
expect(variant).toBe("vertex_ai/claude-opus-4-5-high")
|
||||
})
|
||||
|
||||
it("should handle multiple different prefixes", () => {
|
||||
// #given various custom prefixes
|
||||
expect(getHighVariant("azure/gpt-5")).toBe("azure/gpt-5-high")
|
||||
expect(getHighVariant("bedrock/claude-sonnet-4-5")).toBe("bedrock/claude-sonnet-4-5-high")
|
||||
expect(getHighVariant("custom-llm/gemini-3-pro")).toBe("custom-llm/gemini-3-pro-high")
|
||||
})
|
||||
|
||||
it("should return null for prefixed models without high variant mapping", () => {
|
||||
// #given prefixed model IDs without high variant mapping
|
||||
expect(getHighVariant("vertex_ai/unknown-model")).toBeNull()
|
||||
expect(getHighVariant("custom/llama-3-70b")).toBeNull()
|
||||
})
|
||||
|
||||
it("should return null for already-high prefixed models", () => {
|
||||
// #given prefixed model IDs that are already high
|
||||
expect(getHighVariant("vertex_ai/claude-opus-4-5-high")).toBeNull()
|
||||
expect(getHighVariant("openai/gpt-5-2-high")).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("isAlreadyHighVariant with prefixes", () => {
|
||||
it("should detect -high suffix in prefixed models", () => {
|
||||
// #given prefixed model IDs with -high suffix
|
||||
expect(isAlreadyHighVariant("vertex_ai/claude-opus-4-5-high")).toBe(true)
|
||||
expect(isAlreadyHighVariant("openai/gpt-5-2-high")).toBe(true)
|
||||
expect(isAlreadyHighVariant("custom/gemini-3-pro-high")).toBe(true)
|
||||
})
|
||||
|
||||
it("should return false for prefixed base models", () => {
|
||||
// #given prefixed base model IDs without -high suffix
|
||||
expect(isAlreadyHighVariant("vertex_ai/claude-opus-4-5")).toBe(false)
|
||||
expect(isAlreadyHighVariant("openai/gpt-5-2")).toBe(false)
|
||||
})
|
||||
|
||||
it("should handle prefixed models with dots", () => {
|
||||
// #given prefixed model IDs with dots
|
||||
expect(isAlreadyHighVariant("vertex_ai/gpt-5.2")).toBe(false)
|
||||
expect(isAlreadyHighVariant("vertex_ai/gpt-5.2-high")).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getThinkingConfig with prefixes", () => {
|
||||
it("should return null for custom providers (not in THINKING_CONFIGS)", () => {
|
||||
// #given custom provider with prefixed Claude model
|
||||
const config = getThinkingConfig("dia-llm", "vertex_ai/claude-sonnet-4-5")
|
||||
|
||||
// #then should return null (custom provider not in THINKING_CONFIGS)
|
||||
expect(config).toBeNull()
|
||||
})
|
||||
|
||||
it("should work with prefixed models on known providers", () => {
|
||||
// #given known provider (anthropic) with prefixed model
|
||||
// This tests that the base model name is correctly extracted for capability check
|
||||
const config = getThinkingConfig("anthropic", "custom-prefix/claude-opus-4-5")
|
||||
|
||||
// #then should return thinking config (base model is capable)
|
||||
expect(config).not.toBeNull()
|
||||
expect(config?.thinking).toBeDefined()
|
||||
})
|
||||
|
||||
it("should return null for prefixed models that are already high", () => {
|
||||
// #given prefixed already-high model
|
||||
const config = getThinkingConfig("anthropic", "vertex_ai/claude-opus-4-5-high")
|
||||
|
||||
// #then should return null
|
||||
expect(config).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("Real-world custom provider scenario", () => {
|
||||
it("should handle LLM proxy with vertex_ai prefix correctly", () => {
|
||||
// #given a custom LLM proxy provider using vertex_ai/ prefix
|
||||
const providerID = "dia-llm"
|
||||
const modelID = "vertex_ai/claude-sonnet-4-5"
|
||||
|
||||
// #when getting high variant
|
||||
const highVariant = getHighVariant(modelID)
|
||||
|
||||
// #then should preserve the prefix
|
||||
expect(highVariant).toBe("vertex_ai/claude-sonnet-4-5-high")
|
||||
|
||||
// #and when checking if already high
|
||||
expect(isAlreadyHighVariant(modelID)).toBe(false)
|
||||
expect(isAlreadyHighVariant(highVariant!)).toBe(true)
|
||||
|
||||
// #and when getting thinking config for custom provider
|
||||
const config = getThinkingConfig(providerID, modelID)
|
||||
|
||||
// #then should return null (custom provider, not anthropic)
|
||||
// This prevents applying incompatible thinking configs to custom providers
|
||||
expect(config).toBeNull()
|
||||
})
|
||||
|
||||
it("should not break when switching to high variant in think mode", () => {
|
||||
// #given think mode switching vertex_ai/claude model to high variant
|
||||
const original = "vertex_ai/claude-opus-4-5"
|
||||
const high = getHighVariant(original)
|
||||
|
||||
// #then the high variant should be valid
|
||||
expect(high).toBe("vertex_ai/claude-opus-4-5-high")
|
||||
|
||||
// #and should be recognized as already high
|
||||
expect(isAlreadyHighVariant(high!)).toBe(true)
|
||||
|
||||
// #and switching again should return null (already high)
|
||||
expect(getHighVariant(high!)).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -16,6 +16,26 @@
|
||||
* inconsistencies defensively while maintaining backwards compatibility.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Extracts provider-specific prefix from model ID (if present).
|
||||
* Custom providers may use prefixes for routing (e.g., vertex_ai/, openai/).
|
||||
*
|
||||
* @example
|
||||
* extractModelPrefix("vertex_ai/claude-sonnet-4-5") // { prefix: "vertex_ai/", base: "claude-sonnet-4-5" }
|
||||
* extractModelPrefix("claude-sonnet-4-5") // { prefix: "", base: "claude-sonnet-4-5" }
|
||||
* extractModelPrefix("openai/gpt-5.2") // { prefix: "openai/", base: "gpt-5.2" }
|
||||
*/
|
||||
function extractModelPrefix(modelID: string): { prefix: string; base: string } {
|
||||
const slashIndex = modelID.indexOf("/")
|
||||
if (slashIndex === -1) {
|
||||
return { prefix: "", base: modelID }
|
||||
}
|
||||
return {
|
||||
prefix: modelID.slice(0, slashIndex + 1),
|
||||
base: modelID.slice(slashIndex + 1),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes model IDs to use consistent hyphen formatting.
|
||||
* GitHub Copilot may use dots (claude-opus-4.5) but our maps use hyphens (claude-opus-4-5).
|
||||
@@ -25,6 +45,7 @@
|
||||
* normalizeModelID("claude-opus-4.5") // "claude-opus-4-5"
|
||||
* normalizeModelID("gemini-3.5-pro") // "gemini-3-5-pro"
|
||||
* normalizeModelID("gpt-5.2") // "gpt-5-2"
|
||||
* normalizeModelID("vertex_ai/claude-opus-4.5") // "vertex_ai/claude-opus-4-5"
|
||||
*/
|
||||
function normalizeModelID(modelID: string): string {
|
||||
// Replace dots with hyphens when followed by a digit
|
||||
@@ -142,16 +163,27 @@ const THINKING_CAPABLE_MODELS = {
|
||||
|
||||
export function getHighVariant(modelID: string): string | null {
|
||||
const normalized = normalizeModelID(modelID)
|
||||
const { prefix, base } = extractModelPrefix(normalized)
|
||||
|
||||
if (ALREADY_HIGH.has(normalized)) {
|
||||
// Check if already high variant (with or without prefix)
|
||||
if (ALREADY_HIGH.has(base) || base.endsWith("-high")) {
|
||||
return null
|
||||
}
|
||||
return HIGH_VARIANT_MAP[normalized] ?? null
|
||||
|
||||
// Look up high variant for base model
|
||||
const highBase = HIGH_VARIANT_MAP[base]
|
||||
if (!highBase) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Preserve prefix in the high variant
|
||||
return prefix + highBase
|
||||
}
|
||||
|
||||
export function isAlreadyHighVariant(modelID: string): boolean {
|
||||
const normalized = normalizeModelID(modelID)
|
||||
return ALREADY_HIGH.has(normalized) || normalized.endsWith("-high")
|
||||
const { base } = extractModelPrefix(normalized)
|
||||
return ALREADY_HIGH.has(base) || base.endsWith("-high")
|
||||
}
|
||||
|
||||
type ThinkingProvider = keyof typeof THINKING_CONFIGS
|
||||
@@ -165,6 +197,7 @@ export function getThinkingConfig(
|
||||
modelID: string
|
||||
): Record<string, unknown> | null {
|
||||
const normalized = normalizeModelID(modelID)
|
||||
const { base } = extractModelPrefix(normalized)
|
||||
|
||||
if (isAlreadyHighVariant(normalized)) {
|
||||
return null
|
||||
@@ -179,9 +212,10 @@ export function getThinkingConfig(
|
||||
const config = THINKING_CONFIGS[resolvedProvider]
|
||||
const capablePatterns = THINKING_CAPABLE_MODELS[resolvedProvider]
|
||||
|
||||
const modelLower = normalized.toLowerCase()
|
||||
// Check capability using base model name (without prefix)
|
||||
const baseLower = base.toLowerCase()
|
||||
const isCapable = capablePatterns.some((pattern) =>
|
||||
modelLower.includes(pattern.toLowerCase())
|
||||
baseLower.includes(pattern.toLowerCase())
|
||||
)
|
||||
|
||||
return isCapable ? config : null
|
||||
|
||||
@@ -1,13 +1,23 @@
|
||||
import { describe, expect, test, beforeEach, afterEach, mock } from "bun:test"
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
|
||||
|
||||
import { createTodoContinuationEnforcer } from "./todo-continuation-enforcer"
|
||||
import { setMainSession, subagentSessions } from "../features/claude-code-session-state"
|
||||
import type { BackgroundManager } from "../features/background-agent"
|
||||
import { setMainSession, subagentSessions } from "../features/claude-code-session-state"
|
||||
import { createTodoContinuationEnforcer } from "./todo-continuation-enforcer"
|
||||
|
||||
describe("todo-continuation-enforcer", () => {
|
||||
let promptCalls: Array<{ sessionID: string; agent?: string; text: string }>
|
||||
let promptCalls: Array<{ sessionID: string; agent?: string; model?: { providerID?: string; modelID?: string }; text: string }>
|
||||
let toastCalls: Array<{ title: string; message: string }>
|
||||
|
||||
interface MockMessage {
|
||||
info: {
|
||||
id: string
|
||||
role: "user" | "assistant"
|
||||
error?: { name: string; data?: { message: string } }
|
||||
}
|
||||
}
|
||||
|
||||
let mockMessages: MockMessage[] = []
|
||||
|
||||
function createMockPluginInput() {
|
||||
return {
|
||||
client: {
|
||||
@@ -16,10 +26,12 @@ describe("todo-continuation-enforcer", () => {
|
||||
{ id: "1", content: "Task 1", status: "pending", priority: "high" },
|
||||
{ id: "2", content: "Task 2", status: "completed", priority: "medium" },
|
||||
]}),
|
||||
messages: async () => ({ data: mockMessages }),
|
||||
prompt: async (opts: any) => {
|
||||
promptCalls.push({
|
||||
sessionID: opts.path.id,
|
||||
agent: opts.body.agent,
|
||||
model: opts.body.model,
|
||||
text: opts.body.parts[0].text,
|
||||
})
|
||||
return {}
|
||||
@@ -41,8 +53,8 @@ describe("todo-continuation-enforcer", () => {
|
||||
|
||||
function createMockBackgroundManager(runningTasks: boolean = false): BackgroundManager {
|
||||
return {
|
||||
getTasksByParentSession: () => runningTasks
|
||||
? [{ status: "running" }]
|
||||
getTasksByParentSession: () => runningTasks
|
||||
? [{ status: "running" }]
|
||||
: [],
|
||||
} as any
|
||||
}
|
||||
@@ -50,6 +62,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
beforeEach(() => {
|
||||
promptCalls = []
|
||||
toastCalls = []
|
||||
mockMessages = []
|
||||
setMainSession(undefined)
|
||||
subagentSessions.clear()
|
||||
})
|
||||
@@ -164,58 +177,9 @@ describe("todo-continuation-enforcer", () => {
|
||||
expect(promptCalls[0].sessionID).toBe(bgTaskSession)
|
||||
})
|
||||
|
||||
test("should skip injection when abort error occurs immediately before idle", async () => {
|
||||
// #given - session that just had an abort error
|
||||
const sessionID = "main-error"
|
||||
setMainSession(sessionID)
|
||||
|
||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||
|
||||
// #when - abort error occurs
|
||||
await hook.handler({
|
||||
event: { type: "session.error", properties: { sessionID, error: { name: "AbortError", message: "aborted" } } },
|
||||
})
|
||||
|
||||
// #when - session goes idle immediately after abort
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 3000))
|
||||
|
||||
// #then - no continuation injected (abort was immediately before idle)
|
||||
expect(promptCalls).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("should clear abort state on user message and allow injection", async () => {
|
||||
// #given - session with abort error, then user clears it
|
||||
const sessionID = "main-error-clear"
|
||||
setMainSession(sessionID)
|
||||
|
||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||
|
||||
// #when - abort error occurs
|
||||
await hook.handler({
|
||||
event: { type: "session.error", properties: { sessionID, error: { message: "aborted" } } },
|
||||
})
|
||||
|
||||
// #when - user sends message (clears abort state)
|
||||
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 (abort state was cleared by user message)
|
||||
expect(promptCalls.length).toBe(1)
|
||||
})
|
||||
|
||||
test("should cancel countdown on user message", async () => {
|
||||
test("should cancel countdown on user message after grace period", async () => {
|
||||
// #given - session starting countdown
|
||||
const sessionID = "main-cancel"
|
||||
setMainSession(sessionID)
|
||||
@@ -227,19 +191,46 @@ describe("todo-continuation-enforcer", () => {
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
// #when - user sends message immediately (before 2s countdown)
|
||||
// #when - wait past grace period (500ms), then user sends message
|
||||
await new Promise(r => setTimeout(r, 600))
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "message.updated",
|
||||
properties: { info: { sessionID, role: "user" } }
|
||||
event: {
|
||||
type: "message.updated",
|
||||
properties: { info: { sessionID, role: "user" } }
|
||||
},
|
||||
})
|
||||
|
||||
// #then - wait past countdown time and verify no injection
|
||||
// #then - wait past countdown time and verify no injection (countdown was cancelled)
|
||||
await new Promise(r => setTimeout(r, 2500))
|
||||
expect(promptCalls).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("should ignore user message within grace period", async () => {
|
||||
// #given - session starting countdown
|
||||
const sessionID = "main-grace"
|
||||
setMainSession(sessionID)
|
||||
|
||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||
|
||||
// #when - session goes idle
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
// #when - user message arrives within grace period (immediately)
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "message.updated",
|
||||
properties: { info: { sessionID, role: "user" } }
|
||||
},
|
||||
})
|
||||
|
||||
// #then - countdown should continue (message was ignored)
|
||||
// wait past 2s countdown and verify injection happens
|
||||
await new Promise(r => setTimeout(r, 2500))
|
||||
expect(promptCalls).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("should cancel countdown on assistant activity", async () => {
|
||||
// #given - session starting countdown
|
||||
const sessionID = "main-assistant"
|
||||
@@ -255,9 +246,9 @@ describe("todo-continuation-enforcer", () => {
|
||||
// #when - assistant starts responding
|
||||
await new Promise(r => setTimeout(r, 500))
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "message.part.updated",
|
||||
properties: { info: { sessionID, role: "assistant" } }
|
||||
event: {
|
||||
type: "message.part.updated",
|
||||
properties: { info: { sessionID, role: "assistant" } }
|
||||
},
|
||||
})
|
||||
|
||||
@@ -387,7 +378,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
await new Promise(r => setTimeout(r, 2500))
|
||||
await new Promise(r => setTimeout(r, 3500))
|
||||
|
||||
// #then - first injection happened
|
||||
expect(promptCalls.length).toBe(1)
|
||||
@@ -396,118 +387,17 @@ describe("todo-continuation-enforcer", () => {
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
await new Promise(r => setTimeout(r, 2500))
|
||||
await new Promise(r => setTimeout(r, 3500))
|
||||
|
||||
// #then - second injection also happened (no throttle blocking)
|
||||
expect(promptCalls.length).toBe(2)
|
||||
}, { timeout: 10000 })
|
||||
}, { timeout: 15000 })
|
||||
|
||||
// ============================================================
|
||||
// ABORT "IMMEDIATELY BEFORE" DETECTION TESTS
|
||||
// These tests verify that abort errors only block continuation
|
||||
// when they occur IMMEDIATELY before session.idle, not based
|
||||
// on a time-based cooldown.
|
||||
// ============================================================
|
||||
|
||||
test("should skip injection ONLY when abort error occurs immediately before idle", async () => {
|
||||
// #given - session with incomplete todos
|
||||
const sessionID = "main-abort-immediate"
|
||||
setMainSession(sessionID)
|
||||
|
||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||
|
||||
// #when - abort error occurs (with abort-specific error)
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "session.error",
|
||||
properties: {
|
||||
sessionID,
|
||||
error: { name: "MessageAbortedError", message: "The operation was aborted" }
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// #when - session goes idle IMMEDIATELY after abort (no other events in between)
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 3000))
|
||||
|
||||
// #then - no continuation injected (abort was immediately before idle)
|
||||
expect(promptCalls).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("should inject normally when abort error is followed by assistant activity before idle", async () => {
|
||||
// #given - session with incomplete todos
|
||||
const sessionID = "main-abort-then-assistant"
|
||||
setMainSession(sessionID)
|
||||
|
||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||
|
||||
// #when - abort error occurs
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "session.error",
|
||||
properties: {
|
||||
sessionID,
|
||||
error: { name: "MessageAbortedError", message: "The operation was aborted" }
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// #when - assistant sends a message (intervening event clears abort state)
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "message.updated",
|
||||
properties: { info: { sessionID, role: "assistant" } }
|
||||
},
|
||||
})
|
||||
|
||||
// #when - session goes idle (abort is no longer "immediately before")
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 2500))
|
||||
|
||||
// #then - continuation injected (abort was NOT immediately before idle)
|
||||
expect(promptCalls.length).toBe(1)
|
||||
})
|
||||
|
||||
test("should inject normally when abort error is followed by tool execution before idle", async () => {
|
||||
// #given - session with incomplete todos
|
||||
const sessionID = "main-abort-then-tool"
|
||||
setMainSession(sessionID)
|
||||
|
||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||
|
||||
// #when - abort error occurs
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "session.error",
|
||||
properties: {
|
||||
sessionID,
|
||||
error: { message: "aborted" }
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// #when - tool execution happens (intervening event)
|
||||
await hook.handler({
|
||||
event: { type: "tool.execute.after", properties: { sessionID } },
|
||||
})
|
||||
|
||||
// #when - session goes idle
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 2500))
|
||||
|
||||
// #then - continuation injected (abort was NOT immediately before idle)
|
||||
expect(promptCalls.length).toBe(1)
|
||||
})
|
||||
|
||||
test("should NOT skip for non-abort errors even if immediately before idle", async () => {
|
||||
// #given - session with incomplete todos
|
||||
@@ -518,12 +408,12 @@ describe("todo-continuation-enforcer", () => {
|
||||
|
||||
// #when - non-abort error occurs (e.g., network error, API error)
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "session.error",
|
||||
properties: {
|
||||
sessionID,
|
||||
error: { name: "NetworkError", message: "Connection failed" }
|
||||
}
|
||||
event: {
|
||||
type: "session.error",
|
||||
properties: {
|
||||
sessionID,
|
||||
error: { name: "NetworkError", message: "Connection failed" }
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -538,74 +428,105 @@ describe("todo-continuation-enforcer", () => {
|
||||
expect(promptCalls.length).toBe(1)
|
||||
})
|
||||
|
||||
test("should inject after abort if time passes and new idle event occurs", async () => {
|
||||
// #given - session with incomplete todos, abort happened previously
|
||||
const sessionID = "main-abort-time-passed"
|
||||
|
||||
|
||||
|
||||
|
||||
// ============================================================
|
||||
// API-BASED ABORT DETECTION TESTS
|
||||
// These tests verify that abort is detected by checking
|
||||
// the last assistant message's error field via session.messages API
|
||||
// ============================================================
|
||||
|
||||
test("should skip injection when last assistant message has MessageAbortedError", async () => {
|
||||
// #given - session where last assistant message was aborted
|
||||
const sessionID = "main-api-abort"
|
||||
setMainSession(sessionID)
|
||||
|
||||
mockMessages = [
|
||||
{ info: { id: "msg-1", role: "user" } },
|
||||
{ info: { id: "msg-2", role: "assistant", error: { name: "MessageAbortedError", data: { message: "The operation was aborted" } } } },
|
||||
]
|
||||
|
||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||
|
||||
// #when - abort error occurs
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "session.error",
|
||||
properties: {
|
||||
sessionID,
|
||||
error: { name: "AbortError", message: "cancelled" }
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// #when - first idle (immediately after abort) - should be skipped
|
||||
// #when - session goes idle
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 3000))
|
||||
|
||||
// #then - no continuation (last message was aborted)
|
||||
expect(promptCalls).toHaveLength(0)
|
||||
})
|
||||
|
||||
// #when - second idle event occurs (abort is no longer "immediately before")
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 2500))
|
||||
|
||||
// #then - continuation injected on second idle (abort state was consumed)
|
||||
expect(promptCalls.length).toBe(1)
|
||||
}, { timeout: 10000 })
|
||||
|
||||
test("should handle multiple abort errors correctly - only last one matters", async () => {
|
||||
// #given - session with incomplete todos
|
||||
const sessionID = "main-multi-abort"
|
||||
test("should inject when last assistant message has no error", async () => {
|
||||
// #given - session where last assistant message completed normally
|
||||
const sessionID = "main-api-no-error"
|
||||
setMainSession(sessionID)
|
||||
|
||||
mockMessages = [
|
||||
{ info: { id: "msg-1", role: "user" } },
|
||||
{ info: { id: "msg-2", role: "assistant" } },
|
||||
]
|
||||
|
||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||
|
||||
// #when - first abort error
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "session.error",
|
||||
properties: { sessionID, error: { message: "aborted" } }
|
||||
},
|
||||
})
|
||||
|
||||
// #when - second abort error (immediately before idle)
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "session.error",
|
||||
properties: { sessionID, error: { message: "interrupted" } }
|
||||
},
|
||||
})
|
||||
|
||||
// #when - idle immediately after second abort
|
||||
// #when - session goes idle
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 3000))
|
||||
|
||||
// #then - no continuation (abort was immediately before)
|
||||
// #then - continuation injected (no abort)
|
||||
expect(promptCalls.length).toBe(1)
|
||||
})
|
||||
|
||||
test("should inject when last message is from user (not assistant)", async () => {
|
||||
// #given - session where last message is from user
|
||||
const sessionID = "main-api-user-last"
|
||||
setMainSession(sessionID)
|
||||
|
||||
mockMessages = [
|
||||
{ info: { id: "msg-1", role: "assistant" } },
|
||||
{ info: { id: "msg-2", role: "user" } },
|
||||
]
|
||||
|
||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||
|
||||
// #when - session goes idle
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 3000))
|
||||
|
||||
// #then - continuation injected (last message is user, not aborted assistant)
|
||||
expect(promptCalls.length).toBe(1)
|
||||
})
|
||||
|
||||
test("should skip when last assistant message has any abort-like error", async () => {
|
||||
// #given - session where last assistant message has AbortError (DOMException style)
|
||||
const sessionID = "main-api-abort-dom"
|
||||
setMainSession(sessionID)
|
||||
|
||||
mockMessages = [
|
||||
{ info: { id: "msg-1", role: "user" } },
|
||||
{ info: { id: "msg-2", role: "assistant", error: { name: "AbortError" } } },
|
||||
]
|
||||
|
||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||
|
||||
// #when - session goes idle
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 3000))
|
||||
|
||||
// #then - no continuation (abort error detected)
|
||||
expect(promptCalls).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { existsSync, readdirSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import type { BackgroundManager } from "../features/background-agent"
|
||||
import { getMainSessionID, subagentSessions } from "../features/claude-code-session-state"
|
||||
import {
|
||||
findNearestMessageWithFields,
|
||||
MESSAGE_STORAGE,
|
||||
findNearestMessageWithFields,
|
||||
MESSAGE_STORAGE,
|
||||
} from "../features/hook-message-injector"
|
||||
import type { BackgroundManager } from "../features/background-agent"
|
||||
import { log } from "../shared/logger"
|
||||
|
||||
const HOOK_NAME = "todo-continuation-enforcer"
|
||||
@@ -29,10 +29,10 @@ interface Todo {
|
||||
}
|
||||
|
||||
interface SessionState {
|
||||
lastEventWasAbortError?: boolean
|
||||
countdownTimer?: ReturnType<typeof setTimeout>
|
||||
countdownInterval?: ReturnType<typeof setInterval>
|
||||
isRecovering?: boolean
|
||||
countdownStartedAt?: number
|
||||
}
|
||||
|
||||
const CONTINUATION_PROMPT = `[SYSTEM REMINDER - TODO CONTINUATION]
|
||||
@@ -45,6 +45,7 @@ Incomplete tasks remain in your todo list. Continue working on the next pending
|
||||
|
||||
const COUNTDOWN_SECONDS = 2
|
||||
const TOAST_DURATION_MS = 900
|
||||
const COUNTDOWN_GRACE_PERIOD_MS = 500
|
||||
|
||||
function getMessageDir(sessionID: string): string | null {
|
||||
if (!existsSync(MESSAGE_STORAGE)) return null
|
||||
@@ -60,31 +61,30 @@ function getMessageDir(sessionID: string): string | null {
|
||||
return null
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
function getIncompleteCount(todos: Todo[]): number {
|
||||
return todos.filter(t => t.status !== "completed" && t.status !== "cancelled").length
|
||||
}
|
||||
|
||||
interface MessageInfo {
|
||||
id?: string
|
||||
role?: string
|
||||
error?: { name?: string; data?: unknown }
|
||||
}
|
||||
|
||||
function isLastAssistantMessageAborted(messages: Array<{ info?: MessageInfo }>): boolean {
|
||||
if (!messages || messages.length === 0) return false
|
||||
|
||||
const assistantMessages = messages.filter(m => m.info?.role === "assistant")
|
||||
if (assistantMessages.length === 0) return false
|
||||
|
||||
const lastAssistant = assistantMessages[assistantMessages.length - 1]
|
||||
const errorName = lastAssistant.info?.error?.name
|
||||
|
||||
if (!errorName) return false
|
||||
|
||||
return errorName === "MessageAbortedError" || errorName === "AbortError"
|
||||
}
|
||||
|
||||
export function createTodoContinuationEnforcer(
|
||||
ctx: PluginInput,
|
||||
options: TodoContinuationEnforcerOptions = {}
|
||||
@@ -104,7 +104,7 @@ export function createTodoContinuationEnforcer(
|
||||
function cancelCountdown(sessionID: string): void {
|
||||
const state = sessions.get(sessionID)
|
||||
if (!state) return
|
||||
|
||||
|
||||
if (state.countdownTimer) {
|
||||
clearTimeout(state.countdownTimer)
|
||||
state.countdownTimer = undefined
|
||||
@@ -113,6 +113,7 @@ export function createTodoContinuationEnforcer(
|
||||
clearInterval(state.countdownInterval)
|
||||
state.countdownInterval = undefined
|
||||
}
|
||||
state.countdownStartedAt = undefined
|
||||
}
|
||||
|
||||
function cleanup(sessionID: string): void {
|
||||
@@ -148,7 +149,7 @@ export function createTodoContinuationEnforcer(
|
||||
|
||||
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
|
||||
@@ -183,9 +184,9 @@ export function createTodoContinuationEnforcer(
|
||||
const messageDir = getMessageDir(sessionID)
|
||||
const prevMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
|
||||
|
||||
const hasWritePermission = !prevMessage?.tools ||
|
||||
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
|
||||
@@ -199,18 +200,23 @@ export function createTodoContinuationEnforcer(
|
||||
|
||||
const prompt = `${CONTINUATION_PROMPT}\n\n[Status: ${todos.length - freshIncompleteCount}/${todos.length} completed, ${freshIncompleteCount} remaining]`
|
||||
|
||||
const modelField = prevMessage?.model?.providerID && prevMessage?.model?.modelID
|
||||
? { providerID: prevMessage.model.providerID, modelID: prevMessage.model.modelID }
|
||||
: undefined
|
||||
|
||||
try {
|
||||
log(`[${HOOK_NAME}] Injecting continuation`, { sessionID, agent: prevMessage?.agent, incompleteCount: freshIncompleteCount })
|
||||
|
||||
log(`[${HOOK_NAME}] Injecting continuation`, { sessionID, agent: prevMessage?.agent, model: modelField, incompleteCount: freshIncompleteCount })
|
||||
|
||||
await ctx.client.session.prompt({
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
agent: prevMessage?.agent,
|
||||
model: modelField,
|
||||
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) })
|
||||
@@ -223,6 +229,7 @@ export function createTodoContinuationEnforcer(
|
||||
|
||||
let secondsRemaining = COUNTDOWN_SECONDS
|
||||
showCountdownToast(secondsRemaining, incompleteCount)
|
||||
state.countdownStartedAt = Date.now()
|
||||
|
||||
state.countdownInterval = setInterval(() => {
|
||||
secondsRemaining--
|
||||
@@ -246,12 +253,8 @@ export function createTodoContinuationEnforcer(
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
if (!sessionID) return
|
||||
|
||||
const state = getState(sessionID)
|
||||
const isAbort = isAbortError(props?.error)
|
||||
state.lastEventWasAbortError = isAbort
|
||||
cancelCountdown(sessionID)
|
||||
|
||||
log(`[${HOOK_NAME}] session.error`, { sessionID, isAbort })
|
||||
log(`[${HOOK_NAME}] session.error`, { sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
@@ -264,7 +267,7 @@ export function createTodoContinuationEnforcer(
|
||||
const mainSessionID = getMainSessionID()
|
||||
const isMainSession = sessionID === mainSessionID
|
||||
const isBackgroundTaskSession = subagentSessions.has(sessionID)
|
||||
|
||||
|
||||
if (mainSessionID && !isMainSession && !isBackgroundTaskSession) {
|
||||
log(`[${HOOK_NAME}] Skipped: not main or background task session`, { sessionID })
|
||||
return
|
||||
@@ -277,12 +280,6 @@ export function createTodoContinuationEnforcer(
|
||||
return
|
||||
}
|
||||
|
||||
if (state.lastEventWasAbortError) {
|
||||
state.lastEventWasAbortError = false
|
||||
log(`[${HOOK_NAME}] Skipped: abort error immediately before idle`, { sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
const hasRunningBgTasks = backgroundManager
|
||||
? backgroundManager.getTasksByParentSession(sessionID).some(t => t.status === "running")
|
||||
: false
|
||||
@@ -292,6 +289,21 @@ export function createTodoContinuationEnforcer(
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const messagesResp = await ctx.client.session.messages({
|
||||
path: { id: sessionID },
|
||||
query: { directory: ctx.directory },
|
||||
})
|
||||
const messages = (messagesResp as { data?: Array<{ info?: MessageInfo }> }).data ?? []
|
||||
|
||||
if (isLastAssistantMessageAborted(messages)) {
|
||||
log(`[${HOOK_NAME}] Skipped: last assistant message was aborted`, { sessionID })
|
||||
return
|
||||
}
|
||||
} catch (err) {
|
||||
log(`[${HOOK_NAME}] Messages fetch failed, continuing`, { sessionID, error: String(err) })
|
||||
}
|
||||
|
||||
let todos: Todo[] = []
|
||||
try {
|
||||
const response = await ctx.client.session.todo({ path: { id: sessionID } })
|
||||
@@ -323,14 +335,16 @@ export function createTodoContinuationEnforcer(
|
||||
|
||||
if (!sessionID) return
|
||||
|
||||
const state = sessions.get(sessionID)
|
||||
if (state) {
|
||||
state.lastEventWasAbortError = false
|
||||
}
|
||||
|
||||
if (role === "user") {
|
||||
const state = sessions.get(sessionID)
|
||||
if (state?.countdownStartedAt) {
|
||||
const elapsed = Date.now() - state.countdownStartedAt
|
||||
if (elapsed < COUNTDOWN_GRACE_PERIOD_MS) {
|
||||
log(`[${HOOK_NAME}] Ignoring user message in grace period`, { sessionID, elapsed })
|
||||
return
|
||||
}
|
||||
}
|
||||
cancelCountdown(sessionID)
|
||||
log(`[${HOOK_NAME}] User message: cleared abort state`, { sessionID })
|
||||
}
|
||||
|
||||
if (role === "assistant") {
|
||||
@@ -345,10 +359,6 @@ export function createTodoContinuationEnforcer(
|
||||
const role = info?.role as string | undefined
|
||||
|
||||
if (sessionID && role === "assistant") {
|
||||
const state = sessions.get(sessionID)
|
||||
if (state) {
|
||||
state.lastEventWasAbortError = false
|
||||
}
|
||||
cancelCountdown(sessionID)
|
||||
}
|
||||
return
|
||||
@@ -357,10 +367,6 @@ export function createTodoContinuationEnforcer(
|
||||
if (event.type === "tool.execute.before" || event.type === "tool.execute.after") {
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
if (sessionID) {
|
||||
const state = sessions.get(sessionID)
|
||||
if (state) {
|
||||
state.lastEventWasAbortError = false
|
||||
}
|
||||
cancelCountdown(sessionID)
|
||||
}
|
||||
return
|
||||
|
||||
168
src/hooks/tool-output-truncator.test.ts
Normal file
168
src/hooks/tool-output-truncator.test.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { describe, it, expect, beforeEach, mock, spyOn } from "bun:test"
|
||||
import { createToolOutputTruncatorHook } from "./tool-output-truncator"
|
||||
import * as dynamicTruncator from "../shared/dynamic-truncator"
|
||||
|
||||
describe("createToolOutputTruncatorHook", () => {
|
||||
let hook: ReturnType<typeof createToolOutputTruncatorHook>
|
||||
let truncateSpy: ReturnType<typeof spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
truncateSpy = spyOn(dynamicTruncator, "createDynamicTruncator").mockReturnValue({
|
||||
truncate: mock(async (_sessionID: string, output: string, options?: { targetMaxTokens?: number }) => ({
|
||||
result: output,
|
||||
truncated: false,
|
||||
targetMaxTokens: options?.targetMaxTokens,
|
||||
})),
|
||||
getUsage: mock(async () => null),
|
||||
truncateSync: mock(() => ({ result: "", truncated: false })),
|
||||
})
|
||||
hook = createToolOutputTruncatorHook({} as never)
|
||||
})
|
||||
|
||||
describe("tool.execute.after", () => {
|
||||
const createInput = (tool: string) => ({
|
||||
tool,
|
||||
sessionID: "test-session",
|
||||
callID: "test-call-id",
|
||||
})
|
||||
|
||||
const createOutput = (outputText: string) => ({
|
||||
title: "Result",
|
||||
output: outputText,
|
||||
metadata: {},
|
||||
})
|
||||
|
||||
describe("#given webfetch tool", () => {
|
||||
describe("#when output is processed", () => {
|
||||
it("#then should use aggressive truncation limit (10k tokens)", async () => {
|
||||
const truncateMock = mock(async (_sessionID: string, _output: string, options?: { targetMaxTokens?: number }) => ({
|
||||
result: "truncated",
|
||||
truncated: true,
|
||||
targetMaxTokens: options?.targetMaxTokens,
|
||||
}))
|
||||
truncateSpy.mockReturnValue({
|
||||
truncate: truncateMock,
|
||||
getUsage: mock(async () => null),
|
||||
truncateSync: mock(() => ({ result: "", truncated: false })),
|
||||
})
|
||||
hook = createToolOutputTruncatorHook({} as never)
|
||||
|
||||
const input = createInput("webfetch")
|
||||
const output = createOutput("large content")
|
||||
|
||||
await hook["tool.execute.after"](input, output)
|
||||
|
||||
expect(truncateMock).toHaveBeenCalledWith(
|
||||
"test-session",
|
||||
"large content",
|
||||
{ targetMaxTokens: 10_000 }
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("#when using WebFetch variant", () => {
|
||||
it("#then should also use aggressive truncation limit", async () => {
|
||||
const truncateMock = mock(async (_sessionID: string, _output: string, options?: { targetMaxTokens?: number }) => ({
|
||||
result: "truncated",
|
||||
truncated: true,
|
||||
}))
|
||||
truncateSpy.mockReturnValue({
|
||||
truncate: truncateMock,
|
||||
getUsage: mock(async () => null),
|
||||
truncateSync: mock(() => ({ result: "", truncated: false })),
|
||||
})
|
||||
hook = createToolOutputTruncatorHook({} as never)
|
||||
|
||||
const input = createInput("WebFetch")
|
||||
const output = createOutput("large content")
|
||||
|
||||
await hook["tool.execute.after"](input, output)
|
||||
|
||||
expect(truncateMock).toHaveBeenCalledWith(
|
||||
"test-session",
|
||||
"large content",
|
||||
{ targetMaxTokens: 10_000 }
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given grep tool", () => {
|
||||
describe("#when output is processed", () => {
|
||||
it("#then should use default truncation limit (50k tokens)", async () => {
|
||||
const truncateMock = mock(async (_sessionID: string, _output: string, options?: { targetMaxTokens?: number }) => ({
|
||||
result: "truncated",
|
||||
truncated: true,
|
||||
}))
|
||||
truncateSpy.mockReturnValue({
|
||||
truncate: truncateMock,
|
||||
getUsage: mock(async () => null),
|
||||
truncateSync: mock(() => ({ result: "", truncated: false })),
|
||||
})
|
||||
hook = createToolOutputTruncatorHook({} as never)
|
||||
|
||||
const input = createInput("grep")
|
||||
const output = createOutput("grep output")
|
||||
|
||||
await hook["tool.execute.after"](input, output)
|
||||
|
||||
expect(truncateMock).toHaveBeenCalledWith(
|
||||
"test-session",
|
||||
"grep output",
|
||||
{ targetMaxTokens: 50_000 }
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given non-truncatable tool", () => {
|
||||
describe("#when tool is not in TRUNCATABLE_TOOLS list", () => {
|
||||
it("#then should not call truncator", async () => {
|
||||
const truncateMock = mock(async () => ({
|
||||
result: "truncated",
|
||||
truncated: true,
|
||||
}))
|
||||
truncateSpy.mockReturnValue({
|
||||
truncate: truncateMock,
|
||||
getUsage: mock(async () => null),
|
||||
truncateSync: mock(() => ({ result: "", truncated: false })),
|
||||
})
|
||||
hook = createToolOutputTruncatorHook({} as never)
|
||||
|
||||
const input = createInput("Read")
|
||||
const output = createOutput("file content")
|
||||
|
||||
await hook["tool.execute.after"](input, output)
|
||||
|
||||
expect(truncateMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given truncate_all_tool_outputs enabled", () => {
|
||||
describe("#when any tool output is processed", () => {
|
||||
it("#then should truncate non-listed tools too", async () => {
|
||||
const truncateMock = mock(async (_sessionID: string, _output: string, options?: { targetMaxTokens?: number }) => ({
|
||||
result: "truncated",
|
||||
truncated: true,
|
||||
}))
|
||||
truncateSpy.mockReturnValue({
|
||||
truncate: truncateMock,
|
||||
getUsage: mock(async () => null),
|
||||
truncateSync: mock(() => ({ result: "", truncated: false })),
|
||||
})
|
||||
hook = createToolOutputTruncatorHook({} as never, {
|
||||
experimental: { truncate_all_tool_outputs: true },
|
||||
})
|
||||
|
||||
const input = createInput("Read")
|
||||
const output = createOutput("file content")
|
||||
|
||||
await hook["tool.execute.after"](input, output)
|
||||
|
||||
expect(truncateMock).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -2,6 +2,9 @@ import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import type { ExperimentalConfig } from "../config/schema"
|
||||
import { createDynamicTruncator } from "../shared/dynamic-truncator"
|
||||
|
||||
const DEFAULT_MAX_TOKENS = 50_000 // ~200k chars
|
||||
const WEBFETCH_MAX_TOKENS = 10_000 // ~40k chars - web pages need aggressive truncation
|
||||
|
||||
const TRUNCATABLE_TOOLS = [
|
||||
"grep",
|
||||
"Grep",
|
||||
@@ -21,6 +24,11 @@ const TRUNCATABLE_TOOLS = [
|
||||
"WebFetch",
|
||||
]
|
||||
|
||||
const TOOL_SPECIFIC_MAX_TOKENS: Record<string, number> = {
|
||||
webfetch: WEBFETCH_MAX_TOKENS,
|
||||
WebFetch: WEBFETCH_MAX_TOKENS,
|
||||
}
|
||||
|
||||
interface ToolOutputTruncatorOptions {
|
||||
experimental?: ExperimentalConfig
|
||||
}
|
||||
@@ -36,7 +44,12 @@ export function createToolOutputTruncatorHook(ctx: PluginInput, options?: ToolOu
|
||||
if (!truncateAll && !TRUNCATABLE_TOOLS.includes(input.tool)) return
|
||||
|
||||
try {
|
||||
const { result, truncated } = await truncator.truncate(input.sessionID, output.output)
|
||||
const targetMaxTokens = TOOL_SPECIFIC_MAX_TOKENS[input.tool] ?? DEFAULT_MAX_TOKENS
|
||||
const { result, truncated } = await truncator.truncate(
|
||||
input.sessionID,
|
||||
output.output,
|
||||
{ targetMaxTokens }
|
||||
)
|
||||
if (truncated) {
|
||||
output.output = result
|
||||
}
|
||||
|
||||
63
src/index.ts
63
src/index.ts
@@ -25,13 +25,19 @@ import {
|
||||
createThinkingBlockValidatorHook,
|
||||
createRalphLoopHook,
|
||||
createAutoSlashCommandHook,
|
||||
createEditErrorRecoveryHook,
|
||||
} from "./hooks";
|
||||
import {
|
||||
contextCollector,
|
||||
createContextInjectorHook,
|
||||
createContextInjectorMessagesTransformHook,
|
||||
} from "./features/context-injector";
|
||||
import { createGoogleAntigravityAuthPlugin } from "./auth/antigravity";
|
||||
import {
|
||||
discoverUserClaudeSkills,
|
||||
discoverProjectClaudeSkills,
|
||||
discoverOpencodeGlobalSkills,
|
||||
discoverOpencodeProjectSkills,
|
||||
discoverUserClaudeSkillsAsync,
|
||||
discoverProjectClaudeSkillsAsync,
|
||||
discoverOpencodeGlobalSkillsAsync,
|
||||
discoverOpencodeProjectSkillsAsync,
|
||||
mergeSkills,
|
||||
} from "./features/opencode-skill-loader";
|
||||
import { createBuiltinSkills } from "./features/builtin-skills";
|
||||
@@ -47,18 +53,22 @@ import {
|
||||
createLookAt,
|
||||
createSkillTool,
|
||||
createSkillMcpTool,
|
||||
sessionExists,
|
||||
interactive_bash,
|
||||
getTmuxPath,
|
||||
startTmuxCheck,
|
||||
} from "./tools";
|
||||
import { BackgroundManager } from "./features/background-agent";
|
||||
import { SkillMcpManager } from "./features/skill-mcp-manager";
|
||||
import { type OhMyOpenCodeConfig, type HookName } from "./config";
|
||||
import { type HookName } from "./config";
|
||||
import { log } from "./shared";
|
||||
import { loadPluginConfig } from "./plugin-config";
|
||||
import { createModelCacheState, getModelLimit } from "./plugin-state";
|
||||
import { createConfigHandler } from "./plugin-handlers";
|
||||
|
||||
const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
// Start background tmux check immediately
|
||||
startTmuxCheck();
|
||||
|
||||
const pluginConfig = loadPluginConfig(ctx.directory, ctx);
|
||||
const disabledHooks = new Set(pluginConfig.disabled_hooks ?? []);
|
||||
const isHookEnabled = (hookName: HookName) => !disabledHooks.has(hookName);
|
||||
@@ -128,6 +138,9 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
const keywordDetector = isHookEnabled("keyword-detector")
|
||||
? createKeywordDetectorHook(ctx)
|
||||
: null;
|
||||
const contextInjector = createContextInjectorHook(contextCollector);
|
||||
const contextInjectorMessagesTransform =
|
||||
createContextInjectorMessagesTransformHook(contextCollector);
|
||||
const agentUsageReminder = isHookEnabled("agent-usage-reminder")
|
||||
? createAgentUsageReminderHook(ctx)
|
||||
: null;
|
||||
@@ -145,13 +158,20 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
: null;
|
||||
|
||||
const ralphLoop = isHookEnabled("ralph-loop")
|
||||
? createRalphLoopHook(ctx, { config: pluginConfig.ralph_loop })
|
||||
? createRalphLoopHook(ctx, {
|
||||
config: pluginConfig.ralph_loop,
|
||||
checkSessionExists: async (sessionId) => sessionExists(sessionId),
|
||||
})
|
||||
: null;
|
||||
|
||||
const autoSlashCommand = isHookEnabled("auto-slash-command")
|
||||
? createAutoSlashCommandHook()
|
||||
: null;
|
||||
|
||||
const editErrorRecovery = isHookEnabled("edit-error-recovery")
|
||||
? createEditErrorRecoveryHook(ctx)
|
||||
: null;
|
||||
|
||||
const backgroundManager = new BackgroundManager(ctx);
|
||||
|
||||
const todoContinuationEnforcer = isHookEnabled("todo-continuation-enforcer")
|
||||
@@ -184,13 +204,19 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
return true;
|
||||
});
|
||||
const includeClaudeSkills = pluginConfig.claude_code?.skills !== false;
|
||||
const [userSkills, globalSkills, projectSkills, opencodeProjectSkills] = await Promise.all([
|
||||
includeClaudeSkills ? discoverUserClaudeSkillsAsync() : Promise.resolve([]),
|
||||
discoverOpencodeGlobalSkillsAsync(),
|
||||
includeClaudeSkills ? discoverProjectClaudeSkillsAsync() : Promise.resolve([]),
|
||||
discoverOpencodeProjectSkillsAsync(),
|
||||
]);
|
||||
const mergedSkills = mergeSkills(
|
||||
builtinSkills,
|
||||
pluginConfig.skills,
|
||||
includeClaudeSkills ? discoverUserClaudeSkills() : [],
|
||||
discoverOpencodeGlobalSkills(),
|
||||
includeClaudeSkills ? discoverProjectClaudeSkills() : [],
|
||||
discoverOpencodeProjectSkills()
|
||||
userSkills,
|
||||
globalSkills,
|
||||
projectSkills,
|
||||
opencodeProjectSkills
|
||||
);
|
||||
const skillMcpManager = new SkillMcpManager();
|
||||
const getSessionIDForMcp = () => getMainSessionID() || "";
|
||||
@@ -205,12 +231,9 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
getSessionID: getSessionIDForMcp,
|
||||
});
|
||||
|
||||
const googleAuthHooks =
|
||||
pluginConfig.google_auth !== false
|
||||
? await createGoogleAntigravityAuthPlugin(ctx)
|
||||
: null;
|
||||
|
||||
const tmuxAvailable = await getTmuxPath();
|
||||
const googleAuthHooks = pluginConfig.google_auth !== false
|
||||
? await createGoogleAntigravityAuthPlugin(ctx)
|
||||
: null;
|
||||
|
||||
const configHandler = createConfigHandler({
|
||||
ctx,
|
||||
@@ -228,12 +251,13 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
look_at: lookAt,
|
||||
skill: skillTool,
|
||||
skill_mcp: skillMcpTool,
|
||||
...(tmuxAvailable ? { interactive_bash } : {}),
|
||||
interactive_bash, // Always included, handles missing tmux gracefully via getCachedTmuxPath() ?? "tmux"
|
||||
},
|
||||
|
||||
"chat.message": async (input, output) => {
|
||||
await claudeCodeHooks["chat.message"]?.(input, output);
|
||||
await keywordDetector?.["chat.message"]?.(input, output);
|
||||
await contextInjector["chat.message"]?.(input, output);
|
||||
await autoSlashCommand?.["chat.message"]?.(input, output);
|
||||
|
||||
if (ralphLoop) {
|
||||
@@ -294,6 +318,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 contextInjectorMessagesTransform?.["experimental.chat.messages.transform"]?.(input, output as any);
|
||||
await thinkingBlockValidator?.[
|
||||
"experimental.chat.messages.transform"
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -436,6 +462,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
await emptyTaskResponseDetector?.["tool.execute.after"](input, output);
|
||||
await agentUsageReminder?.["tool.execute.after"](input, output);
|
||||
await interactiveBashSession?.["tool.execute.after"](input, output);
|
||||
await editErrorRecovery?.["tool.execute.after"](input, output);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -21,6 +21,7 @@ import { loadAllPluginComponents } from "../features/claude-code-plugin-loader";
|
||||
import { createBuiltinMcps } from "../mcp";
|
||||
import type { OhMyOpenCodeConfig } from "../config";
|
||||
import { log } from "../shared";
|
||||
import { migrateAgentConfig } from "../shared/permission-compat";
|
||||
import { PLAN_SYSTEM_PROMPT, PLAN_PERMISSION } from "../agents/plan-prompt";
|
||||
import type { ModelCacheState } from "../plugin-state";
|
||||
|
||||
@@ -95,13 +96,32 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
|
||||
config.model as string | undefined
|
||||
);
|
||||
|
||||
const userAgents = (pluginConfig.claude_code?.agents ?? true)
|
||||
const rawUserAgents = (pluginConfig.claude_code?.agents ?? true)
|
||||
? loadUserAgents()
|
||||
: {};
|
||||
const projectAgents = (pluginConfig.claude_code?.agents ?? true)
|
||||
const rawProjectAgents = (pluginConfig.claude_code?.agents ?? true)
|
||||
? loadProjectAgents()
|
||||
: {};
|
||||
const pluginAgents = pluginComponents.agents;
|
||||
const rawPluginAgents = pluginComponents.agents;
|
||||
|
||||
const userAgents = Object.fromEntries(
|
||||
Object.entries(rawUserAgents).map(([k, v]) => [
|
||||
k,
|
||||
v ? migrateAgentConfig(v as Record<string, unknown>) : v,
|
||||
])
|
||||
);
|
||||
const projectAgents = Object.fromEntries(
|
||||
Object.entries(rawProjectAgents).map(([k, v]) => [
|
||||
k,
|
||||
v ? migrateAgentConfig(v as Record<string, unknown>) : v,
|
||||
])
|
||||
);
|
||||
const pluginAgents = Object.fromEntries(
|
||||
Object.entries(rawPluginAgents).map(([k, v]) => [
|
||||
k,
|
||||
v ? migrateAgentConfig(v as Record<string, unknown>) : v,
|
||||
])
|
||||
);
|
||||
|
||||
const isSisyphusEnabled = pluginConfig.sisyphus_agent?.disabled !== true;
|
||||
const builderEnabled =
|
||||
@@ -132,10 +152,13 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
|
||||
if (builderEnabled) {
|
||||
const { name: _buildName, ...buildConfigWithoutName } =
|
||||
configAgent?.build ?? {};
|
||||
const migratedBuildConfig = migrateAgentConfig(
|
||||
buildConfigWithoutName as Record<string, unknown>
|
||||
);
|
||||
const openCodeBuilderOverride =
|
||||
pluginConfig.agents?.["OpenCode-Builder"];
|
||||
const openCodeBuilderBase = {
|
||||
...buildConfigWithoutName,
|
||||
...migratedBuildConfig,
|
||||
description: `${configAgent?.build?.description ?? "Build agent"} (OpenCode default)`,
|
||||
};
|
||||
|
||||
@@ -147,10 +170,14 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
|
||||
if (plannerEnabled) {
|
||||
const { name: _planName, ...planConfigWithoutName } =
|
||||
configAgent?.plan ?? {};
|
||||
const migratedPlanConfig = migrateAgentConfig(
|
||||
planConfigWithoutName as Record<string, unknown>
|
||||
);
|
||||
const plannerSisyphusOverride =
|
||||
pluginConfig.agents?.["Planner-Sisyphus"];
|
||||
const plannerSisyphusBase = {
|
||||
...planConfigWithoutName,
|
||||
...migratedPlanConfig,
|
||||
mode: "primary",
|
||||
prompt: PLAN_SYSTEM_PROMPT,
|
||||
permission: PLAN_PERMISSION,
|
||||
description: `${configAgent?.plan?.description ?? "Plan agent"} (OhMyOpenCode version)`,
|
||||
@@ -162,16 +189,29 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
|
||||
: plannerSisyphusBase;
|
||||
}
|
||||
|
||||
const filteredConfigAgents = configAgent
|
||||
? Object.fromEntries(
|
||||
Object.entries(configAgent).filter(([key]) => {
|
||||
const filteredConfigAgents = configAgent
|
||||
? Object.fromEntries(
|
||||
Object.entries(configAgent)
|
||||
.filter(([key]) => {
|
||||
if (key === "build") return false;
|
||||
if (key === "plan" && replacePlan) return false;
|
||||
return true;
|
||||
})
|
||||
)
|
||||
.map(([key, value]) => [
|
||||
key,
|
||||
value ? migrateAgentConfig(value as Record<string, unknown>) : value,
|
||||
])
|
||||
)
|
||||
: {};
|
||||
|
||||
const migratedBuild = configAgent?.build
|
||||
? migrateAgentConfig(configAgent.build as Record<string, unknown>)
|
||||
: {};
|
||||
|
||||
const planDemoteConfig = replacePlan
|
||||
? { disable: true }
|
||||
: undefined;
|
||||
|
||||
config.agent = {
|
||||
...agentConfig,
|
||||
...Object.fromEntries(
|
||||
@@ -181,10 +221,8 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
|
||||
...projectAgents,
|
||||
...pluginAgents,
|
||||
...filteredConfigAgents,
|
||||
build: { ...configAgent?.build, mode: "subagent" },
|
||||
...(replacePlan
|
||||
? { plan: { ...configAgent?.plan, mode: "subagent" } }
|
||||
: {}),
|
||||
build: { ...migratedBuild, mode: "subagent", hidden: true },
|
||||
...(planDemoteConfig ? { plan: planDemoteConfig } : {}),
|
||||
};
|
||||
} else {
|
||||
config.agent = {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin";
|
||||
|
||||
const ANTHROPIC_ACTUAL_LIMIT = 200_000;
|
||||
const ANTHROPIC_ACTUAL_LIMIT =
|
||||
process.env.ANTHROPIC_1M_CONTEXT === "true" ||
|
||||
process.env.VERTEX_ANTHROPIC_1M_CONTEXT === "true"
|
||||
? 1_000_000
|
||||
: 200_000;
|
||||
const CHARS_PER_TOKEN_ESTIMATE = 4;
|
||||
const DEFAULT_TARGET_MAX_TOKENS = 50_000;
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ import yaml from "js-yaml"
|
||||
export interface FrontmatterResult<T = Record<string, unknown>> {
|
||||
data: T
|
||||
body: string
|
||||
hadFrontmatter: boolean
|
||||
parseError: boolean
|
||||
}
|
||||
|
||||
export function parseFrontmatter<T = Record<string, unknown>>(
|
||||
@@ -12,7 +14,7 @@ export function parseFrontmatter<T = Record<string, unknown>>(
|
||||
const match = content.match(frontmatterRegex)
|
||||
|
||||
if (!match) {
|
||||
return { data: {} as T, body: content }
|
||||
return { data: {} as T, body: content, hadFrontmatter: false, parseError: false }
|
||||
}
|
||||
|
||||
const yamlContent = match[1]
|
||||
@@ -22,8 +24,8 @@ export function parseFrontmatter<T = Record<string, unknown>>(
|
||||
// Use JSON_SCHEMA for security - prevents code execution via YAML tags
|
||||
const parsed = yaml.load(yamlContent, { schema: yaml.JSON_SCHEMA })
|
||||
const data = (parsed ?? {}) as T
|
||||
return { data, body }
|
||||
return { data, body, hadFrontmatter: true, parseError: false }
|
||||
} catch {
|
||||
return { data: {} as T, body }
|
||||
return { data: {} as T, body, hadFrontmatter: true, parseError: true }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,3 +16,6 @@ export * from "./config-errors"
|
||||
export * from "./claude-config-dir"
|
||||
export * from "./jsonc-parser"
|
||||
export * from "./migration"
|
||||
export * from "./opencode-config-dir"
|
||||
export * from "./opencode-version"
|
||||
export * from "./permission-compat"
|
||||
|
||||
224
src/shared/opencode-config-dir.test.ts
Normal file
224
src/shared/opencode-config-dir.test.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import { describe, test, expect, beforeEach, afterEach } from "bun:test"
|
||||
import { homedir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import {
|
||||
getOpenCodeConfigDir,
|
||||
getOpenCodeConfigPaths,
|
||||
isDevBuild,
|
||||
detectExistingConfigDir,
|
||||
TAURI_APP_IDENTIFIER,
|
||||
TAURI_APP_IDENTIFIER_DEV,
|
||||
} from "./opencode-config-dir"
|
||||
|
||||
describe("opencode-config-dir", () => {
|
||||
let originalPlatform: NodeJS.Platform
|
||||
let originalEnv: Record<string, string | undefined>
|
||||
|
||||
beforeEach(() => {
|
||||
originalPlatform = process.platform
|
||||
originalEnv = {
|
||||
APPDATA: process.env.APPDATA,
|
||||
XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME,
|
||||
XDG_DATA_HOME: process.env.XDG_DATA_HOME,
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(process, "platform", { value: originalPlatform })
|
||||
for (const [key, value] of Object.entries(originalEnv)) {
|
||||
if (value !== undefined) {
|
||||
process.env[key] = value
|
||||
} else {
|
||||
delete process.env[key]
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
describe("isDevBuild", () => {
|
||||
test("returns false for null version", () => {
|
||||
expect(isDevBuild(null)).toBe(false)
|
||||
})
|
||||
|
||||
test("returns false for undefined version", () => {
|
||||
expect(isDevBuild(undefined)).toBe(false)
|
||||
})
|
||||
|
||||
test("returns false for production version", () => {
|
||||
expect(isDevBuild("1.0.200")).toBe(false)
|
||||
expect(isDevBuild("2.1.0")).toBe(false)
|
||||
})
|
||||
|
||||
test("returns true for version containing -dev", () => {
|
||||
expect(isDevBuild("1.0.0-dev")).toBe(true)
|
||||
expect(isDevBuild("1.0.0-dev.123")).toBe(true)
|
||||
})
|
||||
|
||||
test("returns true for version containing .dev", () => {
|
||||
expect(isDevBuild("1.0.0.dev")).toBe(true)
|
||||
expect(isDevBuild("1.0.0.dev.456")).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getOpenCodeConfigDir", () => {
|
||||
describe("for opencode CLI binary", () => {
|
||||
test("returns ~/.config/opencode on Linux", () => {
|
||||
// #given opencode CLI binary detected, platform is Linux
|
||||
Object.defineProperty(process, "platform", { value: "linux" })
|
||||
delete process.env.XDG_CONFIG_HOME
|
||||
|
||||
// #when getOpenCodeConfigDir is called with binary="opencode"
|
||||
const result = getOpenCodeConfigDir({ binary: "opencode", version: "1.0.200" })
|
||||
|
||||
// #then returns ~/.config/opencode
|
||||
expect(result).toBe(join(homedir(), ".config", "opencode"))
|
||||
})
|
||||
|
||||
test("returns $XDG_CONFIG_HOME/opencode on Linux when XDG_CONFIG_HOME is set", () => {
|
||||
// #given opencode CLI binary detected, platform is Linux with XDG_CONFIG_HOME set
|
||||
Object.defineProperty(process, "platform", { value: "linux" })
|
||||
process.env.XDG_CONFIG_HOME = "/custom/config"
|
||||
|
||||
// #when getOpenCodeConfigDir is called with binary="opencode"
|
||||
const result = getOpenCodeConfigDir({ binary: "opencode", version: "1.0.200" })
|
||||
|
||||
// #then returns $XDG_CONFIG_HOME/opencode
|
||||
expect(result).toBe("/custom/config/opencode")
|
||||
})
|
||||
|
||||
test("returns ~/.config/opencode on macOS", () => {
|
||||
// #given opencode CLI binary detected, platform is macOS
|
||||
Object.defineProperty(process, "platform", { value: "darwin" })
|
||||
delete process.env.XDG_CONFIG_HOME
|
||||
|
||||
// #when getOpenCodeConfigDir is called with binary="opencode"
|
||||
const result = getOpenCodeConfigDir({ binary: "opencode", version: "1.0.200" })
|
||||
|
||||
// #then returns ~/.config/opencode
|
||||
expect(result).toBe(join(homedir(), ".config", "opencode"))
|
||||
})
|
||||
|
||||
test("returns ~/.config/opencode on Windows by default", () => {
|
||||
// #given opencode CLI binary detected, platform is Windows
|
||||
Object.defineProperty(process, "platform", { value: "win32" })
|
||||
delete process.env.APPDATA
|
||||
|
||||
// #when getOpenCodeConfigDir is called with binary="opencode"
|
||||
const result = getOpenCodeConfigDir({ binary: "opencode", version: "1.0.200", checkExisting: false })
|
||||
|
||||
// #then returns ~/.config/opencode (cross-platform default)
|
||||
expect(result).toBe(join(homedir(), ".config", "opencode"))
|
||||
})
|
||||
})
|
||||
|
||||
describe("for opencode-desktop Tauri binary", () => {
|
||||
test("returns ~/.config/ai.opencode.desktop on Linux", () => {
|
||||
// #given opencode-desktop binary detected, platform is Linux
|
||||
Object.defineProperty(process, "platform", { value: "linux" })
|
||||
delete process.env.XDG_CONFIG_HOME
|
||||
|
||||
// #when getOpenCodeConfigDir is called with binary="opencode-desktop"
|
||||
const result = getOpenCodeConfigDir({ binary: "opencode-desktop", version: "1.0.200", checkExisting: false })
|
||||
|
||||
// #then returns ~/.config/ai.opencode.desktop
|
||||
expect(result).toBe(join(homedir(), ".config", TAURI_APP_IDENTIFIER))
|
||||
})
|
||||
|
||||
test("returns ~/Library/Application Support/ai.opencode.desktop on macOS", () => {
|
||||
// #given opencode-desktop binary detected, platform is macOS
|
||||
Object.defineProperty(process, "platform", { value: "darwin" })
|
||||
|
||||
// #when getOpenCodeConfigDir is called with binary="opencode-desktop"
|
||||
const result = getOpenCodeConfigDir({ binary: "opencode-desktop", version: "1.0.200", checkExisting: false })
|
||||
|
||||
// #then returns ~/Library/Application Support/ai.opencode.desktop
|
||||
expect(result).toBe(join(homedir(), "Library", "Application Support", TAURI_APP_IDENTIFIER))
|
||||
})
|
||||
|
||||
test("returns %APPDATA%/ai.opencode.desktop on Windows", () => {
|
||||
// #given opencode-desktop binary detected, platform is Windows
|
||||
Object.defineProperty(process, "platform", { value: "win32" })
|
||||
process.env.APPDATA = "C:\\Users\\TestUser\\AppData\\Roaming"
|
||||
|
||||
// #when getOpenCodeConfigDir is called with binary="opencode-desktop"
|
||||
const result = getOpenCodeConfigDir({ binary: "opencode-desktop", version: "1.0.200", checkExisting: false })
|
||||
|
||||
// #then returns %APPDATA%/ai.opencode.desktop
|
||||
expect(result).toBe(join("C:\\Users\\TestUser\\AppData\\Roaming", TAURI_APP_IDENTIFIER))
|
||||
})
|
||||
})
|
||||
|
||||
describe("dev build detection", () => {
|
||||
test("returns ai.opencode.desktop.dev path when dev version detected", () => {
|
||||
// #given opencode-desktop dev version
|
||||
Object.defineProperty(process, "platform", { value: "linux" })
|
||||
delete process.env.XDG_CONFIG_HOME
|
||||
|
||||
// #when getOpenCodeConfigDir is called with dev version
|
||||
const result = getOpenCodeConfigDir({ binary: "opencode-desktop", version: "1.0.0-dev.123", checkExisting: false })
|
||||
|
||||
// #then returns path with ai.opencode.desktop.dev
|
||||
expect(result).toBe(join(homedir(), ".config", TAURI_APP_IDENTIFIER_DEV))
|
||||
})
|
||||
|
||||
test("returns ai.opencode.desktop.dev on macOS for dev build", () => {
|
||||
// #given opencode-desktop dev version on macOS
|
||||
Object.defineProperty(process, "platform", { value: "darwin" })
|
||||
|
||||
// #when getOpenCodeConfigDir is called with dev version
|
||||
const result = getOpenCodeConfigDir({ binary: "opencode-desktop", version: "1.0.0-dev", checkExisting: false })
|
||||
|
||||
// #then returns path with ai.opencode.desktop.dev
|
||||
expect(result).toBe(join(homedir(), "Library", "Application Support", TAURI_APP_IDENTIFIER_DEV))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("getOpenCodeConfigPaths", () => {
|
||||
test("returns all config paths for CLI binary", () => {
|
||||
// #given opencode CLI binary on Linux
|
||||
Object.defineProperty(process, "platform", { value: "linux" })
|
||||
delete process.env.XDG_CONFIG_HOME
|
||||
|
||||
// #when getOpenCodeConfigPaths is called
|
||||
const paths = getOpenCodeConfigPaths({ binary: "opencode", version: "1.0.200" })
|
||||
|
||||
// #then returns all expected paths
|
||||
const expectedDir = join(homedir(), ".config", "opencode")
|
||||
expect(paths.configDir).toBe(expectedDir)
|
||||
expect(paths.configJson).toBe(join(expectedDir, "opencode.json"))
|
||||
expect(paths.configJsonc).toBe(join(expectedDir, "opencode.jsonc"))
|
||||
expect(paths.packageJson).toBe(join(expectedDir, "package.json"))
|
||||
expect(paths.omoConfig).toBe(join(expectedDir, "oh-my-opencode.json"))
|
||||
})
|
||||
|
||||
test("returns all config paths for desktop binary", () => {
|
||||
// #given opencode-desktop binary on macOS
|
||||
Object.defineProperty(process, "platform", { value: "darwin" })
|
||||
|
||||
// #when getOpenCodeConfigPaths is called
|
||||
const paths = getOpenCodeConfigPaths({ binary: "opencode-desktop", version: "1.0.200", checkExisting: false })
|
||||
|
||||
// #then returns all expected paths
|
||||
const expectedDir = join(homedir(), "Library", "Application Support", TAURI_APP_IDENTIFIER)
|
||||
expect(paths.configDir).toBe(expectedDir)
|
||||
expect(paths.configJson).toBe(join(expectedDir, "opencode.json"))
|
||||
expect(paths.configJsonc).toBe(join(expectedDir, "opencode.jsonc"))
|
||||
expect(paths.packageJson).toBe(join(expectedDir, "package.json"))
|
||||
expect(paths.omoConfig).toBe(join(expectedDir, "oh-my-opencode.json"))
|
||||
})
|
||||
})
|
||||
|
||||
describe("detectExistingConfigDir", () => {
|
||||
test("returns null when no config exists", () => {
|
||||
// #given no config files exist
|
||||
Object.defineProperty(process, "platform", { value: "linux" })
|
||||
delete process.env.XDG_CONFIG_HOME
|
||||
|
||||
// #when detectExistingConfigDir is called
|
||||
const result = detectExistingConfigDir("opencode", "1.0.200")
|
||||
|
||||
// #then result is either null or a valid string path
|
||||
expect(result === null || typeof result === "string").toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
132
src/shared/opencode-config-dir.ts
Normal file
132
src/shared/opencode-config-dir.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { existsSync } from "node:fs"
|
||||
import { homedir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
|
||||
export type OpenCodeBinaryType = "opencode" | "opencode-desktop"
|
||||
|
||||
export interface OpenCodeConfigDirOptions {
|
||||
binary: OpenCodeBinaryType
|
||||
version?: string | null
|
||||
checkExisting?: boolean
|
||||
}
|
||||
|
||||
export interface OpenCodeConfigPaths {
|
||||
configDir: string
|
||||
configJson: string
|
||||
configJsonc: string
|
||||
packageJson: string
|
||||
omoConfig: string
|
||||
}
|
||||
|
||||
export const TAURI_APP_IDENTIFIER = "ai.opencode.desktop"
|
||||
export const TAURI_APP_IDENTIFIER_DEV = "ai.opencode.desktop.dev"
|
||||
|
||||
export function isDevBuild(version: string | null | undefined): boolean {
|
||||
if (!version) return false
|
||||
return version.includes("-dev") || version.includes(".dev")
|
||||
}
|
||||
|
||||
function getTauriConfigDir(identifier: string): string {
|
||||
const platform = process.platform
|
||||
|
||||
switch (platform) {
|
||||
case "darwin":
|
||||
return join(homedir(), "Library", "Application Support", identifier)
|
||||
|
||||
case "win32": {
|
||||
const appData = process.env.APPDATA || join(homedir(), "AppData", "Roaming")
|
||||
return join(appData, identifier)
|
||||
}
|
||||
|
||||
case "linux":
|
||||
default: {
|
||||
const xdgConfig = process.env.XDG_CONFIG_HOME || join(homedir(), ".config")
|
||||
return join(xdgConfig, identifier)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getCliConfigDir(): string {
|
||||
if (process.platform === "win32") {
|
||||
const crossPlatformDir = join(homedir(), ".config", "opencode")
|
||||
const crossPlatformConfig = join(crossPlatformDir, "opencode.json")
|
||||
|
||||
if (existsSync(crossPlatformConfig)) {
|
||||
return crossPlatformDir
|
||||
}
|
||||
|
||||
const appData = process.env.APPDATA || join(homedir(), "AppData", "Roaming")
|
||||
const appdataDir = join(appData, "opencode")
|
||||
const appdataConfig = join(appdataDir, "opencode.json")
|
||||
|
||||
if (existsSync(appdataConfig)) {
|
||||
return appdataDir
|
||||
}
|
||||
|
||||
return crossPlatformDir
|
||||
}
|
||||
|
||||
const xdgConfig = process.env.XDG_CONFIG_HOME || join(homedir(), ".config")
|
||||
return join(xdgConfig, "opencode")
|
||||
}
|
||||
|
||||
export function getOpenCodeConfigDir(options: OpenCodeConfigDirOptions): string {
|
||||
const { binary, version, checkExisting = true } = options
|
||||
|
||||
if (binary === "opencode") {
|
||||
return getCliConfigDir()
|
||||
}
|
||||
|
||||
const identifier = isDevBuild(version) ? TAURI_APP_IDENTIFIER_DEV : TAURI_APP_IDENTIFIER
|
||||
const tauriDir = getTauriConfigDir(identifier)
|
||||
|
||||
if (checkExisting) {
|
||||
const legacyDir = getCliConfigDir()
|
||||
const legacyConfig = join(legacyDir, "opencode.json")
|
||||
const legacyConfigC = join(legacyDir, "opencode.jsonc")
|
||||
|
||||
if (existsSync(legacyConfig) || existsSync(legacyConfigC)) {
|
||||
return legacyDir
|
||||
}
|
||||
}
|
||||
|
||||
return tauriDir
|
||||
}
|
||||
|
||||
export function getOpenCodeConfigPaths(options: OpenCodeConfigDirOptions): OpenCodeConfigPaths {
|
||||
const configDir = getOpenCodeConfigDir(options)
|
||||
|
||||
return {
|
||||
configDir,
|
||||
configJson: join(configDir, "opencode.json"),
|
||||
configJsonc: join(configDir, "opencode.jsonc"),
|
||||
packageJson: join(configDir, "package.json"),
|
||||
omoConfig: join(configDir, "oh-my-opencode.json"),
|
||||
}
|
||||
}
|
||||
|
||||
export function detectExistingConfigDir(binary: OpenCodeBinaryType, version?: string | null): string | null {
|
||||
const locations: string[] = []
|
||||
|
||||
if (binary === "opencode-desktop") {
|
||||
const identifier = isDevBuild(version) ? TAURI_APP_IDENTIFIER_DEV : TAURI_APP_IDENTIFIER
|
||||
locations.push(getTauriConfigDir(identifier))
|
||||
|
||||
if (isDevBuild(version)) {
|
||||
locations.push(getTauriConfigDir(TAURI_APP_IDENTIFIER))
|
||||
}
|
||||
}
|
||||
|
||||
locations.push(getCliConfigDir())
|
||||
|
||||
for (const dir of locations) {
|
||||
const configJson = join(dir, "opencode.json")
|
||||
const configJsonc = join(dir, "opencode.jsonc")
|
||||
|
||||
if (existsSync(configJson) || existsSync(configJsonc)) {
|
||||
return dir
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
257
src/shared/opencode-version.test.ts
Normal file
257
src/shared/opencode-version.test.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
import { describe, test, expect, beforeEach, afterEach, spyOn, mock } from "bun:test"
|
||||
import * as childProcess from "child_process"
|
||||
import {
|
||||
parseVersion,
|
||||
compareVersions,
|
||||
isVersionGte,
|
||||
isVersionLt,
|
||||
getOpenCodeVersion,
|
||||
supportsNewPermissionSystem,
|
||||
usesLegacyToolsSystem,
|
||||
resetVersionCache,
|
||||
setVersionCache,
|
||||
PERMISSION_BREAKING_VERSION,
|
||||
} from "./opencode-version"
|
||||
|
||||
describe("opencode-version", () => {
|
||||
describe("parseVersion", () => {
|
||||
test("parses simple version", () => {
|
||||
// #given a simple version string
|
||||
const version = "1.2.3"
|
||||
|
||||
// #when parsed
|
||||
const result = parseVersion(version)
|
||||
|
||||
// #then returns array of numbers
|
||||
expect(result).toEqual([1, 2, 3])
|
||||
})
|
||||
|
||||
test("handles v prefix", () => {
|
||||
// #given version with v prefix
|
||||
const version = "v1.2.3"
|
||||
|
||||
// #when parsed
|
||||
const result = parseVersion(version)
|
||||
|
||||
// #then strips prefix and parses correctly
|
||||
expect(result).toEqual([1, 2, 3])
|
||||
})
|
||||
|
||||
test("handles prerelease suffix", () => {
|
||||
// #given version with prerelease
|
||||
const version = "1.2.3-beta.1"
|
||||
|
||||
// #when parsed
|
||||
const result = parseVersion(version)
|
||||
|
||||
// #then ignores prerelease part
|
||||
expect(result).toEqual([1, 2, 3])
|
||||
})
|
||||
|
||||
test("handles two-part version", () => {
|
||||
// #given two-part version
|
||||
const version = "1.2"
|
||||
|
||||
// #when parsed
|
||||
const result = parseVersion(version)
|
||||
|
||||
// #then returns two numbers
|
||||
expect(result).toEqual([1, 2])
|
||||
})
|
||||
})
|
||||
|
||||
describe("compareVersions", () => {
|
||||
test("returns 0 for equal versions", () => {
|
||||
// #given two equal versions
|
||||
// #when compared
|
||||
const result = compareVersions("1.1.1", "1.1.1")
|
||||
|
||||
// #then returns 0
|
||||
expect(result).toBe(0)
|
||||
})
|
||||
|
||||
test("returns 1 when a > b", () => {
|
||||
// #given a is greater than b
|
||||
// #when compared
|
||||
const result = compareVersions("1.2.0", "1.1.0")
|
||||
|
||||
// #then returns 1
|
||||
expect(result).toBe(1)
|
||||
})
|
||||
|
||||
test("returns -1 when a < b", () => {
|
||||
// #given a is less than b
|
||||
// #when compared
|
||||
const result = compareVersions("1.0.9", "1.1.0")
|
||||
|
||||
// #then returns -1
|
||||
expect(result).toBe(-1)
|
||||
})
|
||||
|
||||
test("handles different length versions", () => {
|
||||
// #given versions with different lengths
|
||||
// #when compared
|
||||
expect(compareVersions("1.1", "1.1.0")).toBe(0)
|
||||
expect(compareVersions("1.1.1", "1.1")).toBe(1)
|
||||
expect(compareVersions("1.1", "1.1.1")).toBe(-1)
|
||||
})
|
||||
|
||||
test("handles major version differences", () => {
|
||||
// #given major version difference
|
||||
// #when compared
|
||||
expect(compareVersions("2.0.0", "1.9.9")).toBe(1)
|
||||
expect(compareVersions("1.9.9", "2.0.0")).toBe(-1)
|
||||
})
|
||||
})
|
||||
|
||||
describe("isVersionGte", () => {
|
||||
test("returns true when a >= b", () => {
|
||||
expect(isVersionGte("1.1.1", "1.1.1")).toBe(true)
|
||||
expect(isVersionGte("1.1.2", "1.1.1")).toBe(true)
|
||||
expect(isVersionGte("1.2.0", "1.1.1")).toBe(true)
|
||||
expect(isVersionGte("2.0.0", "1.1.1")).toBe(true)
|
||||
})
|
||||
|
||||
test("returns false when a < b", () => {
|
||||
expect(isVersionGte("1.1.0", "1.1.1")).toBe(false)
|
||||
expect(isVersionGte("1.0.9", "1.1.1")).toBe(false)
|
||||
expect(isVersionGte("0.9.9", "1.1.1")).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("isVersionLt", () => {
|
||||
test("returns true when a < b", () => {
|
||||
expect(isVersionLt("1.1.0", "1.1.1")).toBe(true)
|
||||
expect(isVersionLt("1.0.150", "1.1.1")).toBe(true)
|
||||
})
|
||||
|
||||
test("returns false when a >= b", () => {
|
||||
expect(isVersionLt("1.1.1", "1.1.1")).toBe(false)
|
||||
expect(isVersionLt("1.1.2", "1.1.1")).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getOpenCodeVersion", () => {
|
||||
beforeEach(() => {
|
||||
resetVersionCache()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
resetVersionCache()
|
||||
})
|
||||
|
||||
test("returns cached version on subsequent calls", () => {
|
||||
// #given version is set in cache
|
||||
setVersionCache("1.2.3")
|
||||
|
||||
// #when getting version
|
||||
const result = getOpenCodeVersion()
|
||||
|
||||
// #then returns cached value
|
||||
expect(result).toBe("1.2.3")
|
||||
})
|
||||
|
||||
test("returns null when cache is set to null", () => {
|
||||
// #given cache is explicitly set to null
|
||||
setVersionCache(null)
|
||||
|
||||
// #when getting version (cache is already set)
|
||||
const result = getOpenCodeVersion()
|
||||
|
||||
// #then returns null without executing command
|
||||
expect(result).toBe(null)
|
||||
})
|
||||
})
|
||||
|
||||
describe("supportsNewPermissionSystem", () => {
|
||||
beforeEach(() => {
|
||||
resetVersionCache()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
resetVersionCache()
|
||||
})
|
||||
|
||||
test("returns true for v1.1.1", () => {
|
||||
// #given version is 1.1.1
|
||||
setVersionCache("1.1.1")
|
||||
|
||||
// #when checking permission system support
|
||||
const result = supportsNewPermissionSystem()
|
||||
|
||||
// #then returns true
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
test("returns true for versions above 1.1.1", () => {
|
||||
// #given version is above 1.1.1
|
||||
setVersionCache("1.2.0")
|
||||
|
||||
// #when checking
|
||||
const result = supportsNewPermissionSystem()
|
||||
|
||||
// #then returns true
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
test("returns false for versions below 1.1.1", () => {
|
||||
// #given version is below 1.1.1
|
||||
setVersionCache("1.1.0")
|
||||
|
||||
// #when checking
|
||||
const result = supportsNewPermissionSystem()
|
||||
|
||||
// #then returns false
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
test("returns true when version cannot be detected", () => {
|
||||
// #given version is null (undetectable)
|
||||
setVersionCache(null)
|
||||
|
||||
// #when checking
|
||||
const result = supportsNewPermissionSystem()
|
||||
|
||||
// #then returns true (assume newer version)
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("usesLegacyToolsSystem", () => {
|
||||
beforeEach(() => {
|
||||
resetVersionCache()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
resetVersionCache()
|
||||
})
|
||||
|
||||
test("returns true for versions below 1.1.1", () => {
|
||||
// #given version is below 1.1.1
|
||||
setVersionCache("1.0.150")
|
||||
|
||||
// #when checking
|
||||
const result = usesLegacyToolsSystem()
|
||||
|
||||
// #then returns true
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
test("returns false for v1.1.1 and above", () => {
|
||||
// #given version is 1.1.1
|
||||
setVersionCache("1.1.1")
|
||||
|
||||
// #when checking
|
||||
const result = usesLegacyToolsSystem()
|
||||
|
||||
// #then returns false
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("PERMISSION_BREAKING_VERSION", () => {
|
||||
test("is set to 1.1.1", () => {
|
||||
expect(PERMISSION_BREAKING_VERSION).toBe("1.1.1")
|
||||
})
|
||||
})
|
||||
})
|
||||
72
src/shared/opencode-version.ts
Normal file
72
src/shared/opencode-version.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { execSync } from "child_process"
|
||||
|
||||
export const PERMISSION_BREAKING_VERSION = "1.1.1"
|
||||
|
||||
const NOT_CACHED = Symbol("NOT_CACHED")
|
||||
let cachedVersion: string | null | typeof NOT_CACHED = NOT_CACHED
|
||||
|
||||
export function parseVersion(version: string): number[] {
|
||||
const cleaned = version.replace(/^v/, "").split("-")[0]
|
||||
return cleaned.split(".").map((n) => parseInt(n, 10) || 0)
|
||||
}
|
||||
|
||||
export function compareVersions(a: string, b: string): -1 | 0 | 1 {
|
||||
const partsA = parseVersion(a)
|
||||
const partsB = parseVersion(b)
|
||||
const maxLen = Math.max(partsA.length, partsB.length)
|
||||
|
||||
for (let i = 0; i < maxLen; i++) {
|
||||
const numA = partsA[i] ?? 0
|
||||
const numB = partsB[i] ?? 0
|
||||
if (numA < numB) return -1
|
||||
if (numA > numB) return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
export function isVersionGte(a: string, b: string): boolean {
|
||||
return compareVersions(a, b) >= 0
|
||||
}
|
||||
|
||||
export function isVersionLt(a: string, b: string): boolean {
|
||||
return compareVersions(a, b) < 0
|
||||
}
|
||||
|
||||
export function getOpenCodeVersion(): string | null {
|
||||
if (cachedVersion !== NOT_CACHED) {
|
||||
return cachedVersion
|
||||
}
|
||||
|
||||
try {
|
||||
const result = execSync("opencode --version", {
|
||||
encoding: "utf-8",
|
||||
timeout: 5000,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
}).trim()
|
||||
|
||||
const versionMatch = result.match(/(\d+\.\d+\.\d+(?:-[\w.]+)?)/)
|
||||
cachedVersion = versionMatch?.[1] ?? null
|
||||
return cachedVersion
|
||||
} catch {
|
||||
cachedVersion = null
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function supportsNewPermissionSystem(): boolean {
|
||||
const version = getOpenCodeVersion()
|
||||
if (!version) return true
|
||||
return isVersionGte(version, PERMISSION_BREAKING_VERSION)
|
||||
}
|
||||
|
||||
export function usesLegacyToolsSystem(): boolean {
|
||||
return !supportsNewPermissionSystem()
|
||||
}
|
||||
|
||||
export function resetVersionCache(): void {
|
||||
cachedVersion = NOT_CACHED
|
||||
}
|
||||
|
||||
export function setVersionCache(version: string | null): void {
|
||||
cachedVersion = version
|
||||
}
|
||||
158
src/shared/permission-compat.test.ts
Normal file
158
src/shared/permission-compat.test.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { describe, test, expect, beforeEach, afterEach } from "bun:test"
|
||||
import {
|
||||
createAgentToolRestrictions,
|
||||
migrateToolsToPermission,
|
||||
migratePermissionToTools,
|
||||
migrateAgentConfig,
|
||||
} from "./permission-compat"
|
||||
import { setVersionCache, resetVersionCache } from "./opencode-version"
|
||||
|
||||
describe("permission-compat", () => {
|
||||
beforeEach(() => {
|
||||
resetVersionCache()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
resetVersionCache()
|
||||
})
|
||||
|
||||
describe("createAgentToolRestrictions", () => {
|
||||
test("returns permission format for v1.1.1+", () => {
|
||||
// #given version is 1.1.1
|
||||
setVersionCache("1.1.1")
|
||||
|
||||
// #when creating restrictions
|
||||
const result = createAgentToolRestrictions(["write", "edit"])
|
||||
|
||||
// #then returns permission format
|
||||
expect(result).toEqual({
|
||||
permission: { write: "deny", edit: "deny" },
|
||||
})
|
||||
})
|
||||
|
||||
test("returns tools format for versions below 1.1.1", () => {
|
||||
// #given version is below 1.1.1
|
||||
setVersionCache("1.0.150")
|
||||
|
||||
// #when creating restrictions
|
||||
const result = createAgentToolRestrictions(["write", "edit"])
|
||||
|
||||
// #then returns tools format
|
||||
expect(result).toEqual({
|
||||
tools: { write: false, edit: false },
|
||||
})
|
||||
})
|
||||
|
||||
test("assumes new format when version unknown", () => {
|
||||
// #given version is null
|
||||
setVersionCache(null)
|
||||
|
||||
// #when creating restrictions
|
||||
const result = createAgentToolRestrictions(["write"])
|
||||
|
||||
// #then returns permission format (assumes new version)
|
||||
expect(result).toEqual({
|
||||
permission: { write: "deny" },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("migrateToolsToPermission", () => {
|
||||
test("converts boolean tools to permission values", () => {
|
||||
// #given tools config
|
||||
const tools = { write: false, edit: true, bash: false }
|
||||
|
||||
// #when migrating
|
||||
const result = migrateToolsToPermission(tools)
|
||||
|
||||
// #then converts correctly
|
||||
expect(result).toEqual({
|
||||
write: "deny",
|
||||
edit: "allow",
|
||||
bash: "deny",
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("migratePermissionToTools", () => {
|
||||
test("converts permission to boolean tools", () => {
|
||||
// #given permission config
|
||||
const permission = { write: "deny" as const, edit: "allow" as const }
|
||||
|
||||
// #when migrating
|
||||
const result = migratePermissionToTools(permission)
|
||||
|
||||
// #then converts correctly
|
||||
expect(result).toEqual({ write: false, edit: true })
|
||||
})
|
||||
|
||||
test("excludes ask values", () => {
|
||||
// #given permission with ask
|
||||
const permission = {
|
||||
write: "deny" as const,
|
||||
edit: "ask" as const,
|
||||
bash: "allow" as const,
|
||||
}
|
||||
|
||||
// #when migrating
|
||||
const result = migratePermissionToTools(permission)
|
||||
|
||||
// #then ask is excluded
|
||||
expect(result).toEqual({ write: false, bash: true })
|
||||
})
|
||||
})
|
||||
|
||||
describe("migrateAgentConfig", () => {
|
||||
test("migrates tools to permission for v1.1.1+", () => {
|
||||
// #given v1.1.1 and config with tools
|
||||
setVersionCache("1.1.1")
|
||||
const config = {
|
||||
model: "test",
|
||||
tools: { write: false, edit: false },
|
||||
}
|
||||
|
||||
// #when migrating
|
||||
const result = migrateAgentConfig(config)
|
||||
|
||||
// #then converts to permission
|
||||
expect(result.tools).toBeUndefined()
|
||||
expect(result.permission).toEqual({ write: "deny", edit: "deny" })
|
||||
expect(result.model).toBe("test")
|
||||
})
|
||||
|
||||
test("migrates permission to tools for old versions", () => {
|
||||
// #given old version and config with permission
|
||||
setVersionCache("1.0.150")
|
||||
const config = {
|
||||
model: "test",
|
||||
permission: { write: "deny" as const, edit: "deny" as const },
|
||||
}
|
||||
|
||||
// #when migrating
|
||||
const result = migrateAgentConfig(config)
|
||||
|
||||
// #then converts to tools
|
||||
expect(result.permission).toBeUndefined()
|
||||
expect(result.tools).toEqual({ write: false, edit: false })
|
||||
})
|
||||
|
||||
test("preserves other config fields", () => {
|
||||
// #given config with other fields
|
||||
setVersionCache("1.1.1")
|
||||
const config = {
|
||||
model: "test",
|
||||
temperature: 0.5,
|
||||
prompt: "hello",
|
||||
tools: { write: false },
|
||||
}
|
||||
|
||||
// #when migrating
|
||||
const result = migrateAgentConfig(config)
|
||||
|
||||
// #then preserves other fields
|
||||
expect(result.model).toBe("test")
|
||||
expect(result.temperature).toBe(0.5)
|
||||
expect(result.prompt).toBe("hello")
|
||||
})
|
||||
})
|
||||
})
|
||||
79
src/shared/permission-compat.ts
Normal file
79
src/shared/permission-compat.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { supportsNewPermissionSystem } from "./opencode-version"
|
||||
|
||||
export type PermissionValue = "ask" | "allow" | "deny"
|
||||
|
||||
export interface LegacyToolsFormat {
|
||||
tools: Record<string, boolean>
|
||||
}
|
||||
|
||||
export interface NewPermissionFormat {
|
||||
permission: Record<string, PermissionValue>
|
||||
}
|
||||
|
||||
export type VersionAwareRestrictions = LegacyToolsFormat | NewPermissionFormat
|
||||
|
||||
export function createAgentToolRestrictions(
|
||||
denyTools: string[]
|
||||
): VersionAwareRestrictions {
|
||||
if (supportsNewPermissionSystem()) {
|
||||
return {
|
||||
permission: Object.fromEntries(
|
||||
denyTools.map((tool) => [tool, "deny" as const])
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
tools: Object.fromEntries(denyTools.map((tool) => [tool, false])),
|
||||
}
|
||||
}
|
||||
|
||||
export function migrateToolsToPermission(
|
||||
tools: Record<string, boolean>
|
||||
): Record<string, PermissionValue> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(tools).map(([key, value]) => [
|
||||
key,
|
||||
value ? ("allow" as const) : ("deny" as const),
|
||||
])
|
||||
)
|
||||
}
|
||||
|
||||
export function migratePermissionToTools(
|
||||
permission: Record<string, PermissionValue>
|
||||
): Record<string, boolean> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(permission)
|
||||
.filter(([, value]) => value !== "ask")
|
||||
.map(([key, value]) => [key, value === "allow"])
|
||||
)
|
||||
}
|
||||
|
||||
export function migrateAgentConfig(
|
||||
config: Record<string, unknown>
|
||||
): Record<string, unknown> {
|
||||
const result = { ...config }
|
||||
|
||||
if (supportsNewPermissionSystem()) {
|
||||
if (result.tools && typeof result.tools === "object") {
|
||||
const existingPermission =
|
||||
(result.permission as Record<string, PermissionValue>) || {}
|
||||
const migratedPermission = migrateToolsToPermission(
|
||||
result.tools as Record<string, boolean>
|
||||
)
|
||||
result.permission = { ...migratedPermission, ...existingPermission }
|
||||
delete result.tools
|
||||
}
|
||||
} else {
|
||||
if (result.permission && typeof result.permission === "object") {
|
||||
const existingTools = (result.tools as Record<string, boolean>) || {}
|
||||
const migratedTools = migratePermissionToTools(
|
||||
result.permission as Record<string, PermissionValue>
|
||||
)
|
||||
result.tools = { ...migratedTools, ...existingTools }
|
||||
delete result.permission
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { CliMatch, AnalyzeResult, SgResult } from "./types"
|
||||
import type { AnalyzeResult, SgResult } from "./types"
|
||||
|
||||
export function formatSearchResult(result: SgResult): string {
|
||||
if (result.error) {
|
||||
@@ -93,7 +93,7 @@ export function formatAnalyzeResult(results: AnalyzeResult[], extractedMetaVars:
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
export function formatTransformResult(original: string, transformed: string, editCount: number): string {
|
||||
export function formatTransformResult(_original: string, transformed: string, editCount: number): string {
|
||||
if (editCount === 0) {
|
||||
return "No matches found to transform"
|
||||
}
|
||||
|
||||
@@ -28,6 +28,8 @@ import {
|
||||
session_info,
|
||||
} from "./session-manager"
|
||||
|
||||
export { sessionExists } from "./session-manager/storage"
|
||||
|
||||
export { interactive_bash, startBackgroundCheck as startTmuxCheck } from "./interactive-bash"
|
||||
export { createSkillTool } from "./skill"
|
||||
export { getTmuxPath } from "./interactive-bash/utils"
|
||||
|
||||
@@ -69,10 +69,21 @@ export const LSP_INSTALL_HINTS: Record<string, string> = {
|
||||
php: "npm install -g intelephense",
|
||||
dart: "Included with Dart SDK",
|
||||
"terraform-ls": "See https://github.com/hashicorp/terraform-ls",
|
||||
terraform: "See https://github.com/hashicorp/terraform-ls",
|
||||
prisma: "npm install -g prisma",
|
||||
"ocaml-lsp": "opam install ocaml-lsp-server",
|
||||
texlab: "See https://github.com/latex-lsp/texlab",
|
||||
dockerfile: "npm install -g dockerfile-language-server-nodejs",
|
||||
gleam: "See https://gleam.run/getting-started/installing/",
|
||||
"clojure-lsp": "See https://clojure-lsp.io/installation/",
|
||||
nixd: "nix profile install nixpkgs#nixd",
|
||||
tinymist: "See https://github.com/Myriad-Dreamin/tinymist",
|
||||
"haskell-language-server": "ghcup install hls",
|
||||
bash: "npm install -g bash-language-server",
|
||||
}
|
||||
|
||||
// Synced with OpenCode's server.ts
|
||||
// https://github.com/sst/opencode/blob/main/packages/opencode/src/lsp/server.ts
|
||||
// https://github.com/sst/opencode/blob/dev/packages/opencode/src/lsp/server.ts
|
||||
export const BUILTIN_SERVERS: Record<string, Omit<LSPServerConfig, "id">> = {
|
||||
typescript: {
|
||||
command: ["typescript-language-server", "--stdio"],
|
||||
@@ -161,6 +172,11 @@ export const BUILTIN_SERVERS: Record<string, Omit<LSPServerConfig, "id">> = {
|
||||
command: ["astro-ls", "--stdio"],
|
||||
extensions: [".astro"],
|
||||
},
|
||||
bash: {
|
||||
command: ["bash-language-server", "start"],
|
||||
extensions: [".sh", ".bash", ".zsh", ".ksh"],
|
||||
},
|
||||
// Keep legacy alias for backward compatibility
|
||||
"bash-ls": {
|
||||
command: ["bash-language-server", "start"],
|
||||
extensions: [".sh", ".bash", ".zsh", ".ksh"],
|
||||
@@ -185,14 +201,55 @@ export const BUILTIN_SERVERS: Record<string, Omit<LSPServerConfig, "id">> = {
|
||||
command: ["dart", "language-server", "--lsp"],
|
||||
extensions: [".dart"],
|
||||
},
|
||||
terraform: {
|
||||
command: ["terraform-ls", "serve"],
|
||||
extensions: [".tf", ".tfvars"],
|
||||
},
|
||||
// Legacy alias for backward compatibility
|
||||
"terraform-ls": {
|
||||
command: ["terraform-ls", "serve"],
|
||||
extensions: [".tf", ".tfvars"],
|
||||
},
|
||||
prisma: {
|
||||
command: ["prisma", "language-server"],
|
||||
extensions: [".prisma"],
|
||||
},
|
||||
"ocaml-lsp": {
|
||||
command: ["ocamllsp"],
|
||||
extensions: [".ml", ".mli"],
|
||||
},
|
||||
texlab: {
|
||||
command: ["texlab"],
|
||||
extensions: [".tex", ".bib"],
|
||||
},
|
||||
dockerfile: {
|
||||
command: ["docker-langserver", "--stdio"],
|
||||
extensions: [".dockerfile"],
|
||||
},
|
||||
gleam: {
|
||||
command: ["gleam", "lsp"],
|
||||
extensions: [".gleam"],
|
||||
},
|
||||
"clojure-lsp": {
|
||||
command: ["clojure-lsp", "listen"],
|
||||
extensions: [".clj", ".cljs", ".cljc", ".edn"],
|
||||
},
|
||||
nixd: {
|
||||
command: ["nixd"],
|
||||
extensions: [".nix"],
|
||||
},
|
||||
tinymist: {
|
||||
command: ["tinymist"],
|
||||
extensions: [".typ", ".typc"],
|
||||
},
|
||||
"haskell-language-server": {
|
||||
command: ["haskell-language-server-wrapper", "--lsp"],
|
||||
extensions: [".hs", ".lhs"],
|
||||
},
|
||||
}
|
||||
|
||||
// Synced with OpenCode's language.ts
|
||||
// https://github.com/sst/opencode/blob/main/packages/opencode/src/lsp/language.ts
|
||||
// https://github.com/sst/opencode/blob/dev/packages/opencode/src/lsp/language.ts
|
||||
export const EXT_TO_LANG: Record<string, string> = {
|
||||
".abap": "abap",
|
||||
".bat": "bat",
|
||||
@@ -306,6 +363,14 @@ export const EXT_TO_LANG: Record<string, string> = {
|
||||
".tf": "terraform",
|
||||
".tfvars": "terraform-vars",
|
||||
".hcl": "hcl",
|
||||
".nix": "nix",
|
||||
".typ": "typst",
|
||||
".typc": "typst",
|
||||
".ets": "typescript",
|
||||
".lhs": "haskell",
|
||||
".kt": "kotlin",
|
||||
".kts": "kotlin",
|
||||
".prisma": "prisma",
|
||||
// Additional extensions not in OpenCode
|
||||
".h": "c",
|
||||
".hpp": "cpp",
|
||||
|
||||
@@ -40,7 +40,13 @@ function formatSkillsXml(skills: SkillInfo[]): string {
|
||||
return `\n\n<available_skills>\n${skillsXml}\n</available_skills>`
|
||||
}
|
||||
|
||||
function extractSkillBody(skill: LoadedSkill): string {
|
||||
async function extractSkillBody(skill: LoadedSkill): Promise<string> {
|
||||
if (skill.lazyContent) {
|
||||
const fullTemplate = await skill.lazyContent.load()
|
||||
const templateMatch = fullTemplate.match(/<skill-instruction>([\s\S]*?)<\/skill-instruction>/)
|
||||
return templateMatch ? templateMatch[1].trim() : fullTemplate
|
||||
}
|
||||
|
||||
if (skill.path) {
|
||||
const content = readFileSync(skill.path, "utf-8")
|
||||
const { body } = parseFrontmatter(content)
|
||||
@@ -145,7 +151,7 @@ export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition
|
||||
throw new Error(`Skill "${args.name}" not found. Available skills: ${available || "none"}`)
|
||||
}
|
||||
|
||||
const body = extractSkillBody(skill)
|
||||
const body = await extractSkillBody(skill)
|
||||
const dir = skill.path ? dirname(skill.path) : skill.resolvedPath || process.cwd()
|
||||
|
||||
const output = [
|
||||
|
||||
Reference in New Issue
Block a user