Compare commits

...

24 Commits

Author SHA1 Message Date
github-actions[bot]
83676b36cf release: v2.14.0 2026-01-06 18:14:03 +00:00
YeonGyu-Kim
398075f5df refactor(librarian): optimize prompt to search only when needed
- Add assessment phase before searching to reduce unnecessary tool calls
- Change mandatory minimum parallel calls to suggested ranges
- Allow direct answers from training knowledge for well-known APIs
2026-01-07 03:10:33 +09:00
YeonGyu-Kim
d4347e829d fix(auto-slash-command): load skill content via lazyContentLoader and include builtin skills 2026-01-07 03:00:28 +09:00
YeonGyu-Kim
980b685393 fix(background-agent): release concurrency before prompt to unblock queued tasks
Previously, concurrency was released in finally block AFTER prompt completion.
This caused queued tasks to remain blocked while prompt hangs.

Now release happens BEFORE prompt, allowing next queued task to start immediately
when current task completes, regardless of prompt success/failure.

Also added early release on session creation error for proper cleanup.
2026-01-07 03:00:28 +09:00
YeonGyu-Kim
b5c1cfb57f fix(keyword-detector): use mainSessionID for session check instead of unreliable API
The keyword-detector was using ctx.client.session.get() to check parentID for
determining subagent sessions, but this API didn't reliably return parentID.

This caused non-ultrawork keywords (search, analyze) to be injected in subagent
sessions when they should only work in main sessions.

Changed to use getMainSessionID() comparison, consistent with other hooks like
session-notification and todo-continuation-enforcer.

- Replace unreliable parentID API check with mainSessionID comparison
- Add comprehensive test coverage for session filtering behavior
- Remove unnecessary session.get API call
2026-01-07 03:00:28 +09:00
github-actions[bot]
cd97572d0a @atripathy86 has signed the CLA in code-yeongyu/oh-my-opencode#550 2026-01-06 17:32:42 +00:00
YeonGyu-Kim
b9ec4c7c4a docs: add GitHub follow badge to README files 2026-01-07 01:45:10 +09:00
YeonGyu-Kim
2064568124 fix: correct spawn mock type in session-notification test 2026-01-07 01:43:03 +09:00
YeonGyu-Kim
ad44af9d15 fix: load skill content via lazyContentLoader in slashcommand tool
- Fix #542: slashcommand tool returns empty content for skills
- Add lazyContentLoader to CommandInfo type
- Load skill content in formatLoadedCommand when content is empty
- Filter non-ultrawork keywords in subagent sessions
2026-01-07 01:41:42 +09:00
ananas-viber
d331b484f9 fix: verify zsh exists before using it for hook execution (#544)
The `forceZsh` option on Linux/macOS would use a hardcoded zshPath
without checking if zsh actually exists on the system. This caused
hook commands to fail silently with exit code 127 on systems without
zsh installed.

Changes:
- Always verify zsh exists via findZshPath() before using it
- Fall back to bash -lc if zsh not found (preserves login shell PATH)
- Fall through to spawn with shell:true if neither found

The bash fallback ensures user PATH from .profile/.bashrc is available,
which is important for hooks that depend on custom tool locations.

Tested with opencode v1.1.3 - PreToolUse hooks now execute correctly
on systems without zsh.

Co-authored-by: Anas Viber <ananas-viber@users.noreply.github.com>
2026-01-07 01:37:42 +09:00
João Carlos Magalhães de Castro
4a38e70fa8 fix(session-notification): use node:child_process to avoid Bun shell GC crash (#543)
Replace Bun shell template literals (ctx.$) with node:child_process.spawn
to work around Bun's ShellInterpreter garbage collection bug on Windows.

This bug causes segmentation faults in deinitFromFinalizer during heap
sweeping when shell operations are used repeatedly over time.

Bug references:
- oven-sh/bun#23177 (closed incomplete)
- oven-sh/bun#24368 (still open)
- Pending fix: oven-sh/bun#24093

The fix applies to all platforms for consistency and safety.
2026-01-07 01:37:15 +09:00
YeonGyu-Kim
204ea319cb docs: remove Korean README due to maintenance burden 2026-01-07 01:25:02 +09:00
YeonGyu-Kim
a2bfb5e556 feat(mcp): restore Exa websearch support (#549)
* feat(mcp): restore Exa MCP websearch support

- Add websearch.ts with Exa remote MCP configuration
- Update McpNameSchema to include websearch
- Wire websearch MCP into plugin initialization

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

* test(mcp): update tests and docs for websearch MCP

- Update index.test.ts to verify 3 MCPs (websearch, context7, grep_app)
- Add Exa/websearch documentation to README.md MCPs section

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-07 01:24:50 +09:00
YeonGyu-Kim
f25f7ed0f5 feat(background-agent): add model-based concurrency management (#548)
* feat(config): add BackgroundTaskConfigSchema for model concurrency

🤖 GENERATED WITH ASSISTANCE OF OhMyOpenCode (https://github.com/code-yeongyu/oh-my-opencode)

* feat(background-agent): add ConcurrencyManager for model-based limits

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

* feat(background-agent): integrate ConcurrencyManager into BackgroundManager

🤖 GENERATED WITH ASSISTANCE OF OhMyOpenCode (https://github.com/code-yeongyu/oh-my-opencode)

* test(background-agent): add ConcurrencyManager tests

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

* fix(background-agent): set default concurrency to 5

🤖 Generated with [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

* feat(background-agent): support 0 as unlimited concurrency

Setting concurrency to 0 means unlimited (Infinity).
Works for defaultConcurrency, providerConcurrency, and modelConcurrency.

🤖 Generated with [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-07 01:24:47 +09:00
YeonGyu-Kim
29dbc0f57b chore: cleanup agent model references and defaults (#547)
* refactor(agents): remove unused model references

Consistent cleanup of agent model references across all agent files.

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

* fix(agents): use glm-4.7-free as default librarian model

🤖 Generated with [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

* make playwright skill to be called more
2026-01-07 01:24:44 +09:00
YeonGyu-Kim
544212fa9c docs: add Korean README translation (#546) 2026-01-07 01:24:18 +09:00
YeonGyu-Kim
f3eed731d6 remove: Korean README
- Koreans already read English well
- Machine-translated Korean felt unnatural to maintain
- Reduces maintenance overhead
2026-01-07 01:16:54 +09:00
github-actions[bot]
6f1cabd3f4 @JohnC0de has signed the CLA in code-yeongyu/oh-my-opencode#543 2026-01-06 14:45:36 +00:00
github-actions[bot]
15571d3d95 @ananas-viber has signed the CLA in code-yeongyu/oh-my-opencode#544 2026-01-06 13:22:25 +00:00
github-actions[bot]
556262e791 release: v2.13.2 2026-01-06 09:19:46 +00:00
Sisyphus
375e7f715d fix: prevent background agents from spawning recursive subagents via call_omo_agent (#536) 2026-01-06 17:40:46 +09:00
Sisyphus
5aa0ee125d feat: add English language policy and GitHub issue templates (#534) 2026-01-06 17:13:06 +09:00
github-actions[bot]
d0b3be72c5 @sngweizhi has signed the CLA in code-yeongyu/oh-my-opencode#532 2026-01-06 04:37:05 +00:00
github-actions[bot]
a10903def2 @jkoelker has signed the CLA in code-yeongyu/oh-my-opencode#531 2026-01-06 03:59:47 +00:00
40 changed files with 1247 additions and 1119 deletions

129
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View 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
View 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

View 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
View 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.

View File

@@ -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

View File

@@ -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> -->

File diff suppressed because it is too large Load Diff

View File

@@ -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.
[![GitHub Issues](https://img.shields.io/github/issues/code-yeongyu/oh-my-opencode?color=ff80eb&labelColor=black&style=flat-square)](https://github.com/code-yeongyu/oh-my-opencode/issues)
[![License](https://img.shields.io/badge/license-SUL--1.0-white?labelColor=black&style=flat-square)](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>
@@ -582,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)
@@ -983,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)
@@ -992,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"]
}
```

View File

@@ -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> -->

View File

@@ -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
}
}
}
}
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode",
"version": "2.13.1",
"version": "2.14.0",
"description": "OpenCode plugin - custom agents (oracle, librarian) and enhanced features",
"main": "dist/index.js",
"types": "dist/index.d.ts",

View File

@@ -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
}
]
}

View File

@@ -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:

View File

@@ -28,7 +28,6 @@ export function createExploreAgent(model: string = DEFAULT_MODEL): AgentConfig {
const restrictions = createAgentToolRestrictions([
"write",
"edit",
"background_task",
])
return {

View File

@@ -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:

View File

@@ -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:
\`\`\`

View File

@@ -18,7 +18,6 @@ export function createMultimodalLookerAgent(
"write",
"edit",
"bash",
"background_task",
])
return {

View File

@@ -102,7 +102,6 @@ export function createOracleAgent(model: string = DEFAULT_MODEL): AgentConfig {
"write",
"edit",
"task",
"background_task",
])
const base = {

View File

@@ -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>

View 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
})
})

View 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)
}
}
}
}

View File

@@ -1,2 +1,3 @@
export * from "./types"
export { BackgroundManager } from "./manager"
export { ConcurrencyManager } from "./concurrency"

View File

@@ -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

View File

@@ -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)

View File

@@ -27,6 +27,7 @@ export interface BackgroundTask {
error?: string
progress?: TaskProgress
parentModel?: { providerID: string; modelID: string }
model?: string
}
export interface LaunchInput {

View File

@@ -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.`,

View File

@@ -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 {

View File

@@ -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) {

View 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")
})
})

View File

@@ -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 })

View File

@@ -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")

View File

@@ -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
}
}

View File

@@ -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;

View File

@@ -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)
})
})

View File

@@ -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,
}

View File

@@ -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
View File

@@ -0,0 +1,5 @@
export const websearch = {
type: "remote" as const,
url: "https://mcp.exa.ai/mcp?tools=web_search_exa",
enabled: true,
}

View File

@@ -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
}
}

View File

@@ -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())

View File

@@ -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 {