Compare commits
28 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83676b36cf | ||
|
|
398075f5df | ||
|
|
d4347e829d | ||
|
|
980b685393 | ||
|
|
b5c1cfb57f | ||
|
|
cd97572d0a | ||
|
|
b9ec4c7c4a | ||
|
|
2064568124 | ||
|
|
ad44af9d15 | ||
|
|
d331b484f9 | ||
|
|
4a38e70fa8 | ||
|
|
204ea319cb | ||
|
|
a2bfb5e556 | ||
|
|
f25f7ed0f5 | ||
|
|
29dbc0f57b | ||
|
|
544212fa9c | ||
|
|
f3eed731d6 | ||
|
|
6f1cabd3f4 | ||
|
|
15571d3d95 | ||
|
|
556262e791 | ||
|
|
375e7f715d | ||
|
|
5aa0ee125d | ||
|
|
d0b3be72c5 | ||
|
|
a10903def2 | ||
|
|
dc5a24ac3e | ||
|
|
9d13c6cff1 | ||
|
|
b78e564872 | ||
|
|
c709fafa25 |
129
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
129
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
@@ -0,0 +1,129 @@
|
||||
name: Bug Report
|
||||
description: Report a bug or unexpected behavior in oh-my-opencode
|
||||
title: "[Bug]: "
|
||||
labels: ["bug", "needs-triage"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
**Please write your issue in English.** See our [Language Policy](https://github.com/code-yeongyu/oh-my-opencode/blob/dev/CONTRIBUTING.md#language-policy) for details.
|
||||
|
||||
- type: checkboxes
|
||||
id: prerequisites
|
||||
attributes:
|
||||
label: Prerequisites
|
||||
description: Please confirm the following before submitting
|
||||
options:
|
||||
- label: I have searched existing issues to avoid duplicates
|
||||
required: true
|
||||
- label: I am using the latest version of oh-my-opencode
|
||||
required: true
|
||||
- label: I have read the [documentation](https://github.com/code-yeongyu/oh-my-opencode#readme)
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Bug Description
|
||||
description: A clear and concise description of what the bug is
|
||||
placeholder: Describe the bug in detail...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: reproduction
|
||||
attributes:
|
||||
label: Steps to Reproduce
|
||||
description: Steps to reproduce the behavior
|
||||
placeholder: |
|
||||
1. Configure oh-my-opencode with...
|
||||
2. Run command '...'
|
||||
3. See error...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected Behavior
|
||||
description: What did you expect to happen?
|
||||
placeholder: Describe what should happen...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: actual
|
||||
attributes:
|
||||
label: Actual Behavior
|
||||
description: What actually happened?
|
||||
placeholder: Describe what actually happened...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: doctor
|
||||
attributes:
|
||||
label: Doctor Output
|
||||
description: |
|
||||
**Required:** Run `bunx oh-my-opencode doctor` and paste the full output below.
|
||||
This helps us diagnose your environment and configuration.
|
||||
placeholder: |
|
||||
Paste the output of: bunx oh-my-opencode doctor
|
||||
|
||||
Example:
|
||||
✓ OpenCode version: 1.0.150
|
||||
✓ oh-my-opencode version: 1.2.3
|
||||
✓ Plugin loaded successfully
|
||||
...
|
||||
render: shell
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Error Logs
|
||||
description: If applicable, add any error messages or logs
|
||||
placeholder: Paste error logs here...
|
||||
render: shell
|
||||
|
||||
- type: textarea
|
||||
id: config
|
||||
attributes:
|
||||
label: Configuration
|
||||
description: If relevant, share your oh-my-opencode configuration (remove sensitive data)
|
||||
placeholder: |
|
||||
{
|
||||
"agents": { ... },
|
||||
"disabled_hooks": [ ... ]
|
||||
}
|
||||
render: json
|
||||
|
||||
- type: textarea
|
||||
id: context
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Any other context about the problem
|
||||
placeholder: Add any other context, screenshots, or information...
|
||||
|
||||
- type: dropdown
|
||||
id: os
|
||||
attributes:
|
||||
label: Operating System
|
||||
description: Which operating system are you using?
|
||||
options:
|
||||
- macOS
|
||||
- Linux
|
||||
- Windows
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: opencode-version
|
||||
attributes:
|
||||
label: OpenCode Version
|
||||
description: Run `opencode --version` to get your version
|
||||
placeholder: "1.0.150"
|
||||
validations:
|
||||
required: true
|
||||
8
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
8
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Discord Community
|
||||
url: https://discord.gg/PUwSMR9XNk
|
||||
about: Join our Discord server for real-time discussions and community support
|
||||
- name: Documentation
|
||||
url: https://github.com/code-yeongyu/oh-my-opencode#readme
|
||||
about: Read the comprehensive documentation and guides
|
||||
100
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
100
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
@@ -0,0 +1,100 @@
|
||||
name: Feature Request
|
||||
description: Suggest a new feature or enhancement for oh-my-opencode
|
||||
title: "[Feature]: "
|
||||
labels: ["enhancement", "needs-triage"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
**Please write your issue in English.** See our [Language Policy](https://github.com/code-yeongyu/oh-my-opencode/blob/dev/CONTRIBUTING.md#language-policy) for details.
|
||||
|
||||
- type: checkboxes
|
||||
id: prerequisites
|
||||
attributes:
|
||||
label: Prerequisites
|
||||
description: Please confirm the following before submitting
|
||||
options:
|
||||
- label: I have searched existing issues and discussions to avoid duplicates
|
||||
required: true
|
||||
- label: This feature request is specific to oh-my-opencode (not OpenCode core)
|
||||
required: true
|
||||
- label: I have read the [documentation](https://github.com/code-yeongyu/oh-my-opencode#readme)
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: Problem Description
|
||||
description: What problem does this feature solve? What's the use case?
|
||||
placeholder: |
|
||||
Describe the problem or limitation you're experiencing...
|
||||
Example: "As a user, I find it difficult to..."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: solution
|
||||
attributes:
|
||||
label: Proposed Solution
|
||||
description: Describe how you'd like this feature to work
|
||||
placeholder: |
|
||||
Describe your proposed solution in detail...
|
||||
Example: "Add a new hook that..."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Alternatives Considered
|
||||
description: Have you considered any alternative solutions or workarounds?
|
||||
placeholder: |
|
||||
Describe any alternative solutions you've considered...
|
||||
Example: "I tried using X but it didn't work because..."
|
||||
|
||||
- type: textarea
|
||||
id: doctor
|
||||
attributes:
|
||||
label: Doctor Output (Optional)
|
||||
description: |
|
||||
If relevant to your feature request, run `bunx oh-my-opencode doctor` and paste the output.
|
||||
This helps us understand your environment.
|
||||
placeholder: |
|
||||
Paste the output of: bunx oh-my-opencode doctor
|
||||
(Optional for feature requests)
|
||||
render: shell
|
||||
|
||||
- type: textarea
|
||||
id: context
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Any other context, mockups, or examples
|
||||
placeholder: |
|
||||
Add any other context, screenshots, code examples, or links...
|
||||
Examples from other tools/projects are helpful!
|
||||
|
||||
- type: dropdown
|
||||
id: feature-type
|
||||
attributes:
|
||||
label: Feature Type
|
||||
description: What type of feature is this?
|
||||
options:
|
||||
- New Agent
|
||||
- New Hook
|
||||
- New Tool
|
||||
- New MCP Integration
|
||||
- Configuration Option
|
||||
- Documentation
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: checkboxes
|
||||
id: contribution
|
||||
attributes:
|
||||
label: Contribution
|
||||
description: Are you willing to contribute to this feature?
|
||||
options:
|
||||
- label: I'm willing to submit a PR for this feature
|
||||
- label: I can help with testing
|
||||
- label: I can help with documentation
|
||||
83
.github/ISSUE_TEMPLATE/general.yml
vendored
Normal file
83
.github/ISSUE_TEMPLATE/general.yml
vendored
Normal file
@@ -0,0 +1,83 @@
|
||||
name: Question or Discussion
|
||||
description: Ask a question or start a discussion about oh-my-opencode
|
||||
title: "[Question]: "
|
||||
labels: ["question", "needs-triage"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
**Please write your issue in English.** See our [Language Policy](https://github.com/code-yeongyu/oh-my-opencode/blob/dev/CONTRIBUTING.md#language-policy) for details.
|
||||
|
||||
- type: checkboxes
|
||||
id: prerequisites
|
||||
attributes:
|
||||
label: Prerequisites
|
||||
description: Please confirm the following before submitting
|
||||
options:
|
||||
- label: I have searched existing issues and discussions
|
||||
required: true
|
||||
- label: I have read the [documentation](https://github.com/code-yeongyu/oh-my-opencode#readme)
|
||||
required: true
|
||||
- label: This is a question (not a bug report or feature request)
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: question
|
||||
attributes:
|
||||
label: Question
|
||||
description: What would you like to know or discuss?
|
||||
placeholder: |
|
||||
Ask your question in detail...
|
||||
|
||||
Examples:
|
||||
- How do I configure agent X to do Y?
|
||||
- What's the best practice for Z?
|
||||
- Why does feature A work differently than B?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: context
|
||||
attributes:
|
||||
label: Context
|
||||
description: Provide any relevant context or background
|
||||
placeholder: |
|
||||
What have you tried so far?
|
||||
What's your use case?
|
||||
Any relevant configuration or setup details?
|
||||
|
||||
- type: textarea
|
||||
id: doctor
|
||||
attributes:
|
||||
label: Doctor Output (Optional)
|
||||
description: |
|
||||
If your question is about configuration or setup, run `bunx oh-my-opencode doctor` and paste the output.
|
||||
placeholder: |
|
||||
Paste the output of: bunx oh-my-opencode doctor
|
||||
(Optional for questions)
|
||||
render: shell
|
||||
|
||||
- type: dropdown
|
||||
id: category
|
||||
attributes:
|
||||
label: Question Category
|
||||
description: What is your question about?
|
||||
options:
|
||||
- Configuration
|
||||
- Agent Usage
|
||||
- Hook Behavior
|
||||
- Tool Usage
|
||||
- Installation/Setup
|
||||
- Best Practices
|
||||
- Performance
|
||||
- Integration
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: additional
|
||||
attributes:
|
||||
label: Additional Information
|
||||
description: Any other information that might be helpful
|
||||
placeholder: Links, screenshots, examples, etc.
|
||||
@@ -26,6 +26,29 @@ First off, thanks for taking the time to contribute! This document provides guid
|
||||
|
||||
Be respectful, inclusive, and constructive. We're all here to make better tools together.
|
||||
|
||||
## Language Policy
|
||||
|
||||
**English is the primary language for all communications in this repository.**
|
||||
|
||||
This includes:
|
||||
- Issues and bug reports
|
||||
- Pull requests and code reviews
|
||||
- Documentation and comments
|
||||
- Discussions and community interactions
|
||||
|
||||
### Why English?
|
||||
|
||||
- **Global Accessibility**: English allows contributors from all regions to collaborate effectively
|
||||
- **Consistency**: A single language keeps discussions organized and searchable
|
||||
- **Open Source Best Practice**: Most successful open-source projects use English as the lingua franca
|
||||
|
||||
### Need Help with English?
|
||||
|
||||
If English isn't your first language, don't worry! We value your contributions regardless of perfect grammar. You can:
|
||||
- Use translation tools to help compose messages
|
||||
- Ask for help from other community members
|
||||
- Focus on clear, simple communication rather than perfect prose
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Prerequisites
|
||||
|
||||
17
README.ja.md
17
README.ja.md
@@ -10,6 +10,7 @@
|
||||
> | [<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="GitHub Follow" src="https://img.shields.io/github/followers/code-yeongyu?style=flat-square&logo=github&labelColor=black&color=24292f" width="156px" />](https://github.com/code-yeongyu) | GitHubで[@code-yeongyu](https://github.com/code-yeongyu)をフォローして、他のプロジェクトもチェックしてください。 |
|
||||
|
||||
<!-- <CENTERED SECTION FOR GITHUB DISPLAY> -->
|
||||
|
||||
@@ -189,13 +190,15 @@ Windows から Linux に初めて乗り換えた時のこと、自分の思い
|
||||
|
||||
インストールするだけで、エージェントは以下のようなワークフローで働けるようになります:
|
||||
|
||||
1. バックグラウンドタスクとして Gemini 3 Pro にフロントエンドを書かせている間に、Claude Opus 4.5 がバックエンドを作成し、デバッグで詰まったら GPT 5.2 に助けを求めます。フロントエンドの実装完了報告が来たら、それを検証して出荷します。
|
||||
2. 何か調べる必要があれば、公式ドキュメント、コードベースの全履歴、GitHub に公開されている実装例まで徹底的に調査します。単なる grep だけでなく、内蔵された LSP ツールや AST-Grep まで駆使します。
|
||||
3. LLM に仕事を任せる際、コンテキスト管理の心配はもう不要です。私がやります。
|
||||
- OhMyOpenCode は複数のエージェントを積極的に活用し、コンテキストの負荷を軽減します。
|
||||
- **あなたのエージェントは今や開発チームのリードです。あなたは AI マネージャーです。**
|
||||
4. 頼んだ仕事が完了するまで止まりません。
|
||||
5. このプロジェクトについて深く知りたくない?大丈夫です。ただ 'ultrathink' と入力してください。
|
||||
1. Sisyphusは自分自身でファイルを探し回るような時間の無駄はしません。メインエージェントのコンテキストを軽量に保つため、より高速で安価なモデルへ並列でバックグラウンドタスクを飛ばし、自身の代わりに領域の調査を完了させます。
|
||||
1. SisyphusはリファクタリングにLSPを活用します。その方が確実で、安全、かつ的確だからです。
|
||||
1. UIに関わる重い作業が必要な場合、SisyphusはフロントエンドのタスクをGemini 3 Proに直接デリゲートします。
|
||||
1. もしSisyphusがループに陥ったり壁にぶつかったりしても、無駄に悩み続けることはありません。高IQな戦略的バックアップとしてGPT 5.2を呼び出します。
|
||||
1. 複雑なオープンソースフレームワークを扱っていますか?Sisyphusはサブエージェントを生成し、生のソースコードやドキュメントをリアルタイムで消化します。彼は完全なコンテキスト認識を持って動作します。
|
||||
1. Sisyphusがコメントに触れるとき、その存在意義を証明するか、さもなくば削除します。あなたのコードベースを常にクリーンに保ちます。
|
||||
1. Sisyphusは自身のTODOリストに縛られています。もし始めたことを終わらせられなければ、システムは彼を強制的に「bouldering」モードに戻します。あなたのタスクは、何があろうと完了します。
|
||||
1. 正直、ドキュメントなんて読む必要はありません。ただプロンプトを書いてください。「ultrawork」というキーワードを含めるだけで十分です。Sisyphusが構造を分析し、コンテキストを集め、外部のソースコードまで掘り下げ、仕事が100%完了するまでboulderingを続けます。
|
||||
1. ぶっちゃけ、「ultrawork」と打つのすら面倒ですよね。それなら「ulw」だけでOKです。ただulwと打ち、コーヒーでも飲んでいてください。仕事は終わっています。
|
||||
|
||||
このような機能が不要であれば、前述の通り、特定の機能だけを選んで使うことができます。
|
||||
|
||||
|
||||
1038
README.ko.md
1038
README.ko.md
File diff suppressed because it is too large
Load Diff
25
README.md
25
README.md
@@ -10,6 +10,7 @@
|
||||
> | [<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="GitHub Follow" src="https://img.shields.io/github/followers/code-yeongyu?style=flat-square&logo=github&labelColor=black&color=24292f" width="156px" />](https://github.com/code-yeongyu) | Follow [@code-yeongyu](https://github.com/code-yeongyu) on GitHub for more projects. |
|
||||
|
||||
<!-- <CENTERED SECTION FOR GITHUB DISPLAY> -->
|
||||
|
||||
@@ -41,7 +42,7 @@ No stupid token consumption massive subagents here. No bloat tools here.
|
||||
[](https://github.com/code-yeongyu/oh-my-opencode/issues)
|
||||
[](https://github.com/code-yeongyu/oh-my-opencode/blob/master/LICENSE.md)
|
||||
|
||||
[English](README.md) | [한국어](README.ko.md) | [日本語](README.ja.md) | [简体中文](README.zh-cn.md)
|
||||
[English](README.md) | [日本語](README.ja.md) | [简体中文](README.zh-cn.md)
|
||||
|
||||
</div>
|
||||
|
||||
@@ -128,8 +129,7 @@ No stupid token consumption massive subagents here. No bloat tools here.
|
||||
|
||||
# Oh My OpenCode
|
||||
|
||||
oMoMoMoMoMo···
|
||||
|
||||
Meet Sisyphus: The Batteries-Included Agent that codes like you.
|
||||
|
||||
[Claude Code](https://www.claude.com/product/claude-code) is great.
|
||||
But if you're a hacker, you'll fall head over heels for [OpenCode](https://github.com/sst/opencode).
|
||||
@@ -197,8 +197,17 @@ Meet our main agent: Sisyphus (Opus 4.5 High). Below are the tools Sisyphus uses
|
||||
|
||||
Just by installing this, you make your agents to work like:
|
||||
|
||||
1. While Gemini 3 Pro writes the frontend as a background task, Claude Opus 4.5 handles the backend. Stuck debugging? Call GPT 5.2 for help. When the frontend reports done, verify and ship.
|
||||
2. Need to look something up? It scours official docs, your entire codebase history, and public GitHub implementations—using not just grep but built-in LSP tools and AST-Grep.
|
||||
1. Sisyphus doesn't waste time hunting for files himself; he keeps the main agent's context lean. Instead, he fires off background tasks to faster, cheaper models in parallel to map the territory for him.
|
||||
1. Sisyphus leverages LSP for refactoring; it's more deterministic, safer, and surgical.
|
||||
1. When the heavy lifting requires a UI touch, Sisyphus delegates frontend tasks directly to Gemini 3 Pro.
|
||||
1. If Sisyphus gets stuck in a loop or hits a wall, he doesn't keep banging his head—he calls GPT 5.2 for high-IQ strategic backup.
|
||||
1. Working with a complex open-source framework? Sisyphus spawns subagents to digest the raw source code and documentation in real-time. He operates with total contextual awareness.
|
||||
1. When Sisyphus touches comments, he either justifies their existence or nukes them. He keeps your codebase clean.
|
||||
1. Sisyphus is bound by his TODO list. If he doesn't finish what he started, the system forces him back into "bouldering" mode. Your task gets done, period.
|
||||
1. Honestly, don't even bother reading the docs. Just write your prompt. Include the 'ultrawork' keyword. Sisyphus will analyze the structure, gather the context, dig through external source code, and just keep bouldering until the job is 100% complete.
|
||||
1. Actually, typing 'ultrawork' is too much effort. Just type 'ulw'. Just ulw. Sip your coffee. Your work is done.
|
||||
|
||||
Need to look something up? It scours official docs, your entire codebase history, and public GitHub implementations—using not just grep but built-in LSP tools and AST-Grep.
|
||||
3. Stop worrying about context management when delegating to LLMs. I've got it covered.
|
||||
- OhMyOpenCode aggressively leverages multiple agents to lighten the context load.
|
||||
- **Your agent is now the dev team lead. You're the AI Manager.**
|
||||
@@ -574,6 +583,7 @@ These tools enable agents to reference previous conversations and maintain conti
|
||||
- Use camelCase for function names
|
||||
```
|
||||
- **Online**: Project rules aren't everything. Built-in MCPs for extended capabilities:
|
||||
- **websearch**: Real-time web search powered by [Exa AI](https://exa.ai)
|
||||
- **context7**: Official documentation lookup
|
||||
- **grep_app**: Ultra-fast code search across public GitHub repos (great for finding implementation examples)
|
||||
|
||||
@@ -975,8 +985,9 @@ Available hooks: `todo-continuation-enforcer`, `context-window-monitor`, `sessio
|
||||
|
||||
### MCPs
|
||||
|
||||
Context7 and grep.app MCP enabled by default.
|
||||
Exa, Context7 and grep.app MCP enabled by default.
|
||||
|
||||
- **websearch**: Real-time web search powered by [Exa AI](https://exa.ai) - searches the web and returns relevant content
|
||||
- **context7**: Fetches up-to-date official documentation for libraries
|
||||
- **grep_app**: Ultra-fast code search across millions of public GitHub repositories via [grep.app](https://grep.app)
|
||||
|
||||
@@ -984,7 +995,7 @@ Don't want them? Disable via `disabled_mcps` in `~/.config/opencode/oh-my-openco
|
||||
|
||||
```json
|
||||
{
|
||||
"disabled_mcps": ["context7", "grep_app"]
|
||||
"disabled_mcps": ["websearch", "context7", "grep_app"]
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
> | [<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="GitHub Follow" src="https://img.shields.io/github/followers/code-yeongyu?style=flat-square&logo=github&labelColor=black&color=24292f" width="156px" />](https://github.com/code-yeongyu) | 在 GitHub 上关注 [@code-yeongyu](https://github.com/code-yeongyu),了解更多项目。 |
|
||||
|
||||
<!-- <CENTERED SECTION FOR GITHUB DISPLAY> -->
|
||||
|
||||
@@ -191,13 +192,15 @@ oMoMoMoMoMo···
|
||||
|
||||
装完之后,你的 Agent 画风是这样的:
|
||||
|
||||
1. 后台让 Gemini 3 Pro 写前端,Claude Opus 4.5 同时在写后端。调试卡住了?喊 GPT 5.2 过来救场。前端说搞定了,你验货,上线。
|
||||
2. 要查资料?它会把官方文档、整个代码历史、GitHub 上的公开实现翻个底朝天——靠的不只是 grep,还有内置 LSP 和 AST-Grep。
|
||||
3. 别再操心什么上下文管理了。我包了。
|
||||
- OhMyOpenCode 疯狂压榨多个 Agent,把上下文负担降到最低。
|
||||
- **现在的 Agent 才是开发组长,你?你是 AI 经理。**
|
||||
4. 活儿没干完,绝对不收工。
|
||||
5. 不想研究这么深?没事。输入 "ultrathink" 就完事了。
|
||||
1. Sisyphus 从不把时间浪费在苦哈哈地找文件上,他时刻保持主 Agent 的 Context 精简干练。相反,他会并行启动一堆又快又便宜的背景任务模型,帮他先探路,摸清代码全貌。
|
||||
1. Sisyphus 善用 LSP 进行重构;这种方式更具确定性,更安全,且手术刀般精准。
|
||||
1. 遇到需要 UI 润色的重活儿时,Sisyphus 会直接把前端任务甩给 Gemini 3 Pro 处理。
|
||||
1. 如果 Sisyphus 陷入死循环或碰了壁,他绝不会在那儿死磕——他会呼叫 GPT 5.2 提供高智商的战略支援。
|
||||
1. 在处理复杂的开源框架?Sisyphus 会派生出 Subagents 实时消化源码和文档。他是在拥有全局 Context 意识的情况下进行操作的。
|
||||
1. 当 Sisyphus 动到注释时,他要么证明其存在的价值,要么直接干掉。他只负责保持你的代码库干净整洁。
|
||||
1. Sisyphus 受 TODO 列表的绝对约束。如果活儿没干完,系统会强行把他踢回"推石头(bouldering)"模式。一句话,任务必须搞定。
|
||||
1. 说实话,连文档都别费劲读了。直接写你的 Prompt,带上 'ultrawork' 关键字。Sisyphus 会自动分析结构、抓取 Context、深度挖掘外部源码,然后就这么一直"推石头",直到任务 100% 彻底完成。
|
||||
1. 其实,输入 'ultrawork' 都挺费劲的。直接打 'ulw' 就行。就打 ulw。喝你的咖啡去吧,活儿已经帮你干完了。
|
||||
|
||||
如果你不需要这全套服务,前面说了,挑你喜欢的用。
|
||||
|
||||
|
||||
@@ -1658,6 +1658,35 @@
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"background_task": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"defaultConcurrency": {
|
||||
"type": "number",
|
||||
"minimum": 1
|
||||
},
|
||||
"providerConcurrency": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "number",
|
||||
"minimum": 1
|
||||
}
|
||||
},
|
||||
"modelConcurrency": {
|
||||
"type": "object",
|
||||
"propertyNames": {
|
||||
"type": "string"
|
||||
},
|
||||
"additionalProperties": {
|
||||
"type": "number",
|
||||
"minimum": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode",
|
||||
"version": "2.13.0",
|
||||
"version": "2.14.0",
|
||||
"description": "OpenCode plugin - custom agents (oracle, librarian) and enhanced features",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
|
||||
@@ -223,6 +223,46 @@
|
||||
"created_at": "2026-01-05T11:46:40Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 512
|
||||
},
|
||||
{
|
||||
"name": "jkoelker",
|
||||
"id": 75854,
|
||||
"comment_id": 3713015728,
|
||||
"created_at": "2026-01-06T03:59:38Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 531
|
||||
},
|
||||
{
|
||||
"name": "sngweizhi",
|
||||
"id": 47587454,
|
||||
"comment_id": 3713078490,
|
||||
"created_at": "2026-01-06T04:36:53Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 532
|
||||
},
|
||||
{
|
||||
"name": "ananas-viber",
|
||||
"id": 241022041,
|
||||
"comment_id": 3714661395,
|
||||
"created_at": "2026-01-06T13:16:18Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 544
|
||||
},
|
||||
{
|
||||
"name": "JohnC0de",
|
||||
"id": 88864312,
|
||||
"comment_id": 3714978210,
|
||||
"created_at": "2026-01-06T14:45:26Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 543
|
||||
},
|
||||
{
|
||||
"name": "atripathy86",
|
||||
"id": 3656621,
|
||||
"comment_id": 3715631259,
|
||||
"created_at": "2026-01-06T17:32:32Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 550
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -16,7 +16,7 @@ export const DOCUMENT_WRITER_PROMPT_METADATA: AgentPromptMetadata = {
|
||||
export function createDocumentWriterAgent(
|
||||
model: string = DEFAULT_MODEL
|
||||
): AgentConfig {
|
||||
const restrictions = createAgentToolRestrictions(["background_task"])
|
||||
const restrictions = createAgentToolRestrictions([])
|
||||
|
||||
return {
|
||||
description:
|
||||
|
||||
@@ -28,7 +28,6 @@ export function createExploreAgent(model: string = DEFAULT_MODEL): AgentConfig {
|
||||
const restrictions = createAgentToolRestrictions([
|
||||
"write",
|
||||
"edit",
|
||||
"background_task",
|
||||
])
|
||||
|
||||
return {
|
||||
|
||||
@@ -22,7 +22,7 @@ export const FRONTEND_PROMPT_METADATA: AgentPromptMetadata = {
|
||||
export function createFrontendUiUxEngineerAgent(
|
||||
model: string = DEFAULT_MODEL
|
||||
): AgentConfig {
|
||||
const restrictions = createAgentToolRestrictions(["background_task"])
|
||||
const restrictions = createAgentToolRestrictions([])
|
||||
|
||||
return {
|
||||
description:
|
||||
|
||||
@@ -2,7 +2,7 @@ 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"
|
||||
const DEFAULT_MODEL = "opencode/glm-4.7-free"
|
||||
|
||||
export const LIBRARIAN_PROMPT_METADATA: AgentPromptMetadata = {
|
||||
category: "exploration",
|
||||
@@ -25,7 +25,6 @@ export function createLibrarianAgent(model: string = DEFAULT_MODEL): AgentConfig
|
||||
const restrictions = createAgentToolRestrictions([
|
||||
"write",
|
||||
"edit",
|
||||
"background_task",
|
||||
])
|
||||
|
||||
return {
|
||||
@@ -39,7 +38,7 @@ export function createLibrarianAgent(model: string = DEFAULT_MODEL): AgentConfig
|
||||
|
||||
You are **THE LIBRARIAN**, a specialized open-source codebase understanding agent.
|
||||
|
||||
Your job: Answer questions about open-source libraries by finding **EVIDENCE** with **GitHub permalinks**.
|
||||
Your job: Answer questions about open-source libraries. Provide **EVIDENCE** with **GitHub permalinks** when the question requires verification, implementation details, or current/version-specific information. For well-known APIs and stable concepts, answer directly from knowledge.
|
||||
|
||||
## CRITICAL: DATE AWARENESS
|
||||
|
||||
@@ -51,9 +50,13 @@ Your job: Answer questions about open-source libraries by finding **EVIDENCE** w
|
||||
|
||||
---
|
||||
|
||||
## PHASE 0: REQUEST CLASSIFICATION (MANDATORY FIRST STEP)
|
||||
## PHASE 0: ASSESS BEFORE SEARCHING
|
||||
|
||||
Classify EVERY request into one of these categories before taking action:
|
||||
**First**: Can you answer confidently from training knowledge? If yes, answer directly.
|
||||
|
||||
**Search when**: version-specific info, implementation internals, recent changes, unfamiliar libraries, user explicitly requests source/examples.
|
||||
|
||||
**If search needed**, classify into:
|
||||
|
||||
| Type | Trigger Examples | Tools |
|
||||
|------|------------------|-------|
|
||||
@@ -69,7 +72,7 @@ Classify EVERY request into one of these categories before taking action:
|
||||
### TYPE A: CONCEPTUAL QUESTION
|
||||
**Trigger**: "How do I...", "What is...", "Best practice for...", rough/general questions
|
||||
|
||||
**Execute in parallel (2+ calls)**:
|
||||
**If searching**, use tools as needed:
|
||||
\`\`\`
|
||||
Tool 1: context7_resolve-library-id("library-name")
|
||||
→ then context7_get-library-docs(id, topic: "specific-topic")
|
||||
@@ -101,7 +104,7 @@ Step 4: Construct permalink
|
||||
https://github.com/owner/repo/blob/<sha>/path/to/file#L10-L20
|
||||
\`\`\`
|
||||
|
||||
**Parallel acceleration (4+ calls)**:
|
||||
**For faster results, parallelize**:
|
||||
\`\`\`
|
||||
Tool 1: gh repo clone owner/repo \${TMPDIR:-/tmp}/repo -- --depth 1
|
||||
Tool 2: grep_app_searchGitHub(query: "function_name", repo: "owner/repo")
|
||||
@@ -114,7 +117,7 @@ Tool 4: context7_get-library-docs(id, topic: "relevant-api")
|
||||
### TYPE C: CONTEXT & HISTORY
|
||||
**Trigger**: "Why was this changed?", "What's the history?", "Related issues/PRs?"
|
||||
|
||||
**Execute in parallel (4+ calls)**:
|
||||
**Tools to use**:
|
||||
\`\`\`
|
||||
Tool 1: gh search issues "keyword" --repo owner/repo --state all --limit 10
|
||||
Tool 2: gh search prs "keyword" --repo owner/repo --state merged --limit 10
|
||||
@@ -136,7 +139,7 @@ gh api repos/owner/repo/pulls/<number>/files
|
||||
### TYPE D: COMPREHENSIVE RESEARCH
|
||||
**Trigger**: Complex questions, ambiguous requests, "deep dive into..."
|
||||
|
||||
**Execute ALL available tools in parallel (5+ calls)**:
|
||||
**Use multiple tools as needed**:
|
||||
\`\`\`
|
||||
// Documentation
|
||||
Tool 1: context7_resolve-library-id → context7_get-library-docs
|
||||
@@ -222,14 +225,16 @@ Use OS-appropriate temp directory:
|
||||
|
||||
---
|
||||
|
||||
## PARALLEL EXECUTION REQUIREMENTS
|
||||
## PARALLEL EXECUTION GUIDANCE
|
||||
|
||||
| Request Type | Minimum Parallel Calls |
|
||||
|--------------|----------------------|
|
||||
| TYPE A (Conceptual) | 3+ |
|
||||
| TYPE B (Implementation) | 4+ |
|
||||
| TYPE C (Context) | 4+ |
|
||||
| TYPE D (Comprehensive) | 6+ |
|
||||
When searching is needed, scale effort to question complexity:
|
||||
|
||||
| Request Type | Suggested Calls |
|
||||
|--------------|----------------|
|
||||
| TYPE A (Conceptual) | 1-2 |
|
||||
| TYPE B (Implementation) | 2-3 |
|
||||
| TYPE C (Context) | 2-3 |
|
||||
| TYPE D (Comprehensive) | 3-5 |
|
||||
|
||||
**Always vary queries** when using grep_app:
|
||||
\`\`\`
|
||||
|
||||
@@ -18,7 +18,6 @@ export function createMultimodalLookerAgent(
|
||||
"write",
|
||||
"edit",
|
||||
"bash",
|
||||
"background_task",
|
||||
])
|
||||
|
||||
return {
|
||||
|
||||
@@ -102,7 +102,6 @@ export function createOracleAgent(model: string = DEFAULT_MODEL): AgentConfig {
|
||||
"write",
|
||||
"edit",
|
||||
"task",
|
||||
"background_task",
|
||||
])
|
||||
|
||||
const base = {
|
||||
|
||||
@@ -232,6 +232,12 @@ export const RalphLoopConfigSchema = z.object({
|
||||
state_dir: z.string().optional(),
|
||||
})
|
||||
|
||||
export const BackgroundTaskConfigSchema = z.object({
|
||||
defaultConcurrency: z.number().min(1).optional(),
|
||||
providerConcurrency: z.record(z.string(), z.number().min(1)).optional(),
|
||||
modelConcurrency: z.record(z.string(), z.number().min(1)).optional(),
|
||||
})
|
||||
|
||||
export const OhMyOpenCodeConfigSchema = z.object({
|
||||
$schema: z.string().optional(),
|
||||
disabled_mcps: z.array(AnyMcpNameSchema).optional(),
|
||||
@@ -248,11 +254,13 @@ export const OhMyOpenCodeConfigSchema = z.object({
|
||||
auto_update: z.boolean().optional(),
|
||||
skills: SkillsConfigSchema.optional(),
|
||||
ralph_loop: RalphLoopConfigSchema.optional(),
|
||||
background_task: BackgroundTaskConfigSchema.optional(),
|
||||
})
|
||||
|
||||
export type OhMyOpenCodeConfig = z.infer<typeof OhMyOpenCodeConfigSchema>
|
||||
export type AgentOverrideConfig = z.infer<typeof AgentOverrideConfigSchema>
|
||||
export type AgentOverrides = z.infer<typeof AgentOverridesSchema>
|
||||
export type BackgroundTaskConfig = z.infer<typeof BackgroundTaskConfigSchema>
|
||||
export type AgentName = z.infer<typeof AgentNameSchema>
|
||||
export type HookName = z.infer<typeof HookNameSchema>
|
||||
export type BuiltinCommandName = z.infer<typeof BuiltinCommandNameSchema>
|
||||
|
||||
351
src/features/background-agent/concurrency.test.ts
Normal file
351
src/features/background-agent/concurrency.test.ts
Normal file
@@ -0,0 +1,351 @@
|
||||
import { describe, test, expect, beforeEach } from "bun:test"
|
||||
import { ConcurrencyManager } from "./concurrency"
|
||||
import type { BackgroundTaskConfig } from "../../config/schema"
|
||||
|
||||
describe("ConcurrencyManager.getConcurrencyLimit", () => {
|
||||
test("should return model-specific limit when modelConcurrency is set", () => {
|
||||
// #given
|
||||
const config: BackgroundTaskConfig = {
|
||||
modelConcurrency: { "anthropic/claude-sonnet-4-5": 5 }
|
||||
}
|
||||
const manager = new ConcurrencyManager(config)
|
||||
|
||||
// #when
|
||||
const limit = manager.getConcurrencyLimit("anthropic/claude-sonnet-4-5")
|
||||
|
||||
// #then
|
||||
expect(limit).toBe(5)
|
||||
})
|
||||
|
||||
test("should return provider limit when providerConcurrency is set for model provider", () => {
|
||||
// #given
|
||||
const config: BackgroundTaskConfig = {
|
||||
providerConcurrency: { anthropic: 3 }
|
||||
}
|
||||
const manager = new ConcurrencyManager(config)
|
||||
|
||||
// #when
|
||||
const limit = manager.getConcurrencyLimit("anthropic/claude-sonnet-4-5")
|
||||
|
||||
// #then
|
||||
expect(limit).toBe(3)
|
||||
})
|
||||
|
||||
test("should return provider limit even when modelConcurrency exists but doesn't match", () => {
|
||||
// #given
|
||||
const config: BackgroundTaskConfig = {
|
||||
modelConcurrency: { "google/gemini-3-pro": 5 },
|
||||
providerConcurrency: { anthropic: 3 }
|
||||
}
|
||||
const manager = new ConcurrencyManager(config)
|
||||
|
||||
// #when
|
||||
const limit = manager.getConcurrencyLimit("anthropic/claude-sonnet-4-5")
|
||||
|
||||
// #then
|
||||
expect(limit).toBe(3)
|
||||
})
|
||||
|
||||
test("should return default limit when defaultConcurrency is set", () => {
|
||||
// #given
|
||||
const config: BackgroundTaskConfig = {
|
||||
defaultConcurrency: 2
|
||||
}
|
||||
const manager = new ConcurrencyManager(config)
|
||||
|
||||
// #when
|
||||
const limit = manager.getConcurrencyLimit("anthropic/claude-sonnet-4-5")
|
||||
|
||||
// #then
|
||||
expect(limit).toBe(2)
|
||||
})
|
||||
|
||||
test("should return default 5 when no config provided", () => {
|
||||
// #given
|
||||
const manager = new ConcurrencyManager()
|
||||
|
||||
// #when
|
||||
const limit = manager.getConcurrencyLimit("anthropic/claude-sonnet-4-5")
|
||||
|
||||
// #then
|
||||
expect(limit).toBe(5)
|
||||
})
|
||||
|
||||
test("should return default 5 when config exists but no concurrency settings", () => {
|
||||
// #given
|
||||
const config: BackgroundTaskConfig = {}
|
||||
const manager = new ConcurrencyManager(config)
|
||||
|
||||
// #when
|
||||
const limit = manager.getConcurrencyLimit("anthropic/claude-sonnet-4-5")
|
||||
|
||||
// #then
|
||||
expect(limit).toBe(5)
|
||||
})
|
||||
|
||||
test("should prioritize model-specific over provider-specific over default", () => {
|
||||
// #given
|
||||
const config: BackgroundTaskConfig = {
|
||||
modelConcurrency: { "anthropic/claude-sonnet-4-5": 10 },
|
||||
providerConcurrency: { anthropic: 5 },
|
||||
defaultConcurrency: 2
|
||||
}
|
||||
const manager = new ConcurrencyManager(config)
|
||||
|
||||
// #when
|
||||
const modelLimit = manager.getConcurrencyLimit("anthropic/claude-sonnet-4-5")
|
||||
const providerLimit = manager.getConcurrencyLimit("anthropic/claude-opus-4-5")
|
||||
const defaultLimit = manager.getConcurrencyLimit("google/gemini-3-pro")
|
||||
|
||||
// #then
|
||||
expect(modelLimit).toBe(10)
|
||||
expect(providerLimit).toBe(5)
|
||||
expect(defaultLimit).toBe(2)
|
||||
})
|
||||
|
||||
test("should handle models without provider part", () => {
|
||||
// #given
|
||||
const config: BackgroundTaskConfig = {
|
||||
providerConcurrency: { "custom-model": 4 }
|
||||
}
|
||||
const manager = new ConcurrencyManager(config)
|
||||
|
||||
// #when
|
||||
const limit = manager.getConcurrencyLimit("custom-model")
|
||||
|
||||
// #then
|
||||
expect(limit).toBe(4)
|
||||
})
|
||||
|
||||
test("should return Infinity when defaultConcurrency is 0", () => {
|
||||
// #given
|
||||
const config: BackgroundTaskConfig = { defaultConcurrency: 0 }
|
||||
const manager = new ConcurrencyManager(config)
|
||||
|
||||
// #when
|
||||
const limit = manager.getConcurrencyLimit("any-model")
|
||||
|
||||
// #then
|
||||
expect(limit).toBe(Infinity)
|
||||
})
|
||||
|
||||
test("should return Infinity when providerConcurrency is 0", () => {
|
||||
// #given
|
||||
const config: BackgroundTaskConfig = {
|
||||
providerConcurrency: { anthropic: 0 }
|
||||
}
|
||||
const manager = new ConcurrencyManager(config)
|
||||
|
||||
// #when
|
||||
const limit = manager.getConcurrencyLimit("anthropic/claude-sonnet-4-5")
|
||||
|
||||
// #then
|
||||
expect(limit).toBe(Infinity)
|
||||
})
|
||||
|
||||
test("should return Infinity when modelConcurrency is 0", () => {
|
||||
// #given
|
||||
const config: BackgroundTaskConfig = {
|
||||
modelConcurrency: { "anthropic/claude-sonnet-4-5": 0 }
|
||||
}
|
||||
const manager = new ConcurrencyManager(config)
|
||||
|
||||
// #when
|
||||
const limit = manager.getConcurrencyLimit("anthropic/claude-sonnet-4-5")
|
||||
|
||||
// #then
|
||||
expect(limit).toBe(Infinity)
|
||||
})
|
||||
})
|
||||
|
||||
describe("ConcurrencyManager.acquire/release", () => {
|
||||
let manager: ConcurrencyManager
|
||||
|
||||
beforeEach(() => {
|
||||
// #given
|
||||
const config: BackgroundTaskConfig = {}
|
||||
manager = new ConcurrencyManager(config)
|
||||
})
|
||||
|
||||
test("should allow acquiring up to limit", async () => {
|
||||
// #given
|
||||
const config: BackgroundTaskConfig = { defaultConcurrency: 2 }
|
||||
manager = new ConcurrencyManager(config)
|
||||
|
||||
// #when
|
||||
await manager.acquire("model-a")
|
||||
await manager.acquire("model-a")
|
||||
|
||||
// #then - both resolved without waiting
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
|
||||
test("should allow acquires up to default limit of 5", async () => {
|
||||
// #given - no config = default limit of 5
|
||||
|
||||
// #when
|
||||
await manager.acquire("model-a")
|
||||
await manager.acquire("model-a")
|
||||
await manager.acquire("model-a")
|
||||
await manager.acquire("model-a")
|
||||
await manager.acquire("model-a")
|
||||
|
||||
// #then - all 5 resolved
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
|
||||
test("should queue when limit reached", async () => {
|
||||
// #given
|
||||
const config: BackgroundTaskConfig = { defaultConcurrency: 1 }
|
||||
manager = new ConcurrencyManager(config)
|
||||
await manager.acquire("model-a")
|
||||
|
||||
// #when
|
||||
let resolved = false
|
||||
const waitPromise = manager.acquire("model-a").then(() => { resolved = true })
|
||||
|
||||
// Give microtask queue a chance to run
|
||||
await Promise.resolve()
|
||||
|
||||
// #then - should still be waiting
|
||||
expect(resolved).toBe(false)
|
||||
|
||||
// #when - release
|
||||
manager.release("model-a")
|
||||
await waitPromise
|
||||
|
||||
// #then - now resolved
|
||||
expect(resolved).toBe(true)
|
||||
})
|
||||
|
||||
test("should queue multiple tasks and process in order", async () => {
|
||||
// #given
|
||||
const config: BackgroundTaskConfig = { defaultConcurrency: 1 }
|
||||
manager = new ConcurrencyManager(config)
|
||||
await manager.acquire("model-a")
|
||||
|
||||
// #when
|
||||
const order: string[] = []
|
||||
const task1 = manager.acquire("model-a").then(() => { order.push("1") })
|
||||
const task2 = manager.acquire("model-a").then(() => { order.push("2") })
|
||||
const task3 = manager.acquire("model-a").then(() => { order.push("3") })
|
||||
|
||||
// Give microtask queue a chance to run
|
||||
await Promise.resolve()
|
||||
|
||||
// #then - none resolved yet
|
||||
expect(order).toEqual([])
|
||||
|
||||
// #when - release one at a time
|
||||
manager.release("model-a")
|
||||
await task1
|
||||
expect(order).toEqual(["1"])
|
||||
|
||||
manager.release("model-a")
|
||||
await task2
|
||||
expect(order).toEqual(["1", "2"])
|
||||
|
||||
manager.release("model-a")
|
||||
await task3
|
||||
expect(order).toEqual(["1", "2", "3"])
|
||||
})
|
||||
|
||||
test("should handle independent models separately", async () => {
|
||||
// #given
|
||||
const config: BackgroundTaskConfig = { defaultConcurrency: 1 }
|
||||
manager = new ConcurrencyManager(config)
|
||||
await manager.acquire("model-a")
|
||||
|
||||
// #when - acquire different model
|
||||
const resolved = await Promise.race([
|
||||
manager.acquire("model-b").then(() => "resolved"),
|
||||
Promise.resolve("timeout").then(() => "timeout")
|
||||
])
|
||||
|
||||
// #then - different model should resolve immediately
|
||||
expect(resolved).toBe("resolved")
|
||||
})
|
||||
|
||||
test("should allow re-acquiring after release", async () => {
|
||||
// #given
|
||||
const config: BackgroundTaskConfig = { defaultConcurrency: 1 }
|
||||
manager = new ConcurrencyManager(config)
|
||||
|
||||
// #when
|
||||
await manager.acquire("model-a")
|
||||
manager.release("model-a")
|
||||
await manager.acquire("model-a")
|
||||
|
||||
// #then
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
|
||||
test("should handle release when no acquire", () => {
|
||||
// #given
|
||||
const config: BackgroundTaskConfig = { defaultConcurrency: 2 }
|
||||
manager = new ConcurrencyManager(config)
|
||||
|
||||
// #when - release without acquire
|
||||
manager.release("model-a")
|
||||
|
||||
// #then - should not throw
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
|
||||
test("should handle release when no prior acquire", () => {
|
||||
// #given - default config
|
||||
|
||||
// #when - release without acquire
|
||||
manager.release("model-a")
|
||||
|
||||
// #then - should not throw
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
|
||||
test("should handle multiple acquires and releases correctly", async () => {
|
||||
// #given
|
||||
const config: BackgroundTaskConfig = { defaultConcurrency: 3 }
|
||||
manager = new ConcurrencyManager(config)
|
||||
|
||||
// #when
|
||||
await manager.acquire("model-a")
|
||||
await manager.acquire("model-a")
|
||||
await manager.acquire("model-a")
|
||||
|
||||
// Release all
|
||||
manager.release("model-a")
|
||||
manager.release("model-a")
|
||||
manager.release("model-a")
|
||||
|
||||
// Should be able to acquire again
|
||||
await manager.acquire("model-a")
|
||||
|
||||
// #then
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
|
||||
test("should use model-specific limit for acquire", async () => {
|
||||
// #given
|
||||
const config: BackgroundTaskConfig = {
|
||||
modelConcurrency: { "anthropic/claude-sonnet-4-5": 2 },
|
||||
defaultConcurrency: 5
|
||||
}
|
||||
manager = new ConcurrencyManager(config)
|
||||
await manager.acquire("anthropic/claude-sonnet-4-5")
|
||||
await manager.acquire("anthropic/claude-sonnet-4-5")
|
||||
|
||||
// #when
|
||||
let resolved = false
|
||||
const waitPromise = manager.acquire("anthropic/claude-sonnet-4-5").then(() => { resolved = true })
|
||||
|
||||
// Give microtask queue a chance to run
|
||||
await Promise.resolve()
|
||||
|
||||
// #then - should be waiting (model-specific limit is 2)
|
||||
expect(resolved).toBe(false)
|
||||
|
||||
// Cleanup
|
||||
manager.release("anthropic/claude-sonnet-4-5")
|
||||
await waitPromise
|
||||
})
|
||||
})
|
||||
66
src/features/background-agent/concurrency.ts
Normal file
66
src/features/background-agent/concurrency.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import type { BackgroundTaskConfig } from "../../config/schema"
|
||||
|
||||
export class ConcurrencyManager {
|
||||
private config?: BackgroundTaskConfig
|
||||
private counts: Map<string, number> = new Map()
|
||||
private queues: Map<string, Array<() => void>> = new Map()
|
||||
|
||||
constructor(config?: BackgroundTaskConfig) {
|
||||
this.config = config
|
||||
}
|
||||
|
||||
getConcurrencyLimit(model: string): number {
|
||||
const modelLimit = this.config?.modelConcurrency?.[model]
|
||||
if (modelLimit !== undefined) {
|
||||
return modelLimit === 0 ? Infinity : modelLimit
|
||||
}
|
||||
const provider = model.split('/')[0]
|
||||
const providerLimit = this.config?.providerConcurrency?.[provider]
|
||||
if (providerLimit !== undefined) {
|
||||
return providerLimit === 0 ? Infinity : providerLimit
|
||||
}
|
||||
const defaultLimit = this.config?.defaultConcurrency
|
||||
if (defaultLimit !== undefined) {
|
||||
return defaultLimit === 0 ? Infinity : defaultLimit
|
||||
}
|
||||
return 5
|
||||
}
|
||||
|
||||
async acquire(model: string): Promise<void> {
|
||||
const limit = this.getConcurrencyLimit(model)
|
||||
if (limit === Infinity) {
|
||||
return
|
||||
}
|
||||
|
||||
const current = this.counts.get(model) ?? 0
|
||||
if (current < limit) {
|
||||
this.counts.set(model, current + 1)
|
||||
return
|
||||
}
|
||||
|
||||
return new Promise<void>((resolve) => {
|
||||
const queue = this.queues.get(model) ?? []
|
||||
queue.push(resolve)
|
||||
this.queues.set(model, queue)
|
||||
})
|
||||
}
|
||||
|
||||
release(model: string): void {
|
||||
const limit = this.getConcurrencyLimit(model)
|
||||
if (limit === Infinity) {
|
||||
return
|
||||
}
|
||||
|
||||
const queue = this.queues.get(model)
|
||||
if (queue && queue.length > 0) {
|
||||
const next = queue.shift()!
|
||||
this.counts.set(model, this.counts.get(model) ?? 0)
|
||||
next()
|
||||
} else {
|
||||
const current = this.counts.get(model) ?? 0
|
||||
if (current > 0) {
|
||||
this.counts.set(model, current - 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from "./types"
|
||||
export { BackgroundManager } from "./manager"
|
||||
export { ConcurrencyManager } from "./concurrency"
|
||||
|
||||
@@ -302,6 +302,74 @@ describe("BackgroundManager.getAllDescendantTasks", () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe("BackgroundManager.notifyParentSession - release ordering", () => {
|
||||
test("should unblock queued task even when prompt hangs", async () => {
|
||||
// #given - concurrency limit 1, task1 running, task2 waiting
|
||||
const { ConcurrencyManager } = await import("./concurrency")
|
||||
const concurrencyManager = new ConcurrencyManager({ defaultConcurrency: 1 })
|
||||
|
||||
await concurrencyManager.acquire("explore")
|
||||
|
||||
let task2Resolved = false
|
||||
const task2Promise = concurrencyManager.acquire("explore").then(() => {
|
||||
task2Resolved = true
|
||||
})
|
||||
|
||||
await Promise.resolve()
|
||||
expect(task2Resolved).toBe(false)
|
||||
|
||||
// #when - simulate notifyParentSession: release BEFORE prompt (fixed behavior)
|
||||
let promptStarted = false
|
||||
const simulateNotifyParentSession = async () => {
|
||||
concurrencyManager.release("explore")
|
||||
|
||||
promptStarted = true
|
||||
await new Promise(() => {})
|
||||
}
|
||||
|
||||
simulateNotifyParentSession()
|
||||
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
|
||||
// #then - task2 should be unblocked even though prompt never completes
|
||||
expect(promptStarted).toBe(true)
|
||||
await task2Promise
|
||||
expect(task2Resolved).toBe(true)
|
||||
})
|
||||
|
||||
test("should keep queue blocked if release is after prompt (demonstrates the bug)", async () => {
|
||||
// #given - same setup
|
||||
const { ConcurrencyManager } = await import("./concurrency")
|
||||
const concurrencyManager = new ConcurrencyManager({ defaultConcurrency: 1 })
|
||||
|
||||
await concurrencyManager.acquire("explore")
|
||||
|
||||
let task2Resolved = false
|
||||
concurrencyManager.acquire("explore").then(() => {
|
||||
task2Resolved = true
|
||||
})
|
||||
|
||||
await Promise.resolve()
|
||||
expect(task2Resolved).toBe(false)
|
||||
|
||||
// #when - simulate BUGGY behavior: release AFTER prompt (in finally)
|
||||
const simulateBuggyNotifyParentSession = async () => {
|
||||
try {
|
||||
await new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), 50))
|
||||
} finally {
|
||||
concurrencyManager.release("explore")
|
||||
}
|
||||
}
|
||||
|
||||
await simulateBuggyNotifyParentSession().catch(() => {})
|
||||
|
||||
// #then - task2 resolves only after prompt completes (blocked during hang)
|
||||
await Promise.resolve()
|
||||
expect(task2Resolved).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("BackgroundManager.pruneStaleTasksAndNotifications", () => {
|
||||
let manager: MockBackgroundManager
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ import type {
|
||||
LaunchInput,
|
||||
} from "./types"
|
||||
import { log } from "../../shared/logger"
|
||||
import { ConcurrencyManager } from "./concurrency"
|
||||
import type { BackgroundTaskConfig } from "../../config/schema"
|
||||
import {
|
||||
findNearestMessageWithFields,
|
||||
MESSAGE_STORAGE,
|
||||
@@ -60,12 +62,14 @@ export class BackgroundManager {
|
||||
private client: OpencodeClient
|
||||
private directory: string
|
||||
private pollingInterval?: ReturnType<typeof setInterval>
|
||||
private concurrencyManager: ConcurrencyManager
|
||||
|
||||
constructor(ctx: PluginInput) {
|
||||
constructor(ctx: PluginInput, config?: BackgroundTaskConfig) {
|
||||
this.tasks = new Map()
|
||||
this.notifications = new Map()
|
||||
this.client = ctx.client
|
||||
this.directory = ctx.directory
|
||||
this.concurrencyManager = new ConcurrencyManager(config)
|
||||
}
|
||||
|
||||
async launch(input: LaunchInput): Promise<BackgroundTask> {
|
||||
@@ -73,14 +77,22 @@ export class BackgroundManager {
|
||||
throw new Error("Agent parameter is required")
|
||||
}
|
||||
|
||||
const model = input.agent
|
||||
|
||||
await this.concurrencyManager.acquire(model)
|
||||
|
||||
const createResult = await this.client.session.create({
|
||||
body: {
|
||||
parentID: input.parentSessionID,
|
||||
title: `Background: ${input.description}`,
|
||||
},
|
||||
}).catch((error) => {
|
||||
this.concurrencyManager.release(model)
|
||||
throw error
|
||||
})
|
||||
|
||||
if (createResult.error) {
|
||||
this.concurrencyManager.release(model)
|
||||
throw new Error(`Failed to create background session: ${createResult.error}`)
|
||||
}
|
||||
|
||||
@@ -102,6 +114,7 @@ export class BackgroundManager {
|
||||
lastUpdate: new Date(),
|
||||
},
|
||||
parentModel: input.parentModel,
|
||||
model,
|
||||
}
|
||||
|
||||
this.tasks.set(task.id, task)
|
||||
@@ -116,6 +129,7 @@ export class BackgroundManager {
|
||||
tools: {
|
||||
task: false,
|
||||
background_task: false,
|
||||
call_omo_agent: false,
|
||||
},
|
||||
parts: [{ type: "text", text: input.prompt }],
|
||||
},
|
||||
@@ -131,6 +145,9 @@ export class BackgroundManager {
|
||||
existingTask.error = errorMessage
|
||||
}
|
||||
existingTask.completedAt = new Date()
|
||||
if (existingTask.model) {
|
||||
this.concurrencyManager.release(existingTask.model)
|
||||
}
|
||||
this.markForNotification(existingTask)
|
||||
this.notifyParentSession(existingTask)
|
||||
}
|
||||
@@ -252,6 +269,9 @@ export class BackgroundManager {
|
||||
task.error = "Session deleted"
|
||||
}
|
||||
|
||||
if (task.model) {
|
||||
this.concurrencyManager.release(task.model)
|
||||
}
|
||||
this.tasks.delete(task.id)
|
||||
this.clearNotificationsForTask(task.id)
|
||||
subagentSessions.delete(sessionID)
|
||||
@@ -329,6 +349,10 @@ export class BackgroundManager {
|
||||
|
||||
const taskId = task.id
|
||||
setTimeout(async () => {
|
||||
if (task.model) {
|
||||
this.concurrencyManager.release(task.model)
|
||||
}
|
||||
|
||||
try {
|
||||
const messageDir = getMessageDir(task.parentSessionID)
|
||||
const prevMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
|
||||
@@ -351,7 +375,6 @@ export class BackgroundManager {
|
||||
} 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)
|
||||
@@ -390,6 +413,9 @@ export class BackgroundManager {
|
||||
task.status = "error"
|
||||
task.error = "Task timed out after 30 minutes"
|
||||
task.completedAt = new Date()
|
||||
if (task.model) {
|
||||
this.concurrencyManager.release(task.model)
|
||||
}
|
||||
this.clearNotificationsForTask(taskId)
|
||||
this.tasks.delete(taskId)
|
||||
subagentSessions.delete(task.sessionID)
|
||||
|
||||
@@ -27,6 +27,7 @@ export interface BackgroundTask {
|
||||
error?: string
|
||||
progress?: TaskProgress
|
||||
parentModel?: { providerID: string; modelID: string }
|
||||
model?: string
|
||||
}
|
||||
|
||||
export interface LaunchInput {
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { CommandDefinition } from "../claude-code-command-loader"
|
||||
import type { BuiltinCommandName, BuiltinCommands } from "./types"
|
||||
import { INIT_DEEP_TEMPLATE } from "./templates/init-deep"
|
||||
import { RALPH_LOOP_TEMPLATE, CANCEL_RALPH_TEMPLATE } from "./templates/ralph-loop"
|
||||
import { REFACTOR_TEMPLATE } from "./templates/refactor"
|
||||
|
||||
const BUILTIN_COMMAND_DEFINITIONS: Record<BuiltinCommandName, Omit<CommandDefinition, "name">> = {
|
||||
"init-deep": {
|
||||
@@ -32,6 +33,14 @@ $ARGUMENTS
|
||||
${CANCEL_RALPH_TEMPLATE}
|
||||
</command-instruction>`,
|
||||
},
|
||||
refactor: {
|
||||
description:
|
||||
"(builtin) Intelligent refactoring command with LSP, AST-grep, architecture analysis, codemap, and TDD verification.",
|
||||
template: `<command-instruction>
|
||||
${REFACTOR_TEMPLATE}
|
||||
</command-instruction>`,
|
||||
argumentHint: "<refactoring-target> [--scope=<file|module|project>] [--strategy=<safe|aggressive>]",
|
||||
},
|
||||
}
|
||||
|
||||
export function loadBuiltinCommands(
|
||||
|
||||
624
src/features/builtin-commands/templates/refactor.ts
Normal file
624
src/features/builtin-commands/templates/refactor.ts
Normal file
@@ -0,0 +1,624 @@
|
||||
export const REFACTOR_TEMPLATE = `# Intelligent Refactor Command
|
||||
|
||||
## Usage
|
||||
\`\`\`
|
||||
/refactor <refactoring-target> [--scope=<file|module|project>] [--strategy=<safe|aggressive>]
|
||||
|
||||
Arguments:
|
||||
refactoring-target: What to refactor. Can be:
|
||||
- File path: src/auth/handler.ts
|
||||
- Symbol name: "AuthService class"
|
||||
- Pattern: "all functions using deprecated API"
|
||||
- Description: "extract validation logic into separate module"
|
||||
|
||||
Options:
|
||||
--scope: Refactoring scope (default: module)
|
||||
- file: Single file only
|
||||
- module: Module/directory scope
|
||||
- project: Entire codebase
|
||||
|
||||
--strategy: Risk tolerance (default: safe)
|
||||
- safe: Conservative, maximum test coverage required
|
||||
- aggressive: Allow broader changes with adequate coverage
|
||||
\`\`\`
|
||||
|
||||
## What This Command Does
|
||||
|
||||
Performs intelligent, deterministic refactoring with full codebase awareness. Unlike blind search-and-replace, this command:
|
||||
|
||||
1. **Understands your intent** - Analyzes what you actually want to achieve
|
||||
2. **Maps the codebase** - Builds a definitive codemap before touching anything
|
||||
3. **Assesses risk** - Evaluates test coverage and determines verification strategy
|
||||
4. **Plans meticulously** - Creates a detailed plan with Plan agent
|
||||
5. **Executes precisely** - Step-by-step refactoring with LSP and AST-grep
|
||||
6. **Verifies constantly** - Runs tests after each change to ensure zero regression
|
||||
|
||||
---
|
||||
|
||||
# PHASE 0: INTENT GATE (MANDATORY FIRST STEP)
|
||||
|
||||
**BEFORE ANY ACTION, classify and validate the request.**
|
||||
|
||||
## Step 0.1: Parse Request Type
|
||||
|
||||
| Signal | Classification | Action |
|
||||
|--------|----------------|--------|
|
||||
| Specific file/symbol | Explicit | Proceed to codebase analysis |
|
||||
| "Refactor X to Y" | Clear transformation | Proceed to codebase analysis |
|
||||
| "Improve", "Clean up" | Open-ended | **MUST ask**: "What specific improvement?" |
|
||||
| Ambiguous scope | Uncertain | **MUST ask**: "Which modules/files?" |
|
||||
| Missing context | Incomplete | **MUST ask**: "What's the desired outcome?" |
|
||||
|
||||
## Step 0.2: Validate Understanding
|
||||
|
||||
Before proceeding, confirm:
|
||||
- [ ] Target is clearly identified
|
||||
- [ ] Desired outcome is understood
|
||||
- [ ] Scope is defined (file/module/project)
|
||||
- [ ] Success criteria can be articulated
|
||||
|
||||
**If ANY of above is unclear, ASK CLARIFYING QUESTION:**
|
||||
|
||||
\`\`\`
|
||||
I want to make sure I understand the refactoring goal correctly.
|
||||
|
||||
**What I understood**: [interpretation]
|
||||
**What I'm unsure about**: [specific ambiguity]
|
||||
|
||||
Options I see:
|
||||
1. [Option A] - [implications]
|
||||
2. [Option B] - [implications]
|
||||
|
||||
**My recommendation**: [suggestion with reasoning]
|
||||
|
||||
Should I proceed with [recommendation], or would you prefer differently?
|
||||
\`\`\`
|
||||
|
||||
## Step 0.3: Create Initial Todos
|
||||
|
||||
**IMMEDIATELY after understanding the request, create todos:**
|
||||
|
||||
\`\`\`
|
||||
TodoWrite([
|
||||
{"id": "phase-1", "content": "PHASE 1: Codebase Analysis - launch parallel explore agents", "status": "pending", "priority": "high"},
|
||||
{"id": "phase-2", "content": "PHASE 2: Build Codemap - map dependencies and impact zones", "status": "pending", "priority": "high"},
|
||||
{"id": "phase-3", "content": "PHASE 3: Test Assessment - analyze test coverage and verification strategy", "status": "pending", "priority": "high"},
|
||||
{"id": "phase-4", "content": "PHASE 4: Plan Generation - invoke Plan agent for detailed refactoring plan", "status": "pending", "priority": "high"},
|
||||
{"id": "phase-5", "content": "PHASE 5: Execute Refactoring - step-by-step with continuous verification", "status": "pending", "priority": "high"},
|
||||
{"id": "phase-6", "content": "PHASE 6: Final Verification - full test suite and regression check", "status": "pending", "priority": "high"}
|
||||
])
|
||||
\`\`\`
|
||||
|
||||
---
|
||||
|
||||
# PHASE 1: CODEBASE ANALYSIS (PARALLEL EXPLORATION)
|
||||
|
||||
**Mark phase-1 as in_progress.**
|
||||
|
||||
## 1.1: Launch Parallel Explore Agents (BACKGROUND)
|
||||
|
||||
Fire ALL of these simultaneously using \`call_omo_agent\`:
|
||||
|
||||
\`\`\`
|
||||
// Agent 1: Find the refactoring target
|
||||
call_omo_agent(
|
||||
subagent_type="explore",
|
||||
run_in_background=true,
|
||||
prompt="Find all occurrences and definitions of [TARGET].
|
||||
Report: file paths, line numbers, usage patterns."
|
||||
)
|
||||
|
||||
// Agent 2: Find related code
|
||||
call_omo_agent(
|
||||
subagent_type="explore",
|
||||
run_in_background=true,
|
||||
prompt="Find all code that imports, uses, or depends on [TARGET].
|
||||
Report: dependency chains, import graphs."
|
||||
)
|
||||
|
||||
// Agent 3: Find similar patterns
|
||||
call_omo_agent(
|
||||
subagent_type="explore",
|
||||
run_in_background=true,
|
||||
prompt="Find similar code patterns to [TARGET] in the codebase.
|
||||
Report: analogous implementations, established conventions."
|
||||
)
|
||||
|
||||
// Agent 4: Find tests
|
||||
call_omo_agent(
|
||||
subagent_type="explore",
|
||||
run_in_background=true,
|
||||
prompt="Find all test files related to [TARGET].
|
||||
Report: test file paths, test case names, coverage indicators."
|
||||
)
|
||||
|
||||
// Agent 5: Architecture context
|
||||
call_omo_agent(
|
||||
subagent_type="explore",
|
||||
run_in_background=true,
|
||||
prompt="Find architectural patterns and module organization around [TARGET].
|
||||
Report: module boundaries, layer structure, design patterns in use."
|
||||
)
|
||||
\`\`\`
|
||||
|
||||
## 1.2: Direct Tool Exploration (WHILE AGENTS RUN)
|
||||
|
||||
While background agents are running, use direct tools:
|
||||
|
||||
### LSP Tools for Precise Analysis:
|
||||
|
||||
\`\`\`typescript
|
||||
// Get symbol information at target location
|
||||
lsp_hover(filePath, line, character) // Type info, docs, signatures
|
||||
|
||||
// Find definition(s)
|
||||
lsp_goto_definition(filePath, line, character) // Where is it defined?
|
||||
|
||||
// Find ALL usages across workspace
|
||||
lsp_find_references(filePath, line, character, includeDeclaration=true)
|
||||
|
||||
// Get file structure
|
||||
lsp_document_symbols(filePath) // Hierarchical outline
|
||||
|
||||
// Search symbols by name
|
||||
lsp_workspace_symbols(filePath, query="[target_symbol]")
|
||||
|
||||
// Get current diagnostics
|
||||
lsp_diagnostics(filePath) // Errors, warnings before we start
|
||||
\`\`\`
|
||||
|
||||
### AST-Grep for Pattern Analysis:
|
||||
|
||||
\`\`\`typescript
|
||||
// Find structural patterns
|
||||
ast_grep_search(
|
||||
pattern="function $NAME($$$) { $$$ }", // or relevant pattern
|
||||
lang="typescript", // or relevant language
|
||||
paths=["src/"]
|
||||
)
|
||||
|
||||
// Preview refactoring (DRY RUN)
|
||||
ast_grep_replace(
|
||||
pattern="[old_pattern]",
|
||||
rewrite="[new_pattern]",
|
||||
lang="[language]",
|
||||
dryRun=true // ALWAYS preview first
|
||||
)
|
||||
\`\`\`
|
||||
|
||||
### Grep for Text Patterns:
|
||||
|
||||
\`\`\`
|
||||
grep(pattern="[search_term]", path="src/", include="*.ts")
|
||||
\`\`\`
|
||||
|
||||
## 1.3: Collect Background Results
|
||||
|
||||
\`\`\`
|
||||
background_output(task_id="[agent_1_id]")
|
||||
background_output(task_id="[agent_2_id]")
|
||||
...
|
||||
\`\`\`
|
||||
|
||||
**Mark phase-1 as completed after all results collected.**
|
||||
|
||||
---
|
||||
|
||||
# PHASE 2: BUILD CODEMAP (DEPENDENCY MAPPING)
|
||||
|
||||
**Mark phase-2 as in_progress.**
|
||||
|
||||
## 2.1: Construct Definitive Codemap
|
||||
|
||||
Based on Phase 1 results, build:
|
||||
|
||||
\`\`\`
|
||||
## CODEMAP: [TARGET]
|
||||
|
||||
### Core Files (Direct Impact)
|
||||
- \`path/to/file.ts:L10-L50\` - Primary definition
|
||||
- \`path/to/file2.ts:L25\` - Key usage
|
||||
|
||||
### Dependency Graph
|
||||
\`\`\`
|
||||
[TARGET]
|
||||
├── imports from:
|
||||
│ ├── module-a (types)
|
||||
│ └── module-b (utils)
|
||||
├── imported by:
|
||||
│ ├── consumer-1.ts
|
||||
│ ├── consumer-2.ts
|
||||
│ └── consumer-3.ts
|
||||
└── used by:
|
||||
├── handler.ts (direct call)
|
||||
└── service.ts (dependency injection)
|
||||
\`\`\`
|
||||
|
||||
### Impact Zones
|
||||
| Zone | Risk Level | Files Affected | Test Coverage |
|
||||
|------|------------|----------------|---------------|
|
||||
| Core | HIGH | 3 files | 85% covered |
|
||||
| Consumers | MEDIUM | 8 files | 70% covered |
|
||||
| Edge | LOW | 2 files | 50% covered |
|
||||
|
||||
### Established Patterns
|
||||
- Pattern A: [description] - used in N places
|
||||
- Pattern B: [description] - established convention
|
||||
\`\`\`
|
||||
|
||||
## 2.2: Identify Refactoring Constraints
|
||||
|
||||
Based on codemap:
|
||||
- **MUST follow**: [existing patterns identified]
|
||||
- **MUST NOT break**: [critical dependencies]
|
||||
- **Safe to change**: [isolated code zones]
|
||||
- **Requires migration**: [breaking changes impact]
|
||||
|
||||
**Mark phase-2 as completed.**
|
||||
|
||||
---
|
||||
|
||||
# PHASE 3: TEST ASSESSMENT (VERIFICATION STRATEGY)
|
||||
|
||||
**Mark phase-3 as in_progress.**
|
||||
|
||||
## 3.1: Detect Test Infrastructure
|
||||
|
||||
\`\`\`bash
|
||||
# Check for test commands
|
||||
cat package.json | jq '.scripts | keys[] | select(test("test"))'
|
||||
|
||||
# Or for Python
|
||||
ls -la pytest.ini pyproject.toml setup.cfg
|
||||
|
||||
# Or for Go
|
||||
ls -la *_test.go
|
||||
\`\`\`
|
||||
|
||||
## 3.2: Analyze Test Coverage
|
||||
|
||||
\`\`\`
|
||||
// Find all tests related to target
|
||||
call_omo_agent(
|
||||
subagent_type="explore",
|
||||
run_in_background=false, // Need this synchronously
|
||||
prompt="Analyze test coverage for [TARGET]:
|
||||
1. Which test files cover this code?
|
||||
2. What test cases exist?
|
||||
3. Are there integration tests?
|
||||
4. What edge cases are tested?
|
||||
5. Estimated coverage percentage?"
|
||||
)
|
||||
\`\`\`
|
||||
|
||||
## 3.3: Determine Verification Strategy
|
||||
|
||||
Based on test analysis:
|
||||
|
||||
| Coverage Level | Strategy |
|
||||
|----------------|----------|
|
||||
| HIGH (>80%) | Run existing tests after each step |
|
||||
| MEDIUM (50-80%) | Run tests + add safety assertions |
|
||||
| LOW (<50%) | **PAUSE**: Propose adding tests first |
|
||||
| NONE | **BLOCK**: Refuse aggressive refactoring |
|
||||
|
||||
**If coverage is LOW or NONE, ask user:**
|
||||
|
||||
\`\`\`
|
||||
Test coverage for [TARGET] is [LEVEL].
|
||||
|
||||
**Risk Assessment**: Refactoring without adequate tests is dangerous.
|
||||
|
||||
Options:
|
||||
1. Add tests first, then refactor (RECOMMENDED)
|
||||
2. Proceed with extra caution, manual verification required
|
||||
3. Abort refactoring
|
||||
|
||||
Which approach do you prefer?
|
||||
\`\`\`
|
||||
|
||||
## 3.4: Document Verification Plan
|
||||
|
||||
\`\`\`
|
||||
## VERIFICATION PLAN
|
||||
|
||||
### Test Commands
|
||||
- Unit: \`bun test\` / \`npm test\` / \`pytest\` / etc.
|
||||
- Integration: [command if exists]
|
||||
- Type check: \`tsc --noEmit\` / \`pyright\` / etc.
|
||||
|
||||
### Verification Checkpoints
|
||||
After each refactoring step:
|
||||
1. lsp_diagnostics → zero new errors
|
||||
2. Run test command → all pass
|
||||
3. Type check → clean
|
||||
|
||||
### Regression Indicators
|
||||
- [Specific test that must pass]
|
||||
- [Behavior that must be preserved]
|
||||
- [API contract that must not change]
|
||||
\`\`\`
|
||||
|
||||
**Mark phase-3 as completed.**
|
||||
|
||||
---
|
||||
|
||||
# PHASE 4: PLAN GENERATION (PLAN AGENT)
|
||||
|
||||
**Mark phase-4 as in_progress.**
|
||||
|
||||
## 4.1: Invoke Plan Agent
|
||||
|
||||
\`\`\`
|
||||
Task(
|
||||
subagent_type="plan",
|
||||
prompt="Create a detailed refactoring plan:
|
||||
|
||||
## Refactoring Goal
|
||||
[User's original request]
|
||||
|
||||
## Codemap (from Phase 2)
|
||||
[Insert codemap here]
|
||||
|
||||
## Test Coverage (from Phase 3)
|
||||
[Insert verification plan here]
|
||||
|
||||
## Constraints
|
||||
- MUST follow existing patterns: [list]
|
||||
- MUST NOT break: [critical paths]
|
||||
- MUST run tests after each step
|
||||
|
||||
## Requirements
|
||||
1. Break down into atomic refactoring steps
|
||||
2. Each step must be independently verifiable
|
||||
3. Order steps by dependency (what must happen first)
|
||||
4. Specify exact files and line ranges for each step
|
||||
5. Include rollback strategy for each step
|
||||
6. Define commit checkpoints"
|
||||
)
|
||||
\`\`\`
|
||||
|
||||
## 4.2: Review and Validate Plan
|
||||
|
||||
After receiving plan from Plan agent:
|
||||
|
||||
1. **Verify completeness**: All identified files addressed?
|
||||
2. **Verify safety**: Each step reversible?
|
||||
3. **Verify order**: Dependencies respected?
|
||||
4. **Verify verification**: Test commands specified?
|
||||
|
||||
## 4.3: Register Detailed Todos
|
||||
|
||||
Convert Plan agent output into granular todos:
|
||||
|
||||
\`\`\`
|
||||
TodoWrite([
|
||||
// Each step from the plan becomes a todo
|
||||
{"id": "refactor-1", "content": "Step 1: [description]", "status": "pending", "priority": "high"},
|
||||
{"id": "verify-1", "content": "Verify Step 1: run tests", "status": "pending", "priority": "high"},
|
||||
{"id": "refactor-2", "content": "Step 2: [description]", "status": "pending", "priority": "medium"},
|
||||
{"id": "verify-2", "content": "Verify Step 2: run tests", "status": "pending", "priority": "medium"},
|
||||
// ... continue for all steps
|
||||
])
|
||||
\`\`\`
|
||||
|
||||
**Mark phase-4 as completed.**
|
||||
|
||||
---
|
||||
|
||||
# PHASE 5: EXECUTE REFACTORING (DETERMINISTIC EXECUTION)
|
||||
|
||||
**Mark phase-5 as in_progress.**
|
||||
|
||||
## 5.1: Execution Protocol
|
||||
|
||||
For EACH refactoring step:
|
||||
|
||||
### Pre-Step
|
||||
1. Mark step todo as \`in_progress\`
|
||||
2. Read current file state
|
||||
3. Verify lsp_diagnostics is baseline
|
||||
|
||||
### Execute Step
|
||||
Use appropriate tool:
|
||||
|
||||
**For Symbol Renames:**
|
||||
\`\`\`typescript
|
||||
lsp_prepare_rename(filePath, line, character) // Validate rename is possible
|
||||
lsp_rename(filePath, line, character, newName) // Execute rename
|
||||
\`\`\`
|
||||
|
||||
**For Pattern Transformations:**
|
||||
\`\`\`typescript
|
||||
// Preview first
|
||||
ast_grep_replace(pattern, rewrite, lang, dryRun=true)
|
||||
|
||||
// If preview looks good, execute
|
||||
ast_grep_replace(pattern, rewrite, lang, dryRun=false)
|
||||
\`\`\`
|
||||
|
||||
**For Structural Changes:**
|
||||
\`\`\`typescript
|
||||
// Use Edit tool for precise changes
|
||||
edit(filePath, oldString, newString)
|
||||
\`\`\`
|
||||
|
||||
### Post-Step Verification (MANDATORY)
|
||||
|
||||
\`\`\`typescript
|
||||
// 1. Check diagnostics
|
||||
lsp_diagnostics(filePath) // Must be clean or same as baseline
|
||||
|
||||
// 2. Run tests
|
||||
bash("bun test") // Or appropriate test command
|
||||
|
||||
// 3. Type check
|
||||
bash("tsc --noEmit") // Or appropriate type check
|
||||
\`\`\`
|
||||
|
||||
### Step Completion
|
||||
1. If verification passes → Mark step todo as \`completed\`
|
||||
2. If verification fails → **STOP AND FIX**
|
||||
|
||||
## 5.2: Failure Recovery Protocol
|
||||
|
||||
If ANY verification fails:
|
||||
|
||||
1. **STOP** immediately
|
||||
2. **REVERT** the failed change
|
||||
3. **DIAGNOSE** what went wrong
|
||||
4. **OPTIONS**:
|
||||
- Fix the issue and retry
|
||||
- Skip this step (if optional)
|
||||
- Consult oracle agent for help
|
||||
- Ask user for guidance
|
||||
|
||||
**NEVER proceed to next step with broken tests.**
|
||||
|
||||
## 5.3: Commit Checkpoints
|
||||
|
||||
After each logical group of changes:
|
||||
|
||||
\`\`\`bash
|
||||
git add [changed-files]
|
||||
git commit -m "refactor(scope): description
|
||||
|
||||
[details of what was changed and why]"
|
||||
\`\`\`
|
||||
|
||||
**Mark phase-5 as completed when all refactoring steps done.**
|
||||
|
||||
---
|
||||
|
||||
# PHASE 6: FINAL VERIFICATION (REGRESSION CHECK)
|
||||
|
||||
**Mark phase-6 as in_progress.**
|
||||
|
||||
## 6.1: Full Test Suite
|
||||
|
||||
\`\`\`bash
|
||||
# Run complete test suite
|
||||
bun test # or npm test, pytest, go test, etc.
|
||||
\`\`\`
|
||||
|
||||
## 6.2: Type Check
|
||||
|
||||
\`\`\`bash
|
||||
# Full type check
|
||||
tsc --noEmit # or equivalent
|
||||
\`\`\`
|
||||
|
||||
## 6.3: Lint Check
|
||||
|
||||
\`\`\`bash
|
||||
# Run linter
|
||||
eslint . # or equivalent
|
||||
\`\`\`
|
||||
|
||||
## 6.4: Build Verification (if applicable)
|
||||
|
||||
\`\`\`bash
|
||||
# Ensure build still works
|
||||
bun run build # or npm run build, etc.
|
||||
\`\`\`
|
||||
|
||||
## 6.5: Final Diagnostics
|
||||
|
||||
\`\`\`typescript
|
||||
// Check all changed files
|
||||
for (file of changedFiles) {
|
||||
lsp_diagnostics(file) // Must all be clean
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
## 6.6: Generate Summary
|
||||
|
||||
\`\`\`markdown
|
||||
## Refactoring Complete
|
||||
|
||||
### What Changed
|
||||
- [List of changes made]
|
||||
|
||||
### Files Modified
|
||||
- \`path/to/file.ts\` - [what changed]
|
||||
- \`path/to/file2.ts\` - [what changed]
|
||||
|
||||
### Verification Results
|
||||
- Tests: PASSED (X/Y passing)
|
||||
- Type Check: CLEAN
|
||||
- Lint: CLEAN
|
||||
- Build: SUCCESS
|
||||
|
||||
### No Regressions Detected
|
||||
All existing tests pass. No new errors introduced.
|
||||
\`\`\`
|
||||
|
||||
**Mark phase-6 as completed.**
|
||||
|
||||
---
|
||||
|
||||
# CRITICAL RULES
|
||||
|
||||
## NEVER DO
|
||||
- Skip lsp_diagnostics check after changes
|
||||
- Proceed with failing tests
|
||||
- Make changes without understanding impact
|
||||
- Use \`as any\`, \`@ts-ignore\`, \`@ts-expect-error\`
|
||||
- Delete tests to make them pass
|
||||
- Commit broken code
|
||||
- Refactor without understanding existing patterns
|
||||
|
||||
## ALWAYS DO
|
||||
- Understand before changing
|
||||
- Preview before applying (ast_grep dryRun=true)
|
||||
- Verify after every change
|
||||
- Follow existing codebase patterns
|
||||
- Keep todos updated in real-time
|
||||
- Commit at logical checkpoints
|
||||
- Report issues immediately
|
||||
|
||||
## ABORT CONDITIONS
|
||||
If any of these occur, **STOP and consult user**:
|
||||
- Test coverage is zero for target code
|
||||
- Changes would break public API
|
||||
- Refactoring scope is unclear
|
||||
- 3 consecutive verification failures
|
||||
- User-defined constraints violated
|
||||
|
||||
---
|
||||
|
||||
# Tool Usage Philosophy
|
||||
|
||||
You already know these tools. Use them intelligently:
|
||||
|
||||
## LSP Tools
|
||||
Leverage the full LSP toolset (\`lsp_*\`) for precision analysis. Key patterns:
|
||||
- **Understand before changing**: \`lsp_hover\`, \`lsp_goto_definition\` to grasp context
|
||||
- **Impact analysis**: \`lsp_find_references\` to map all usages before modification
|
||||
- **Safe refactoring**: \`lsp_prepare_rename\` → \`lsp_rename\` for symbol renames
|
||||
- **Continuous verification**: \`lsp_diagnostics\` after every change
|
||||
|
||||
## AST-Grep
|
||||
Use \`ast_grep_search\` and \`ast_grep_replace\` for structural transformations.
|
||||
**Critical**: Always \`dryRun=true\` first, review, then execute.
|
||||
|
||||
## Agents
|
||||
- \`explore\`: Parallel codebase pattern discovery
|
||||
- \`plan\`: Detailed refactoring plan generation
|
||||
- \`oracle\`: Consult for complex architectural decisions
|
||||
- \`librarian\`: **Use proactively** when encountering deprecated methods or library migration tasks. Query official docs and OSS examples for modern replacements.
|
||||
|
||||
## Deprecated Code & Library Migration
|
||||
When you encounter deprecated methods/APIs during refactoring:
|
||||
1. Fire \`librarian\` to find the recommended modern alternative
|
||||
2. **DO NOT auto-upgrade to latest version** unless user explicitly requests migration
|
||||
3. If user requests library migration, use \`librarian\` to fetch latest API docs before making changes
|
||||
|
||||
---
|
||||
|
||||
**Remember: Refactoring without tests is reckless. Refactoring without understanding is destructive. This command ensures you do neither.**
|
||||
|
||||
<user-request>
|
||||
$ARGUMENTS
|
||||
</user-request>
|
||||
`
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { CommandDefinition } from "../claude-code-command-loader"
|
||||
|
||||
export type BuiltinCommandName = "init-deep" | "ralph-loop" | "cancel-ralph"
|
||||
export type BuiltinCommandName = "init-deep" | "ralph-loop" | "cancel-ralph" | "refactor"
|
||||
|
||||
export interface BuiltinCommandConfig {
|
||||
disabled_commands?: BuiltinCommandName[]
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { BuiltinSkill } from "./types"
|
||||
|
||||
const playwrightSkill: BuiltinSkill = {
|
||||
name: "playwright",
|
||||
description: "Browser automation with Playwright MCP. Use for web scraping, testing, screenshots, and browser interactions.",
|
||||
description: "MUST USE for any browser-related tasks. Browser automation via Playwright MCP - verification, browsing, information gathering, web scraping, testing, screenshots, and all browser interactions.",
|
||||
template: `# Playwright Browser Automation
|
||||
|
||||
This skill provides browser automation capabilities via the Playwright MCP server.`,
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
} from "../../shared"
|
||||
import type { CommandFrontmatter } from "../../features/claude-code-command-loader/types"
|
||||
import { isMarkdownFile } from "../../shared/file-utils"
|
||||
import { discoverAllSkills, type LoadedSkill } from "../../features/opencode-skill-loader"
|
||||
import { discoverAllSkills, type LoadedSkill, type LazyContentLoader } from "../../features/opencode-skill-loader"
|
||||
import type { ParsedSlashCommand } from "./types"
|
||||
|
||||
interface CommandScope {
|
||||
@@ -32,6 +32,7 @@ interface CommandInfo {
|
||||
metadata: CommandMetadata
|
||||
content?: string
|
||||
scope: CommandScope["type"]
|
||||
lazyContentLoader?: LazyContentLoader
|
||||
}
|
||||
|
||||
function discoverCommandsFromDir(commandsDir: string, scope: CommandScope["type"]): CommandInfo[] {
|
||||
@@ -91,10 +92,15 @@ function skillToCommandInfo(skill: LoadedSkill): CommandInfo {
|
||||
},
|
||||
content: skill.definition.template,
|
||||
scope: "skill",
|
||||
lazyContentLoader: skill.lazyContent,
|
||||
}
|
||||
}
|
||||
|
||||
async function discoverAllCommands(): Promise<CommandInfo[]> {
|
||||
export interface ExecutorOptions {
|
||||
skills?: LoadedSkill[]
|
||||
}
|
||||
|
||||
async function discoverAllCommands(options?: ExecutorOptions): Promise<CommandInfo[]> {
|
||||
const userCommandsDir = join(getClaudeConfigDir(), "commands")
|
||||
const projectCommandsDir = join(process.cwd(), ".claude", "commands")
|
||||
const opencodeGlobalDir = join(homedir(), ".config", "opencode", "command")
|
||||
@@ -105,7 +111,7 @@ async function discoverAllCommands(): Promise<CommandInfo[]> {
|
||||
const projectCommands = discoverCommandsFromDir(projectCommandsDir, "project")
|
||||
const opencodeProjectCommands = discoverCommandsFromDir(opencodeProjectDir, "opencode-project")
|
||||
|
||||
const skills = await discoverAllSkills()
|
||||
const skills = options?.skills ?? await discoverAllSkills()
|
||||
const skillCommands = skills.map(skillToCommandInfo)
|
||||
|
||||
return [
|
||||
@@ -117,8 +123,8 @@ async function discoverAllCommands(): Promise<CommandInfo[]> {
|
||||
]
|
||||
}
|
||||
|
||||
async function findCommand(commandName: string): Promise<CommandInfo | null> {
|
||||
const allCommands = await discoverAllCommands()
|
||||
async function findCommand(commandName: string, options?: ExecutorOptions): Promise<CommandInfo | null> {
|
||||
const allCommands = await discoverAllCommands(options)
|
||||
return allCommands.find(
|
||||
(cmd) => cmd.name.toLowerCase() === commandName.toLowerCase()
|
||||
) ?? null
|
||||
@@ -149,8 +155,13 @@ async function formatCommandTemplate(cmd: CommandInfo, args: string): Promise<st
|
||||
sections.push("---\n")
|
||||
sections.push("## Command Instructions\n")
|
||||
|
||||
let content = cmd.content || ""
|
||||
if (!content && cmd.lazyContentLoader) {
|
||||
content = await cmd.lazyContentLoader.load()
|
||||
}
|
||||
|
||||
const commandDir = cmd.path ? dirname(cmd.path) : process.cwd()
|
||||
const withFileRefs = await resolveFileReferencesInText(cmd.content || "", commandDir)
|
||||
const withFileRefs = await resolveFileReferencesInText(content, commandDir)
|
||||
const resolvedContent = await resolveCommandsInText(withFileRefs)
|
||||
sections.push(resolvedContent.trim())
|
||||
|
||||
@@ -169,8 +180,8 @@ export interface ExecuteResult {
|
||||
error?: string
|
||||
}
|
||||
|
||||
export async function executeSlashCommand(parsed: ParsedSlashCommand): Promise<ExecuteResult> {
|
||||
const command = await findCommand(parsed.command)
|
||||
export async function executeSlashCommand(parsed: ParsedSlashCommand, options?: ExecutorOptions): Promise<ExecuteResult> {
|
||||
const command = await findCommand(parsed.command, options)
|
||||
|
||||
if (!command) {
|
||||
return {
|
||||
|
||||
@@ -2,7 +2,7 @@ import {
|
||||
detectSlashCommand,
|
||||
extractPromptText,
|
||||
} from "./detector"
|
||||
import { executeSlashCommand } from "./executor"
|
||||
import { executeSlashCommand, type ExecutorOptions } from "./executor"
|
||||
import { log } from "../../shared"
|
||||
import {
|
||||
AUTO_SLASH_COMMAND_TAG_OPEN,
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
AutoSlashCommandHookInput,
|
||||
AutoSlashCommandHookOutput,
|
||||
} from "./types"
|
||||
import type { LoadedSkill } from "../../features/opencode-skill-loader"
|
||||
|
||||
export * from "./detector"
|
||||
export * from "./executor"
|
||||
@@ -20,7 +21,15 @@ export * from "./types"
|
||||
|
||||
const sessionProcessedCommands = new Set<string>()
|
||||
|
||||
export function createAutoSlashCommandHook() {
|
||||
export interface AutoSlashCommandHookOptions {
|
||||
skills?: LoadedSkill[]
|
||||
}
|
||||
|
||||
export function createAutoSlashCommandHook(options?: AutoSlashCommandHookOptions) {
|
||||
const executorOptions: ExecutorOptions = {
|
||||
skills: options?.skills,
|
||||
}
|
||||
|
||||
return {
|
||||
"chat.message": async (
|
||||
input: AutoSlashCommandHookInput,
|
||||
@@ -52,7 +61,7 @@ export function createAutoSlashCommandHook() {
|
||||
args: parsed.args,
|
||||
})
|
||||
|
||||
const result = await executeSlashCommand(parsed)
|
||||
const result = await executeSlashCommand(parsed, executorOptions)
|
||||
|
||||
const idx = output.parts.findIndex((p) => p.type === "text" && p.text)
|
||||
if (idx < 0) {
|
||||
|
||||
125
src/hooks/keyword-detector/index.test.ts
Normal file
125
src/hooks/keyword-detector/index.test.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { describe, expect, test, beforeEach, afterEach, spyOn } from "bun:test"
|
||||
import { createKeywordDetectorHook } from "./index"
|
||||
import { setMainSession } from "../../features/claude-code-session-state"
|
||||
import * as sharedModule from "../../shared"
|
||||
|
||||
describe("keyword-detector session filtering", () => {
|
||||
let logCalls: Array<{ msg: string; data?: unknown }>
|
||||
|
||||
beforeEach(() => {
|
||||
setMainSession(undefined)
|
||||
logCalls = []
|
||||
spyOn(sharedModule, "log").mockImplementation((msg: string, data?: unknown) => {
|
||||
logCalls.push({ msg, data })
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
setMainSession(undefined)
|
||||
})
|
||||
|
||||
function createMockPluginInput(options: { toastCalls?: string[] } = {}) {
|
||||
const toastCalls = options.toastCalls ?? []
|
||||
return {
|
||||
client: {
|
||||
tui: {
|
||||
showToast: async (opts: any) => {
|
||||
toastCalls.push(opts.body.title)
|
||||
},
|
||||
},
|
||||
},
|
||||
} as any
|
||||
}
|
||||
|
||||
test("should skip non-ultrawork keywords in non-main session (using mainSessionID check)", async () => {
|
||||
// #given - main session is set, different session submits search keyword
|
||||
const mainSessionID = "main-123"
|
||||
const subagentSessionID = "subagent-456"
|
||||
setMainSession(mainSessionID)
|
||||
|
||||
const hook = createKeywordDetectorHook(createMockPluginInput())
|
||||
const output = {
|
||||
message: {} as Record<string, unknown>,
|
||||
parts: [{ type: "text", text: "search mode 찾아줘" }],
|
||||
}
|
||||
|
||||
// #when - non-main session triggers keyword detection
|
||||
await hook["chat.message"](
|
||||
{ sessionID: subagentSessionID },
|
||||
output
|
||||
)
|
||||
|
||||
// #then - search keyword should be filtered out based on mainSessionID comparison
|
||||
const skipLog = logCalls.find(c => c.msg.includes("Skipping non-ultrawork keywords in non-main session"))
|
||||
expect(skipLog).toBeDefined()
|
||||
})
|
||||
|
||||
test("should allow ultrawork keywords in non-main session", async () => {
|
||||
// #given - main session is set, different session submits ultrawork keyword
|
||||
const mainSessionID = "main-123"
|
||||
const subagentSessionID = "subagent-456"
|
||||
setMainSession(mainSessionID)
|
||||
|
||||
const toastCalls: string[] = []
|
||||
const hook = createKeywordDetectorHook(createMockPluginInput({ toastCalls }))
|
||||
const output = {
|
||||
message: {} as Record<string, unknown>,
|
||||
parts: [{ type: "text", text: "ultrawork mode" }],
|
||||
}
|
||||
|
||||
// #when - non-main session triggers ultrawork keyword
|
||||
await hook["chat.message"](
|
||||
{ sessionID: subagentSessionID },
|
||||
output
|
||||
)
|
||||
|
||||
// #then - ultrawork should still work (variant set to max)
|
||||
expect(output.message.variant).toBe("max")
|
||||
expect(toastCalls).toContain("Ultrawork Mode Activated")
|
||||
})
|
||||
|
||||
test("should allow all keywords in main session", async () => {
|
||||
// #given - main session submits search keyword
|
||||
const mainSessionID = "main-123"
|
||||
setMainSession(mainSessionID)
|
||||
|
||||
const hook = createKeywordDetectorHook(createMockPluginInput())
|
||||
const output = {
|
||||
message: {} as Record<string, unknown>,
|
||||
parts: [{ type: "text", text: "search mode 찾아줘" }],
|
||||
}
|
||||
|
||||
// #when - main session triggers keyword detection
|
||||
await hook["chat.message"](
|
||||
{ sessionID: mainSessionID },
|
||||
output
|
||||
)
|
||||
|
||||
// #then - search keyword should be detected (output unchanged but detection happens)
|
||||
// Note: search keywords don't set variant, they inject messages via context-injector
|
||||
// This test verifies the detection logic runs without filtering
|
||||
expect(output.message.variant).toBeUndefined() // search doesn't set variant
|
||||
})
|
||||
|
||||
test("should allow all keywords when mainSessionID is not set", async () => {
|
||||
// #given - no main session set (early startup or standalone mode)
|
||||
setMainSession(undefined)
|
||||
|
||||
const toastCalls: string[] = []
|
||||
const hook = createKeywordDetectorHook(createMockPluginInput({ toastCalls }))
|
||||
const output = {
|
||||
message: {} as Record<string, unknown>,
|
||||
parts: [{ type: "text", text: "ultrawork search" }],
|
||||
}
|
||||
|
||||
// #when - any session triggers keyword detection
|
||||
await hook["chat.message"](
|
||||
{ sessionID: "any-session" },
|
||||
output
|
||||
)
|
||||
|
||||
// #then - all keywords should work
|
||||
expect(output.message.variant).toBe("max")
|
||||
expect(toastCalls).toContain("Ultrawork Mode Activated")
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { detectKeywordsWithType, extractPromptText, removeCodeBlocks } from "./detector"
|
||||
import { log } from "../../shared"
|
||||
import { getMainSessionID } from "../../features/claude-code-session-state"
|
||||
|
||||
export * from "./detector"
|
||||
export * from "./constants"
|
||||
@@ -21,12 +22,28 @@ export function createKeywordDetectorHook(ctx: PluginInput) {
|
||||
}
|
||||
): Promise<void> => {
|
||||
const promptText = extractPromptText(output.parts)
|
||||
const detectedKeywords = detectKeywordsWithType(removeCodeBlocks(promptText), input.agent)
|
||||
let detectedKeywords = detectKeywordsWithType(removeCodeBlocks(promptText), input.agent)
|
||||
|
||||
if (detectedKeywords.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
// Only ultrawork keywords work in non-main sessions
|
||||
// Other keywords (search, analyze, etc.) only work in main sessions
|
||||
const mainSessionID = getMainSessionID()
|
||||
const isNonMainSession = mainSessionID && input.sessionID !== mainSessionID
|
||||
|
||||
if (isNonMainSession) {
|
||||
detectedKeywords = detectedKeywords.filter((k) => k.type === "ultrawork")
|
||||
if (detectedKeywords.length === 0) {
|
||||
log(`[keyword-detector] Skipping non-ultrawork keywords in non-main session`, {
|
||||
sessionID: input.sessionID,
|
||||
mainSessionID,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const hasUltrawork = detectedKeywords.some((k) => k.type === "ultrawork")
|
||||
if (hasUltrawork) {
|
||||
log(`[keyword-detector] Ultrawork mode activated`, { sessionID: input.sessionID })
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { describe, expect, test, beforeEach, afterEach, spyOn } from "bun:test"
|
||||
import { describe, expect, test, beforeEach, afterEach, spyOn, mock } from "bun:test"
|
||||
import { EventEmitter } from "node:events"
|
||||
import * as childProcess from "node:child_process"
|
||||
|
||||
import { createSessionNotification } from "./session-notification"
|
||||
import { setMainSession, subagentSessions } from "../features/claude-code-session-state"
|
||||
@@ -6,20 +8,11 @@ import * as utils from "./session-notification-utils"
|
||||
|
||||
describe("session-notification", () => {
|
||||
let notificationCalls: string[]
|
||||
let spawnMock: ReturnType<typeof spyOn>
|
||||
|
||||
function createMockPluginInput() {
|
||||
return {
|
||||
$: async (cmd: TemplateStringsArray | string, ...values: any[]) => {
|
||||
// #given - track notification commands (osascript, notify-send, powershell)
|
||||
const cmdStr = typeof cmd === "string"
|
||||
? cmd
|
||||
: cmd.reduce((acc, part, i) => acc + part + (values[i] ?? ""), "")
|
||||
|
||||
if (cmdStr.includes("osascript") || cmdStr.includes("notify-send") || cmdStr.includes("powershell")) {
|
||||
notificationCalls.push(cmdStr)
|
||||
}
|
||||
return { stdout: "", stderr: "", exitCode: 0 }
|
||||
},
|
||||
$: async () => ({ stdout: "", stderr: "", exitCode: 0 }),
|
||||
client: {
|
||||
session: {
|
||||
todo: async () => ({ data: [] }),
|
||||
@@ -32,6 +25,18 @@ describe("session-notification", () => {
|
||||
beforeEach(() => {
|
||||
notificationCalls = []
|
||||
|
||||
// Mock spawn to track notification commands
|
||||
// Uses node:child_process.spawn instead of Bun shell to avoid GC crash
|
||||
spawnMock = spyOn(childProcess, "spawn").mockImplementation(((cmd: string, args?: string[]) => {
|
||||
// Track notification commands (osascript, notify-send, powershell)
|
||||
if (cmd.includes("osascript") || cmd.includes("notify-send") || cmd.includes("powershell")) {
|
||||
notificationCalls.push(`${cmd} ${(args ?? []).join(" ")}`)
|
||||
}
|
||||
const emitter = new EventEmitter()
|
||||
setTimeout(() => emitter.emit("close", 0), 0)
|
||||
return emitter as any
|
||||
}) as typeof childProcess.spawn)
|
||||
|
||||
spyOn(utils, "getOsascriptPath").mockResolvedValue("/usr/bin/osascript")
|
||||
spyOn(utils, "getNotifySendPath").mockResolvedValue("/usr/bin/notify-send")
|
||||
spyOn(utils, "getPowershellPath").mockResolvedValue("powershell")
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { platform } from "os"
|
||||
import { spawn } from "node:child_process"
|
||||
import { subagentSessions, getMainSessionID } from "../features/claude-code-session-state"
|
||||
import {
|
||||
getOsascriptPath,
|
||||
@@ -11,6 +12,21 @@ import {
|
||||
startBackgroundCheck,
|
||||
} from "./session-notification-utils"
|
||||
|
||||
/**
|
||||
* Execute a command using node:child_process instead of Bun shell.
|
||||
* This avoids Bun's ShellInterpreter GC bug on Windows (oven-sh/bun#23177, #24368).
|
||||
*/
|
||||
function execCommand(command: string, args: string[]): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const proc = spawn(command, args, {
|
||||
stdio: "ignore",
|
||||
detached: false,
|
||||
})
|
||||
proc.on("close", () => resolve())
|
||||
proc.on("error", () => resolve())
|
||||
})
|
||||
}
|
||||
|
||||
interface Todo {
|
||||
content: string
|
||||
status: string
|
||||
@@ -65,14 +81,17 @@ async function sendNotification(
|
||||
|
||||
const esTitle = title.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
|
||||
const esMessage = message.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
|
||||
await ctx.$`${osascriptPath} -e ${"display notification \"" + esMessage + "\" with title \"" + esTitle + "\""}`.catch(() => {})
|
||||
const script = `display notification "${esMessage}" with title "${esTitle}"`
|
||||
// Use node:child_process instead of Bun shell to avoid potential GC issues
|
||||
await execCommand(osascriptPath, ["-e", script]).catch(() => {})
|
||||
break
|
||||
}
|
||||
case "linux": {
|
||||
const notifySendPath = await getNotifySendPath()
|
||||
if (!notifySendPath) return
|
||||
|
||||
await ctx.$`${notifySendPath} ${title} ${message} 2>/dev/null`.catch(() => {})
|
||||
// Use node:child_process instead of Bun shell to avoid potential GC issues
|
||||
await execCommand(notifySendPath, [title, message]).catch(() => {})
|
||||
break
|
||||
}
|
||||
case "win32": {
|
||||
@@ -93,7 +112,8 @@ $Toast = [Windows.UI.Notifications.ToastNotification]::new($SerializedXml)
|
||||
$Notifier = [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('OpenCode')
|
||||
$Notifier.Show($Toast)
|
||||
`.trim().replace(/\n/g, "; ")
|
||||
await ctx.$`${powershellPath} -Command ${toastScript}`.catch(() => {})
|
||||
// Use node:child_process instead of Bun shell to avoid GC crash (oven-sh/bun#23177)
|
||||
await execCommand(powershellPath, ["-Command", toastScript]).catch(() => {})
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -104,17 +124,19 @@ async function playSound(ctx: PluginInput, p: Platform, soundPath: string): Prom
|
||||
case "darwin": {
|
||||
const afplayPath = await getAfplayPath()
|
||||
if (!afplayPath) return
|
||||
ctx.$`${afplayPath} ${soundPath}`.catch(() => {})
|
||||
// Use node:child_process instead of Bun shell to avoid potential GC issues
|
||||
execCommand(afplayPath, [soundPath]).catch(() => {})
|
||||
break
|
||||
}
|
||||
case "linux": {
|
||||
const paplayPath = await getPaplayPath()
|
||||
if (paplayPath) {
|
||||
ctx.$`${paplayPath} ${soundPath} 2>/dev/null`.catch(() => {})
|
||||
// Use node:child_process instead of Bun shell to avoid potential GC issues
|
||||
execCommand(paplayPath, [soundPath]).catch(() => {})
|
||||
} else {
|
||||
const aplayPath = await getAplayPath()
|
||||
if (aplayPath) {
|
||||
ctx.$`${aplayPath} ${soundPath} 2>/dev/null`.catch(() => {})
|
||||
execCommand(aplayPath, [soundPath]).catch(() => {})
|
||||
}
|
||||
}
|
||||
break
|
||||
@@ -122,7 +144,9 @@ async function playSound(ctx: PluginInput, p: Platform, soundPath: string): Prom
|
||||
case "win32": {
|
||||
const powershellPath = await getPowershellPath()
|
||||
if (!powershellPath) return
|
||||
ctx.$`${powershellPath} -Command ${"(New-Object Media.SoundPlayer '" + soundPath + "').PlaySync()"}`.catch(() => {})
|
||||
// Use node:child_process instead of Bun shell to avoid GC crash (oven-sh/bun#23177)
|
||||
const soundScript = `(New-Object Media.SoundPlayer '${soundPath.replace(/'/g, "''")}').PlaySync()`
|
||||
execCommand(powershellPath, ["-Command", soundScript]).catch(() => {})
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
12
src/index.ts
12
src/index.ts
@@ -166,10 +166,6 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
})
|
||||
: null;
|
||||
|
||||
const autoSlashCommand = isHookEnabled("auto-slash-command")
|
||||
? createAutoSlashCommandHook()
|
||||
: null;
|
||||
|
||||
const editErrorRecovery = isHookEnabled("edit-error-recovery")
|
||||
? createEditErrorRecoveryHook(ctx)
|
||||
: null;
|
||||
@@ -239,6 +235,10 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
skills: mergedSkills,
|
||||
});
|
||||
|
||||
const autoSlashCommand = isHookEnabled("auto-slash-command")
|
||||
? createAutoSlashCommandHook({ skills: mergedSkills })
|
||||
: null;
|
||||
|
||||
const googleAuthHooks = pluginConfig.google_auth !== false
|
||||
? await createGoogleAntigravityAuthPlugin(ctx)
|
||||
: null;
|
||||
@@ -264,10 +264,6 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
},
|
||||
|
||||
"chat.message": async (input, output) => {
|
||||
if (input.agent === "Sisyphus") {
|
||||
(output.message as Record<string, unknown>).variant = "max"
|
||||
}
|
||||
|
||||
await claudeCodeHooks["chat.message"]?.(input, output);
|
||||
await keywordDetector?.["chat.message"]?.(input, output);
|
||||
await contextInjector["chat.message"]?.(input, output);
|
||||
|
||||
@@ -10,9 +10,10 @@ describe("createBuiltinMcps", () => {
|
||||
const result = createBuiltinMcps(disabledMcps)
|
||||
|
||||
//#then
|
||||
expect(result).toHaveProperty("websearch")
|
||||
expect(result).toHaveProperty("context7")
|
||||
expect(result).toHaveProperty("grep_app")
|
||||
expect(Object.keys(result)).toHaveLength(2)
|
||||
expect(Object.keys(result)).toHaveLength(3)
|
||||
})
|
||||
|
||||
test("should filter out disabled built-in MCPs", () => {
|
||||
@@ -23,19 +24,21 @@ describe("createBuiltinMcps", () => {
|
||||
const result = createBuiltinMcps(disabledMcps)
|
||||
|
||||
//#then
|
||||
expect(result).toHaveProperty("websearch")
|
||||
expect(result).not.toHaveProperty("context7")
|
||||
expect(result).toHaveProperty("grep_app")
|
||||
expect(Object.keys(result)).toHaveLength(1)
|
||||
expect(Object.keys(result)).toHaveLength(2)
|
||||
})
|
||||
|
||||
test("should filter out both built-in MCPs when both disabled", () => {
|
||||
test("should filter out all built-in MCPs when all disabled", () => {
|
||||
//#given
|
||||
const disabledMcps = ["context7", "grep_app"]
|
||||
const disabledMcps = ["websearch", "context7", "grep_app"]
|
||||
|
||||
//#when
|
||||
const result = createBuiltinMcps(disabledMcps)
|
||||
|
||||
//#then
|
||||
expect(result).not.toHaveProperty("websearch")
|
||||
expect(result).not.toHaveProperty("context7")
|
||||
expect(result).not.toHaveProperty("grep_app")
|
||||
expect(Object.keys(result)).toHaveLength(0)
|
||||
@@ -49,9 +52,10 @@ describe("createBuiltinMcps", () => {
|
||||
const result = createBuiltinMcps(disabledMcps)
|
||||
|
||||
//#then
|
||||
expect(result).toHaveProperty("websearch")
|
||||
expect(result).not.toHaveProperty("context7")
|
||||
expect(result).toHaveProperty("grep_app")
|
||||
expect(Object.keys(result)).toHaveLength(1)
|
||||
expect(Object.keys(result)).toHaveLength(2)
|
||||
})
|
||||
|
||||
test("should handle empty disabled_mcps by default", () => {
|
||||
@@ -60,9 +64,10 @@ describe("createBuiltinMcps", () => {
|
||||
const result = createBuiltinMcps()
|
||||
|
||||
//#then
|
||||
expect(result).toHaveProperty("websearch")
|
||||
expect(result).toHaveProperty("context7")
|
||||
expect(result).toHaveProperty("grep_app")
|
||||
expect(Object.keys(result)).toHaveLength(2)
|
||||
expect(Object.keys(result)).toHaveLength(3)
|
||||
})
|
||||
|
||||
test("should only filter built-in MCPs, ignoring unknown names", () => {
|
||||
@@ -73,8 +78,9 @@ describe("createBuiltinMcps", () => {
|
||||
const result = createBuiltinMcps(disabledMcps)
|
||||
|
||||
//#then
|
||||
expect(result).toHaveProperty("websearch")
|
||||
expect(result).toHaveProperty("context7")
|
||||
expect(result).toHaveProperty("grep_app")
|
||||
expect(Object.keys(result)).toHaveLength(2)
|
||||
expect(Object.keys(result)).toHaveLength(3)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { websearch } from "./websearch"
|
||||
import { context7 } from "./context7"
|
||||
import { grep_app } from "./grep-app"
|
||||
import type { McpName } from "./types"
|
||||
@@ -5,6 +6,7 @@ import type { McpName } from "./types"
|
||||
export { McpNameSchema, type McpName } from "./types"
|
||||
|
||||
const allBuiltinMcps: Record<McpName, { type: "remote"; url: string; enabled: boolean }> = {
|
||||
websearch,
|
||||
context7,
|
||||
grep_app,
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { z } from "zod"
|
||||
|
||||
export const McpNameSchema = z.enum(["context7", "grep_app"])
|
||||
export const McpNameSchema = z.enum(["websearch", "context7", "grep_app"])
|
||||
|
||||
export type McpName = z.infer<typeof McpNameSchema>
|
||||
|
||||
|
||||
5
src/mcp/websearch.ts
Normal file
5
src/mcp/websearch.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export const websearch = {
|
||||
type: "remote" as const,
|
||||
url: "https://mcp.exa.ai/mcp?tools=web_search_exa",
|
||||
enabled: true,
|
||||
}
|
||||
@@ -96,26 +96,18 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
|
||||
config.model as string | undefined
|
||||
);
|
||||
|
||||
const rawUserAgents = (pluginConfig.claude_code?.agents ?? true)
|
||||
// Claude Code agents: Do NOT apply permission migration
|
||||
// Claude Code uses whitelist-based tools format which is semantically different
|
||||
// from OpenCode's denylist-based permission system
|
||||
const userAgents = (pluginConfig.claude_code?.agents ?? true)
|
||||
? loadUserAgents()
|
||||
: {};
|
||||
const rawProjectAgents = (pluginConfig.claude_code?.agents ?? true)
|
||||
const projectAgents = (pluginConfig.claude_code?.agents ?? true)
|
||||
? loadProjectAgents()
|
||||
: {};
|
||||
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,
|
||||
])
|
||||
);
|
||||
// Plugin agents: Apply permission migration for compatibility
|
||||
const rawPluginAgents = pluginComponents.agents;
|
||||
const pluginAgents = Object.fromEntries(
|
||||
Object.entries(rawPluginAgents).map(([k, v]) => [
|
||||
k,
|
||||
|
||||
@@ -5,16 +5,17 @@ import { existsSync } from "fs"
|
||||
import { homedir } from "os"
|
||||
|
||||
const DEFAULT_ZSH_PATHS = ["/bin/zsh", "/usr/bin/zsh", "/usr/local/bin/zsh"]
|
||||
const DEFAULT_BASH_PATHS = ["/bin/bash", "/usr/bin/bash", "/usr/local/bin/bash"]
|
||||
|
||||
function getHomeDir(): string {
|
||||
return process.env.HOME || process.env.USERPROFILE || homedir()
|
||||
}
|
||||
|
||||
function findZshPath(customZshPath?: string): string | null {
|
||||
if (customZshPath && existsSync(customZshPath)) {
|
||||
return customZshPath
|
||||
function findShellPath(defaultPaths: string[], customPath?: string): string | null {
|
||||
if (customPath && existsSync(customPath)) {
|
||||
return customPath
|
||||
}
|
||||
for (const path of DEFAULT_ZSH_PATHS) {
|
||||
for (const path of defaultPaths) {
|
||||
if (existsSync(path)) {
|
||||
return path
|
||||
}
|
||||
@@ -22,6 +23,14 @@ function findZshPath(customZshPath?: string): string | null {
|
||||
return null
|
||||
}
|
||||
|
||||
function findZshPath(customZshPath?: string): string | null {
|
||||
return findShellPath(DEFAULT_ZSH_PATHS, customZshPath)
|
||||
}
|
||||
|
||||
function findBashPath(): string | null {
|
||||
return findShellPath(DEFAULT_BASH_PATHS)
|
||||
}
|
||||
|
||||
const execAsync = promisify(exec)
|
||||
|
||||
export interface CommandResult {
|
||||
@@ -55,10 +64,18 @@ export async function executeHookCommand(
|
||||
let finalCommand = expandedCommand
|
||||
|
||||
if (options?.forceZsh) {
|
||||
const zshPath = options.zshPath || findZshPath()
|
||||
// Always verify shell exists before using it
|
||||
const zshPath = findZshPath(options.zshPath)
|
||||
const escapedCommand = expandedCommand.replace(/'/g, "'\\''")
|
||||
if (zshPath) {
|
||||
const escapedCommand = expandedCommand.replace(/'/g, "'\\''")
|
||||
finalCommand = `${zshPath} -lc '${escapedCommand}'`
|
||||
} else {
|
||||
// Fall back to bash login shell to preserve PATH from user profile
|
||||
const bashPath = findBashPath()
|
||||
if (bashPath) {
|
||||
finalCommand = `${bashPath} -lc '${escapedCommand}'`
|
||||
}
|
||||
// If neither zsh nor bash found, fall through to spawn with shell: true
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -80,6 +80,7 @@ function skillToCommandInfo(skill: LoadedSkill): CommandInfo {
|
||||
},
|
||||
content: skill.definition.template,
|
||||
scope: skill.scope,
|
||||
lazyContentLoader: skill.lazyContent,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,8 +113,13 @@ async function formatLoadedCommand(cmd: CommandInfo): Promise<string> {
|
||||
sections.push("---\n")
|
||||
sections.push("## Command Instructions\n")
|
||||
|
||||
let content = cmd.content || ""
|
||||
if (!content && cmd.lazyContentLoader) {
|
||||
content = await cmd.lazyContentLoader.load()
|
||||
}
|
||||
|
||||
const commandDir = cmd.path ? dirname(cmd.path) : process.cwd()
|
||||
const withFileRefs = await resolveFileReferencesInText(cmd.content || "", commandDir)
|
||||
const withFileRefs = await resolveFileReferencesInText(content, commandDir)
|
||||
const resolvedContent = await resolveCommandsInText(withFileRefs)
|
||||
sections.push(resolvedContent.trim())
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { LoadedSkill } from "../../features/opencode-skill-loader"
|
||||
import type { LoadedSkill, LazyContentLoader } from "../../features/opencode-skill-loader"
|
||||
|
||||
export type CommandScope = "builtin" | "config" | "user" | "project" | "opencode" | "opencode-project"
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface CommandInfo {
|
||||
metadata: CommandMetadata
|
||||
content?: string
|
||||
scope: CommandScope
|
||||
lazyContentLoader?: LazyContentLoader
|
||||
}
|
||||
|
||||
export interface SlashcommandToolOptions {
|
||||
|
||||
Reference in New Issue
Block a user