Compare commits

...

57 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
github-actions[bot]
dc5a24ac3e release: v2.13.1 2026-01-05 17:16:18 +00:00
YeonGyu-Kim
9d13c6cff1 fix(config): skip permission migration for Claude Code agents
Claude Code uses whitelist-based tools format which is semantically
different from OpenCode's denylist-based permission system. Apply
migration only to plugin agents for compatibility.

🤖 Generated with [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode) assistance
2026-01-06 02:10:34 +09:00
YeonGyu-Kim
b78e564872 feat(builtin-commands): add /refactor command for intelligent LSP/AST-based refactoring
Ports the refactor command from ~/.config/opencode/command/refactor.md to the project as a builtin command.

The /refactor command provides deterministic, LSP/AST-aware refactoring with:
- Automatic intent analysis and codebase mapping
- Risk assessment with test coverage verification
- Detailed planning via Plan agent
- Step-by-step execution with continuous verification
- Zero-regression guarantees via comprehensive testing

Supports multiple refactoring scopes (file/module/project) and strategies (safe/aggressive).

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-06 01:49:53 +09:00
YeonGyu-Kim
c709fafa25 docs: update 'Just Install It' section with detailed Sisyphus workflow across all languages
🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-06 01:42:59 +09:00
github-actions[bot]
5914a393ad release: v2.13.0 2026-01-05 15:03:55 +00:00
YeonGyu-Kim
4e5b3566a2 feat(tools): refactor slashcommand to support options and caching
- Extract createSlashcommandTool factory with SlashcommandToolOptions
- Export discoverCommandsSync for external use
- Move description building to lazy evaluation with caching
- Support pre-warming cache with provided commands and skills
- Simplify tool initialization in plugin with new factory approach

This allows the slashcommand tool to be instantiated with custom options
while maintaining backward compatibility through lazy loading.

🤖 Generated with assistance of [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-05 23:45:01 +09:00
YeonGyu-Kim
898d3e6175 fix(cli): migrate Gemini models to explicit antigravity- prefix for quota routing
All Gemini model references now use the `antigravity-` prefix to ensure explicit
routing to Antigravity quota pools instead of relying on legacy `-preview` suffix
disambiguation. This approach prevents potential breakage if Google removes the
`-preview` suffix in future versions.

Updates include:
- config-manager: Updated Gemini model assignments with antigravity- prefix
- config-manager.test.ts: Updated test assertions to match new naming convention
- install.ts: Updated config summary display to show antigravity-prefixed model name

Migration follows opencode-antigravity-auth plugin v1.2.7+ guidance for explicit
quota routing configuration.

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-05 23:36:10 +09:00
YeonGyu-Kim
21236d88a7 chore(keyword-detector): add verification guarantee section to ultrawork prompt
Added comprehensive VERIFICATION GUARANTEE section to ultrawork prompt to enforce proof-based task completion. Includes:
- Pre-implementation success criteria definition (Functional, Observable, Pass/Fail)
- Mandatory Test Plan template for non-trivial tasks
- Execution & Evidence requirements table (Build, Test, Manual Verify, Regression)
- TDD workflow with evidence requirements
- Verification anti-patterns and blocking violations

This enhancement ensures agents must provide PROOF that something works before claiming completion - eliminating vague "it should work now" claims without evidence.

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-05 23:21:25 +09:00
YeonGyu-Kim
ea8ca1a100 docs: update model names and auth versions for latest releases
- Update to antigravity v1.2.7 model naming conventions
- Update Codex auth version from 4.2.0 to 4.3.0
- Remove hotfix documentation (resolved in v4.3.0)
- Document available models and variants

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-05 23:03:30 +09:00
YeonGyu-Kim
66acb0e444 chore(auth): remove deprecated models and ChatGPT hotfix
- Remove gemini-3-pro-medium and gemini-3-flash-lite (deprecated in antigravity v1.2.7)
- Remove CHATGPT_HOTFIX_REPO variable and setupChatGPTHotfix() function
- Update CODEX_PROVIDER_CONFIG to modern variants system

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-05 23:03:11 +09:00
YeonGyu-Kim
f7c8763462 chore(keyword-detector): revert ultrawork to stronger agent utilization instructions
- Restore [CODE RED] maximum precision requirement
- Restore YOU MUST LEVERAGE ALL AVAILABLE AGENTS directive
- Restore TELL THE USER WHAT AGENTS YOU WILL LEVERAGE NOW
- Restore explicit parallel exploration/librarian spawn workflow
- Keep mandatory ULTRAWORK MODE ENABLED! message
- Simplify constants structure for better maintainability

This addresses the issue where explore/librarian agents were being called less frequently after recent prompt changes.

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-05 22:44:06 +09:00
YeonGyu-Kim
ee2f390bf6 chore(keyword-detector): add mandatory ultrawork mode message
🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-05 22:44:06 +09:00
YeonGyu-Kim
ae6495dc17 config(fallback): use opencode/glm-4.7-free as default fallback model
🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-05 22:44:06 +09:00
popododo0720
b8b8d14b1c docs: update auth plugin versions to latest releases (#477)
- opencode-antigravity-auth: 1.1.2 → 1.2.7
- opencode-openai-codex-auth: 4.1.1 → 4.2.0

Fixes #463
2026-01-05 22:41:44 +09:00
Sisyphus
7a10b24bbd feat: allow disabled_mcps to accept any MCP name (#513) 2026-01-05 21:12:05 +09:00
github-actions[bot]
258463a146 @luosky has signed the CLA in code-yeongyu/oh-my-opencode#512 2026-01-05 11:49:53 +00:00
Sisyphus
0f890c11c2 fix(test): increase timeout in duration test to prevent flakiness (#508) 2026-01-05 20:20:46 +09:00
YeonGyu-Kim
e81002ba43 docs: remove websearch_exa from feature documentation
🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-05 20:09:34 +09:00
YeonGyu-Kim
a20f011014 docs(librarian): make web search conditional in agent prompt
🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-05 20:09:27 +09:00
YeonGyu-Kim
48174ec25a chore(config): update schema after websearch_exa removal
🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-05 20:09:20 +09:00
YeonGyu-Kim
26e77a0a89 test(doctor): update MCP checks for websearch_exa removal
🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-05 20:09:15 +09:00
YeonGyu-Kim
a5c71473a5 refactor(mcp): remove websearch_exa as built-in MCP server
🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-05 20:09:09 +09:00
github-actions[bot]
aecfc77fb6 @raydocs has signed the CLA in code-yeongyu/oh-my-opencode#499 2026-01-05 07:39:55 +00:00
YeonGyu-Kim
5a4261a607 fix(hooks): pass input.agent parameter to keyword detector
Wire agent information through keyword detector hooks:
- Pass input.agent to detectKeywordsWithType in keyword-detector hook
- Pass input.agent to detectKeywordsWithType in claude-code-hooks
- Enables agent-aware ultrawork message generation

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-05 16:26:35 +09:00
YeonGyu-Kim
6913613398 fix(keyword-detector): implement agent-aware ultrawork message generation
Restructure ultrawork message generation to support agent-specific instructions.
- Extract ultrawork message components into modular constants
- Add getUltraworkMessage(agentName) function that adapts instructions based on agent type
- Support planner-specific vs default agent execution patterns
- Pass agentName parameter through detector.ts for message resolution

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-05 16:26:29 +09:00
YeonGyu-Kim
d27a1efd94 feat(keyword-detector): enable variant='max' for ultrawork mode
🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-05 13:44:07 +09:00
YeonGyu-Kim
bc05fb6671 feat(sisyphus): enable variant='max' for maximum reasoning effort
🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-05 13:44:07 +09:00
YeonGyu-Kim
7937d72cbf refactor(loaders): migrate to async-first pattern for commands and skills
- Remove all sync functions from command loader (async now default)
- Remove sync load functions from skill loader (async now default)
- Add resolveSymlinkAsync to file-utils.ts
- Update all callers to use async versions:
  - config-handler.ts
  - index.ts
  - tools/slashcommand/tools.ts
  - tools/skill/tools.ts
  - hooks/auto-slash-command/executor.ts
  - loader.test.ts
- All 607 tests pass, build succeeds

Generated with assistance of 🤖 [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-05 13:44:07 +09:00
YeonGyu-Kim
fe11ba294c perf(startup): parallelize command and skill loading in config-handler
- Add async versions of skill loader functions (loadUserSkillsAsync, loadProjectSkillsAsync, loadOpencodeGlobalSkillsAsync, loadOpencodeProjectSkillsAsync)
- Use Promise.all to load 8 loaders concurrently instead of sequentially
- Improves startup performance by eliminating serial I/O bottlenecks

Generated with assistance of OhMyOpenCode
2026-01-05 13:44:07 +09:00
github-actions[bot]
6b5a8263f9 @popododo0720 has signed the CLA in code-yeongyu/oh-my-opencode#477 2026-01-05 04:07:46 +00:00
YeonGyu-Kim
65b00c9720 fix: fix Planner-Sisyphus visibility for OpenCode 1.1.1
- Change Planner-Sisyphus mode from "all" to "primary" for proper Tab selector visibility
- Fixes agent visibility breaking changes introduced in OpenCode 1.1.1
- 🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-05 09:45:05 +09:00
github-actions[bot]
5ed031db63 release: v2.12.4 2026-01-05 00:30:37 +00:00
YeonGyu-Kim
0553676ab0 fix: use mode 'all' for Planner-Sisyphus agent and inherit default model
🤖 Generated with assistance of OhMyOpenCode

- Fix Planner-Sisyphus agent config: use `mode: 'all'` instead of `mode: 'primary'` to show in Tab selector
- Use model fallback to default config model when plan agent model not specified
- Demote original plan agent to `subagent` mode with `hidden: true` instead of `disable: true`
- Destructure `mode` from plan config to avoid passing it to migratedPlanConfig
2026-01-05 09:26:42 +09:00
63 changed files with 2759 additions and 1953 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

@@ -20,7 +20,7 @@ oh-my-opencode/
│ ├── auth/ # Google Antigravity OAuth - see src/auth/AGENTS.md
│ ├── shared/ # Cross-cutting utilities - see src/shared/AGENTS.md
│ ├── cli/ # CLI installer, doctor - see src/cli/AGENTS.md
│ ├── mcp/ # MCP configs: context7, websearch_exa, grep_app
│ ├── mcp/ # MCP configs: context7, grep_app
│ ├── config/ # Zod schema, TypeScript types
│ └── index.ts # Main plugin entry (464 lines)
├── script/ # build-schema.ts, publish.ts, generate-changelog.ts

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
@@ -89,7 +112,7 @@ oh-my-opencode/
│ ├── agents/ # AI agents (OmO, oracle, librarian, explore, etc.)
│ ├── hooks/ # 21 lifecycle hooks
│ ├── tools/ # LSP (11), AST-Grep, Grep, Glob, etc.
│ ├── mcp/ # MCP server integrations (context7, websearch_exa, grep_app)
│ ├── mcp/ # MCP server integrations (context7, grep_app)
│ ├── features/ # Claude Code compatibility layers
│ ├── config/ # Zod schemas and TypeScript types
│ ├── auth/ # Google Antigravity OAuth

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> -->
@@ -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と打ち、コーヒーでも飲んでいてください。仕事は終わっています。
このような機能が不要であれば、前述の通り、特定の機能だけを選んで使うことができます。
@@ -297,7 +300,7 @@ opencode auth login
{
"plugin": [
"oh-my-opencode",
"opencode-antigravity-auth@1.1.2"
"opencode-antigravity-auth@1.2.7"
]
}
```
@@ -322,7 +325,7 @@ opencode auth login
}
```
**利用可能なモデル名**: `google/gemini-3-pro-high`, `google/gemini-3-pro-medium`, `google/gemini-3-pro-low`, `google/gemini-3-flash`, `google/gemini-3-flash`, `google/gemini-3-flash-lite`, `google/claude-sonnet-4-5`, `google/claude-sonnet-4-5-thinking`, `google/claude-opus-4-5-thinking`, `google/gpt-oss-120b-medium`
**利用可能なモデル名**: `google/antigravity-gemini-3-pro-high`, `google/antigravity-gemini-3-pro-low`, `google/antigravity-gemini-3-flash`, `google/antigravity-claude-sonnet-4-5`, `google/antigravity-claude-sonnet-4-5-thinking-low`, `google/antigravity-claude-sonnet-4-5-thinking-medium`, `google/antigravity-claude-sonnet-4-5-thinking-high`, `google/antigravity-claude-opus-4-5-thinking-low`, `google/antigravity-claude-opus-4-5-thinking-medium`, `google/antigravity-claude-opus-4-5-thinking-high`, `google/gemini-3-pro-preview`, `google/gemini-3-flash-preview`, `google/gemini-2.5-pro`, `google/gemini-2.5-flash`
その後、認証を行います:
@@ -345,26 +348,19 @@ opencode auth login
{
"plugin": [
"oh-my-opencode",
"opencode-openai-codex-auth@4.1.1"
"opencode-openai-codex-auth@4.3.0"
]
}
```
**重要**: 現在、公式 npm パッケージに 400 エラー (`"No tool call found for function call output with call_id"`) を引き起こすバグがあります。修正版がリリースされるまでは、**ホットフィックスブランチの使用を推奨します**。`~/.config/opencode/package.json` を修正してください:
##### モデル設定
```json
{
"dependencies": {
"opencode-openai-codex-auth": "code-yeongyu/opencode-openai-codex-auth#fix/orphaned-function-call-output-with-tools"
}
}
```
その後、`cd ~/.config/opencode && bun i` を実行してください。`opencode.json` ではバージョン指定なしで `"opencode-openai-codex-auth"` として使用します(`@4.1.0` は除外)。
#### 4.3.1 モデル設定
`opencode.json` に完全なモデル設定も構成する必要があります。
[opencode-openai-codex-auth ドキュメント](https://github.com/numman-ali/opencode-openai-codex-auth)を読み、[`config/full-opencode.json`](https://github.com/numman-ali/opencode-openai-codex-auth/blob/main/config/full-opencode.json) から provider/models 設定をコピーして、ユーザーの既存のセットアップを壊さないよう慎重にマージしてください。
[opencode-openai-codex-auth ドキュメント](https://github.com/numman-ali/opencode-openai-codex-auth)を読み、[`config/opencode-modern.json`](https://github.com/numman-ali/opencode-openai-codex-auth/blob/main/config/opencode-modern.json)OpenCode v1.0.210+)または [`config/opencode-legacy.json`](https://github.com/numman-ali/opencode-openai-codex-auth/blob/main/config/opencode-legacy.json)(旧バージョン)から provider/models 設定をコピーして、ユーザーの既存のセットアップを壊さないよう慎重にマージしてください。
**利用可能なモデル**: `openai/gpt-5.2`, `openai/gpt-5.2-codex`, `openai/gpt-5.1-codex-max`, `openai/gpt-5.1-codex`, `openai/gpt-5.1-codex-mini`, `openai/gpt-5.1`
**Variants** (OpenCode v1.0.210+): `--variant=<none|low|medium|high|xhigh>` オプションで推論強度を制御できます。
その後、認証を行います:
@@ -563,7 +559,6 @@ OpenCode セッション履歴をナビゲートおよび検索するための
```
- **Online**: プロジェクトのルールがすべてではありません。拡張機能のための内蔵 MCP を提供します:
- **context7**: ライブラリの最新公式ドキュメントを取得
- **websearch_exa**: Exa AI を活用したリアルタイムウェブ検索
- **grep_app**: 数百万の公開 GitHub リポジトリから超高速コード検索(実装例を探すのに最適)
#### マルチモーダルを活用し、トークンは節約する
@@ -655,7 +650,7 @@ Oh My OpenCode は以下の場所からフックを読み込んで実行しま
| トグル | `false` の場合、ロードが無効になるパス | 影響を受けないもの |
| ---------- | ------------------------------------------------------------------------------------- | ----------------------------------------------------- |
| `mcp` | `~/.claude/.mcp.json`, `./.mcp.json`, `./.claude/.mcp.json` | 内蔵 MCP (context7, websearch_exa) |
| `mcp` | `~/.claude/.mcp.json`, `./.mcp.json`, `./.claude/.mcp.json` | 内蔵 MCP (context7, grep_app) |
| `commands` | `~/.claude/commands/*.md`, `./.claude/commands/*.md` | `~/.config/opencode/command/`, `./.opencode/command/` |
| `skills` | `~/.claude/skills/*/SKILL.md`, `./.claude/skills/*/SKILL.md` | - |
| `agents` | `~/.claude/agents/*.md`, `./.claude/agents/*.md` | 内蔵エージェント (oracle, librarian 等) |
@@ -928,17 +923,16 @@ Oh My OpenCode は以下の場所からフックを読み込んで実行しま
### MCPs
コンテキスト7、Exa、grep.app MCP がデフォルトで有効になっています。
Context7、grep.app MCP がデフォルトで有効になっています。
- **context7**: ライブラリの最新公式ドキュメントを取得
- **websearch_exa**: Exa AI を活用したリアルタイムウェブ検索
- **grep_app**: [grep.app](https://grep.app) を通じて数百万の公開 GitHub リポジトリから超高速コード検索
不要であれば、`~/.config/opencode/oh-my-opencode.json` または `.opencode/oh-my-opencode.json` の `disabled_mcps` を使用して無効化できます:
```json
{
"disabled_mcps": ["context7", "websearch_exa", "grep_app"]
"disabled_mcps": ["context7", "grep_app"]
}
```

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>
@@ -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.**
@@ -321,7 +330,7 @@ First, add the opencode-antigravity-auth plugin:
{
"plugin": [
"oh-my-opencode",
"opencode-antigravity-auth@1.1.2"
"opencode-antigravity-auth@1.2.7"
]
}
```
@@ -346,7 +355,7 @@ The `opencode-antigravity-auth` plugin uses different model names than the built
}
```
**Available model names**: `google/gemini-3-pro-high`, `google/gemini-3-pro-medium`, `google/gemini-3-pro-low`, `google/gemini-3-flash`, `google/gemini-3-flash`, `google/gemini-3-flash-lite`, `google/claude-sonnet-4-5`, `google/claude-sonnet-4-5-thinking`, `google/claude-opus-4-5-thinking`, `google/gpt-oss-120b-medium`
**Available model names**: `google/antigravity-gemini-3-pro-high`, `google/antigravity-gemini-3-pro-low`, `google/antigravity-gemini-3-flash`, `google/antigravity-claude-sonnet-4-5`, `google/antigravity-claude-sonnet-4-5-thinking-low`, `google/antigravity-claude-sonnet-4-5-thinking-medium`, `google/antigravity-claude-sonnet-4-5-thinking-high`, `google/antigravity-claude-opus-4-5-thinking-low`, `google/antigravity-claude-opus-4-5-thinking-medium`, `google/antigravity-claude-opus-4-5-thinking-high`, `google/gemini-3-pro-preview`, `google/gemini-3-flash-preview`, `google/gemini-2.5-pro`, `google/gemini-2.5-flash`
Then authenticate:
@@ -369,26 +378,19 @@ First, add the opencode-openai-codex-auth plugin:
{
"plugin": [
"oh-my-opencode",
"opencode-openai-codex-auth@4.1.1"
"opencode-openai-codex-auth@4.3.0"
]
}
```
**Important**: The official npm package currently has a bug causing 400 errors (`"No tool call found for function call output with call_id"`). **Use the hotfix branch** until fixed. Edit `~/.config/opencode/package.json`:
```json
{
"dependencies": {
"opencode-openai-codex-auth": "code-yeongyu/opencode-openai-codex-auth#fix/orphaned-function-call-output-with-tools"
}
}
```
Then run `cd ~/.config/opencode && bun i`. In `opencode.json`, use `"opencode-openai-codex-auth"` without the version suffix.
##### Model Configuration
You'll also need full model settings in `opencode.json`.
Read the [opencode-openai-codex-auth documentation](https://github.com/numman-ali/opencode-openai-codex-auth), copy provider/models config from [`config/full-opencode.json`](https://github.com/numman-ali/opencode-openai-codex-auth/blob/main/config/full-opencode.json), and merge carefully to avoid breaking the user's existing setup.
Read the [opencode-openai-codex-auth documentation](https://github.com/numman-ali/opencode-openai-codex-auth), copy provider/models config from [`config/opencode-modern.json`](https://github.com/numman-ali/opencode-openai-codex-auth/blob/main/config/opencode-modern.json) (for OpenCode v1.0.210+) or [`config/opencode-legacy.json`](https://github.com/numman-ali/opencode-openai-codex-auth/blob/main/config/opencode-legacy.json) (for older versions), and merge carefully to avoid breaking the user's existing setup.
**Available models**: `openai/gpt-5.2`, `openai/gpt-5.2-codex`, `openai/gpt-5.1-codex-max`, `openai/gpt-5.1-codex`, `openai/gpt-5.1-codex-mini`, `openai/gpt-5.1`
**Variants** (OpenCode v1.0.210+): Use `--variant=<none|low|medium|high|xhigh>` for reasoning effort control.
Then authenticate:
@@ -581,8 +583,8 @@ 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
- **websearch_exa**: Real-time web search
- **grep_app**: Ultra-fast code search across public GitHub repos (great for finding implementation examples)
#### Be Multimodal. Save Tokens.
@@ -694,7 +696,7 @@ Disable specific Claude Code compatibility features with the `claude_code` confi
| Toggle | When `false`, stops loading from... | Unaffected |
| ---------- | ------------------------------------------------------------------------------------- | ----------------------------------------------------- |
| `mcp` | `~/.claude/.mcp.json`, `./.mcp.json`, `./.claude/.mcp.json` | Built-in MCP (context7, websearch_exa) |
| `mcp` | `~/.claude/.mcp.json`, `./.mcp.json`, `./.claude/.mcp.json` | Built-in MCP (context7, grep_app) |
| `commands` | `~/.claude/commands/*.md`, `./.claude/commands/*.md` | `~/.config/opencode/command/`, `./.opencode/command/` |
| `skills` | `~/.claude/skills/*/SKILL.md`, `./.claude/skills/*/SKILL.md` | - |
| `agents` | `~/.claude/agents/*.md`, `./.claude/agents/*.md` | Built-in agents (oracle, librarian, etc.) |
@@ -983,17 +985,17 @@ Available hooks: `todo-continuation-enforcer`, `context-window-monitor`, `sessio
### MCPs
Context7, Exa, 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
- **websearch_exa**: Real-time web search powered by Exa AI
- **grep_app**: Ultra-fast code search across millions of public GitHub repositories via [grep.app](https://grep.app)
Don't want them? Disable via `disabled_mcps` in `~/.config/opencode/oh-my-opencode.json` or `.opencode/oh-my-opencode.json`:
```json
{
"disabled_mcps": ["context7", "websearch_exa", "grep_app"]
"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> -->
@@ -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。喝你的咖啡去吧活儿已经帮你干完了。
如果你不需要这全套服务,前面说了,挑你喜欢的用。
@@ -305,7 +308,7 @@ opencode auth login
{
"plugin": [
"oh-my-opencode",
"opencode-antigravity-auth@1.1.2"
"opencode-antigravity-auth@1.2.7"
]
}
```
@@ -330,7 +333,7 @@ opencode auth login
}
```
**可用模型名**`google/gemini-3-pro-high`, `google/gemini-3-pro-medium`, `google/gemini-3-pro-low`, `google/gemini-3-flash`, `google/gemini-3-flash`, `google/gemini-3-flash-lite`, `google/claude-sonnet-4-5`, `google/claude-sonnet-4-5-thinking`, `google/claude-opus-4-5-thinking`, `google/gpt-oss-120b-medium`
**可用模型名**`google/antigravity-gemini-3-pro-high`, `google/antigravity-gemini-3-pro-low`, `google/antigravity-gemini-3-flash`, `google/antigravity-claude-sonnet-4-5`, `google/antigravity-claude-sonnet-4-5-thinking-low`, `google/antigravity-claude-sonnet-4-5-thinking-medium`, `google/antigravity-claude-sonnet-4-5-thinking-high`, `google/antigravity-claude-opus-4-5-thinking-low`, `google/antigravity-claude-opus-4-5-thinking-medium`, `google/antigravity-claude-opus-4-5-thinking-high`, `google/gemini-3-pro-preview`, `google/gemini-3-flash-preview`, `google/gemini-2.5-pro`, `google/gemini-2.5-flash`
然后认证:
@@ -353,26 +356,19 @@ opencode auth login
{
"plugin": [
"oh-my-opencode",
"opencode-openai-codex-auth@4.1.1"
"opencode-openai-codex-auth@4.3.0"
]
}
```
**重要**:现在官方 npm 包有个 bug 会报 400 错(`"No tool call found for function call output with call_id"`)。修复版出来前,**一定要用 hotfix 分支**。改一下 `~/.config/opencode/package.json`
```json
{
"dependencies": {
"opencode-openai-codex-auth": "code-yeongyu/opencode-openai-codex-auth#fix/orphaned-function-call-output-with-tools"
}
}
```
然后跑 `cd ~/.config/opencode && bun i`。在 `opencode.json` 里用 `"opencode-openai-codex-auth"`(别带版本号)。
##### 模型配置
要在 `opencode.json` 里配完整的模型设置。
去读 [opencode-openai-codex-auth 文档](https://github.com/numman-ali/opencode-openai-codex-auth),从 [`config/full-opencode.json`](https://github.com/numman-ali/opencode-openai-codex-auth/blob/main/config/full-opencode.json) 抄 provider/models 配置,动脑子合并,别搞炸了。
去读 [opencode-openai-codex-auth 文档](https://github.com/numman-ali/opencode-openai-codex-auth),从 [`config/opencode-modern.json`](https://github.com/numman-ali/opencode-openai-codex-auth/blob/main/config/opencode-modern.json)OpenCode v1.0.210+)或 [`config/opencode-legacy.json`](https://github.com/numman-ali/opencode-openai-codex-auth/blob/main/config/opencode-legacy.json)(旧版本)抄 provider/models 配置,动脑子合并,别搞炸了。
**可用模型**`openai/gpt-5.2`, `openai/gpt-5.2-codex`, `openai/gpt-5.1-codex-max`, `openai/gpt-5.1-codex`, `openai/gpt-5.1-codex-mini`, `openai/gpt-5.1`
**Variants**OpenCode v1.0.210+):用 `--variant=<none|low|medium|high|xhigh>` 控制推理强度。
然后认证:
@@ -567,7 +563,6 @@ OhMyOpenCode 让这些成为可能。
```
- **在线资源**:项目里的规矩不够用?内置 MCP 来凑:
- **context7**:查最新的官方文档
- **websearch_exa**Exa AI 实时搜网
- **grep_app**:用 [grep.app](https://grep.app) 在几百万个 GitHub 仓库里秒搜代码(找抄作业的例子神器)
#### 多模态全开Token 省着用
@@ -659,7 +654,7 @@ Oh My OpenCode 会扫这些地方:
| 开关 | 设为 `false` 就停用的路径 | 不受影响的 |
| ---------- | ------------------------------------------------------------------------------------- | ----------------------------------------------------- |
| `mcp` | `~/.claude/.mcp.json`, `./.mcp.json`, `./.claude/.mcp.json` | 内置 MCPcontext7、websearch_exa |
| `mcp` | `~/.claude/.mcp.json`, `./.mcp.json`, `./.claude/.mcp.json` | 内置 MCPcontext7、grep_app |
| `commands` | `~/.claude/commands/*.md`, `./.claude/commands/*.md` | `~/.config/opencode/command/`, `./.opencode/command/` |
| `skills` | `~/.claude/skills/*/SKILL.md`, `./.claude/skills/*/SKILL.md` | - |
| `agents` | `~/.claude/agents/*.md`, `./.claude/agents/*.md` | 内置 Agentoracle、librarian 等) |
@@ -932,17 +927,16 @@ Sisyphus Agent 也能自定义:
### MCPs
默认送你 Context7、Exa 和 grep.app MCP。
默认送你 Context7 和 grep.app MCP。
- **context7**:查最新的官方文档
- **websearch_exa**Exa AI 实时搜网
- **grep_app**[grep.app](https://grep.app) 极速搜 GitHub 代码
不想要?在 `~/.config/opencode/oh-my-opencode.json` 或 `.opencode/oh-my-opencode.json` 的 `disabled_mcps` 里关掉:
```json
{
"disabled_mcps": ["context7", "websearch_exa", "grep_app"]
"disabled_mcps": ["context7", "grep_app"]
}
```

View File

@@ -12,11 +12,7 @@
"type": "array",
"items": {
"type": "string",
"enum": [
"websearch_exa",
"context7",
"grep_app"
]
"minLength": 1
}
},
"disabled_agents": {
@@ -1662,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.12.3",
"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

@@ -199,6 +199,70 @@
"created_at": "2026-01-04T17:42:02Z",
"repoId": 1108837393,
"pullRequestNo": 484
},
{
"name": "popododo0720",
"id": 78542988,
"comment_id": 3708870772,
"created_at": "2026-01-05T04:07:35Z",
"repoId": 1108837393,
"pullRequestNo": 477
},
{
"name": "raydocs",
"id": 139067258,
"comment_id": 3709269581,
"created_at": "2026-01-05T07:39:43Z",
"repoId": 1108837393,
"pullRequestNo": 499
},
{
"name": "luosky",
"id": 307601,
"comment_id": 3710103143,
"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,16 +50,20 @@ 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 |
|------|------------------|-------|
| **TYPE A: CONCEPTUAL** | "How do I use X?", "Best practice for Y?" | context7 + websearch_exa (parallel) |
| **TYPE A: CONCEPTUAL** | "How do I use X?", "Best practice for Y?" | context7 + web search (if available) in parallel |
| **TYPE B: IMPLEMENTATION** | "How does X implement Y?", "Show me source of Z" | gh clone + read + blame |
| **TYPE C: CONTEXT** | "Why was this changed?", "History of X?" | gh issues/prs + git log/blame |
| **TYPE D: COMPREHENSIVE** | Complex/ambiguous requests | ALL tools in parallel |
| **TYPE C: CONTEXT** | "Why was this changed?", "What's the history?", "Related issues/PRs?" | gh issues/prs + git log/blame |
| **TYPE D: COMPREHENSIVE** | Complex/ambiguous requests | ALL available tools in parallel |
---
@@ -69,12 +72,12 @@ 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 (3+ calls)**:
**If searching**, use tools as needed:
\`\`\`
Tool 1: context7_resolve-library-id("library-name")
→ then context7_get-library-docs(id, topic: "specific-topic")
Tool 2: websearch_exa_web_search_exa("library-name topic 2025")
Tool 3: grep_app_searchGitHub(query: "usage pattern", language: ["TypeScript"])
Tool 2: grep_app_searchGitHub(query: "usage pattern", language: ["TypeScript"])
Tool 3 (optional): If web search is available, search "library-name topic 2025"
\`\`\`
**Output**: Summarize findings with links to official docs and real-world examples.
@@ -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,21 +139,22 @@ gh api repos/owner/repo/pulls/<number>/files
### TYPE D: COMPREHENSIVE RESEARCH
**Trigger**: Complex questions, ambiguous requests, "deep dive into..."
**Execute ALL in parallel (6+ calls)**:
**Use multiple tools as needed**:
\`\`\`
// Documentation & Web
// Documentation
Tool 1: context7_resolve-library-id → context7_get-library-docs
Tool 2: websearch_exa_web_search_exa("topic recent updates")
// Code Search
Tool 3: grep_app_searchGitHub(query: "pattern1", language: [...])
Tool 4: grep_app_searchGitHub(query: "pattern2", useRegexp: true)
Tool 2: grep_app_searchGitHub(query: "pattern1", language: [...])
Tool 3: grep_app_searchGitHub(query: "pattern2", useRegexp: true)
// Source Analysis
Tool 5: gh repo clone owner/repo \${TMPDIR:-/tmp}/repo -- --depth 1
Tool 4: gh repo clone owner/repo \${TMPDIR:-/tmp}/repo -- --depth 1
// Context
Tool 6: gh search issues "topic" --repo owner/repo
Tool 5: gh search issues "topic" --repo owner/repo
// Optional: If web search is available, search for recent updates
\`\`\`
---
@@ -196,7 +200,6 @@ https://github.com/tanstack/query/blob/abc123def/packages/react-query/src/useQue
| Purpose | Tool | Command/Usage |
|---------|------|---------------|
| **Official Docs** | context7 | \`context7_resolve-library-id\`\`context7_get-library-docs\` |
| **Latest Info** | websearch_exa | \`websearch_exa_web_search_exa("query 2025")\` |
| **Fast Code Search** | grep_app | \`grep_app_searchGitHub(query, language, useRegexp)\` |
| **Deep Code Search** | gh CLI | \`gh search code "query" --repo owner/repo\` |
| **Clone Repo** | gh CLI | \`gh repo clone owner/repo \${TMPDIR:-/tmp}/name -- --depth 1\` |
@@ -205,6 +208,7 @@ https://github.com/tanstack/query/blob/abc123def/packages/react-query/src/useQue
| **Release Info** | gh CLI | \`gh api repos/owner/repo/releases/latest\` |
| **Git History** | git | \`git log\`, \`git blame\`, \`git show\` |
| **Read URL** | webfetch | \`webfetch(url)\` for blog posts, SO threads |
| **Web Search** | (if available) | Use any available web search tool for latest info |
### Temp Directory
@@ -221,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

@@ -11,11 +11,9 @@ describe("config-manager ANTIGRAVITY_PROVIDER_CONFIG", () => {
expect(models).toBeTruthy()
const required = [
"gemini-3-pro-high",
"gemini-3-pro-medium",
"gemini-3-pro-low",
"gemini-3-flash",
"gemini-3-flash-lite",
"antigravity-gemini-3-pro-high",
"antigravity-gemini-3-pro-low",
"antigravity-gemini-3-flash",
]
for (const key of required) {

View File

@@ -55,8 +55,6 @@ function getOmoConfig(): string {
return getConfigContext().paths.omoConfig
}
const CHATGPT_HOTFIX_REPO = "code-yeongyu/opencode-openai-codex-auth#fix/orphaned-function-call-output-with-tools"
const BUN_INSTALL_TIMEOUT_SECONDS = 60
const BUN_INSTALL_TIMEOUT_MS = BUN_INSTALL_TIMEOUT_SECONDS * 1000
@@ -276,31 +274,33 @@ export function generateOmoConfig(installConfig: InstallConfig): Record<string,
const agents: Record<string, Record<string, unknown>> = {}
if (!installConfig.hasClaude) {
agents["Sisyphus"] = { model: "opencode/big-pickle" }
agents["Sisyphus"] = { model: "opencode/glm-4.7-free" }
}
agents["librarian"] = { model: "opencode/glm-4.7-free" }
// Gemini models use `antigravity-` prefix for explicit Antigravity quota routing
// @see ANTIGRAVITY_PROVIDER_CONFIG comments for rationale
if (installConfig.hasGemini) {
agents["librarian"] = { model: "google/gemini-3-flash" }
agents["explore"] = { model: "google/gemini-3-flash" }
agents["explore"] = { model: "google/antigravity-gemini-3-flash" }
} else if (installConfig.hasClaude && installConfig.isMax20) {
agents["explore"] = { model: "anthropic/claude-haiku-4-5" }
} else {
agents["librarian"] = { model: "opencode/big-pickle" }
agents["explore"] = { model: "opencode/big-pickle" }
agents["explore"] = { model: "opencode/glm-4.7-free" }
}
if (!installConfig.hasChatGPT) {
agents["oracle"] = {
model: installConfig.hasClaude ? "anthropic/claude-opus-4-5" : "opencode/big-pickle",
model: installConfig.hasClaude ? "anthropic/claude-opus-4-5" : "opencode/glm-4.7-free",
}
}
if (installConfig.hasGemini) {
agents["frontend-ui-ux-engineer"] = { model: "google/gemini-3-pro-high" }
agents["document-writer"] = { model: "google/gemini-3-flash" }
agents["multimodal-looker"] = { model: "google/gemini-3-flash" }
agents["frontend-ui-ux-engineer"] = { model: "google/antigravity-gemini-3-pro-high" }
agents["document-writer"] = { model: "google/antigravity-gemini-3-flash" }
agents["multimodal-looker"] = { model: "google/antigravity-gemini-3-flash" }
} else {
const fallbackModel = installConfig.hasClaude ? "anthropic/claude-opus-4-5" : "opencode/big-pickle"
const fallbackModel = installConfig.hasClaude ? "anthropic/claude-opus-4-5" : "opencode/glm-4.7-free"
agents["frontend-ui-ux-engineer"] = { model: fallbackModel }
agents["document-writer"] = { model: fallbackModel }
agents["multimodal-looker"] = { model: fallbackModel }
@@ -441,48 +441,6 @@ export async function addAuthPlugins(config: InstallConfig): Promise<ConfigMerge
}
}
export function setupChatGPTHotfix(): ConfigMergeResult {
try {
ensureConfigDir()
} catch (err) {
return { success: false, configPath: getConfigDir(), error: formatErrorWithSuggestion(err, "create config directory") }
}
const packageJsonPath = getPackageJson()
try {
let packageJson: Record<string, unknown> = {}
if (existsSync(packageJsonPath)) {
try {
const stat = statSync(packageJsonPath)
const content = readFileSync(packageJsonPath, "utf-8")
if (stat.size > 0 && !isEmptyOrWhitespace(content)) {
packageJson = JSON.parse(content)
if (typeof packageJson !== "object" || packageJson === null || Array.isArray(packageJson)) {
packageJson = {}
}
}
} catch (parseErr) {
if (parseErr instanceof SyntaxError) {
packageJson = {}
} else {
throw parseErr
}
}
}
const deps = (packageJson.dependencies ?? {}) as Record<string, string>
deps["opencode-openai-codex-auth"] = CHATGPT_HOTFIX_REPO
packageJson.dependencies = deps
writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2) + "\n")
return { success: true, configPath: packageJsonPath }
} catch (err) {
return { success: false, configPath: packageJsonPath, error: formatErrorWithSuggestion(err, "setup ChatGPT hotfix in package.json") }
}
}
export interface BunInstallResult {
success: boolean
timedOut?: boolean
@@ -541,45 +499,44 @@ export async function runBunInstallWithDetails(): Promise<BunInstallResult> {
}
}
/**
* Antigravity Provider Configuration
*
* IMPORTANT: Model names MUST use `antigravity-` prefix for stability.
*
* The opencode-antigravity-auth plugin supports two naming conventions:
* - `antigravity-gemini-3-pro-high` (RECOMMENDED, explicit Antigravity quota routing)
* - `gemini-3-pro-high` (LEGACY, backward compatible but may break in future)
*
* Legacy names rely on Gemini CLI using `-preview` suffix for disambiguation.
* If Google removes `-preview`, legacy names may route to wrong quota.
*
* @see https://github.com/NoeFabris/opencode-antigravity-auth#migration-guide-v127
*/
export const ANTIGRAVITY_PROVIDER_CONFIG = {
google: {
name: "Google",
// NOTE: opencode-antigravity-auth expects full model specs (name/limit/modalities).
// If these are incomplete, models may appear but fail at runtime (e.g. 404).
models: {
"gemini-3-pro-high": {
"antigravity-gemini-3-pro-high": {
name: "Gemini 3 Pro High (Antigravity)",
thinking: true,
attachment: true,
limit: { context: 1048576, output: 65535 },
modalities: { input: ["text", "image", "pdf"], output: ["text"] },
},
"gemini-3-pro-medium": {
name: "Gemini 3 Pro Medium (Antigravity)",
thinking: true,
attachment: true,
limit: { context: 1048576, output: 65535 },
modalities: { input: ["text", "image", "pdf"], output: ["text"] },
},
"gemini-3-pro-low": {
"antigravity-gemini-3-pro-low": {
name: "Gemini 3 Pro Low (Antigravity)",
thinking: true,
attachment: true,
limit: { context: 1048576, output: 65535 },
modalities: { input: ["text", "image", "pdf"], output: ["text"] },
},
"gemini-3-flash": {
"antigravity-gemini-3-flash": {
name: "Gemini 3 Flash (Antigravity)",
attachment: true,
limit: { context: 1048576, output: 65536 },
modalities: { input: ["text", "image", "pdf"], output: ["text"] },
},
"gemini-3-flash-lite": {
name: "Gemini 3 Flash Lite (Antigravity)",
attachment: true,
limit: { context: 1048576, output: 65536 },
modalities: { input: ["text", "image", "pdf"], output: ["text"] },
},
},
},
}
@@ -587,12 +544,48 @@ export const ANTIGRAVITY_PROVIDER_CONFIG = {
const CODEX_PROVIDER_CONFIG = {
openai: {
name: "OpenAI",
api: "codex",
options: {
reasoningEffort: "medium",
reasoningSummary: "auto",
textVerbosity: "medium",
include: ["reasoning.encrypted_content"],
store: false,
},
models: {
"gpt-5.2": { name: "GPT-5.2" },
"o3": { name: "o3", thinking: true },
"o4-mini": { name: "o4-mini", thinking: true },
"codex-1": { name: "Codex-1" },
"gpt-5.2": {
name: "GPT 5.2 (OAuth)",
limit: { context: 272000, output: 128000 },
modalities: { input: ["text", "image"], output: ["text"] },
variants: {
none: { reasoningEffort: "none", reasoningSummary: "auto", textVerbosity: "medium" },
low: { reasoningEffort: "low", reasoningSummary: "auto", textVerbosity: "medium" },
medium: { reasoningEffort: "medium", reasoningSummary: "auto", textVerbosity: "medium" },
high: { reasoningEffort: "high", reasoningSummary: "detailed", textVerbosity: "medium" },
xhigh: { reasoningEffort: "xhigh", reasoningSummary: "detailed", textVerbosity: "medium" },
},
},
"gpt-5.2-codex": {
name: "GPT 5.2 Codex (OAuth)",
limit: { context: 272000, output: 128000 },
modalities: { input: ["text", "image"], output: ["text"] },
variants: {
low: { reasoningEffort: "low", reasoningSummary: "auto", textVerbosity: "medium" },
medium: { reasoningEffort: "medium", reasoningSummary: "auto", textVerbosity: "medium" },
high: { reasoningEffort: "high", reasoningSummary: "detailed", textVerbosity: "medium" },
xhigh: { reasoningEffort: "xhigh", reasoningSummary: "detailed", textVerbosity: "medium" },
},
},
"gpt-5.1-codex-max": {
name: "GPT 5.1 Codex Max (OAuth)",
limit: { context: 272000, output: 128000 },
modalities: { input: ["text", "image"], output: ["text"] },
variants: {
low: { reasoningEffort: "low", reasoningSummary: "detailed", textVerbosity: "medium" },
medium: { reasoningEffort: "medium", reasoningSummary: "detailed", textVerbosity: "medium" },
high: { reasoningEffort: "high", reasoningSummary: "detailed", textVerbosity: "medium" },
xhigh: { reasoningEffort: "xhigh", reasoningSummary: "detailed", textVerbosity: "medium" },
},
},
},
},
}
@@ -698,17 +691,17 @@ export function detectCurrentConfig(): DetectedConfig {
const agents = omoConfig.agents ?? {}
if (agents["Sisyphus"]?.model === "opencode/big-pickle") {
if (agents["Sisyphus"]?.model === "opencode/glm-4.7-free") {
result.hasClaude = false
result.isMax20 = false
} else if (agents["librarian"]?.model === "opencode/big-pickle") {
} else if (agents["librarian"]?.model === "opencode/glm-4.7-free") {
result.hasClaude = true
result.isMax20 = false
}
if (agents["oracle"]?.model?.startsWith("anthropic/")) {
result.hasChatGPT = false
} else if (agents["oracle"]?.model === "opencode/big-pickle") {
} else if (agents["oracle"]?.model === "opencode/glm-4.7-free") {
result.hasChatGPT = false
}

View File

@@ -9,11 +9,10 @@ describe("mcp check", () => {
const servers = mcp.getBuiltinMcpInfo()
// #then should include expected servers
expect(servers.length).toBe(3)
expect(servers.length).toBe(2)
expect(servers.every((s) => s.type === "builtin")).toBe(true)
expect(servers.every((s) => s.enabled === true)).toBe(true)
expect(servers.map((s) => s.id)).toContain("context7")
expect(servers.map((s) => s.id)).toContain("websearch_exa")
expect(servers.map((s) => s.id)).toContain("grep_app")
})
})
@@ -37,7 +36,7 @@ describe("mcp check", () => {
// #then should pass
expect(result.status).toBe("pass")
expect(result.message).toContain("3")
expect(result.message).toContain("2")
expect(result.message).toContain("enabled")
})
@@ -48,7 +47,6 @@ describe("mcp check", () => {
// #then should list servers
expect(result.details?.some((d) => d.includes("context7"))).toBe(true)
expect(result.details?.some((d) => d.includes("websearch_exa"))).toBe(true)
expect(result.details?.some((d) => d.includes("grep_app"))).toBe(true)
})
})

View File

@@ -5,7 +5,7 @@ import type { CheckResult, CheckDefinition, McpServerInfo } from "../types"
import { CHECK_IDS, CHECK_NAMES } from "../constants"
import { parseJsonc } from "../../../shared"
const BUILTIN_MCP_SERVERS = ["context7", "websearch_exa", "grep_app"]
const BUILTIN_MCP_SERVERS = ["context7", "grep_app"]
const MCP_CONFIG_PATHS = [
join(homedir(), ".claude", ".mcp.json"),

View File

@@ -30,7 +30,7 @@ describe("runner", () => {
name: "Test Check",
category: "installation",
check: async () => {
await new Promise((r) => setTimeout(r, 10))
await new Promise((r) => setTimeout(r, 50))
return { name: "Test", status: "pass", message: "OK" }
},
}

View File

@@ -7,8 +7,6 @@ import {
isOpenCodeInstalled,
getOpenCodeVersion,
addAuthPlugins,
setupChatGPTHotfix,
runBunInstall,
addProviderConfig,
detectCurrentConfig,
} from "./config-manager"
@@ -48,10 +46,10 @@ function formatConfigSummary(config: InstallConfig): string {
lines.push(color.bold(color.white("Agent Configuration")))
lines.push("")
const sisyphusModel = config.hasClaude ? "claude-opus-4-5" : "big-pickle"
const oracleModel = config.hasChatGPT ? "gpt-5.2" : (config.hasClaude ? "claude-opus-4-5" : "big-pickle")
const librarianModel = config.hasClaude && config.isMax20 ? "claude-sonnet-4-5" : "big-pickle"
const frontendModel = config.hasGemini ? "gemini-3-pro-high" : (config.hasClaude ? "claude-opus-4-5" : "big-pickle")
const sisyphusModel = config.hasClaude ? "claude-opus-4-5" : "glm-4.7-free"
const oracleModel = config.hasChatGPT ? "gpt-5.2" : (config.hasClaude ? "claude-opus-4-5" : "glm-4.7-free")
const librarianModel = "glm-4.7-free"
const frontendModel = config.hasGemini ? "antigravity-gemini-3-pro-high" : (config.hasClaude ? "claude-opus-4-5" : "glm-4.7-free")
lines.push(` ${SYMBOLS.bullet} Sisyphus ${SYMBOLS.arrow} ${color.cyan(sisyphusModel)}`)
lines.push(` ${SYMBOLS.bullet} Oracle ${SYMBOLS.arrow} ${color.cyan(oracleModel)}`)
@@ -163,7 +161,7 @@ async function runTuiMode(detected: DetectedConfig): Promise<InstallConfig | nul
const claude = await p.select({
message: "Do you have a Claude Pro/Max subscription?",
options: [
{ value: "no" as const, label: "No", hint: "Will use opencode/big-pickle as fallback" },
{ value: "no" as const, label: "No", hint: "Will use opencode/glm-4.7-free as fallback" },
{ value: "yes" as const, label: "Yes (standard)", hint: "Claude Opus 4.5 for orchestration" },
{ value: "max20" as const, label: "Yes (max20 mode)", hint: "Full power with Claude Sonnet 4.5 for Librarian" },
],
@@ -279,26 +277,6 @@ async function runNonTuiInstall(args: InstallArgs): Promise<number> {
step += 2
}
if (config.hasChatGPT) {
printStep(step++, totalSteps, "Setting up ChatGPT hotfix...")
const hotfixResult = setupChatGPTHotfix()
if (!hotfixResult.success) {
printError(`Failed: ${hotfixResult.error}`)
return 1
}
printSuccess(`Hotfix configured ${SYMBOLS.arrow} ${color.dim(hotfixResult.configPath)}`)
printInfo("Installing dependencies with bun...")
const bunSuccess = await runBunInstall()
if (bunSuccess) {
printSuccess("Dependencies installed")
} else {
printWarning("bun install failed - run manually: cd ~/.config/opencode && bun i")
}
} else {
step++
}
printStep(step++, totalSteps, "Writing oh-my-opencode configuration...")
const omoResult = writeOmoConfig(config)
if (!omoResult.success) {
@@ -310,7 +288,7 @@ async function runNonTuiInstall(args: InstallArgs): Promise<number> {
printBox(formatConfigSummary(config), isUpdate ? "Updated Configuration" : "Installation Complete")
if (!config.hasClaude && !config.hasChatGPT && !config.hasGemini) {
printWarning("No model providers configured. Using opencode/big-pickle as fallback.")
printWarning("No model providers configured. Using opencode/glm-4.7-free as fallback.")
}
if ((config.hasClaude || config.hasChatGPT || config.hasGemini) && !args.skipAuth) {
@@ -410,25 +388,6 @@ export async function install(args: InstallArgs): Promise<number> {
s.stop(`Provider config added to ${color.cyan(providerResult.configPath)}`)
}
if (config.hasChatGPT) {
s.start("Setting up ChatGPT hotfix")
const hotfixResult = setupChatGPTHotfix()
if (!hotfixResult.success) {
s.stop(`Failed to setup hotfix: ${hotfixResult.error}`)
p.outro(color.red("Installation failed."))
return 1
}
s.stop(`Hotfix configured in ${color.cyan(hotfixResult.configPath)}`)
s.start("Installing dependencies with bun")
const bunSuccess = await runBunInstall()
if (bunSuccess) {
s.stop("Dependencies installed")
} else {
s.stop(color.yellow("bun install failed - run manually: cd ~/.config/opencode && bun i"))
}
}
s.start("Writing oh-my-opencode configuration")
const omoResult = writeOmoConfig(config)
if (!omoResult.success) {
@@ -439,7 +398,7 @@ export async function install(args: InstallArgs): Promise<number> {
s.stop(`Config written to ${color.cyan(omoResult.configPath)}`)
if (!config.hasClaude && !config.hasChatGPT && !config.hasGemini) {
p.log.warn("No model providers configured. Using opencode/big-pickle as fallback.")
p.log.warn("No model providers configured. Using opencode/glm-4.7-free as fallback.")
}
p.note(formatConfigSummary(config), isUpdate ? "Updated Configuration" : "Installation Complete")

136
src/config/schema.test.ts Normal file
View File

@@ -0,0 +1,136 @@
import { describe, expect, test } from "bun:test"
import { OhMyOpenCodeConfigSchema } from "./schema"
describe("disabled_mcps schema", () => {
test("should accept built-in MCP names", () => {
//#given
const config = {
disabled_mcps: ["context7", "grep_app"],
}
//#when
const result = OhMyOpenCodeConfigSchema.safeParse(config)
//#then
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.disabled_mcps).toEqual(["context7", "grep_app"])
}
})
test("should accept custom MCP names", () => {
//#given
const config = {
disabled_mcps: ["playwright", "sqlite", "custom-mcp"],
}
//#when
const result = OhMyOpenCodeConfigSchema.safeParse(config)
//#then
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.disabled_mcps).toEqual(["playwright", "sqlite", "custom-mcp"])
}
})
test("should accept mixed built-in and custom names", () => {
//#given
const config = {
disabled_mcps: ["context7", "playwright", "custom-server"],
}
//#when
const result = OhMyOpenCodeConfigSchema.safeParse(config)
//#then
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.disabled_mcps).toEqual(["context7", "playwright", "custom-server"])
}
})
test("should accept empty array", () => {
//#given
const config = {
disabled_mcps: [],
}
//#when
const result = OhMyOpenCodeConfigSchema.safeParse(config)
//#then
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.disabled_mcps).toEqual([])
}
})
test("should reject non-string values", () => {
//#given
const config = {
disabled_mcps: [123, true, null],
}
//#when
const result = OhMyOpenCodeConfigSchema.safeParse(config)
//#then
expect(result.success).toBe(false)
})
test("should accept undefined (optional field)", () => {
//#given
const config = {}
//#when
const result = OhMyOpenCodeConfigSchema.safeParse(config)
//#then
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.disabled_mcps).toBeUndefined()
}
})
test("should reject empty strings", () => {
//#given
const config = {
disabled_mcps: [""],
}
//#when
const result = OhMyOpenCodeConfigSchema.safeParse(config)
//#then
expect(result.success).toBe(false)
})
test("should accept MCP names with various naming patterns", () => {
//#given
const config = {
disabled_mcps: [
"my-custom-mcp",
"my_custom_mcp",
"myCustomMcp",
"my.custom.mcp",
"my-custom-mcp-123",
],
}
//#when
const result = OhMyOpenCodeConfigSchema.safeParse(config)
//#then
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.disabled_mcps).toEqual([
"my-custom-mcp",
"my_custom_mcp",
"myCustomMcp",
"my.custom.mcp",
"my-custom-mcp-123",
])
}
})
})

View File

@@ -1,5 +1,5 @@
import { z } from "zod"
import { McpNameSchema } from "../mcp/types"
import { AnyMcpNameSchema, McpNameSchema } from "../mcp/types"
const PermissionValue = z.enum(["ask", "allow", "deny"])
@@ -232,9 +232,15 @@ 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(McpNameSchema).optional(),
disabled_mcps: z.array(AnyMcpNameSchema).optional(),
disabled_agents: z.array(BuiltinAgentNameSchema).optional(),
disabled_skills: z.array(BuiltinSkillNameSchema).optional(),
disabled_hooks: z.array(HookNameSchema).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>
@@ -265,4 +273,4 @@ export type SkillsConfig = z.infer<typeof SkillsConfigSchema>
export type SkillDefinition = z.infer<typeof SkillDefinitionSchema>
export type RalphLoopConfig = z.infer<typeof RalphLoopConfigSchema>
export { McpNameSchema, type McpName } from "../mcp/types"
export { AnyMcpNameSchema, type AnyMcpName, McpNameSchema, type McpName } from "../mcp/types"

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,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(

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

View File

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

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

@@ -1,6 +1,6 @@
import { existsSync, readdirSync, readFileSync, realpathSync, type Dirent } from "fs"
import { promises as fsPromises } from "fs"
import { promises as fs, type Dirent } from "fs"
import { join, basename } from "path"
import { homedir } from "os"
import { parseFrontmatter } from "../../shared/frontmatter"
import { sanitizeModelField } from "../../shared/model-sanitizer"
import { isMarkdownFile } from "../../shared/file-utils"
@@ -8,19 +8,21 @@ import { getClaudeConfigDir } from "../../shared"
import { log } from "../../shared/logger"
import type { CommandScope, CommandDefinition, CommandFrontmatter, LoadedCommand } from "./types"
function loadCommandsFromDir(
async function loadCommandsFromDir(
commandsDir: string,
scope: CommandScope,
visited: Set<string> = new Set(),
prefix: string = ""
): LoadedCommand[] {
if (!existsSync(commandsDir)) {
): Promise<LoadedCommand[]> {
try {
await fs.access(commandsDir)
} catch {
return []
}
let realPath: string
try {
realPath = realpathSync(commandsDir)
realPath = await fs.realpath(commandsDir)
} catch (error) {
log(`Failed to resolve command directory: ${commandsDir}`, error)
return []
@@ -33,7 +35,7 @@ function loadCommandsFromDir(
let entries: Dirent[]
try {
entries = readdirSync(commandsDir, { withFileTypes: true })
entries = await fs.readdir(commandsDir, { withFileTypes: true })
} catch (error) {
log(`Failed to read command directory: ${commandsDir}`, error)
return []
@@ -46,7 +48,8 @@ function loadCommandsFromDir(
if (entry.name.startsWith(".")) continue
const subDirPath = join(commandsDir, entry.name)
const subPrefix = prefix ? `${prefix}:${entry.name}` : entry.name
commands.push(...loadCommandsFromDir(subDirPath, scope, visited, subPrefix))
const subCommands = await loadCommandsFromDir(subDirPath, scope, visited, subPrefix)
commands.push(...subCommands)
continue
}
@@ -57,7 +60,7 @@ function loadCommandsFromDir(
const commandName = prefix ? `${prefix}:${baseCommandName}` : baseCommandName
try {
const content = readFileSync(commandPath, "utf-8")
const content = await fs.readFile(commandPath, "utf-8")
const { data, body } = parseFrontmatter<CommandFrontmatter>(content)
const wrappedTemplate = `<command-instruction>
@@ -106,154 +109,36 @@ function commandsToRecord(commands: LoadedCommand[]): Record<string, CommandDefi
return result
}
export function loadUserCommands(): Record<string, CommandDefinition> {
export async function loadUserCommands(): Promise<Record<string, CommandDefinition>> {
const userCommandsDir = join(getClaudeConfigDir(), "commands")
const commands = loadCommandsFromDir(userCommandsDir, "user")
const commands = await loadCommandsFromDir(userCommandsDir, "user")
return commandsToRecord(commands)
}
export function loadProjectCommands(): Record<string, CommandDefinition> {
export async function loadProjectCommands(): Promise<Record<string, CommandDefinition>> {
const projectCommandsDir = join(process.cwd(), ".claude", "commands")
const commands = loadCommandsFromDir(projectCommandsDir, "project")
const commands = await loadCommandsFromDir(projectCommandsDir, "project")
return commandsToRecord(commands)
}
export function loadOpencodeGlobalCommands(): Record<string, CommandDefinition> {
const { homedir } = require("os")
export async function loadOpencodeGlobalCommands(): Promise<Record<string, CommandDefinition>> {
const opencodeCommandsDir = join(homedir(), ".config", "opencode", "command")
const commands = loadCommandsFromDir(opencodeCommandsDir, "opencode")
const commands = await loadCommandsFromDir(opencodeCommandsDir, "opencode")
return commandsToRecord(commands)
}
export function loadOpencodeProjectCommands(): Record<string, CommandDefinition> {
export async function loadOpencodeProjectCommands(): Promise<Record<string, CommandDefinition>> {
const opencodeProjectDir = join(process.cwd(), ".opencode", "command")
const commands = loadCommandsFromDir(opencodeProjectDir, "opencode-project")
const commands = await loadCommandsFromDir(opencodeProjectDir, "opencode-project")
return commandsToRecord(commands)
}
async function loadCommandsFromDirAsync(
commandsDir: string,
scope: CommandScope,
visited: Set<string> = new Set(),
prefix: string = ""
): Promise<LoadedCommand[]> {
try {
await fsPromises.access(commandsDir)
} catch {
return []
}
let realPath: string
try {
realPath = await fsPromises.realpath(commandsDir)
} catch (error) {
log(`Failed to resolve command directory: ${commandsDir}`, error)
return []
}
if (visited.has(realPath)) {
return []
}
visited.add(realPath)
let entries: Dirent[]
try {
entries = await fsPromises.readdir(commandsDir, { withFileTypes: true })
} catch (error) {
log(`Failed to read command directory: ${commandsDir}`, error)
return []
}
const commands: LoadedCommand[] = []
for (const entry of entries) {
if (entry.isDirectory()) {
if (entry.name.startsWith(".")) continue
const subDirPath = join(commandsDir, entry.name)
const subPrefix = prefix ? `${prefix}:${entry.name}` : entry.name
const subCommands = await loadCommandsFromDirAsync(subDirPath, scope, visited, subPrefix)
commands.push(...subCommands)
continue
}
if (!isMarkdownFile(entry)) continue
const commandPath = join(commandsDir, entry.name)
const baseCommandName = basename(entry.name, ".md")
const commandName = prefix ? `${prefix}:${baseCommandName}` : baseCommandName
try {
const content = await fsPromises.readFile(commandPath, "utf-8")
const { data, body } = parseFrontmatter<CommandFrontmatter>(content)
const wrappedTemplate = `<command-instruction>
${body.trim()}
</command-instruction>
<user-request>
$ARGUMENTS
</user-request>`
const formattedDescription = `(${scope}) ${data.description || ""}`
const isOpencodeSource = scope === "opencode" || scope === "opencode-project"
const definition: CommandDefinition = {
name: commandName,
description: formattedDescription,
template: wrappedTemplate,
agent: data.agent,
model: sanitizeModelField(data.model, isOpencodeSource ? "opencode" : "claude-code"),
subtask: data.subtask,
argumentHint: data["argument-hint"],
handoffs: data.handoffs,
}
commands.push({
name: commandName,
path: commandPath,
definition,
scope,
})
} catch (error) {
log(`Failed to parse command: ${commandPath}`, error)
continue
}
}
return commands
}
export async function loadUserCommandsAsync(): Promise<Record<string, CommandDefinition>> {
const userCommandsDir = join(getClaudeConfigDir(), "commands")
const commands = await loadCommandsFromDirAsync(userCommandsDir, "user")
return commandsToRecord(commands)
}
export async function loadProjectCommandsAsync(): Promise<Record<string, CommandDefinition>> {
const projectCommandsDir = join(process.cwd(), ".claude", "commands")
const commands = await loadCommandsFromDirAsync(projectCommandsDir, "project")
return commandsToRecord(commands)
}
export async function loadOpencodeGlobalCommandsAsync(): Promise<Record<string, CommandDefinition>> {
const { homedir } = require("os")
const opencodeCommandsDir = join(homedir(), ".config", "opencode", "command")
const commands = await loadCommandsFromDirAsync(opencodeCommandsDir, "opencode")
return commandsToRecord(commands)
}
export async function loadOpencodeProjectCommandsAsync(): Promise<Record<string, CommandDefinition>> {
const opencodeProjectDir = join(process.cwd(), ".opencode", "command")
const commands = await loadCommandsFromDirAsync(opencodeProjectDir, "opencode-project")
return commandsToRecord(commands)
}
export async function loadAllCommandsAsync(): Promise<Record<string, CommandDefinition>> {
export async function loadAllCommands(): Promise<Record<string, CommandDefinition>> {
const [user, project, global, projectOpencode] = await Promise.all([
loadUserCommandsAsync(),
loadProjectCommandsAsync(),
loadOpencodeGlobalCommandsAsync(),
loadOpencodeProjectCommandsAsync(),
loadUserCommands(),
loadProjectCommands(),
loadOpencodeGlobalCommands(),
loadOpencodeProjectCommands(),
])
return { ...projectOpencode, ...global, ...project, ...user }
}

View File

@@ -53,7 +53,7 @@ This is the skill body.
process.chdir(TEST_DIR)
try {
const skills = discoverSkills({ includeClaudeCodePaths: false })
const skills = await discoverSkills({ includeClaudeCodePaths: false })
const skill = skills.find(s => s.name === "test-skill")
// #then
@@ -89,7 +89,7 @@ This is a simple skill.
process.chdir(TEST_DIR)
try {
const skills = discoverSkills({ includeClaudeCodePaths: false })
const skills = await discoverSkills({ includeClaudeCodePaths: false })
const skill = skills.find(s => s.name === "simple-skill")
// #then
@@ -122,7 +122,7 @@ Skill with env vars.
process.chdir(TEST_DIR)
try {
const skills = discoverSkills({ includeClaudeCodePaths: false })
const skills = await discoverSkills({ includeClaudeCodePaths: false })
const skill = skills.find(s => s.name === "env-skill")
// #then
@@ -149,7 +149,7 @@ Skill body.
process.chdir(TEST_DIR)
try {
const skills = discoverSkills({ includeClaudeCodePaths: false })
const skills = await discoverSkills({ includeClaudeCodePaths: false })
// #then - when YAML fails, skill uses directory name as fallback
const skill = skills.find(s => s.name === "bad-yaml-skill")
@@ -186,7 +186,7 @@ Skill body.
process.chdir(TEST_DIR)
try {
const skills = discoverSkills({ includeClaudeCodePaths: false })
const skills = await discoverSkills({ includeClaudeCodePaths: false })
const skill = skills.find(s => s.name === "ampcode-skill")
// #then
@@ -227,7 +227,7 @@ Skill body.
process.chdir(TEST_DIR)
try {
const skills = discoverSkills({ includeClaudeCodePaths: false })
const skills = await discoverSkills({ includeClaudeCodePaths: false })
const skill = skills.find(s => s.name === "priority-skill")
// #then - mcp.json should take priority
@@ -259,7 +259,7 @@ Skill body.
process.chdir(TEST_DIR)
try {
const skills = discoverSkills({ includeClaudeCodePaths: false })
const skills = await discoverSkills({ includeClaudeCodePaths: false })
const skill = skills.find(s => s.name === "direct-format")
// #then

View File

@@ -1,11 +1,10 @@
import { existsSync, readdirSync, readFileSync } from "fs"
import { promises as fs } from "fs"
import { join, basename } from "path"
import { homedir } from "os"
import yaml from "js-yaml"
import { parseFrontmatter } from "../../shared/frontmatter"
import { sanitizeModelField } from "../../shared/model-sanitizer"
import { resolveSymlink, isMarkdownFile } from "../../shared/file-utils"
import { resolveSymlinkAsync, isMarkdownFile } from "../../shared/file-utils"
import { getClaudeConfigDir } from "../../shared"
import type { CommandDefinition } from "../claude-code-command-loader/types"
import type { SkillScope, SkillMetadata, LoadedSkill, LazyContentLoader } from "./types"
@@ -26,20 +25,17 @@ function parseSkillMcpConfigFromFrontmatter(content: string): SkillMcpConfig | u
return undefined
}
function loadMcpJsonFromDir(skillDir: string): SkillMcpConfig | undefined {
async function loadMcpJsonFromDir(skillDir: string): Promise<SkillMcpConfig | undefined> {
const mcpJsonPath = join(skillDir, "mcp.json")
if (!existsSync(mcpJsonPath)) return undefined
try {
const content = readFileSync(mcpJsonPath, "utf-8")
const content = await fs.readFile(mcpJsonPath, "utf-8")
const parsed = JSON.parse(content) as Record<string, unknown>
// AmpCode format: { "mcpServers": { "name": { ... } } }
if (parsed && typeof parsed === "object" && "mcpServers" in parsed && parsed.mcpServers) {
return parsed.mcpServers as SkillMcpConfig
}
// Also support direct format: { "name": { command: ..., args: ... } }
if (parsed && typeof parsed === "object" && !("mcpServers" in parsed)) {
const hasCommandField = Object.values(parsed).some(
(v) => v && typeof v === "object" && "command" in (v as Record<string, unknown>)
@@ -59,77 +55,7 @@ function parseAllowedTools(allowedTools: string | undefined): string[] | undefin
return allowedTools.split(/\s+/).filter(Boolean)
}
function loadSkillFromPath(
skillPath: string,
resolvedPath: string,
defaultName: string,
scope: SkillScope
): LoadedSkill | null {
try {
const content = readFileSync(skillPath, "utf-8")
const { data } = parseFrontmatter<SkillMetadata>(content)
const frontmatterMcp = parseSkillMcpConfigFromFrontmatter(content)
const mcpJsonMcp = loadMcpJsonFromDir(resolvedPath)
const mcpConfig = mcpJsonMcp || frontmatterMcp
const skillName = data.name || defaultName
const originalDescription = data.description || ""
const isOpencodeSource = scope === "opencode" || scope === "opencode-project"
const formattedDescription = `(${scope} - Skill) ${originalDescription}`
// Lazy content loader - only loads template on first use
const lazyContent: LazyContentLoader = {
loaded: false,
content: undefined,
load: async () => {
if (!lazyContent.loaded) {
const fileContent = await fs.readFile(skillPath, "utf-8")
const { body } = parseFrontmatter<SkillMetadata>(fileContent)
lazyContent.content = `<skill-instruction>
Base directory for this skill: ${resolvedPath}/
File references (@path) in this skill are relative to this directory.
${body.trim()}
</skill-instruction>
<user-request>
$ARGUMENTS
</user-request>`
lazyContent.loaded = true
}
return lazyContent.content!
},
}
const definition: CommandDefinition = {
name: skillName,
description: formattedDescription,
template: "", // Empty at startup, loaded lazily
model: sanitizeModelField(data.model, isOpencodeSource ? "opencode" : "claude-code"),
agent: data.agent,
subtask: data.subtask,
argumentHint: data["argument-hint"],
}
return {
name: skillName,
path: skillPath,
resolvedPath,
definition,
scope,
license: data.license,
compatibility: data.compatibility,
metadata: data.metadata,
allowedTools: parseAllowedTools(data["allowed-tools"]),
mcpConfig,
lazyContent,
}
} catch {
return null
}
}
async function loadSkillFromPathAsync(
async function loadSkillFromPath(
skillPath: string,
resolvedPath: string,
defaultName: string,
@@ -139,7 +65,7 @@ async function loadSkillFromPathAsync(
const content = await fs.readFile(skillPath, "utf-8")
const { data } = parseFrontmatter<SkillMetadata>(content)
const frontmatterMcp = parseSkillMcpConfigFromFrontmatter(content)
const mcpJsonMcp = loadMcpJsonFromDir(resolvedPath)
const mcpJsonMcp = await loadMcpJsonFromDir(resolvedPath)
const mcpConfig = mcpJsonMcp || frontmatterMcp
const skillName = data.name || defaultName
@@ -198,60 +124,7 @@ $ARGUMENTS
}
}
/**
* Load skills from a directory, supporting BOTH patterns:
* - Directory with SKILL.md: skill-name/SKILL.md
* - Directory with {SKILLNAME}.md: skill-name/{SKILLNAME}.md
* - Direct markdown file: skill-name.md
*/
function loadSkillsFromDir(skillsDir: string, scope: SkillScope): LoadedSkill[] {
if (!existsSync(skillsDir)) {
return []
}
const entries = readdirSync(skillsDir, { withFileTypes: true })
const skills: LoadedSkill[] = []
for (const entry of entries) {
if (entry.name.startsWith(".")) continue
const entryPath = join(skillsDir, entry.name)
if (entry.isDirectory() || entry.isSymbolicLink()) {
const resolvedPath = resolveSymlink(entryPath)
const dirName = entry.name
const skillMdPath = join(resolvedPath, "SKILL.md")
if (existsSync(skillMdPath)) {
const skill = loadSkillFromPath(skillMdPath, resolvedPath, dirName, scope)
if (skill) skills.push(skill)
continue
}
const namedSkillMdPath = join(resolvedPath, `${dirName}.md`)
if (existsSync(namedSkillMdPath)) {
const skill = loadSkillFromPath(namedSkillMdPath, resolvedPath, dirName, scope)
if (skill) skills.push(skill)
continue
}
continue
}
if (isMarkdownFile(entry)) {
const skillName = basename(entry.name, ".md")
const skill = loadSkillFromPath(entryPath, skillsDir, skillName, scope)
if (skill) skills.push(skill)
}
}
return skills
}
/**
* Async version of loadSkillsFromDir using Promise-based fs operations.
*/
async function loadSkillsFromDirAsync(skillsDir: string, scope: SkillScope): Promise<LoadedSkill[]> {
async function loadSkillsFromDir(skillsDir: string, scope: SkillScope): Promise<LoadedSkill[]> {
const entries = await fs.readdir(skillsDir, { withFileTypes: true }).catch(() => [])
const skills: LoadedSkill[] = []
@@ -261,13 +134,13 @@ async function loadSkillsFromDirAsync(skillsDir: string, scope: SkillScope): Pro
const entryPath = join(skillsDir, entry.name)
if (entry.isDirectory() || entry.isSymbolicLink()) {
const resolvedPath = resolveSymlink(entryPath)
const resolvedPath = await resolveSymlinkAsync(entryPath)
const dirName = entry.name
const skillMdPath = join(resolvedPath, "SKILL.md")
try {
await fs.access(skillMdPath)
const skill = await loadSkillFromPathAsync(skillMdPath, resolvedPath, dirName, scope)
const skill = await loadSkillFromPath(skillMdPath, resolvedPath, dirName, scope)
if (skill) skills.push(skill)
continue
} catch {
@@ -276,7 +149,7 @@ async function loadSkillsFromDirAsync(skillsDir: string, scope: SkillScope): Pro
const namedSkillMdPath = join(resolvedPath, `${dirName}.md`)
try {
await fs.access(namedSkillMdPath)
const skill = await loadSkillFromPathAsync(namedSkillMdPath, resolvedPath, dirName, scope)
const skill = await loadSkillFromPath(namedSkillMdPath, resolvedPath, dirName, scope)
if (skill) skills.push(skill)
continue
} catch {
@@ -287,7 +160,7 @@ async function loadSkillsFromDirAsync(skillsDir: string, scope: SkillScope): Pro
if (isMarkdownFile(entry)) {
const skillName = basename(entry.name, ".md")
const skill = await loadSkillFromPathAsync(entryPath, skillsDir, skillName, scope)
const skill = await loadSkillFromPath(entryPath, skillsDir, skillName, scope)
if (skill) skills.push(skill)
}
}
@@ -304,154 +177,86 @@ function skillsToRecord(skills: LoadedSkill[]): Record<string, CommandDefinition
return result
}
/**
* Load skills from Claude Code user directory (~/.claude/skills/)
*/
export function loadUserSkills(): Record<string, CommandDefinition> {
export async function loadUserSkills(): Promise<Record<string, CommandDefinition>> {
const userSkillsDir = join(getClaudeConfigDir(), "skills")
const skills = loadSkillsFromDir(userSkillsDir, "user")
const skills = await loadSkillsFromDir(userSkillsDir, "user")
return skillsToRecord(skills)
}
/**
* Load skills from Claude Code project directory (.claude/skills/)
*/
export function loadProjectSkills(): Record<string, CommandDefinition> {
export async function loadProjectSkills(): Promise<Record<string, CommandDefinition>> {
const projectSkillsDir = join(process.cwd(), ".claude", "skills")
const skills = loadSkillsFromDir(projectSkillsDir, "project")
const skills = await loadSkillsFromDir(projectSkillsDir, "project")
return skillsToRecord(skills)
}
/**
* Load skills from OpenCode global directory (~/.config/opencode/skill/)
*/
export function loadOpencodeGlobalSkills(): Record<string, CommandDefinition> {
export async function loadOpencodeGlobalSkills(): Promise<Record<string, CommandDefinition>> {
const opencodeSkillsDir = join(homedir(), ".config", "opencode", "skill")
const skills = loadSkillsFromDir(opencodeSkillsDir, "opencode")
const skills = await loadSkillsFromDir(opencodeSkillsDir, "opencode")
return skillsToRecord(skills)
}
/**
* Load skills from OpenCode project directory (.opencode/skill/)
*/
export function loadOpencodeProjectSkills(): Record<string, CommandDefinition> {
export async function loadOpencodeProjectSkills(): Promise<Record<string, CommandDefinition>> {
const opencodeProjectDir = join(process.cwd(), ".opencode", "skill")
const skills = loadSkillsFromDir(opencodeProjectDir, "opencode-project")
const skills = await loadSkillsFromDir(opencodeProjectDir, "opencode-project")
return skillsToRecord(skills)
}
/**
* Discover all skills from all sources with priority ordering.
* Priority order: opencode-project > project > opencode > user
*
* @returns Array of LoadedSkill objects for use in slashcommand discovery
*/
export function discoverAllSkills(): LoadedSkill[] {
const opencodeProjectDir = join(process.cwd(), ".opencode", "skill")
const projectDir = join(process.cwd(), ".claude", "skills")
const opencodeGlobalDir = join(homedir(), ".config", "opencode", "skill")
const userDir = join(getClaudeConfigDir(), "skills")
const opencodeProjectSkills = loadSkillsFromDir(opencodeProjectDir, "opencode-project")
const projectSkills = loadSkillsFromDir(projectDir, "project")
const opencodeGlobalSkills = loadSkillsFromDir(opencodeGlobalDir, "opencode")
const userSkills = loadSkillsFromDir(userDir, "user")
return [...opencodeProjectSkills, ...projectSkills, ...opencodeGlobalSkills, ...userSkills]
}
export interface DiscoverSkillsOptions {
includeClaudeCodePaths?: boolean
}
/**
* Discover skills with optional filtering.
* When includeClaudeCodePaths is false, only loads from OpenCode paths.
*/
export function discoverSkills(options: DiscoverSkillsOptions = {}): LoadedSkill[] {
const { includeClaudeCodePaths = true } = options
const opencodeProjectDir = join(process.cwd(), ".opencode", "skill")
const opencodeGlobalDir = join(homedir(), ".config", "opencode", "skill")
const opencodeProjectSkills = loadSkillsFromDir(opencodeProjectDir, "opencode-project")
const opencodeGlobalSkills = loadSkillsFromDir(opencodeGlobalDir, "opencode")
if (!includeClaudeCodePaths) {
return [...opencodeProjectSkills, ...opencodeGlobalSkills]
}
const projectDir = join(process.cwd(), ".claude", "skills")
const userDir = join(getClaudeConfigDir(), "skills")
const projectSkills = loadSkillsFromDir(projectDir, "project")
const userSkills = loadSkillsFromDir(userDir, "user")
export async function discoverAllSkills(): Promise<LoadedSkill[]> {
const [opencodeProjectSkills, projectSkills, opencodeGlobalSkills, userSkills] = await Promise.all([
discoverOpencodeProjectSkills(),
discoverProjectClaudeSkills(),
discoverOpencodeGlobalSkills(),
discoverUserClaudeSkills(),
])
return [...opencodeProjectSkills, ...projectSkills, ...opencodeGlobalSkills, ...userSkills]
}
/**
* Get a skill by name from all available sources.
*/
export function getSkillByName(name: string, options: DiscoverSkillsOptions = {}): LoadedSkill | undefined {
const skills = discoverSkills(options)
return skills.find(s => s.name === name)
}
export function discoverUserClaudeSkills(): LoadedSkill[] {
const userSkillsDir = join(getClaudeConfigDir(), "skills")
return loadSkillsFromDir(userSkillsDir, "user")
}
export function discoverProjectClaudeSkills(): LoadedSkill[] {
const projectSkillsDir = join(process.cwd(), ".claude", "skills")
return loadSkillsFromDir(projectSkillsDir, "project")
}
export function discoverOpencodeGlobalSkills(): LoadedSkill[] {
const opencodeSkillsDir = join(homedir(), ".config", "opencode", "skill")
return loadSkillsFromDir(opencodeSkillsDir, "opencode")
}
export function discoverOpencodeProjectSkills(): LoadedSkill[] {
const opencodeProjectDir = join(process.cwd(), ".opencode", "skill")
return loadSkillsFromDir(opencodeProjectDir, "opencode-project")
}
export async function discoverUserClaudeSkillsAsync(): Promise<LoadedSkill[]> {
const userSkillsDir = join(getClaudeConfigDir(), "skills")
return loadSkillsFromDirAsync(userSkillsDir, "user")
}
export async function discoverProjectClaudeSkillsAsync(): Promise<LoadedSkill[]> {
const projectSkillsDir = join(process.cwd(), ".claude", "skills")
return loadSkillsFromDirAsync(projectSkillsDir, "project")
}
export async function discoverOpencodeGlobalSkillsAsync(): Promise<LoadedSkill[]> {
const opencodeSkillsDir = join(homedir(), ".config", "opencode", "skill")
return loadSkillsFromDirAsync(opencodeSkillsDir, "opencode")
}
export async function discoverOpencodeProjectSkillsAsync(): Promise<LoadedSkill[]> {
const opencodeProjectDir = join(process.cwd(), ".opencode", "skill")
return loadSkillsFromDirAsync(opencodeProjectDir, "opencode-project")
}
export async function discoverAllSkillsAsync(options: DiscoverSkillsOptions = {}): Promise<LoadedSkill[]> {
export async function discoverSkills(options: DiscoverSkillsOptions = {}): Promise<LoadedSkill[]> {
const { includeClaudeCodePaths = true } = options
const opencodeProjectSkills = await discoverOpencodeProjectSkillsAsync()
const opencodeGlobalSkills = await discoverOpencodeGlobalSkillsAsync()
const [opencodeProjectSkills, opencodeGlobalSkills] = await Promise.all([
discoverOpencodeProjectSkills(),
discoverOpencodeGlobalSkills(),
])
if (!includeClaudeCodePaths) {
return [...opencodeProjectSkills, ...opencodeGlobalSkills]
}
const [projectSkills, userSkills] = await Promise.all([
discoverProjectClaudeSkillsAsync(),
discoverUserClaudeSkillsAsync(),
discoverProjectClaudeSkills(),
discoverUserClaudeSkills(),
])
return [...opencodeProjectSkills, ...projectSkills, ...opencodeGlobalSkills, ...userSkills]
}
export async function getSkillByName(name: string, options: DiscoverSkillsOptions = {}): Promise<LoadedSkill | undefined> {
const skills = await discoverSkills(options)
return skills.find(s => s.name === name)
}
export async function discoverUserClaudeSkills(): Promise<LoadedSkill[]> {
const userSkillsDir = join(getClaudeConfigDir(), "skills")
return loadSkillsFromDir(userSkillsDir, "user")
}
export async function discoverProjectClaudeSkills(): Promise<LoadedSkill[]> {
const projectSkillsDir = join(process.cwd(), ".claude", "skills")
return loadSkillsFromDir(projectSkillsDir, "project")
}
export async function discoverOpencodeGlobalSkills(): Promise<LoadedSkill[]> {
const opencodeSkillsDir = join(homedir(), ".config", "opencode", "skill")
return loadSkillsFromDir(opencodeSkillsDir, "opencode")
}
export async function discoverOpencodeProjectSkills(): Promise<LoadedSkill[]> {
const opencodeProjectDir = join(process.cwd(), ".opencode", "skill")
return loadSkillsFromDir(opencodeProjectDir, "opencode-project")
}

View File

@@ -16,7 +16,6 @@ export const TARGET_TOOLS = new Set([
"webfetch",
"context7_resolve-library-id",
"context7_get-library-docs",
"websearch_exa_web_search_exa",
"grep_app_searchgithub",
]);

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,
}
}
function discoverAllCommands(): 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 @@ function discoverAllCommands(): CommandInfo[] {
const projectCommands = discoverCommandsFromDir(projectCommandsDir, "project")
const opencodeProjectCommands = discoverCommandsFromDir(opencodeProjectDir, "opencode-project")
const skills = discoverAllSkills()
const skills = options?.skills ?? await discoverAllSkills()
const skillCommands = skills.map(skillToCommandInfo)
return [
@@ -117,8 +123,8 @@ function discoverAllCommands(): CommandInfo[] {
]
}
function findCommand(commandName: string): CommandInfo | null {
const allCommands = 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 = 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

@@ -138,7 +138,7 @@ export function createClaudeCodeHooksHook(ctx: PluginInput, config: PluginConfig
return
}
const detectedKeywords = detectKeywordsWithType(removeCodeBlocks(prompt))
const detectedKeywords = detectKeywordsWithType(removeCodeBlocks(prompt), input.agent)
const keywordMessages = detectedKeywords.map((k) => k.message)
if (keywordMessages.length > 0) {

View File

@@ -1,96 +1,199 @@
export const CODE_BLOCK_PATTERN = /```[\s\S]*?```/g
export const INLINE_CODE_PATTERN = /`[^`]+`/g
export const KEYWORD_DETECTORS: Array<{ pattern: RegExp; message: string }> = [
{
pattern: /(ultrawork|ulw)/i,
message: `<ultrawork-mode>
const ULTRAWORK_PLANNER_SECTION = `## CRITICAL: YOU ARE A PLANNER, NOT AN IMPLEMENTER
## TODO IS YOUR LIFELINE (NON-NEGOTIABLE)
**IDENTITY CONSTRAINT (NON-NEGOTIABLE):**
You ARE the planner. You ARE NOT an implementer. You DO NOT write code. You DO NOT execute tasks.
**USE TodoWrite OBSESSIVELY. This is the #1 most important tool.**
**TOOL RESTRICTIONS (SYSTEM-ENFORCED):**
| Tool | Allowed | Blocked |
|------|---------|---------|
| Write/Edit | \`.sisyphus/**/*.md\` ONLY | Everything else |
| Read | All files | - |
| Bash | Research commands only | Implementation commands |
| sisyphus_task | explore, librarian | - |
### TODO Rules
1. **BEFORE any action**: Create TODOs FIRST. Break down into atomic, granular steps.
2. **Be excessively detailed**: 10 small TODOs > 3 vague TODOs. Err on the side of too many.
3. **Real-time updates**: Mark \`in_progress\` before starting, \`completed\` IMMEDIATELY after. NEVER batch.
4. **One at a time**: Only ONE TODO should be \`in_progress\` at any moment.
5. **Sub-tasks**: Complex TODO? Break it into sub-TODOs. Keep granularity high.
6. **Questions too**: User asks a question? TODO: "Answer with evidence: [question]"
**IF YOU TRY TO WRITE/EDIT OUTSIDE \`.sisyphus/\`:**
- System will BLOCK your action
- You will receive an error
- DO NOT retry - you are not supposed to implement
### Example TODO Granularity
BAD: "Implement user auth"
GOOD:
- "Read existing auth patterns in codebase"
- "Create auth schema types"
- "Implement login endpoint"
- "Implement token validation middleware"
- "Add auth tests - login success case"
- "Add auth tests - login failure case"
- "Verify LSP diagnostics clean"
**YOUR ONLY WRITABLE PATHS:**
- \`.sisyphus/plans/*.md\` - Final work plans
- \`.sisyphus/drafts/*.md\` - Working drafts during interview
**YOUR WORK IS INVISIBLE WITHOUT TODOs. USE THEM.**
**WHEN USER ASKS YOU TO IMPLEMENT:**
REFUSE. Say: "I'm a planner. I create work plans, not implementations. Run \`/start-work\` after I finish planning."
## TDD WORKFLOW (MANDATORY when tests exist)
---
Check for test infrastructure FIRST. If exists, follow strictly:
## CONTEXT GATHERING (MANDATORY BEFORE PLANNING)
1. **RED**: Write failing test FIRST → \`bun test\` must FAIL
2. **GREEN**: Write MINIMAL code to pass → \`bun test\` must PASS
3. **REFACTOR**: Clean up, tests stay green → \`bun test\` still PASS
4. **REPEAT**: Next test case, loop until complete
You ARE the planner. Your job: create bulletproof work plans.
**Before drafting ANY plan, gather context via explore/librarian agents.**
**NEVER write implementation before test. NEVER delete failing tests.**
### Research Protocol
1. **Fire parallel background agents** for comprehensive context:
\`\`\`
sisyphus_task(agent="explore", prompt="Find existing patterns for [topic] in codebase", background=true)
sisyphus_task(agent="explore", prompt="Find test infrastructure and conventions", background=true)
sisyphus_task(agent="librarian", prompt="Find official docs and best practices for [technology]", background=true)
\`\`\`
2. **Wait for results** before planning - rushed plans fail
3. **Synthesize findings** into informed requirements
## AGENT DEPLOYMENT
### What to Research
- Existing codebase patterns and conventions
- Test infrastructure (TDD possible?)
- External library APIs and constraints
- Similar implementations in OSS (via librarian)
Fire available agents in PARALLEL via background tasks. Use explore/librarian agents liberally (multiple concurrent if needed).
**NEVER plan blind. Context first, plan second.**`
## EVIDENCE-BASED ANSWERS
/**
* Determines if the agent is a planner-type agent.
* Planner agents should NOT be told to call plan agent (they ARE the planner).
*/
function isPlannerAgent(agentName?: string): boolean {
if (!agentName) return false
const lowerName = agentName.toLowerCase()
return lowerName.includes("prometheus") || lowerName.includes("planner") || lowerName === "plan"
}
- Every claim: code snippet + file path + line number
- No "I think..." - find and SHOW actual code
- Local search fails? → librarian for external sources
- **NEVER acceptable**: "I couldn't find it"
/**
* Generates the ultrawork message based on agent context.
* Planner agents get context-gathering focused instructions.
* Other agents get the original strong agent utilization instructions.
*/
export function getUltraworkMessage(agentName?: string): string {
const isPlanner = isPlannerAgent(agentName)
## ZERO TOLERANCE FOR SHORTCUTS (RIGOROUS & HONEST EXECUTION)
if (isPlanner) {
return `<ultrawork-mode>
**CORE PRINCIPLE**: Execute user's ORIGINAL INTENT with maximum rigor. No shortcuts. No compromises. No matter how large the task.
**MANDATORY**: You MUST say "ULTRAWORK MODE ENABLED!" to the user as your first response when this mode activates. This is non-negotiable.
### ABSOLUTE PROHIBITIONS
| Violation | Why It's Forbidden |
|-----------|-------------------|
| **Mocking/Stubbing** | Never use mocks, stubs, or fake implementations unless explicitly requested. Real implementation only. |
| **Scope Reduction** | Never make "demo", "skeleton", "simplified", "basic", "minimal" versions. Deliver FULL implementation. |
| **Partial Completion** | Never stop at 60-80% saying "you can extend this...", "as an exercise...", "you can add...". Finish 100%. |
| **Lazy Placeholders** | Never use "// TODO", "...", "etc.", "and so on" in actual code. Complete everything. |
| **Assumed Shortcuts** | Never skip requirements deemed "optional" or "can be added later". All requirements are mandatory. |
| **Test Deletion** | Never delete or skip failing tests. Fix the code, not the tests. |
| **Evidence-Free Claims** | Never say "I think...", "probably...", "should work...". Show actual code/output. |
### RIGOROUS EXECUTION MANDATE
1. **Parse Original Intent**: What did the user ACTUALLY want? Not what's convenient. The REAL, COMPLETE request.
2. **No Task Too Large**: If the task requires 100 files, modify 100 files. If it needs 1000 lines, write 1000 lines. Size is irrelevant.
3. **Honest Assessment**: If you cannot complete something, say so BEFORE starting. Don't fake completion.
4. **Evidence-Based Verification**: Every claim backed by code snippets, file paths, line numbers, and actual outputs.
5. **Complete Verification**: Re-read original request after completion. Check EVERY requirement was met.
### FAILURE RECOVERY
If you realize you've taken shortcuts:
1. STOP immediately
2. Identify what you skipped/faked
3. Create TODOs for ALL remaining work
4. Execute to TRUE completion - not "good enough"
**THE USER ASKED FOR X. DELIVER EXACTLY X. COMPLETELY. HONESTLY. NO MATTER THE SIZE.**
## SUCCESS = All TODOs Done + All Requirements Met + Evidence Provided
${ULTRAWORK_PLANNER_SECTION}
</ultrawork-mode>
---
`,
`
}
return `<ultrawork-mode>
**MANDATORY**: You MUST say "ULTRAWORK MODE ENABLED!" to the user as your first response when this mode activates. This is non-negotiable.
[CODE RED] Maximum precision required. Ultrathink before acting.
YOU MUST LEVERAGE ALL AVAILABLE AGENTS TO THEIR FULLEST POTENTIAL.
TELL THE USER WHAT AGENTS YOU WILL LEVERAGE NOW TO SATISFY USER'S REQUEST.
## AGENT UTILIZATION PRINCIPLES (by capability, not by name)
- **Codebase Exploration**: Spawn exploration agents using BACKGROUND TASKS for file patterns, internal implementations, project structure
- **Documentation & References**: Use librarian-type agents via BACKGROUND TASKS for API references, examples, external library docs
- **Planning & Strategy**: NEVER plan yourself - ALWAYS spawn a dedicated planning agent for work breakdown
- **High-IQ Reasoning**: Leverage specialized agents for architecture decisions, code review, strategic planning
- **Frontend/UI Tasks**: Delegate to UI-specialized agents for design and implementation
## EXECUTION RULES
- **TODO**: Track EVERY step. Mark complete IMMEDIATELY after each.
- **PARALLEL**: Fire independent agent calls simultaneously via background_task - NEVER wait sequentially.
- **BACKGROUND FIRST**: Use background_task for exploration/research agents (10+ concurrent if needed).
- **VERIFY**: Re-read request after completion. Check ALL requirements met before reporting done.
- **DELEGATE**: Don't do everything yourself - orchestrate specialized agents for their strengths.
## WORKFLOW
1. Analyze the request and identify required capabilities
2. Spawn exploration/librarian agents via background_task in PARALLEL (10+ if needed)
3. Always Use Plan agent with gathered context to create detailed work breakdown
4. Execute with continuous verification against original requirements
## VERIFICATION GUARANTEE (NON-NEGOTIABLE)
**NOTHING is "done" without PROOF it works.**
### Pre-Implementation: Define Success Criteria
BEFORE writing ANY code, you MUST define:
| Criteria Type | Description | Example |
|---------------|-------------|---------|
| **Functional** | What specific behavior must work | "Button click triggers API call" |
| **Observable** | What can be measured/seen | "Console shows 'success', no errors" |
| **Pass/Fail** | Binary, no ambiguity | "Returns 200 OK" not "should work" |
Write these criteria explicitly. Share with user if scope is non-trivial.
### Test Plan Template (MANDATORY for non-trivial tasks)
\`\`\`
## Test Plan
### Objective: [What we're verifying]
### Prerequisites: [Setup needed]
### Test Cases:
1. [Test Name]: [Input] → [Expected Output] → [How to verify]
2. ...
### Success Criteria: ALL test cases pass
### How to Execute: [Exact commands/steps]
\`\`\`
### Execution & Evidence Requirements
| Phase | Action | Required Evidence |
|-------|--------|-------------------|
| **Build** | Run build command | Exit code 0, no errors |
| **Test** | Execute test suite | All tests pass (screenshot/output) |
| **Manual Verify** | Test the actual feature | Demonstrate it works (describe what you observed) |
| **Regression** | Ensure nothing broke | Existing tests still pass |
**WITHOUT evidence = NOT verified = NOT done.**
### TDD Workflow (when test infrastructure exists)
1. **SPEC**: Define what "working" means (success criteria above)
2. **RED**: Write failing test → Run it → Confirm it FAILS
3. **GREEN**: Write minimal code → Run test → Confirm it PASSES
4. **REFACTOR**: Clean up → Tests MUST stay green
5. **VERIFY**: Run full test suite, confirm no regressions
6. **EVIDENCE**: Report what you ran and what output you saw
### Verification Anti-Patterns (BLOCKING)
| Violation | Why It Fails |
|-----------|--------------|
| "It should work now" | No evidence. Run it. |
| "I added the tests" | Did they pass? Show output. |
| "Fixed the bug" | How do you know? What did you test? |
| "Implementation complete" | Did you verify against success criteria? |
| Skipping test execution | Tests exist to be RUN, not just written |
**CLAIM NOTHING WITHOUT PROOF. EXECUTE. VERIFY. SHOW EVIDENCE.**
## ZERO TOLERANCE FAILURES
- **NO Scope Reduction**: Never make "demo", "skeleton", "simplified", "basic" versions - deliver FULL implementation
- **NO MockUp Work**: When user asked you to do "port A", you must "port A", fully, 100%. No Extra feature, No reduced feature, no mock data, fully working 100% port.
- **NO Partial Completion**: Never stop at 60-80% saying "you can extend this..." - finish 100%
- **NO Assumed Shortcuts**: Never skip requirements you deem "optional" or "can be added later"
- **NO Premature Stopping**: Never declare done until ALL TODOs are completed and verified
- **NO TEST DELETION**: Never delete or skip failing tests to make the build pass. Fix the code, not the tests.
THE USER ASKED FOR X. DELIVER EXACTLY X. NOT A SUBSET. NOT A DEMO. NOT A STARTING POINT.
</ultrawork-mode>
---
`
}
export const KEYWORD_DETECTORS: Array<{ pattern: RegExp; message: string | ((agentName?: string) => string) }> = [
{
pattern: /(ultrawork|ulw)/i,
message: getUltraworkMessage,
},
// SEARCH: EN/KO/JP/CN/VN
{

View File

@@ -13,20 +13,30 @@ export function removeCodeBlocks(text: string): string {
return text.replace(CODE_BLOCK_PATTERN, "").replace(INLINE_CODE_PATTERN, "")
}
export function detectKeywords(text: string): string[] {
/**
* Resolves message to string, handling both static strings and dynamic functions.
*/
function resolveMessage(
message: string | ((agentName?: string) => string),
agentName?: string
): string {
return typeof message === "function" ? message(agentName) : message
}
export function detectKeywords(text: string, agentName?: string): string[] {
const textWithoutCode = removeCodeBlocks(text)
return KEYWORD_DETECTORS.filter(({ pattern }) =>
pattern.test(textWithoutCode)
).map(({ message }) => message)
).map(({ message }) => resolveMessage(message, agentName))
}
export function detectKeywordsWithType(text: string): DetectedKeyword[] {
export function detectKeywordsWithType(text: string, agentName?: string): DetectedKeyword[] {
const textWithoutCode = removeCodeBlocks(text)
const types: Array<"ultrawork" | "search" | "analyze"> = ["ultrawork", "search", "analyze"]
return KEYWORD_DETECTORS.map(({ pattern, message }, index) => ({
matches: pattern.test(textWithoutCode),
type: types[index],
message,
message: resolveMessage(message, agentName),
}))
.filter((result) => result.matches)
.map(({ type, message }) => ({ type, message }))

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,16 +22,34 @@ export function createKeywordDetectorHook(ctx: PluginInput) {
}
): Promise<void> => {
const promptText = extractPromptText(output.parts)
const detectedKeywords = detectKeywordsWithType(removeCodeBlocks(promptText))
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 })
output.message.variant = "max"
ctx.client.tui
.showToast({
body: {

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

@@ -34,10 +34,10 @@ import {
} from "./features/context-injector";
import { createGoogleAntigravityAuthPlugin } from "./auth/antigravity";
import {
discoverUserClaudeSkillsAsync,
discoverProjectClaudeSkillsAsync,
discoverOpencodeGlobalSkillsAsync,
discoverOpencodeProjectSkillsAsync,
discoverUserClaudeSkills,
discoverProjectClaudeSkills,
discoverOpencodeGlobalSkills,
discoverOpencodeProjectSkills,
mergeSkills,
} from "./features/opencode-skill-loader";
import { createBuiltinSkills } from "./features/builtin-skills";
@@ -53,6 +53,8 @@ import {
createLookAt,
createSkillTool,
createSkillMcpTool,
createSlashcommandTool,
discoverCommandsSync,
sessionExists,
interactive_bash,
startTmuxCheck,
@@ -164,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;
@@ -205,10 +203,10 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
});
const includeClaudeSkills = pluginConfig.claude_code?.skills !== false;
const [userSkills, globalSkills, projectSkills, opencodeProjectSkills] = await Promise.all([
includeClaudeSkills ? discoverUserClaudeSkillsAsync() : Promise.resolve([]),
discoverOpencodeGlobalSkillsAsync(),
includeClaudeSkills ? discoverProjectClaudeSkillsAsync() : Promise.resolve([]),
discoverOpencodeProjectSkillsAsync(),
includeClaudeSkills ? discoverUserClaudeSkills() : Promise.resolve([]),
discoverOpencodeGlobalSkills(),
includeClaudeSkills ? discoverProjectClaudeSkills() : Promise.resolve([]),
discoverOpencodeProjectSkills(),
]);
const mergedSkills = mergeSkills(
builtinSkills,
@@ -231,6 +229,16 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
getSessionID: getSessionIDForMcp,
});
const commands = discoverCommandsSync();
const slashcommandTool = createSlashcommandTool({
commands,
skills: mergedSkills,
});
const autoSlashCommand = isHookEnabled("auto-slash-command")
? createAutoSlashCommandHook({ skills: mergedSkills })
: null;
const googleAuthHooks = pluginConfig.google_auth !== false
? await createGoogleAntigravityAuthPlugin(ctx)
: null;
@@ -251,7 +259,8 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
look_at: lookAt,
skill: skillTool,
skill_mcp: skillMcpTool,
interactive_bash, // Always included, handles missing tmux gracefully via getCachedTmuxPath() ?? "tmux"
slashcommand: slashcommandTool,
interactive_bash,
},
"chat.message": async (input, output) => {

86
src/mcp/index.test.ts Normal file
View File

@@ -0,0 +1,86 @@
import { describe, expect, test } from "bun:test"
import { createBuiltinMcps } from "./index"
describe("createBuiltinMcps", () => {
test("should return all MCPs when disabled_mcps is empty", () => {
//#given
const disabledMcps: string[] = []
//#when
const result = createBuiltinMcps(disabledMcps)
//#then
expect(result).toHaveProperty("websearch")
expect(result).toHaveProperty("context7")
expect(result).toHaveProperty("grep_app")
expect(Object.keys(result)).toHaveLength(3)
})
test("should filter out disabled built-in MCPs", () => {
//#given
const disabledMcps = ["context7"]
//#when
const result = createBuiltinMcps(disabledMcps)
//#then
expect(result).toHaveProperty("websearch")
expect(result).not.toHaveProperty("context7")
expect(result).toHaveProperty("grep_app")
expect(Object.keys(result)).toHaveLength(2)
})
test("should filter out all built-in MCPs when all disabled", () => {
//#given
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)
})
test("should ignore custom MCP names in disabled_mcps", () => {
//#given
const disabledMcps = ["context7", "playwright", "custom"]
//#when
const result = createBuiltinMcps(disabledMcps)
//#then
expect(result).toHaveProperty("websearch")
expect(result).not.toHaveProperty("context7")
expect(result).toHaveProperty("grep_app")
expect(Object.keys(result)).toHaveLength(2)
})
test("should handle empty disabled_mcps by default", () => {
//#given
//#when
const result = createBuiltinMcps()
//#then
expect(result).toHaveProperty("websearch")
expect(result).toHaveProperty("context7")
expect(result).toHaveProperty("grep_app")
expect(Object.keys(result)).toHaveLength(3)
})
test("should only filter built-in MCPs, ignoring unknown names", () => {
//#given
const disabledMcps = ["playwright", "sqlite", "unknown-mcp"]
//#when
const result = createBuiltinMcps(disabledMcps)
//#then
expect(result).toHaveProperty("websearch")
expect(result).toHaveProperty("context7")
expect(result).toHaveProperty("grep_app")
expect(Object.keys(result)).toHaveLength(3)
})
})

View File

@@ -1,4 +1,4 @@
import { websearch_exa } from "./websearch-exa"
import { websearch } from "./websearch"
import { context7 } from "./context7"
import { grep_app } from "./grep-app"
import type { McpName } from "./types"
@@ -6,16 +6,16 @@ import type { McpName } from "./types"
export { McpNameSchema, type McpName } from "./types"
const allBuiltinMcps: Record<McpName, { type: "remote"; url: string; enabled: boolean }> = {
websearch_exa,
websearch,
context7,
grep_app,
}
export function createBuiltinMcps(disabledMcps: McpName[] = []) {
export function createBuiltinMcps(disabledMcps: string[] = []) {
const mcps: Record<string, { type: "remote"; url: string; enabled: boolean }> = {}
for (const [name, config] of Object.entries(allBuiltinMcps)) {
if (!disabledMcps.includes(name as McpName)) {
if (!disabledMcps.includes(name)) {
mcps[name] = config
}
}

View File

@@ -1,5 +1,9 @@
import { z } from "zod"
export const McpNameSchema = z.enum(["websearch_exa", "context7", "grep_app"])
export const McpNameSchema = z.enum(["websearch", "context7", "grep_app"])
export type McpName = z.infer<typeof McpNameSchema>
export const AnyMcpNameSchema = z.string().min(1)
export type AnyMcpName = z.infer<typeof AnyMcpNameSchema>

View File

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

View File

@@ -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,
@@ -168,16 +160,17 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
}
if (plannerEnabled) {
const { name: _planName, ...planConfigWithoutName } =
const { name: _planName, mode: _planMode, ...planConfigWithoutName } =
configAgent?.plan ?? {};
const migratedPlanConfig = migrateAgentConfig(
planConfigWithoutName as Record<string, unknown>
);
const plannerSisyphusOverride =
pluginConfig.agents?.["Planner-Sisyphus"];
const defaultModel = config.model as string | undefined;
const plannerSisyphusBase = {
...migratedPlanConfig,
mode: "primary",
model: (migratedPlanConfig as Record<string, unknown>).model ?? defaultModel,
mode: "primary" as const,
prompt: PLAN_SYSTEM_PROMPT,
permission: PLAN_PERMISSION,
description: `${configAgent?.plan?.description ?? "Plan agent"} (OhMyOpenCode version)`,
@@ -209,7 +202,7 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
: {};
const planDemoteConfig = replacePlan
? { disable: true }
? { mode: "subagent" as const, hidden: true }
: undefined;
config.agent = {
@@ -281,24 +274,31 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
};
const builtinCommands = loadBuiltinCommands(pluginConfig.disabled_commands);
const userCommands = (pluginConfig.claude_code?.commands ?? true)
? loadUserCommands()
: {};
const opencodeGlobalCommands = loadOpencodeGlobalCommands();
const systemCommands = (config.command as Record<string, unknown>) ?? {};
const projectCommands = (pluginConfig.claude_code?.commands ?? true)
? loadProjectCommands()
: {};
const opencodeProjectCommands = loadOpencodeProjectCommands();
const userSkills = (pluginConfig.claude_code?.skills ?? true)
? loadUserSkills()
: {};
const projectSkills = (pluginConfig.claude_code?.skills ?? true)
? loadProjectSkills()
: {};
const opencodeGlobalSkills = loadOpencodeGlobalSkills();
const opencodeProjectSkills = loadOpencodeProjectSkills();
// Parallel loading of all commands and skills for faster startup
const includeClaudeCommands = pluginConfig.claude_code?.commands ?? true;
const includeClaudeSkills = pluginConfig.claude_code?.skills ?? true;
const [
userCommands,
projectCommands,
opencodeGlobalCommands,
opencodeProjectCommands,
userSkills,
projectSkills,
opencodeGlobalSkills,
opencodeProjectSkills,
] = await Promise.all([
includeClaudeCommands ? loadUserCommands() : Promise.resolve({}),
includeClaudeCommands ? loadProjectCommands() : Promise.resolve({}),
loadOpencodeGlobalCommands(),
loadOpencodeProjectCommands(),
includeClaudeSkills ? loadUserSkills() : Promise.resolve({}),
includeClaudeSkills ? loadProjectSkills() : Promise.resolve({}),
loadOpencodeGlobalSkills(),
loadOpencodeProjectSkills(),
]);
config.command = {
...builtinCommands,

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

@@ -1,4 +1,5 @@
import { lstatSync, readlinkSync } from "fs"
import { promises as fs } from "fs"
import { resolve } from "path"
export function isMarkdownFile(entry: { name: string; isFile: () => boolean }): boolean {
@@ -24,3 +25,16 @@ export function resolveSymlink(filePath: string): string {
return filePath
}
}
export async function resolveSymlinkAsync(filePath: string): Promise<string> {
try {
const stats = await fs.lstat(filePath)
if (stats.isSymbolicLink()) {
const linkTarget = await fs.readlink(filePath)
return resolve(filePath, "..", linkTarget)
}
return filePath
} catch {
return filePath
}
}

View File

@@ -19,7 +19,7 @@ import {
import { grep } from "./grep"
import { glob } from "./glob"
import { slashcommand } from "./slashcommand"
export { createSlashcommandTool, discoverCommandsSync } from "./slashcommand"
import {
session_list,
@@ -73,7 +73,6 @@ export const builtinTools: Record<string, ToolDefinition> = {
ast_grep_replace,
grep,
glob,
slashcommand,
session_list,
session_read,
session_search,

View File

@@ -129,22 +129,38 @@ async function formatMcpCapabilities(
}
export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition {
const skills = options.skills ?? discoverSkills({ includeClaudeCodePaths: !options.opencodeOnly })
const skillInfos = skills.map(loadedSkillToInfo)
let cachedSkills: LoadedSkill[] | null = null
let cachedDescription: string | null = null
const description = skillInfos.length === 0
? TOOL_DESCRIPTION_NO_SKILLS
: TOOL_DESCRIPTION_PREFIX + formatSkillsXml(skillInfos)
const getSkills = async (): Promise<LoadedSkill[]> => {
if (options.skills) return options.skills
if (cachedSkills) return cachedSkills
cachedSkills = await discoverSkills({ includeClaudeCodePaths: !options.opencodeOnly })
return cachedSkills
}
const getDescription = async (): Promise<string> => {
if (cachedDescription) return cachedDescription
const skills = await getSkills()
const skillInfos = skills.map(loadedSkillToInfo)
cachedDescription = skillInfos.length === 0
? TOOL_DESCRIPTION_NO_SKILLS
: TOOL_DESCRIPTION_PREFIX + formatSkillsXml(skillInfos)
return cachedDescription
}
getDescription()
return tool({
description,
get description() {
return cachedDescription ?? TOOL_DESCRIPTION_PREFIX
},
args: {
name: tool.schema.string().describe("The skill identifier from available_skills (e.g., 'code-review')"),
},
async execute(args: SkillArgs) {
const skill = options.skills
? skills.find(s => s.name === args.name)
: skills.find(s => s.name === args.name)
const skills = await getSkills()
const skill = skills.find(s => s.name === args.name)
if (!skill) {
const available = skills.map(s => s.name).join(", ")

View File

@@ -1,2 +1,2 @@
export * from "./types"
export { slashcommand } from "./tools"
export { slashcommand, createSlashcommandTool, discoverCommandsSync } from "./tools"

View File

@@ -6,7 +6,7 @@ import type { CommandFrontmatter } from "../../features/claude-code-command-load
import { isMarkdownFile } from "../../shared/file-utils"
import { getClaudeConfigDir } from "../../shared"
import { discoverAllSkills, type LoadedSkill } from "../../features/opencode-skill-loader"
import type { CommandScope, CommandMetadata, CommandInfo } from "./types"
import type { CommandScope, CommandMetadata, CommandInfo, SlashcommandToolOptions } from "./types"
function discoverCommandsFromDir(commandsDir: string, scope: CommandScope): CommandInfo[] {
if (!existsSync(commandsDir)) {
@@ -51,7 +51,7 @@ function discoverCommandsFromDir(commandsDir: string, scope: CommandScope): Comm
return commands
}
function discoverCommandsSync(): CommandInfo[] {
export function discoverCommandsSync(): CommandInfo[] {
const { homedir } = require("os")
const userCommandsDir = join(getClaudeConfigDir(), "commands")
const projectCommandsDir = join(process.cwd(), ".claude", "commands")
@@ -80,22 +80,10 @@ function skillToCommandInfo(skill: LoadedSkill): CommandInfo {
},
content: skill.definition.template,
scope: skill.scope,
lazyContentLoader: skill.lazyContent,
}
}
const availableCommands = discoverCommandsSync()
const availableSkills = discoverAllSkills()
const availableItems = [
...availableCommands,
...availableSkills.map(skillToCommandInfo),
]
const commandListForDescription = availableItems
.map((cmd) => {
const hint = cmd.metadata.argumentHint ? ` ${cmd.metadata.argumentHint}` : ""
return `- /${cmd.name}${hint}: ${cmd.metadata.description} (${cmd.scope})`
})
.join("\n")
async function formatLoadedCommand(cmd: CommandInfo): Promise<string> {
const sections: string[] = []
@@ -125,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())
@@ -151,62 +144,109 @@ function formatCommandList(items: CommandInfo[]): string {
return lines.join("\n")
}
export const slashcommand: ToolDefinition = tool({
description: `Load a skill to get detailed instructions for a specific task.
const TOOL_DESCRIPTION_PREFIX = `Load a skill to get detailed instructions for a specific task.
Skills provide specialized knowledge and step-by-step guidance.
Use this when a task matches an available skill's description.
`
function buildDescriptionFromItems(items: CommandInfo[]): string {
const commandListForDescription = items
.map((cmd) => {
const hint = cmd.metadata.argumentHint ? ` ${cmd.metadata.argumentHint}` : ""
return `- /${cmd.name}${hint}: ${cmd.metadata.description} (${cmd.scope})`
})
.join("\n")
return `${TOOL_DESCRIPTION_PREFIX}
<available_skills>
${commandListForDescription}
</available_skills>`,
</available_skills>`
}
args: {
command: tool.schema
.string()
.describe(
"The slash command to execute (without the leading slash). E.g., 'commit', 'plan', 'execute'."
),
},
export function createSlashcommandTool(options: SlashcommandToolOptions = {}): ToolDefinition {
let cachedCommands: CommandInfo[] | null = options.commands ?? null
let cachedSkills: LoadedSkill[] | null = options.skills ?? null
let cachedDescription: string | null = null
async execute(args) {
const commands = discoverCommandsSync()
const skills = discoverAllSkills()
const allItems = [
...commands,
...skills.map(skillToCommandInfo),
]
const getCommands = (): CommandInfo[] => {
if (cachedCommands) return cachedCommands
cachedCommands = discoverCommandsSync()
return cachedCommands
}
if (!args.command) {
return formatCommandList(allItems) + "\n\nProvide a command or skill name to execute."
}
const getSkills = async (): Promise<LoadedSkill[]> => {
if (cachedSkills) return cachedSkills
cachedSkills = await discoverAllSkills()
return cachedSkills
}
const cmdName = args.command.replace(/^\//, "")
const getAllItems = async (): Promise<CommandInfo[]> => {
const commands = getCommands()
const skills = await getSkills()
return [...commands, ...skills.map(skillToCommandInfo)]
}
const exactMatch = allItems.find(
(cmd) => cmd.name.toLowerCase() === cmdName.toLowerCase()
)
const buildDescription = async (): Promise<string> => {
if (cachedDescription) return cachedDescription
const allItems = await getAllItems()
cachedDescription = buildDescriptionFromItems(allItems)
return cachedDescription
}
if (exactMatch) {
return await formatLoadedCommand(exactMatch)
}
// Pre-warm the cache immediately
buildDescription()
const partialMatches = allItems.filter((cmd) =>
cmd.name.toLowerCase().includes(cmdName.toLowerCase())
)
return tool({
get description() {
return cachedDescription ?? TOOL_DESCRIPTION_PREFIX
},
if (partialMatches.length > 0) {
const matchList = partialMatches.map((cmd) => `/${cmd.name}`).join(", ")
return (
`No exact match for "/${cmdName}". Did you mean: ${matchList}?\n\n` +
formatCommandList(allItems)
args: {
command: tool.schema
.string()
.describe(
"The slash command to execute (without the leading slash). E.g., 'commit', 'plan', 'execute'."
),
},
async execute(args) {
const allItems = await getAllItems()
if (!args.command) {
return formatCommandList(allItems) + "\n\nProvide a command or skill name to execute."
}
const cmdName = args.command.replace(/^\//, "")
const exactMatch = allItems.find(
(cmd) => cmd.name.toLowerCase() === cmdName.toLowerCase()
)
}
return (
`Command or skill "/${cmdName}" not found.\n\n` +
formatCommandList(allItems) +
"\n\nTry a different name."
)
},
})
if (exactMatch) {
return await formatLoadedCommand(exactMatch)
}
const partialMatches = allItems.filter((cmd) =>
cmd.name.toLowerCase().includes(cmdName.toLowerCase())
)
if (partialMatches.length > 0) {
const matchList = partialMatches.map((cmd) => `/${cmd.name}`).join(", ")
return (
`No exact match for "/${cmdName}". Did you mean: ${matchList}?\n\n` +
formatCommandList(allItems)
)
}
return (
`Command or skill "/${cmdName}" not found.\n\n` +
formatCommandList(allItems) +
"\n\nTry a different name."
)
},
})
}
// Default instance for backward compatibility (lazy loading)
export const slashcommand = createSlashcommandTool()

View File

@@ -1,3 +1,5 @@
import type { LoadedSkill, LazyContentLoader } from "../../features/opencode-skill-loader"
export type CommandScope = "builtin" | "config" | "user" | "project" | "opencode" | "opencode-project"
export interface CommandMetadata {
@@ -15,4 +17,12 @@ export interface CommandInfo {
metadata: CommandMetadata
content?: string
scope: CommandScope
lazyContentLoader?: LazyContentLoader
}
export interface SlashcommandToolOptions {
/** Pre-loaded commands (skip discovery if provided) */
commands?: CommandInfo[]
/** Pre-loaded skills (skip discovery if provided) */
skills?: LoadedSkill[]
}