Compare commits
135 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
83676b36cf | ||
|
|
398075f5df | ||
|
|
d4347e829d | ||
|
|
980b685393 | ||
|
|
b5c1cfb57f | ||
|
|
cd97572d0a | ||
|
|
b9ec4c7c4a | ||
|
|
2064568124 | ||
|
|
ad44af9d15 | ||
|
|
d331b484f9 | ||
|
|
4a38e70fa8 | ||
|
|
204ea319cb | ||
|
|
a2bfb5e556 | ||
|
|
f25f7ed0f5 | ||
|
|
29dbc0f57b | ||
|
|
544212fa9c | ||
|
|
f3eed731d6 | ||
|
|
6f1cabd3f4 | ||
|
|
15571d3d95 | ||
|
|
556262e791 | ||
|
|
375e7f715d | ||
|
|
5aa0ee125d | ||
|
|
d0b3be72c5 | ||
|
|
a10903def2 | ||
|
|
dc5a24ac3e | ||
|
|
9d13c6cff1 | ||
|
|
b78e564872 | ||
|
|
c709fafa25 | ||
|
|
5914a393ad | ||
|
|
4e5b3566a2 | ||
|
|
898d3e6175 | ||
|
|
21236d88a7 | ||
|
|
ea8ca1a100 | ||
|
|
66acb0e444 | ||
|
|
f7c8763462 | ||
|
|
ee2f390bf6 | ||
|
|
ae6495dc17 | ||
|
|
b8b8d14b1c | ||
|
|
7a10b24bbd | ||
|
|
258463a146 | ||
|
|
0f890c11c2 | ||
|
|
e81002ba43 | ||
|
|
a20f011014 | ||
|
|
48174ec25a | ||
|
|
26e77a0a89 | ||
|
|
a5c71473a5 | ||
|
|
aecfc77fb6 | ||
|
|
5a4261a607 | ||
|
|
6913613398 | ||
|
|
d27a1efd94 | ||
|
|
bc05fb6671 | ||
|
|
7937d72cbf | ||
|
|
fe11ba294c | ||
|
|
6b5a8263f9 | ||
|
|
65b00c9720 | ||
|
|
5ed031db63 | ||
|
|
0553676ab0 | ||
|
|
b80b373230 | ||
|
|
f55046228f | ||
|
|
2992902283 | ||
|
|
b66c8dc1d1 | ||
|
|
8f2209a138 | ||
|
|
6c3ef65aed | ||
|
|
e1e8b24941 | ||
|
|
0d0ddefbfe | ||
|
|
09f72e2902 | ||
|
|
5f63aff01d | ||
|
|
6fd9734337 | ||
|
|
4bf853fc91 | ||
|
|
87134d3390 | ||
|
|
36c42ac92f | ||
|
|
56fe32caab | ||
|
|
09756b8ffc | ||
|
|
9ba9f906c5 | ||
|
|
ce69007fde | ||
|
|
b1f36d61a8 | ||
|
|
97e51c42dc | ||
|
|
91d2705804 | ||
|
|
6575dfcbc4 | ||
|
|
59b0e6943d | ||
|
|
9d64f213ee | ||
|
|
e572c7c321 | ||
|
|
be2adff3ef | ||
|
|
37f4c48183 | ||
|
|
a49fbeec5f | ||
|
|
7a7b16fb62 | ||
|
|
ae781f1e14 | ||
|
|
d7645a4058 | ||
|
|
16927729c7 | ||
|
|
a4ba63cd1c | ||
|
|
063db0d390 | ||
|
|
dc52395ead | ||
|
|
c8e9f90900 | ||
|
|
6fbc5ba582 | ||
|
|
fc76ea9d93 | ||
|
|
2a3b45bea5 | ||
|
|
79b80e5a2f | ||
|
|
e2cbe8c29b | ||
|
|
99c7df5640 | ||
|
|
f61e1a5f2b | ||
|
|
03c51c9321 | ||
|
|
c10994563b | ||
|
|
d188688dd8 | ||
|
|
95645effd7 | ||
|
|
00b8f622d5 | ||
|
|
967e53258c | ||
|
|
c40f562434 | ||
|
|
a9523bc607 | ||
|
|
f26bf24c33 | ||
|
|
bc65fcea7e | ||
|
|
3a8eac751e | ||
|
|
48dc8298dd | ||
|
|
8bc9d6a540 | ||
|
|
6a6e20cf5d | ||
|
|
3a5aea7f4b | ||
|
|
a4812801b4 | ||
|
|
6422ff270b | ||
|
|
3c27206777 | ||
|
|
8510a2273d | ||
|
|
a8ca3ad5fb | ||
|
|
30e0cc6ef1 | ||
|
|
f345101f91 | ||
|
|
d09c994b91 | ||
|
|
8c30974c18 | ||
|
|
c341c156ec | ||
|
|
b1528c590d | ||
|
|
8b9913345b | ||
|
|
fa204d8af0 | ||
|
|
924fa79bd3 | ||
|
|
c78241e78e | ||
|
|
d0694e5aa4 | ||
|
|
4a9bdc89aa | ||
|
|
50afbf7c37 | ||
|
|
b64b3f96e6 | ||
|
|
e3ad790185 |
129
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
129
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
@@ -0,0 +1,129 @@
|
||||
name: Bug Report
|
||||
description: Report a bug or unexpected behavior in oh-my-opencode
|
||||
title: "[Bug]: "
|
||||
labels: ["bug", "needs-triage"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
**Please write your issue in English.** See our [Language Policy](https://github.com/code-yeongyu/oh-my-opencode/blob/dev/CONTRIBUTING.md#language-policy) for details.
|
||||
|
||||
- type: checkboxes
|
||||
id: prerequisites
|
||||
attributes:
|
||||
label: Prerequisites
|
||||
description: Please confirm the following before submitting
|
||||
options:
|
||||
- label: I have searched existing issues to avoid duplicates
|
||||
required: true
|
||||
- label: I am using the latest version of oh-my-opencode
|
||||
required: true
|
||||
- label: I have read the [documentation](https://github.com/code-yeongyu/oh-my-opencode#readme)
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Bug Description
|
||||
description: A clear and concise description of what the bug is
|
||||
placeholder: Describe the bug in detail...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: reproduction
|
||||
attributes:
|
||||
label: Steps to Reproduce
|
||||
description: Steps to reproduce the behavior
|
||||
placeholder: |
|
||||
1. Configure oh-my-opencode with...
|
||||
2. Run command '...'
|
||||
3. See error...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected Behavior
|
||||
description: What did you expect to happen?
|
||||
placeholder: Describe what should happen...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: actual
|
||||
attributes:
|
||||
label: Actual Behavior
|
||||
description: What actually happened?
|
||||
placeholder: Describe what actually happened...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: doctor
|
||||
attributes:
|
||||
label: Doctor Output
|
||||
description: |
|
||||
**Required:** Run `bunx oh-my-opencode doctor` and paste the full output below.
|
||||
This helps us diagnose your environment and configuration.
|
||||
placeholder: |
|
||||
Paste the output of: bunx oh-my-opencode doctor
|
||||
|
||||
Example:
|
||||
✓ OpenCode version: 1.0.150
|
||||
✓ oh-my-opencode version: 1.2.3
|
||||
✓ Plugin loaded successfully
|
||||
...
|
||||
render: shell
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Error Logs
|
||||
description: If applicable, add any error messages or logs
|
||||
placeholder: Paste error logs here...
|
||||
render: shell
|
||||
|
||||
- type: textarea
|
||||
id: config
|
||||
attributes:
|
||||
label: Configuration
|
||||
description: If relevant, share your oh-my-opencode configuration (remove sensitive data)
|
||||
placeholder: |
|
||||
{
|
||||
"agents": { ... },
|
||||
"disabled_hooks": [ ... ]
|
||||
}
|
||||
render: json
|
||||
|
||||
- type: textarea
|
||||
id: context
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Any other context about the problem
|
||||
placeholder: Add any other context, screenshots, or information...
|
||||
|
||||
- type: dropdown
|
||||
id: os
|
||||
attributes:
|
||||
label: Operating System
|
||||
description: Which operating system are you using?
|
||||
options:
|
||||
- macOS
|
||||
- Linux
|
||||
- Windows
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: opencode-version
|
||||
attributes:
|
||||
label: OpenCode Version
|
||||
description: Run `opencode --version` to get your version
|
||||
placeholder: "1.0.150"
|
||||
validations:
|
||||
required: true
|
||||
8
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
8
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Discord Community
|
||||
url: https://discord.gg/PUwSMR9XNk
|
||||
about: Join our Discord server for real-time discussions and community support
|
||||
- name: Documentation
|
||||
url: https://github.com/code-yeongyu/oh-my-opencode#readme
|
||||
about: Read the comprehensive documentation and guides
|
||||
100
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
100
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
@@ -0,0 +1,100 @@
|
||||
name: Feature Request
|
||||
description: Suggest a new feature or enhancement for oh-my-opencode
|
||||
title: "[Feature]: "
|
||||
labels: ["enhancement", "needs-triage"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
**Please write your issue in English.** See our [Language Policy](https://github.com/code-yeongyu/oh-my-opencode/blob/dev/CONTRIBUTING.md#language-policy) for details.
|
||||
|
||||
- type: checkboxes
|
||||
id: prerequisites
|
||||
attributes:
|
||||
label: Prerequisites
|
||||
description: Please confirm the following before submitting
|
||||
options:
|
||||
- label: I have searched existing issues and discussions to avoid duplicates
|
||||
required: true
|
||||
- label: This feature request is specific to oh-my-opencode (not OpenCode core)
|
||||
required: true
|
||||
- label: I have read the [documentation](https://github.com/code-yeongyu/oh-my-opencode#readme)
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: Problem Description
|
||||
description: What problem does this feature solve? What's the use case?
|
||||
placeholder: |
|
||||
Describe the problem or limitation you're experiencing...
|
||||
Example: "As a user, I find it difficult to..."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: solution
|
||||
attributes:
|
||||
label: Proposed Solution
|
||||
description: Describe how you'd like this feature to work
|
||||
placeholder: |
|
||||
Describe your proposed solution in detail...
|
||||
Example: "Add a new hook that..."
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Alternatives Considered
|
||||
description: Have you considered any alternative solutions or workarounds?
|
||||
placeholder: |
|
||||
Describe any alternative solutions you've considered...
|
||||
Example: "I tried using X but it didn't work because..."
|
||||
|
||||
- type: textarea
|
||||
id: doctor
|
||||
attributes:
|
||||
label: Doctor Output (Optional)
|
||||
description: |
|
||||
If relevant to your feature request, run `bunx oh-my-opencode doctor` and paste the output.
|
||||
This helps us understand your environment.
|
||||
placeholder: |
|
||||
Paste the output of: bunx oh-my-opencode doctor
|
||||
(Optional for feature requests)
|
||||
render: shell
|
||||
|
||||
- type: textarea
|
||||
id: context
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: Any other context, mockups, or examples
|
||||
placeholder: |
|
||||
Add any other context, screenshots, code examples, or links...
|
||||
Examples from other tools/projects are helpful!
|
||||
|
||||
- type: dropdown
|
||||
id: feature-type
|
||||
attributes:
|
||||
label: Feature Type
|
||||
description: What type of feature is this?
|
||||
options:
|
||||
- New Agent
|
||||
- New Hook
|
||||
- New Tool
|
||||
- New MCP Integration
|
||||
- Configuration Option
|
||||
- Documentation
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: checkboxes
|
||||
id: contribution
|
||||
attributes:
|
||||
label: Contribution
|
||||
description: Are you willing to contribute to this feature?
|
||||
options:
|
||||
- label: I'm willing to submit a PR for this feature
|
||||
- label: I can help with testing
|
||||
- label: I can help with documentation
|
||||
83
.github/ISSUE_TEMPLATE/general.yml
vendored
Normal file
83
.github/ISSUE_TEMPLATE/general.yml
vendored
Normal file
@@ -0,0 +1,83 @@
|
||||
name: Question or Discussion
|
||||
description: Ask a question or start a discussion about oh-my-opencode
|
||||
title: "[Question]: "
|
||||
labels: ["question", "needs-triage"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
**Please write your issue in English.** See our [Language Policy](https://github.com/code-yeongyu/oh-my-opencode/blob/dev/CONTRIBUTING.md#language-policy) for details.
|
||||
|
||||
- type: checkboxes
|
||||
id: prerequisites
|
||||
attributes:
|
||||
label: Prerequisites
|
||||
description: Please confirm the following before submitting
|
||||
options:
|
||||
- label: I have searched existing issues and discussions
|
||||
required: true
|
||||
- label: I have read the [documentation](https://github.com/code-yeongyu/oh-my-opencode#readme)
|
||||
required: true
|
||||
- label: This is a question (not a bug report or feature request)
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: question
|
||||
attributes:
|
||||
label: Question
|
||||
description: What would you like to know or discuss?
|
||||
placeholder: |
|
||||
Ask your question in detail...
|
||||
|
||||
Examples:
|
||||
- How do I configure agent X to do Y?
|
||||
- What's the best practice for Z?
|
||||
- Why does feature A work differently than B?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: context
|
||||
attributes:
|
||||
label: Context
|
||||
description: Provide any relevant context or background
|
||||
placeholder: |
|
||||
What have you tried so far?
|
||||
What's your use case?
|
||||
Any relevant configuration or setup details?
|
||||
|
||||
- type: textarea
|
||||
id: doctor
|
||||
attributes:
|
||||
label: Doctor Output (Optional)
|
||||
description: |
|
||||
If your question is about configuration or setup, run `bunx oh-my-opencode doctor` and paste the output.
|
||||
placeholder: |
|
||||
Paste the output of: bunx oh-my-opencode doctor
|
||||
(Optional for questions)
|
||||
render: shell
|
||||
|
||||
- type: dropdown
|
||||
id: category
|
||||
attributes:
|
||||
label: Question Category
|
||||
description: What is your question about?
|
||||
options:
|
||||
- Configuration
|
||||
- Agent Usage
|
||||
- Hook Behavior
|
||||
- Tool Usage
|
||||
- Installation/Setup
|
||||
- Best Practices
|
||||
- Performance
|
||||
- Integration
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: additional
|
||||
attributes:
|
||||
label: Additional Information
|
||||
description: Any other information that might be helpful
|
||||
placeholder: Links, screenshots, examples, etc.
|
||||
22
.github/workflows/lint-workflows.yml
vendored
Normal file
22
.github/workflows/lint-workflows.yml
vendored
Normal file
@@ -0,0 +1,22 @@
|
||||
name: Lint Workflows
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- '.github/workflows/**'
|
||||
pull_request:
|
||||
paths:
|
||||
- '.github/workflows/**'
|
||||
|
||||
jobs:
|
||||
actionlint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Install actionlint
|
||||
run: |
|
||||
bash <(curl -sSL https://raw.githubusercontent.com/rhysd/actionlint/v1.7.10/scripts/download-actionlint.bash)
|
||||
|
||||
- name: Run actionlint
|
||||
run: ./actionlint -color -shellcheck=""
|
||||
138
.github/workflows/sisyphus-agent.yml
vendored
138
.github/workflows/sisyphus-agent.yml
vendored
@@ -15,13 +15,13 @@ jobs:
|
||||
agent:
|
||||
runs-on: ubuntu-latest
|
||||
# @sisyphus-dev-ai mention only (maintainers, exclude self)
|
||||
if: |
|
||||
if: >-
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(contains(github.event.comment.body, '@sisyphus-dev-ai') &&
|
||||
github.event.comment.user.login != 'sisyphus-dev-ai' &&
|
||||
contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association))
|
||||
(github.event_name == 'issue_comment' &&
|
||||
contains(github.event.comment.body || '', '@sisyphus-dev-ai') &&
|
||||
(github.event.comment.user.login || '') != 'sisyphus-dev-ai' &&
|
||||
contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association || ''))
|
||||
|
||||
# Minimal default GITHUB_TOKEN permissions
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -156,6 +156,71 @@ jobs:
|
||||
|
||||
OMO_JSON=~/.config/opencode/oh-my-opencode.json
|
||||
PROMPT_APPEND=$(cat << 'PROMPT_EOF'
|
||||
<ultrawork-mode>
|
||||
[CODE RED] Maximum precision required. Ultrathink before acting.
|
||||
|
||||
YOU MUST LEVERAGE ALL AVAILABLE AGENTS TO THEIR FULLEST POTENTIAL.
|
||||
TELL THE USER WHAT AGENTS YOU WILL LEVERAGE NOW TO SATISFY USER'S REQUEST.
|
||||
|
||||
## AGENT UTILIZATION PRINCIPLES (by capability, not by name)
|
||||
- **Codebase Exploration**: Spawn exploration agents using BACKGROUND TASKS for file patterns, internal implementations, project structure
|
||||
- **Documentation & References**: Use librarian-type agents via BACKGROUND TASKS for API references, examples, external library docs
|
||||
- **Planning & Strategy**: For implementation tasks, spawn a dedicated planning agent for work breakdown (not needed for simple questions/investigations)
|
||||
- **High-IQ Reasoning**: Leverage specialized agents for architecture decisions, code review, strategic planning
|
||||
- **Frontend/UI Tasks**: Delegate to UI-specialized agents for design and implementation
|
||||
|
||||
## EXECUTION RULES
|
||||
- **TODO**: Track EVERY step. Mark complete IMMEDIATELY after each.
|
||||
- **PARALLEL**: Fire independent agent calls simultaneously via background_task - NEVER wait sequentially.
|
||||
- **BACKGROUND FIRST**: Use background_task for exploration/research agents (10+ concurrent if needed).
|
||||
- **VERIFY**: Re-read request after completion. Check ALL requirements met before reporting done.
|
||||
- **DELEGATE**: Don't do everything yourself - orchestrate specialized agents for their strengths.
|
||||
|
||||
## WORKFLOW
|
||||
1. Analyze the request and identify required capabilities
|
||||
2. Spawn exploration/librarian agents via background_task in PARALLEL (10+ if needed)
|
||||
3. Always Use Plan agent with gathered context to create detailed work breakdown
|
||||
4. Execute with continuous verification against original requirements
|
||||
|
||||
## TDD (if test infrastructure exists)
|
||||
|
||||
1. Write spec (requirements)
|
||||
2. Write tests (failing)
|
||||
3. RED: tests fail
|
||||
4. Implement minimal code
|
||||
5. GREEN: tests pass
|
||||
6. Refactor if needed (must stay green)
|
||||
7. Next feature, repeat
|
||||
|
||||
## ZERO TOLERANCE FAILURES
|
||||
- **NO Scope Reduction**: Never make "demo", "skeleton", "simplified", "basic" versions - deliver FULL implementation
|
||||
- **NO MockUp Work**: When user asked you to do "port A", you must "port A", fully, 100%. No Extra feature, No reduced feature, no mock data, fully working 100% port.
|
||||
- **NO Partial Completion**: Never stop at 60-80% saying "you can extend this..." - finish 100%
|
||||
- **NO Assumed Shortcuts**: Never skip requirements you deem "optional" or "can be added later"
|
||||
- **NO Premature Stopping**: Never declare done until ALL TODOs are completed and verified
|
||||
- **NO TEST DELETION**: Never delete or skip failing tests to make the build pass. Fix the code, not the tests.
|
||||
|
||||
THE USER ASKED FOR X. DELIVER EXACTLY X. NOT A SUBSET. NOT A DEMO. NOT A STARTING POINT.
|
||||
|
||||
</ultrawork-mode>
|
||||
|
||||
---
|
||||
|
||||
|
||||
[analyze-mode]
|
||||
ANALYSIS MODE. Gather context before diving deep:
|
||||
|
||||
CONTEXT GATHERING (parallel):
|
||||
- 1-2 explore agents (codebase patterns, implementations)
|
||||
- 1-2 librarian agents (if external library involved)
|
||||
- Direct tools: Grep, AST-grep, LSP for targeted searches
|
||||
|
||||
IF COMPLEX (architecture, multi-system, debugging after 2+ failures):
|
||||
- Consult oracle for strategic guidance
|
||||
|
||||
SYNTHESIZE findings before proceeding.
|
||||
|
||||
---
|
||||
|
||||
## GitHub Actions Environment
|
||||
|
||||
@@ -244,14 +309,17 @@ jobs:
|
||||
AUTHOR="$COMMENT_AUTHOR"
|
||||
COMMENT_ID="$COMMENT_ID_VAL"
|
||||
|
||||
# Check if PR or Issue
|
||||
if gh api "repos/$REPO/issues/${ISSUE_NUM}" | jq -e '.pull_request' > /dev/null; then
|
||||
# Check if PR or Issue and get title
|
||||
ISSUE_DATA=$(gh api "repos/$REPO/issues/${ISSUE_NUM}")
|
||||
TITLE=$(echo "$ISSUE_DATA" | jq -r '.title')
|
||||
if echo "$ISSUE_DATA" | jq -e '.pull_request' > /dev/null; then
|
||||
echo "type=pr" >> $GITHUB_OUTPUT
|
||||
echo "number=${ISSUE_NUM}" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "type=issue" >> $GITHUB_OUTPUT
|
||||
echo "number=${ISSUE_NUM}" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
echo "title=${TITLE}" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
echo "comment<<EOF" >> $GITHUB_OUTPUT
|
||||
@@ -297,15 +365,32 @@ jobs:
|
||||
COMMENT_AUTHOR: ${{ steps.context.outputs.author }}
|
||||
CONTEXT_TYPE: ${{ steps.context.outputs.type }}
|
||||
CONTEXT_NUMBER: ${{ steps.context.outputs.number }}
|
||||
CONTEXT_TITLE: ${{ steps.context.outputs.title }}
|
||||
REPO_NAME: ${{ github.repository }}
|
||||
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
run: |
|
||||
export PATH="$HOME/.opencode/bin:$PATH"
|
||||
|
||||
PROMPT=$(cat <<'PROMPT_EOF'
|
||||
[analyze-mode]
|
||||
ANALYSIS MODE. Gather context before diving deep:
|
||||
|
||||
CONTEXT GATHERING (parallel):
|
||||
- 1-2 explore agents (codebase patterns, implementations)
|
||||
- 1-2 librarian agents (if external library involved)
|
||||
- Direct tools: Grep, AST-grep, LSP for targeted searches
|
||||
|
||||
IF COMPLEX (architecture, multi-system, debugging after 2+ failures):
|
||||
- Consult oracle for strategic guidance
|
||||
|
||||
SYNTHESIZE findings before proceeding.
|
||||
|
||||
---
|
||||
|
||||
Your username is @sisyphus-dev-ai, mentioned by @AUTHOR_PLACEHOLDER in REPO_PLACEHOLDER.
|
||||
|
||||
## Context
|
||||
- Title: TITLE_PLACEHOLDER
|
||||
- Type: TYPE_PLACEHOLDER
|
||||
- Number: #NUMBER_PLACEHOLDER
|
||||
- Repository: REPO_PLACEHOLDER
|
||||
@@ -316,8 +401,42 @@ jobs:
|
||||
|
||||
---
|
||||
|
||||
Write everything using the todo tools.
|
||||
Then investigate and satisfy the request. Only if user requested to you to work explicitely, then use plan agent to plan, todo obsessivley then create a PR to `BRANCH_PLACEHOLDER` branch.
|
||||
## CRITICAL: First Steps (MUST DO BEFORE ANYTHING ELSE)
|
||||
|
||||
### [CODE RED] MANDATORY CONTEXT READING - ZERO EXCEPTIONS
|
||||
|
||||
**YOU MUST READ ALL CONTENT. NOT SOME. NOT MOST. ALL.**
|
||||
|
||||
1. **READ FULL CONVERSATION** - Execute ALL commands below before ANY other action:
|
||||
- **Issues**: `gh issue view NUMBER_PLACEHOLDER --comments`
|
||||
- **PRs**: Use ALL THREE commands to get COMPLETE context:
|
||||
```bash
|
||||
gh pr view NUMBER_PLACEHOLDER --comments
|
||||
gh api repos/REPO_PLACEHOLDER/pulls/NUMBER_PLACEHOLDER/comments
|
||||
gh api repos/REPO_PLACEHOLDER/pulls/NUMBER_PLACEHOLDER/reviews
|
||||
```
|
||||
|
||||
**WHAT TO EXTRACT FROM THE CONVERSATION:**
|
||||
- The ORIGINAL issue/PR description (first message) - this is often the TRUE requirement
|
||||
- ALL previous attempts and their outcomes
|
||||
- ALL decisions made and their reasoning
|
||||
- ALL feedback, criticism, and rejection reasons
|
||||
- ANY linked issues, PRs, or external references
|
||||
- The EXACT ask from the user who mentioned you
|
||||
|
||||
**FAILURE TO READ EVERYTHING = GUARANTEED FAILURE**
|
||||
You WILL make wrong assumptions. You WILL repeat past mistakes. You WILL miss critical context.
|
||||
|
||||
2. **CREATE TODOS IMMEDIATELY**: Right after reading, create your todo list using todo tools.
|
||||
- First todo: "Summarize issue/PR context and requirements"
|
||||
- Break down ALL work into atomic, verifiable steps
|
||||
- Plan everything BEFORE starting any work
|
||||
|
||||
---
|
||||
|
||||
|
||||
Plan everything using todo tools.
|
||||
Then investigate and satisfy the request. Only if user requested to you to work explicitly, then use plan agent to plan, todo obsessively then create a PR to `BRANCH_PLACEHOLDER` branch.
|
||||
When done, report the result to the issue/PR with `gh issue comment NUMBER_PLACEHOLDER` or `gh pr comment NUMBER_PLACEHOLDER`.
|
||||
PROMPT_EOF
|
||||
)
|
||||
@@ -326,6 +445,7 @@ jobs:
|
||||
PROMPT="${PROMPT//REPO_PLACEHOLDER/$REPO_NAME}"
|
||||
PROMPT="${PROMPT//TYPE_PLACEHOLDER/$CONTEXT_TYPE}"
|
||||
PROMPT="${PROMPT//NUMBER_PLACEHOLDER/$CONTEXT_NUMBER}"
|
||||
PROMPT="${PROMPT//TITLE_PLACEHOLDER/$CONTEXT_TITLE}"
|
||||
PROMPT="${PROMPT//BRANCH_PLACEHOLDER/$DEFAULT_BRANCH}"
|
||||
PROMPT="${PROMPT//COMMENT_PLACEHOLDER/$USER_COMMENT}"
|
||||
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,4 +1,5 @@
|
||||
# Dependencies
|
||||
.sisyphus/
|
||||
node_modules/
|
||||
|
||||
# Build output
|
||||
|
||||
37
AGENTS.md
37
AGENTS.md
@@ -1,7 +1,7 @@
|
||||
# PROJECT KNOWLEDGE BASE
|
||||
|
||||
**Generated:** 2026-01-02T10:35:00+09:00
|
||||
**Commit:** bebe660
|
||||
**Generated:** 2026-01-02T22:41:22+09:00
|
||||
**Commit:** d0694e5
|
||||
**Branch:** dev
|
||||
|
||||
## OVERVIEW
|
||||
@@ -20,9 +20,9 @@ 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 (723 lines)
|
||||
│ └── index.ts # Main plugin entry (464 lines)
|
||||
├── script/ # build-schema.ts, publish.ts, generate-changelog.ts
|
||||
└── dist/ # Build output (ESM + .d.ts)
|
||||
```
|
||||
@@ -39,6 +39,29 @@ oh-my-opencode/
|
||||
| Config schema | `src/config/schema.ts` | Run `bun run build:schema` after |
|
||||
| Claude Code compat | `src/features/claude-code-*-loader/` | Command, skill, agent, mcp loaders |
|
||||
|
||||
## TDD (Test-Driven Development)
|
||||
|
||||
**MANDATORY for new features and bug fixes.** Follow RED-GREEN-REFACTOR:
|
||||
|
||||
```
|
||||
1. RED - Write failing test first (test MUST fail)
|
||||
2. GREEN - Write MINIMAL code to pass (nothing more)
|
||||
3. REFACTOR - Clean up while tests stay GREEN
|
||||
4. REPEAT - Next test case
|
||||
```
|
||||
|
||||
| Phase | Action | Verification |
|
||||
|-------|--------|--------------|
|
||||
| **RED** | Write test describing expected behavior | `bun test` → FAIL (expected) |
|
||||
| **GREEN** | Implement minimum code to pass | `bun test` → PASS |
|
||||
| **REFACTOR** | Improve code quality, remove duplication | `bun test` → PASS (must stay green) |
|
||||
|
||||
**Rules:**
|
||||
- NEVER write implementation before test
|
||||
- NEVER delete failing tests to "pass" - fix the code
|
||||
- One test at a time - don't batch
|
||||
- Test file naming: `*.test.ts` alongside source
|
||||
|
||||
## CONVENTIONS
|
||||
|
||||
- **Bun only**: `bun run`, `bun test`, `bunx` (NEVER npm/npx)
|
||||
@@ -46,7 +69,7 @@ oh-my-opencode/
|
||||
- **Build**: `bun build` (ESM) + `tsc --emitDeclarationOnly`
|
||||
- **Exports**: Barrel pattern in index.ts; explicit named exports for tools/hooks
|
||||
- **Naming**: kebab-case directories, createXXXHook/createXXXTool factories
|
||||
- **Testing**: BDD comments `#given`, `#when`, `#then` (same as AAA)
|
||||
- **Testing**: BDD comments `#given`, `#when`, `#then` (same as AAA); TDD workflow (RED-GREEN-REFACTOR)
|
||||
- **Temperature**: 0.1 for code agents, max 0.3
|
||||
|
||||
## ANTI-PATTERNS
|
||||
@@ -96,11 +119,11 @@ CI auto-commits schema changes on master, maintains rolling `next` draft release
|
||||
|
||||
| File | Lines | Description |
|
||||
|------|-------|-------------|
|
||||
| `src/index.ts` | 723 | Main plugin, all hook/tool init |
|
||||
| `src/index.ts` | 464 | Main plugin, all hook/tool init |
|
||||
| `src/cli/config-manager.ts` | 669 | JSONC parsing, env detection |
|
||||
| `src/auth/antigravity/fetch.ts` | 621 | Token refresh, URL rewriting |
|
||||
| `src/tools/lsp/client.ts` | 611 | LSP protocol, JSON-RPC |
|
||||
| `src/hooks/anthropic-context-window-limit-recovery/executor.ts` | 554 | Multi-stage recovery |
|
||||
| `src/hooks/anthropic-context-window-limit-recovery/executor.ts` | 564 | Multi-stage recovery |
|
||||
| `src/agents/sisyphus.ts` | 504 | Orchestrator prompt |
|
||||
|
||||
## NOTES
|
||||
|
||||
@@ -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
|
||||
|
||||
89
README.ja.md
89
README.ja.md
@@ -2,12 +2,15 @@
|
||||
>
|
||||
> *「私はエージェントが生成したコードと人間が書いたコードを区別できない、しかしはるかに多くのことを達成できる世界を作り、ソフトウェア革命を起こすことを目指しています。私はこの旅に個人的な時間、情熱、そして資金を注ぎ込んできましたし、これからもそうし続けます。」*
|
||||
>
|
||||
> [](https://x.com/justsisyphus/status/2006250634354548963)
|
||||
> > **オーケストレーターが来ます。今週中に。[Xで通知を受け取る](https://x.com/justsisyphus/status/2006250634354548963)**
|
||||
>
|
||||
> 一緒に歩みましょう!
|
||||
>
|
||||
> | [<img alt="Discord link" src="https://img.shields.io/discord/1452487457085063218?color=5865F2&label=discord&labelColor=black&logo=discord&logoColor=white&style=flat-square" width="156px" />](https://discord.gg/PWpXmbhF) | [Discordコミュニティ](https://discord.gg/PWpXmbhF)に参加して、コントリビューターや`oh-my-opencode`仲間とつながりましょう。 |
|
||||
> | [<img alt="Discord link" src="https://img.shields.io/discord/1452487457085063218?color=5865F2&label=discord&labelColor=black&logo=discord&logoColor=white&style=flat-square" width="156px" />](https://discord.gg/PUwSMR9XNk) | [Discordコミュニティ](https://discord.gg/PUwSMR9XNk)に参加して、コントリビューターや`oh-my-opencode`仲間とつながりましょう。 |
|
||||
> | :-----| :----- |
|
||||
> | [<img alt="X link" src="https://img.shields.io/badge/Follow-%40justsisyphus-00CED1?style=flat-square&logo=x&labelColor=black" width="156px" />](https://x.com/justsisyphus) | `oh-my-opencode`に関するニュースは私のXアカウントで投稿していましたが、無実の罪で凍結されたため、<br />[@justsisyphus](https://x.com/justsisyphus)が代わりに更新を投稿しています。 |
|
||||
> | [<img alt="Sponsor" src="https://img.shields.io/badge/Sponsor-❤-ff69b4?style=flat-square&logo=github-sponsors&labelColor=black" width="156px" />](https://github.com/sponsors/code-yeongyu) | [スポンサーになって](https://github.com/sponsors/code-yeongyu) `oh-my-opencode` の開発を応援してください。皆さまのご支援がこのプロジェクトを成長させます。 |
|
||||
> | [<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> -->
|
||||
|
||||
@@ -46,6 +49,11 @@
|
||||
|
||||
> "Oh My Opencodeを使って、たった1日で8000個のeslint警告を解消しました" — [Jacob Ferrari](https://x.com/jacobferrari_/status/2003258761952289061)
|
||||
|
||||
> "Ohmyopencodeとralph loopを使って、一晩で45,000行のtauriアプリをSaaSウェブアプリに変換しました。インタビュープロンプトから始めて、質問に対する評価と推奨を求めました。作業する様子を見ているのは驚きでしたし、朝起きたらほぼ完成したウェブサイトがありました!" - [James Hargis](https://x.com/hargabyte/status/2007299688261882202)
|
||||
|
||||
> "今週末はopen code、oh my opencode、supermemoryでマインクラフト/ソウルライクな何かを作る実験をしています。"
|
||||
> "昼食後の散歩に行く間に、しゃがみアニメーションを追加するよう頼みました。[動画]" - [MagiMetal](https://x.com/MagiMetal/status/2005374704178373023)
|
||||
|
||||
> "これをコアに取り入れて彼を採用すべきです。マジで。本当に、本当に、本当に良いです" — Henning Kilset
|
||||
|
||||
> "@yeon_gyu_kimを説得できるなら雇うべきです。彼はopencodeに革命を起こしました" — [mysticaltech](https://x.com/mysticaltech/status/2001858758608376079)
|
||||
@@ -56,6 +64,8 @@
|
||||
|
||||
> "Oh My Opencodeは頂点に立っています、敵はいません" — [RyanOnThePath](https://x.com/RyanOnThePath/status/2001438321252118548)
|
||||
|
||||
> "Oh My OpenCode Is Actually Insane" - [YouTube - Darren Builds AI](https://www.youtube.com/watch?v=G_Snfh2M41M)
|
||||
|
||||
> "シジフォスという名前自体が美しいじゃないですか?" — Sigrid ([@sigridjin_eth](https://x.com/sigridjin_eth))
|
||||
|
||||
---
|
||||
@@ -70,6 +80,19 @@
|
||||
- [インストール](#インストール)
|
||||
- [人間の方へ](#人間の方へ)
|
||||
- [LLM エージェントの方へ](#llm-エージェントの方へ)
|
||||
- [ステップ 0: サブスクリプション情報を確認](#ステップ-0-サブスクリプション情報を確認)
|
||||
- [ステップ 1: OpenCode インストール確認](#ステップ-1-opencode-インストール確認)
|
||||
- [ステップ 2: インストーラーを実行](#ステップ-2-インストーラーを実行)
|
||||
- [ステップ 3: 設定確認](#ステップ-3-設定確認)
|
||||
- [ステップ 4: 認証情報の設定](#ステップ-4-認証情報の設定)
|
||||
- [4.1 Anthropic (Claude)](#41-anthropic-claude)
|
||||
- [4.2 Google Gemini (Antigravity OAuth)](#42-google-gemini-antigravity-oauth)
|
||||
- [4.3 OpenAI (ChatGPT Plus/Pro)](#43-openai-chatgpt-pluspro)
|
||||
- [⚠️ 注意](#️-注意)
|
||||
- [セットアップの確認](#セットアップの確認)
|
||||
- [ユーザーに「おめでとうございます!🎉」と伝える](#ユーザーにおめでとうございますと伝える)
|
||||
- [複雑すぎますか?](#複雑すぎますか)
|
||||
- [アンインストール](#アンインストール)
|
||||
- [機能](#機能)
|
||||
- [Agents: あなたの新しいチームメイト](#agents-あなたの新しいチームメイト)
|
||||
- [バックグラウンドエージェント: 本当のチームのように働く](#バックグラウンドエージェント-本当のチームのように働く)
|
||||
@@ -167,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と打ち、コーヒーでも飲んでいてください。仕事は終わっています。
|
||||
|
||||
このような機能が不要であれば、前述の通り、特定の機能だけを選んで使うことができます。
|
||||
|
||||
@@ -275,7 +300,7 @@ opencode auth login
|
||||
{
|
||||
"plugin": [
|
||||
"oh-my-opencode",
|
||||
"opencode-antigravity-auth@1.1.2"
|
||||
"opencode-antigravity-auth@1.2.7"
|
||||
]
|
||||
}
|
||||
```
|
||||
@@ -300,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`
|
||||
|
||||
その後、認証を行います:
|
||||
|
||||
@@ -323,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>` オプションで推論強度を制御できます。
|
||||
|
||||
その後、認証を行います:
|
||||
|
||||
@@ -541,7 +559,6 @@ OpenCode セッション履歴をナビゲートおよび検索するための
|
||||
```
|
||||
- **Online**: プロジェクトのルールがすべてではありません。拡張機能のための内蔵 MCP を提供します:
|
||||
- **context7**: ライブラリの最新公式ドキュメントを取得
|
||||
- **websearch_exa**: Exa AI を活用したリアルタイムウェブ検索
|
||||
- **grep_app**: 数百万の公開 GitHub リポジトリから超高速コード検索(実装例を探すのに最適)
|
||||
|
||||
#### マルチモーダルを活用し、トークンは節約する
|
||||
@@ -633,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 等) |
|
||||
@@ -826,7 +843,7 @@ Oh My OpenCode は以下の場所からフックを読み込んで実行しま
|
||||
有効時(デフォルト)、Sisyphus はオプションの特殊エージェントを備えた強力なオーケストレーターを提供します:
|
||||
|
||||
- **Sisyphus**: プライマリオーケストレーターエージェント (Claude Opus 4.5)
|
||||
- **Builder-Sisyphus**: OpenCode のデフォルトビルドエージェント(SDK 制限により名前変更、デフォルトで無効)
|
||||
- **OpenCode-Builder**: OpenCode のデフォルトビルドエージェント(SDK 制限により名前変更、デフォルトで無効)
|
||||
- **Planner-Sisyphus**: OpenCode のデフォルトプランエージェント(SDK 制限により名前変更、デフォルトで有効)
|
||||
|
||||
**設定オプション:**
|
||||
@@ -842,7 +859,7 @@ Oh My OpenCode は以下の場所からフックを読み込んで実行しま
|
||||
}
|
||||
```
|
||||
|
||||
**例:Builder-Sisyphus を有効化:**
|
||||
**例:OpenCode-Builder を有効化:**
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -852,7 +869,7 @@ Oh My OpenCode は以下の場所からフックを読み込んで実行しま
|
||||
}
|
||||
```
|
||||
|
||||
これにより、Sisyphus と並行して Builder-Sisyphus エージェントを有効化できます。Sisyphus が有効な場合、デフォルトのビルドエージェントは常にサブエージェントモードに降格されます。
|
||||
これにより、Sisyphus と並行して OpenCode-Builder エージェントを有効化できます。Sisyphus が有効な場合、デフォルトのビルドエージェントは常にサブエージェントモードに降格されます。
|
||||
|
||||
**例:すべての Sisyphus オーケストレーションを無効化:**
|
||||
|
||||
@@ -873,7 +890,7 @@ Oh My OpenCode は以下の場所からフックを読み込んで実行しま
|
||||
"model": "anthropic/claude-sonnet-4",
|
||||
"temperature": 0.3
|
||||
},
|
||||
"Builder-Sisyphus": {
|
||||
"OpenCode-Builder": {
|
||||
"model": "anthropic/claude-opus-4"
|
||||
},
|
||||
"Planner-Sisyphus": {
|
||||
@@ -886,7 +903,7 @@ Oh My OpenCode は以下の場所からフックを読み込んで実行しま
|
||||
| オプション | デフォルト | 説明 |
|
||||
| --------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `disabled` | `false` | `true` の場合、すべての Sisyphus オーケストレーションを無効化し、元の build/plan をプライマリとして復元します。 |
|
||||
| `default_builder_enabled` | `false` | `true` の場合、Builder-Sisyphus エージェントを有効化します(OpenCode build と同じ、SDK 制限により名前変更)。デフォルトでは無効です。 |
|
||||
| `default_builder_enabled` | `false` | `true` の場合、OpenCode-Builder エージェントを有効化します(OpenCode build と同じ、SDK 制限により名前変更)。デフォルトでは無効です。 |
|
||||
| `planner_enabled` | `true` | `true` の場合、Planner-Sisyphus エージェントを有効化します(OpenCode plan と同じ、SDK 制限により名前変更)。デフォルトで有効です。 |
|
||||
| `replace_plan` | `true` | `true` の場合、デフォルトのプランエージェントをサブエージェントモードに降格させます。`false` に設定すると、Planner-Sisyphus とデフォルトのプランの両方を利用できます。 |
|
||||
|
||||
@@ -906,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"]
|
||||
}
|
||||
```
|
||||
|
||||
@@ -1025,5 +1041,8 @@ OpenCode が Debian / ArchLinux だとしたら、Oh My OpenCode は Ubuntu / [O
|
||||
- **Numman Ali** [GitHub](https://github.com/numman-ali) [X](https://x.com/nummanali)
|
||||
- 最初のスポンサー
|
||||
- **Aaron Iker** [GitHub](https://github.com/aaroniker) [X](https://x.com/aaroniker)
|
||||
- **Suyeol Jeon (devxoul)** [GitHub](https://github.com/devxoul)
|
||||
- 私のキャリアをスタートさせてくださった方であり、優れたエージェンティックワークフローをどのように構築できるかについて多大なインスピレーションを与えてくださった方です。優れたチームを作るために優れたシステムをどう設計すべきか多くのことを学び、その学びがこのharnessを作る上で大きな助けとなりました。
|
||||
- **Hyerin Won (devwon)** [GitHub](https://github.com/devwon)
|
||||
|
||||
*素晴らしいヒーロー画像を作成してくれた [@junhoyeo](https://github.com/junhoyeo) に感謝します*
|
||||
|
||||
1022
README.ko.md
1022
README.ko.md
File diff suppressed because it is too large
Load Diff
79
README.md
79
README.md
@@ -7,10 +7,10 @@
|
||||
>
|
||||
> Be with us!
|
||||
>
|
||||
> | [<img alt="Discord link" src="https://img.shields.io/discord/1452487457085063218?color=5865F2&label=discord&labelColor=black&logo=discord&logoColor=white&style=flat-square" width="156px" />](https://discord.gg/PWpXmbhF) | Join our [Discord community](https://discord.gg/PWpXmbhF) to connect with contributors and fellow `oh-my-opencode` users. |
|
||||
> | [<img alt="Discord link" src="https://img.shields.io/discord/1452487457085063218?color=5865F2&label=discord&labelColor=black&logo=discord&logoColor=white&style=flat-square" width="156px" />](https://discord.gg/PUwSMR9XNk) | Join our [Discord community](https://discord.gg/PUwSMR9XNk) to connect with contributors and fellow `oh-my-opencode` users. |
|
||||
> | :-----| :----- |
|
||||
> | [<img alt="X link" src="https://img.shields.io/badge/Follow-%40justsisyphus-00CED1?style=flat-square&logo=x&labelColor=black" width="156px" />](https://x.com/justsisyphus) | News and updates for `oh-my-opencode` used to be posted on my X account. <br /> Since it was suspended mistakenly, [@justsisyphus](https://x.com/justsisyphus) now posts updates on my behalf. |
|
||||
> | [<img alt="Sponsor" src="https://img.shields.io/badge/Sponsor-❤-ff69b4?style=flat-square&logo=github-sponsors&labelColor=black" width="156px" />](https://github.com/sponsors/code-yeongyu) | Support the development of `oh-my-opencode` by [becoming a sponsor](https://github.com/sponsors/code-yeongyu). Your contribution helps keep this project alive and growing. |
|
||||
> | [<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> -->
|
||||
|
||||
@@ -42,7 +42,7 @@ No stupid token consumption massive subagents here. No bloat tools here.
|
||||
[](https://github.com/code-yeongyu/oh-my-opencode/issues)
|
||||
[](https://github.com/code-yeongyu/oh-my-opencode/blob/master/LICENSE.md)
|
||||
|
||||
[English](README.md) | [한국어](README.ko.md) | [日本語](README.ja.md) | [简体中文](README.zh-cn.md)
|
||||
[English](README.md) | [日本語](README.ja.md) | [简体中文](README.zh-cn.md)
|
||||
|
||||
</div>
|
||||
|
||||
@@ -54,6 +54,11 @@ No stupid token consumption massive subagents here. No bloat tools here.
|
||||
|
||||
> "Knocked out 8000 eslint warnings with Oh My Opencode, just in a day" — [Jacob Ferrari](https://x.com/jacobferrari_/status/2003258761952289061)
|
||||
|
||||
> "I converted a 45k line tauri app into a SaaS web app overnight using Ohmyopencode and ralph loop. Started with interview me prompt, asked it for ratings and recommendations on the questions. It was amazing to watch it work and to wake up this morning to a mostly working website!" - [James Hargis](https://x.com/hargabyte/status/2007299688261882202)
|
||||
|
||||
> "Experimenting with open code, oh my opencode and supermemory this weekend to build some minecraft/souls-like abomination."
|
||||
> "Asking it to add crouch animations while I go take my post-lunch walk. [Video]" - [MagiMetal](https://x.com/MagiMetal/status/2005374704178373023)
|
||||
|
||||
> "You guys should pull this into core and recruit him. Seriously. It's really, really, really good." — Henning Kilset
|
||||
|
||||
> "Hire @yeon_gyu_kim if you can convince him, this dude has revolutionized opencode." — [mysticaltech](https://x.com/mysticaltech/status/2001858758608376079)
|
||||
@@ -64,7 +69,9 @@ No stupid token consumption massive subagents here. No bloat tools here.
|
||||
|
||||
> "Oh My Opencode is king of the hill and has no contenders" — [RyanOnThePath](https://x.com/RyanOnThePath/status/2001438321252118548)
|
||||
|
||||
> "Isn't the name Sisyphus itself beautiful?" — Sigrid ([@sigridjin_eth](https://x.com/sigridjin_eth))
|
||||
> "Oh My OpenCode Is Actually Insane" - [YouTube - Darren Builds AI](https://www.youtube.com/watch?v=G_Snfh2M41M)
|
||||
>
|
||||
> "Isn't the name Sisyphus beautiful by itself?" — Sigrid ([@sigridjin_eth](https://x.com/sigridjin_eth))
|
||||
|
||||
---
|
||||
|
||||
@@ -122,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).
|
||||
@@ -191,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.**
|
||||
@@ -315,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"
|
||||
]
|
||||
}
|
||||
```
|
||||
@@ -340,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:
|
||||
|
||||
@@ -363,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:
|
||||
|
||||
@@ -575,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.
|
||||
@@ -688,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.) |
|
||||
@@ -897,7 +905,7 @@ Available built-in skills: `playwright`
|
||||
When enabled (default), Sisyphus provides a powerful orchestrator with optional specialized agents:
|
||||
|
||||
- **Sisyphus**: Primary orchestrator agent (Claude Opus 4.5)
|
||||
- **Builder-Sisyphus**: OpenCode's default build agent, renamed due to SDK limitations (disabled by default)
|
||||
- **OpenCode-Builder**: OpenCode's default build agent, renamed due to SDK limitations (disabled by default)
|
||||
- **Planner-Sisyphus**: OpenCode's default plan agent, renamed due to SDK limitations (enabled by default)
|
||||
|
||||
**Configuration Options:**
|
||||
@@ -913,7 +921,7 @@ When enabled (default), Sisyphus provides a powerful orchestrator with optional
|
||||
}
|
||||
```
|
||||
|
||||
**Example: Enable Builder-Sisyphus:**
|
||||
**Example: Enable OpenCode-Builder:**
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -923,7 +931,7 @@ When enabled (default), Sisyphus provides a powerful orchestrator with optional
|
||||
}
|
||||
```
|
||||
|
||||
This enables Builder-Sisyphus agent alongside Sisyphus. The default build agent is always demoted to subagent mode when Sisyphus is enabled.
|
||||
This enables OpenCode-Builder agent alongside Sisyphus. The default build agent is always demoted to subagent mode when Sisyphus is enabled.
|
||||
|
||||
**Example: Disable all Sisyphus orchestration:**
|
||||
|
||||
@@ -944,7 +952,7 @@ You can also customize Sisyphus agents like other agents:
|
||||
"model": "anthropic/claude-sonnet-4",
|
||||
"temperature": 0.3
|
||||
},
|
||||
"Builder-Sisyphus": {
|
||||
"OpenCode-Builder": {
|
||||
"model": "anthropic/claude-opus-4"
|
||||
},
|
||||
"Planner-Sisyphus": {
|
||||
@@ -957,7 +965,7 @@ You can also customize Sisyphus agents like other agents:
|
||||
| Option | Default | Description |
|
||||
| --------------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `disabled` | `false` | When `true`, disables all Sisyphus orchestration and restores original build/plan as primary. |
|
||||
| `default_builder_enabled` | `false` | When `true`, enables Builder-Sisyphus agent (same as OpenCode build, renamed due to SDK limitations). Disabled by default. |
|
||||
| `default_builder_enabled` | `false` | When `true`, enables OpenCode-Builder agent (same as OpenCode build, renamed due to SDK limitations). Disabled by default. |
|
||||
| `planner_enabled` | `true` | When `true`, enables Planner-Sisyphus agent (same as OpenCode plan, renamed due to SDK limitations). Enabled by default. |
|
||||
| `replace_plan` | `true` | When `true`, demotes default plan agent to subagent mode. Set to `false` to keep both Planner-Sisyphus and default plan available. |
|
||||
|
||||
@@ -977,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"]
|
||||
}
|
||||
```
|
||||
|
||||
@@ -1096,5 +1104,8 @@ I have no affiliation with any project or model mentioned here. This is purely p
|
||||
- **Numman Ali** [GitHub](https://github.com/numman-ali) [X](https://x.com/nummanali)
|
||||
- The first sponsor
|
||||
- **Aaron Iker** [GitHub](https://github.com/aaroniker) [X](https://x.com/aaroniker)
|
||||
- **Suyeol Jeon (devxoul)** [GitHub](https://github.com/devxoul)
|
||||
- The person who launched my career and inspired me deeply on how to build great agentic workflows. I learned so much about designing great systems to build great teams, and those lessons were instrumental in creating this harness.
|
||||
- **Hyerin Won (devwon)** [GitHub](https://github.com/devwon)
|
||||
|
||||
*Special thanks to [@junhoyeo](https://github.com/junhoyeo) for this amazing hero image.*
|
||||
|
||||
@@ -2,12 +2,15 @@
|
||||
>
|
||||
> *"我致力于引发一场软件革命,创造一个AI生成的代码与人类代码无法区分、却能实现更多的世界。我已经在这段旅程中投入了个人时间、热情和资金,并将继续这样做。"*
|
||||
>
|
||||
> [](https://x.com/justsisyphus/status/2006250634354548963)
|
||||
> > **编排器即将到来。就在本周。[在X上获取通知](https://x.com/justsisyphus/status/2006250634354548963)**
|
||||
>
|
||||
> 与我们同行!
|
||||
>
|
||||
> | [<img alt="Discord link" src="https://img.shields.io/discord/1452487457085063218?color=5865F2&label=discord&labelColor=black&logo=discord&logoColor=white&style=flat-square" width="156px" />](https://discord.gg/PWpXmbhF) | 加入我们的 [Discord 社区](https://discord.gg/PWpXmbhF),和贡献者们、`oh-my-opencode` 用户们一起交流。 |
|
||||
> | [<img alt="Discord link" src="https://img.shields.io/discord/1452487457085063218?color=5865F2&label=discord&labelColor=black&logo=discord&logoColor=white&style=flat-square" width="156px" />](https://discord.gg/PUwSMR9XNk) | 加入我们的 [Discord 社区](https://discord.gg/PUwSMR9XNk),和贡献者们、`oh-my-opencode` 用户们一起交流。 |
|
||||
> | :-----| :----- |
|
||||
> | [<img alt="X link" src="https://img.shields.io/badge/Follow-%40justsisyphus-00CED1?style=flat-square&logo=x&labelColor=black" width="156px" />](https://x.com/justsisyphus) | `oh-my-opencode` 的消息之前在我的 X 账号发,但账号被无辜封了,<br />现在 [@justsisyphus](https://x.com/justsisyphus) 替我发更新。 |
|
||||
> | [<img alt="Sponsor" src="https://img.shields.io/badge/Sponsor-❤-ff69b4?style=flat-square&logo=github-sponsors&labelColor=black" width="156px" />](https://github.com/sponsors/code-yeongyu) | [成为赞助者](https://github.com/sponsors/code-yeongyu),支持 `oh-my-opencode` 的开发。您的支持让这个项目持续成长。 |
|
||||
> | [<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> -->
|
||||
|
||||
@@ -48,6 +51,11 @@
|
||||
|
||||
> "只用了一天,就用 Oh My Opencode 干掉了 8000 个 eslint 警告" — [Jacob Ferrari](https://x.com/jacobferrari_/status/2003258761952289061)
|
||||
|
||||
> "用Ohmyopencode和ralph loop,一夜之间把45,000行的tauri应用转成了SaaS网页应用。从面试提示开始,让它对问题进行评分和推荐。看着它工作真是太神奇了,早上醒来一个基本能用的网站就搞定了!" - [James Hargis](https://x.com/hargabyte/status/2007299688261882202)
|
||||
|
||||
> "这个周末在用open code、oh my opencode和supermemory做一个我的世界/魂类的怪物项目。"
|
||||
> "吃完午饭去散步的时候让它加蹲下动画。[视频]" - [MagiMetal](https://x.com/MagiMetal/status/2005374704178373023)
|
||||
|
||||
> "你们应该把它合并到核心代码里并聘用他。认真的。这真的、真的、真的很好" — Henning Kilset
|
||||
|
||||
> "如果你能说服 @yeon_gyu_kim,就雇佣他吧,这家伙彻底改变了 opencode" — [mysticaltech](https://x.com/mysticaltech/status/2001858758608376079)
|
||||
@@ -58,6 +66,8 @@
|
||||
|
||||
> "Oh My Opencode 独孤求败,没有对手" — [RyanOnThePath](https://x.com/RyanOnThePath/status/2001438321252118548)
|
||||
|
||||
> "Oh My OpenCode Is Actually Insane" - [YouTube - Darren Builds AI](https://www.youtube.com/watch?v=G_Snfh2M41M)
|
||||
|
||||
> "西西弗斯这个名字本身不就很美吗?" — Sigrid ([@sigridjin_eth](https://x.com/sigridjin_eth))
|
||||
|
||||
---
|
||||
@@ -72,6 +82,19 @@
|
||||
- [安装](#安装)
|
||||
- [人类专用](#人类专用)
|
||||
- [给 LLM Agent 看的](#给-llm-agent-看的)
|
||||
- [步骤 0:确认订阅情况](#步骤-0确认订阅情况)
|
||||
- [步骤 1:确认 OpenCode 装没装](#步骤-1确认-opencode-装没装)
|
||||
- [步骤 2:跑安装程序](#步骤-2跑安装程序)
|
||||
- [步骤 3:验货](#步骤-3验货)
|
||||
- [步骤 4:搞定认证](#步骤-4搞定认证)
|
||||
- [4.1 Anthropic (Claude)](#41-anthropic-claude)
|
||||
- [4.2 Google Gemini (Antigravity OAuth)](#42-google-gemini-antigravity-oauth)
|
||||
- [4.3 OpenAI (ChatGPT Plus/Pro)](#43-openai-chatgpt-pluspro)
|
||||
- [⚠️ 注意](#️-注意)
|
||||
- [检查作业](#检查作业)
|
||||
- [跟用户说"恭喜!🎉"](#跟用户说恭喜)
|
||||
- [太麻烦了?](#太麻烦了)
|
||||
- [卸载](#卸载)
|
||||
- [功能](#功能)
|
||||
- [Agents:你的神队友](#agents你的神队友)
|
||||
- [后台 Agent:像真正的团队一样干活](#后台-agent像真正的团队一样干活)
|
||||
@@ -169,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。喝你的咖啡去吧,活儿已经帮你干完了。
|
||||
|
||||
如果你不需要这全套服务,前面说了,挑你喜欢的用。
|
||||
|
||||
@@ -283,7 +308,7 @@ opencode auth login
|
||||
{
|
||||
"plugin": [
|
||||
"oh-my-opencode",
|
||||
"opencode-antigravity-auth@1.1.2"
|
||||
"opencode-antigravity-auth@1.2.7"
|
||||
]
|
||||
}
|
||||
```
|
||||
@@ -308,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`
|
||||
|
||||
然后认证:
|
||||
|
||||
@@ -331,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>` 控制推理强度。
|
||||
|
||||
然后认证:
|
||||
|
||||
@@ -545,7 +563,6 @@ OhMyOpenCode 让这些成为可能。
|
||||
```
|
||||
- **在线资源**:项目里的规矩不够用?内置 MCP 来凑:
|
||||
- **context7**:查最新的官方文档
|
||||
- **websearch_exa**:Exa AI 实时搜网
|
||||
- **grep_app**:用 [grep.app](https://grep.app) 在几百万个 GitHub 仓库里秒搜代码(找抄作业的例子神器)
|
||||
|
||||
#### 多模态全开,Token 省着用
|
||||
@@ -637,7 +654,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` | 内置 Agent(oracle、librarian 等) |
|
||||
@@ -830,7 +847,7 @@ Agent 爽了,你自然也爽。但我还想直接让你爽。
|
||||
默认开启。Sisyphus 提供一个强力的编排器,带可选的专门 Agent:
|
||||
|
||||
- **Sisyphus**:主编排 Agent(Claude Opus 4.5)
|
||||
- **Builder-Sisyphus**:OpenCode 默认构建 Agent(因 SDK 限制仅改名,默认禁用)
|
||||
- **OpenCode-Builder**:OpenCode 默认构建 Agent(因 SDK 限制仅改名,默认禁用)
|
||||
- **Planner-Sisyphus**:OpenCode 默认计划 Agent(因 SDK 限制仅改名,默认启用)
|
||||
|
||||
**配置选项:**
|
||||
@@ -846,7 +863,7 @@ Agent 爽了,你自然也爽。但我还想直接让你爽。
|
||||
}
|
||||
```
|
||||
|
||||
**示例:启用 Builder-Sisyphus:**
|
||||
**示例:启用 OpenCode-Builder:**
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -856,7 +873,7 @@ Agent 爽了,你自然也爽。但我还想直接让你爽。
|
||||
}
|
||||
```
|
||||
|
||||
这样能和 Sisyphus 一起启用 Builder-Sisyphus Agent。启用 Sisyphus 后,默认构建 Agent 总会降级为子 Agent 模式。
|
||||
这样能和 Sisyphus 一起启用 OpenCode-Builder Agent。启用 Sisyphus 后,默认构建 Agent 总会降级为子 Agent 模式。
|
||||
|
||||
**示例:禁用所有 Sisyphus 编排:**
|
||||
|
||||
@@ -877,7 +894,7 @@ Sisyphus Agent 也能自定义:
|
||||
"model": "anthropic/claude-sonnet-4",
|
||||
"temperature": 0.3
|
||||
},
|
||||
"Builder-Sisyphus": {
|
||||
"OpenCode-Builder": {
|
||||
"model": "anthropic/claude-opus-4"
|
||||
},
|
||||
"Planner-Sisyphus": {
|
||||
@@ -890,7 +907,7 @@ Sisyphus Agent 也能自定义:
|
||||
| 选项 | 默认值 | 说明 |
|
||||
| --------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `disabled` | `false` | 设为 `true` 就禁用所有 Sisyphus 编排,恢复原来的 build/plan。 |
|
||||
| `default_builder_enabled` | `false` | 设为 `true` 就启用 Builder-Sisyphus Agent(与 OpenCode build 相同,因 SDK 限制仅改名)。默认禁用。 |
|
||||
| `default_builder_enabled` | `false` | 设为 `true` 就启用 OpenCode-Builder Agent(与 OpenCode build 相同,因 SDK 限制仅改名)。默认禁用。 |
|
||||
| `planner_enabled` | `true` | 设为 `true` 就启用 Planner-Sisyphus Agent(与 OpenCode plan 相同,因 SDK 限制仅改名)。默认启用。 |
|
||||
| `replace_plan` | `true` | 设为 `true` 就把默认计划 Agent 降级为子 Agent 模式。设为 `false` 可以同时保留 Planner-Sisyphus 和默认计划。 |
|
||||
|
||||
@@ -910,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"]
|
||||
}
|
||||
```
|
||||
|
||||
@@ -1028,5 +1044,8 @@ Oh My OpenCode 送你重构工具(重命名、代码操作)。
|
||||
- **Numman Ali** [GitHub](https://github.com/numman-ali) [X](https://x.com/nummanali)
|
||||
- 第一位赞助者
|
||||
- **Aaron Iker** [GitHub](https://github.com/aaroniker) [X](https://x.com/aaroniker)
|
||||
- **Suyeol Jeon (devxoul)** [GitHub](https://github.com/devxoul)
|
||||
- 他是开启我职业生涯的人,也是在如何构建优秀的代理工作流方面给了我很多启发的人。我从他那里学到了很多关于如何设计好的系统来打造优秀团队的知识,这些经验对开发这个harness起到了巨大的帮助作用。
|
||||
- **Hyerin Won (devwon)** [GitHub](https://github.com/devwon)
|
||||
|
||||
*感谢 [@junhoyeo](https://github.com/junhoyeo) 制作了这张超帅的 hero 图。*
|
||||
|
||||
@@ -12,11 +12,7 @@
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"websearch_exa",
|
||||
"context7",
|
||||
"grep_app"
|
||||
]
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"disabled_agents": {
|
||||
@@ -74,7 +70,8 @@
|
||||
"preemptive-compaction",
|
||||
"compaction-context-injector",
|
||||
"claude-code-hooks",
|
||||
"auto-slash-command"
|
||||
"auto-slash-command",
|
||||
"edit-error-recovery"
|
||||
]
|
||||
}
|
||||
},
|
||||
@@ -1661,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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
8
bun.lock
8
bun.lock
@@ -11,8 +11,8 @@
|
||||
"@code-yeongyu/comment-checker": "^0.6.1",
|
||||
"@modelcontextprotocol/sdk": "^1.25.1",
|
||||
"@openauthjs/openauth": "^0.4.3",
|
||||
"@opencode-ai/plugin": "^1.0.162",
|
||||
"@opencode-ai/sdk": "^1.0.162",
|
||||
"@opencode-ai/plugin": "^1.1.1",
|
||||
"@opencode-ai/sdk": "^1.1.1",
|
||||
"commander": "^14.0.2",
|
||||
"hono": "^4.10.4",
|
||||
"js-yaml": "^4.1.1",
|
||||
@@ -84,9 +84,9 @@
|
||||
|
||||
"@openauthjs/openauth": ["@openauthjs/openauth@0.4.3", "", { "dependencies": { "@standard-schema/spec": "1.0.0-beta.3", "aws4fetch": "1.0.20", "jose": "5.9.6" }, "peerDependencies": { "arctic": "^2.2.2", "hono": "^4.0.0" } }, "sha512-RlnjqvHzqcbFVymEwhlUEuac4utA5h4nhSK/i2szZuQmxTIqbGUxZ+nM+avM+VV4Ing+/ZaNLKILoXS3yrkOOw=="],
|
||||
|
||||
"@opencode-ai/plugin": ["@opencode-ai/plugin@1.0.162", "", { "dependencies": { "@opencode-ai/sdk": "1.0.162", "zod": "4.1.8" } }, "sha512-tiJw7SCfSlG/3tY2O0J2UT06OLuazOzsv1zYlFbLxLy/EVedtW0pzxYalO20a4e//vInvOXFkhd2jLyB5vNEVA=="],
|
||||
"@opencode-ai/plugin": ["@opencode-ai/plugin@1.1.1", "", { "dependencies": { "@opencode-ai/sdk": "1.1.1", "zod": "4.1.8" } }, "sha512-OZGvpDal8YsSo6dnatHfwviSToGZ6mJJyEKZGxUyWDuGCP7VhcoPkoM16ktl7TCVHkDK+TdwY9tKzkzFqQNc5w=="],
|
||||
|
||||
"@opencode-ai/sdk": ["@opencode-ai/sdk@1.0.162", "", {}, "sha512-+XqRErBUt9eb1m3i/7WkZc/QCKCCjTaGV3MvhLhs/CUwbUn767D/ugzcG/i2ec8j/4nQmjJbjPDRmrQfvF1Qjw=="],
|
||||
"@opencode-ai/sdk": ["@opencode-ai/sdk@1.1.1", "", {}, "sha512-PfXujMrHGeMnpS8Gd2BXSY+zZajlztcAvcokf06NtAhd0Mbo/hCLXgW0NBCQ+3FX3e/G2PNwz2DqMdtzyIZaCQ=="],
|
||||
|
||||
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode",
|
||||
"version": "2.12.0",
|
||||
"version": "2.14.0",
|
||||
"description": "OpenCode plugin - custom agents (oracle, librarian) and enhanced features",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
@@ -56,8 +56,8 @@
|
||||
"@code-yeongyu/comment-checker": "^0.6.1",
|
||||
"@modelcontextprotocol/sdk": "^1.25.1",
|
||||
"@openauthjs/openauth": "^0.4.3",
|
||||
"@opencode-ai/plugin": "^1.0.162",
|
||||
"@opencode-ai/sdk": "^1.0.162",
|
||||
"@opencode-ai/plugin": "^1.1.1",
|
||||
"@opencode-ai/sdk": "^1.1.1",
|
||||
"commander": "^14.0.2",
|
||||
"hono": "^4.10.4",
|
||||
"js-yaml": "^4.1.1",
|
||||
|
||||
@@ -143,6 +143,126 @@
|
||||
"created_at": "2025-12-31T20:40:20Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 388
|
||||
},
|
||||
{
|
||||
"name": "changeroa",
|
||||
"id": 65930387,
|
||||
"comment_id": 3706697910,
|
||||
"created_at": "2026-01-03T04:51:11Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 446
|
||||
},
|
||||
{
|
||||
"name": "hqone",
|
||||
"id": 13660872,
|
||||
"comment_id": 3707019551,
|
||||
"created_at": "2026-01-03T12:21:52Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 451
|
||||
},
|
||||
{
|
||||
"name": "fparrav",
|
||||
"id": 9319430,
|
||||
"comment_id": 3707456044,
|
||||
"created_at": "2026-01-03T23:51:28Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 469
|
||||
},
|
||||
{
|
||||
"name": "ChiR24",
|
||||
"id": 125826529,
|
||||
"comment_id": 3707776762,
|
||||
"created_at": "2026-01-04T06:14:36Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 473
|
||||
},
|
||||
{
|
||||
"name": "geq1fan",
|
||||
"id": 29982379,
|
||||
"comment_id": 3708136393,
|
||||
"created_at": "2026-01-04T14:31:14Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 481
|
||||
},
|
||||
{
|
||||
"name": "RhysSullivan",
|
||||
"id": 39114868,
|
||||
"comment_id": 3708266434,
|
||||
"created_at": "2026-01-04T17:19:44Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 482
|
||||
},
|
||||
{
|
||||
"name": "Skyline-23",
|
||||
"id": 62983047,
|
||||
"comment_id": 3708282461,
|
||||
"created_at": "2026-01-04T17:42:02Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 484
|
||||
},
|
||||
{
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
import type { AgentPromptMetadata } from "./types"
|
||||
import { createAgentToolRestrictions } from "../shared/permission-compat"
|
||||
|
||||
const DEFAULT_MODEL = "google/gemini-3-flash-preview"
|
||||
|
||||
@@ -15,12 +16,14 @@ export const DOCUMENT_WRITER_PROMPT_METADATA: AgentPromptMetadata = {
|
||||
export function createDocumentWriterAgent(
|
||||
model: string = DEFAULT_MODEL
|
||||
): AgentConfig {
|
||||
const restrictions = createAgentToolRestrictions([])
|
||||
|
||||
return {
|
||||
description:
|
||||
"A technical writer who crafts clear, comprehensive documentation. Specializes in README files, API docs, architecture docs, and user guides. MUST BE USED when executing documentation tasks from ai-todo list plans.",
|
||||
mode: "subagent" as const,
|
||||
model,
|
||||
tools: { background_task: false },
|
||||
...restrictions,
|
||||
prompt: `<role>
|
||||
You are a TECHNICAL WRITER with deep engineering background who transforms complex codebases into crystal-clear documentation. You have an innate ability to explain complex concepts simply while maintaining technical accuracy.
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
import type { AgentPromptMetadata } from "./types"
|
||||
import { createAgentToolRestrictions } from "../shared/permission-compat"
|
||||
|
||||
const DEFAULT_MODEL = "opencode/grok-code"
|
||||
|
||||
@@ -24,13 +25,18 @@ export const EXPLORE_PROMPT_METADATA: AgentPromptMetadata = {
|
||||
}
|
||||
|
||||
export function createExploreAgent(model: string = DEFAULT_MODEL): AgentConfig {
|
||||
const restrictions = createAgentToolRestrictions([
|
||||
"write",
|
||||
"edit",
|
||||
])
|
||||
|
||||
return {
|
||||
description:
|
||||
'Contextual grep for codebases. Answers "Where is X?", "Which file has Y?", "Find the code that does Z". Fire multiple in parallel for broad searches. Specify thoroughness: "quick" for basic, "medium" for moderate, "very thorough" for comprehensive analysis.',
|
||||
mode: "subagent" as const,
|
||||
model,
|
||||
temperature: 0.1,
|
||||
tools: { write: false, edit: false, background_task: false },
|
||||
...restrictions,
|
||||
prompt: `You are a codebase search specialist. Your job: find files and code, return actionable results.
|
||||
|
||||
## Your Mission
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
import type { AgentPromptMetadata } from "./types"
|
||||
import { createAgentToolRestrictions } from "../shared/permission-compat"
|
||||
|
||||
const DEFAULT_MODEL = "google/gemini-3-pro-preview"
|
||||
|
||||
@@ -21,12 +22,14 @@ export const FRONTEND_PROMPT_METADATA: AgentPromptMetadata = {
|
||||
export function createFrontendUiUxEngineerAgent(
|
||||
model: string = DEFAULT_MODEL
|
||||
): AgentConfig {
|
||||
const restrictions = createAgentToolRestrictions([])
|
||||
|
||||
return {
|
||||
description:
|
||||
"A designer-turned-developer who crafts stunning UI/UX even without design mockups. Code may be a bit messy, but the visual output is always fire.",
|
||||
mode: "subagent" as const,
|
||||
model,
|
||||
tools: { background_task: false },
|
||||
...restrictions,
|
||||
prompt: `# Role: Designer-Turned-Developer
|
||||
|
||||
You are a designer who learned to code. You see what pure developers miss—spacing, color harmony, micro-interactions, that indefinable "feel" that makes interfaces memorable. Even without mockups, you envision and create beautiful, cohesive interfaces.
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
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",
|
||||
@@ -21,18 +22,23 @@ export const LIBRARIAN_PROMPT_METADATA: AgentPromptMetadata = {
|
||||
}
|
||||
|
||||
export function createLibrarianAgent(model: string = DEFAULT_MODEL): AgentConfig {
|
||||
const restrictions = createAgentToolRestrictions([
|
||||
"write",
|
||||
"edit",
|
||||
])
|
||||
|
||||
return {
|
||||
description:
|
||||
"Specialized codebase understanding agent for multi-repository analysis, searching remote codebases, retrieving official documentation, and finding implementation examples using GitHub CLI, Context7, and Web Search. MUST BE USED when users ask to look up code in remote repositories, explain library internals, or find usage examples in open source.",
|
||||
mode: "subagent" as const,
|
||||
model,
|
||||
temperature: 0.1,
|
||||
tools: { write: false, edit: false, background_task: false },
|
||||
...restrictions,
|
||||
prompt: `# THE LIBRARIAN
|
||||
|
||||
You are **THE LIBRARIAN**, a specialized open-source codebase understanding agent.
|
||||
|
||||
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
|
||||
|
||||
@@ -44,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 |
|
||||
|
||||
---
|
||||
|
||||
@@ -62,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.
|
||||
@@ -94,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")
|
||||
@@ -107,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
|
||||
@@ -129,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
|
||||
\`\`\`
|
||||
|
||||
---
|
||||
@@ -189,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\` |
|
||||
@@ -198,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
|
||||
|
||||
@@ -214,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:
|
||||
\`\`\`
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
import type { AgentPromptMetadata } from "./types"
|
||||
import { createAgentToolRestrictions } from "../shared/permission-compat"
|
||||
|
||||
const DEFAULT_MODEL = "google/gemini-3-flash"
|
||||
|
||||
@@ -13,13 +14,19 @@ export const MULTIMODAL_LOOKER_PROMPT_METADATA: AgentPromptMetadata = {
|
||||
export function createMultimodalLookerAgent(
|
||||
model: string = DEFAULT_MODEL
|
||||
): AgentConfig {
|
||||
const restrictions = createAgentToolRestrictions([
|
||||
"write",
|
||||
"edit",
|
||||
"bash",
|
||||
])
|
||||
|
||||
return {
|
||||
description:
|
||||
"Analyze media files (PDFs, images, diagrams) that require interpretation beyond raw text. Extracts specific information or summaries from documents, describes visual content. Use when you need analyzed/extracted data rather than literal file contents.",
|
||||
mode: "subagent" as const,
|
||||
model,
|
||||
temperature: 0.1,
|
||||
tools: { write: false, edit: false, bash: false, background_task: false },
|
||||
...restrictions,
|
||||
prompt: `You interpret media files that cannot be read as plain text.
|
||||
|
||||
Your job: examine the attached file and extract ONLY what was requested.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
import type { AgentPromptMetadata } from "./types"
|
||||
import { isGptModel } from "./types"
|
||||
import { createAgentToolRestrictions } from "../shared/permission-compat"
|
||||
|
||||
const DEFAULT_MODEL = "openai/gpt-5.2"
|
||||
|
||||
@@ -97,21 +98,27 @@ Organize your final answer in three tiers:
|
||||
Your response goes directly to the user with no intermediate processing. Make your final message self-contained: a clear recommendation they can act on immediately, covering both what to do and why.`
|
||||
|
||||
export function createOracleAgent(model: string = DEFAULT_MODEL): AgentConfig {
|
||||
const restrictions = createAgentToolRestrictions([
|
||||
"write",
|
||||
"edit",
|
||||
"task",
|
||||
])
|
||||
|
||||
const base = {
|
||||
description:
|
||||
"Expert technical advisor with deep reasoning for architecture decisions, code analysis, and engineering guidance.",
|
||||
mode: "subagent" as const,
|
||||
model,
|
||||
temperature: 0.1,
|
||||
tools: { write: false, edit: false, task: false, background_task: false },
|
||||
...restrictions,
|
||||
prompt: ORACLE_SYSTEM_PROMPT,
|
||||
}
|
||||
} as AgentConfig
|
||||
|
||||
if (isGptModel(model)) {
|
||||
return { ...base, reasoningEffort: "medium", textVerbosity: "high" }
|
||||
return { ...base, reasoningEffort: "medium", textVerbosity: "high" } as AgentConfig
|
||||
}
|
||||
|
||||
return { ...base, thinking: { type: "enabled", budgetTokens: 32000 } }
|
||||
return { ...base, thinking: { type: "enabled", budgetTokens: 32000 } } as AgentConfig
|
||||
}
|
||||
|
||||
export const oracleAgent = createOracleAgent()
|
||||
|
||||
@@ -307,3 +307,26 @@ export function buildAntiPatternsSection(agents: AvailableAgent[]): string {
|
||||
|----------|-----------|
|
||||
${patterns.join("\n")}`
|
||||
}
|
||||
|
||||
export function buildUltraworkAgentSection(agents: AvailableAgent[]): string {
|
||||
if (agents.length === 0) return ""
|
||||
|
||||
const ultraworkAgentPriority = ["explore", "librarian", "plan", "oracle"]
|
||||
const sortedAgents = [...agents].sort((a, b) => {
|
||||
const aIdx = ultraworkAgentPriority.indexOf(a.name)
|
||||
const bIdx = ultraworkAgentPriority.indexOf(b.name)
|
||||
if (aIdx === -1 && bIdx === -1) return 0
|
||||
if (aIdx === -1) return 1
|
||||
if (bIdx === -1) return -1
|
||||
return aIdx - bIdx
|
||||
})
|
||||
|
||||
const lines: string[] = []
|
||||
for (const agent of sortedAgents) {
|
||||
const shortDesc = agent.description.split(".")[0] || agent.description
|
||||
const suffix = (agent.name === "explore" || agent.name === "librarian") ? " (multiple)" : ""
|
||||
lines.push(`- **${agent.name}${suffix}**: ${shortDesc}`)
|
||||
}
|
||||
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,18 +1,59 @@
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync, statSync } from "node:fs"
|
||||
import { homedir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { parseJsonc } from "../shared"
|
||||
import {
|
||||
parseJsonc,
|
||||
getOpenCodeConfigPaths,
|
||||
type OpenCodeBinaryType,
|
||||
type OpenCodeConfigPaths,
|
||||
} from "../shared"
|
||||
import type { ConfigMergeResult, DetectedConfig, InstallConfig } from "./types"
|
||||
|
||||
const OPENCODE_CONFIG_DIR = join(homedir(), ".config", "opencode")
|
||||
const OPENCODE_JSON = join(OPENCODE_CONFIG_DIR, "opencode.json")
|
||||
const OPENCODE_JSONC = join(OPENCODE_CONFIG_DIR, "opencode.jsonc")
|
||||
const OPENCODE_PACKAGE_JSON = join(OPENCODE_CONFIG_DIR, "package.json")
|
||||
const OMO_CONFIG = join(OPENCODE_CONFIG_DIR, "oh-my-opencode.json")
|
||||
|
||||
const OPENCODE_BINARIES = ["opencode", "opencode-desktop"] as const
|
||||
|
||||
const CHATGPT_HOTFIX_REPO = "code-yeongyu/opencode-openai-codex-auth#fix/orphaned-function-call-output-with-tools"
|
||||
interface ConfigContext {
|
||||
binary: OpenCodeBinaryType
|
||||
version: string | null
|
||||
paths: OpenCodeConfigPaths
|
||||
}
|
||||
|
||||
let configContext: ConfigContext | null = null
|
||||
|
||||
export function initConfigContext(binary: OpenCodeBinaryType, version: string | null): void {
|
||||
const paths = getOpenCodeConfigPaths({ binary, version })
|
||||
configContext = { binary, version, paths }
|
||||
}
|
||||
|
||||
export function getConfigContext(): ConfigContext {
|
||||
if (!configContext) {
|
||||
const paths = getOpenCodeConfigPaths({ binary: "opencode", version: null })
|
||||
configContext = { binary: "opencode", version: null, paths }
|
||||
}
|
||||
return configContext
|
||||
}
|
||||
|
||||
export function resetConfigContext(): void {
|
||||
configContext = null
|
||||
}
|
||||
|
||||
function getConfigDir(): string {
|
||||
return getConfigContext().paths.configDir
|
||||
}
|
||||
|
||||
function getConfigJson(): string {
|
||||
return getConfigContext().paths.configJson
|
||||
}
|
||||
|
||||
function getConfigJsonc(): string {
|
||||
return getConfigContext().paths.configJsonc
|
||||
}
|
||||
|
||||
function getPackageJson(): string {
|
||||
return getConfigContext().paths.packageJson
|
||||
}
|
||||
|
||||
function getOmoConfig(): string {
|
||||
return getConfigContext().paths.omoConfig
|
||||
}
|
||||
|
||||
const BUN_INSTALL_TIMEOUT_SECONDS = 60
|
||||
const BUN_INSTALL_TIMEOUT_MS = BUN_INSTALL_TIMEOUT_SECONDS * 1000
|
||||
@@ -76,13 +117,16 @@ interface OpenCodeConfig {
|
||||
}
|
||||
|
||||
export function detectConfigFormat(): { format: ConfigFormat; path: string } {
|
||||
if (existsSync(OPENCODE_JSONC)) {
|
||||
return { format: "jsonc", path: OPENCODE_JSONC }
|
||||
const configJsonc = getConfigJsonc()
|
||||
const configJson = getConfigJson()
|
||||
|
||||
if (existsSync(configJsonc)) {
|
||||
return { format: "jsonc", path: configJsonc }
|
||||
}
|
||||
if (existsSync(OPENCODE_JSON)) {
|
||||
return { format: "json", path: OPENCODE_JSON }
|
||||
if (existsSync(configJson)) {
|
||||
return { format: "json", path: configJson }
|
||||
}
|
||||
return { format: "none", path: OPENCODE_JSON }
|
||||
return { format: "none", path: configJson }
|
||||
}
|
||||
|
||||
interface ParseConfigResult {
|
||||
@@ -129,8 +173,9 @@ function parseConfigWithError(path: string): ParseConfigResult {
|
||||
}
|
||||
|
||||
function ensureConfigDir(): void {
|
||||
if (!existsSync(OPENCODE_CONFIG_DIR)) {
|
||||
mkdirSync(OPENCODE_CONFIG_DIR, { recursive: true })
|
||||
const configDir = getConfigDir()
|
||||
if (!existsSync(configDir)) {
|
||||
mkdirSync(configDir, { recursive: true })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,7 +183,7 @@ export function addPluginToOpenCodeConfig(): ConfigMergeResult {
|
||||
try {
|
||||
ensureConfigDir()
|
||||
} catch (err) {
|
||||
return { success: false, configPath: OPENCODE_CONFIG_DIR, error: formatErrorWithSuggestion(err, "create config directory") }
|
||||
return { success: false, configPath: getConfigDir(), error: formatErrorWithSuggestion(err, "create config directory") }
|
||||
}
|
||||
|
||||
const { format, path } = detectConfigFormat()
|
||||
@@ -229,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 }
|
||||
@@ -270,50 +317,52 @@ export function writeOmoConfig(installConfig: InstallConfig): ConfigMergeResult
|
||||
try {
|
||||
ensureConfigDir()
|
||||
} catch (err) {
|
||||
return { success: false, configPath: OPENCODE_CONFIG_DIR, error: formatErrorWithSuggestion(err, "create config directory") }
|
||||
return { success: false, configPath: getConfigDir(), error: formatErrorWithSuggestion(err, "create config directory") }
|
||||
}
|
||||
|
||||
const omoConfigPath = getOmoConfig()
|
||||
|
||||
try {
|
||||
const newConfig = generateOmoConfig(installConfig)
|
||||
|
||||
if (existsSync(OMO_CONFIG)) {
|
||||
if (existsSync(omoConfigPath)) {
|
||||
try {
|
||||
const stat = statSync(OMO_CONFIG)
|
||||
const content = readFileSync(OMO_CONFIG, "utf-8")
|
||||
const stat = statSync(omoConfigPath)
|
||||
const content = readFileSync(omoConfigPath, "utf-8")
|
||||
|
||||
if (stat.size === 0 || isEmptyOrWhitespace(content)) {
|
||||
writeFileSync(OMO_CONFIG, JSON.stringify(newConfig, null, 2) + "\n")
|
||||
return { success: true, configPath: OMO_CONFIG }
|
||||
writeFileSync(omoConfigPath, JSON.stringify(newConfig, null, 2) + "\n")
|
||||
return { success: true, configPath: omoConfigPath }
|
||||
}
|
||||
|
||||
const existing = parseJsonc<Record<string, unknown>>(content)
|
||||
if (!existing || typeof existing !== "object" || Array.isArray(existing)) {
|
||||
writeFileSync(OMO_CONFIG, JSON.stringify(newConfig, null, 2) + "\n")
|
||||
return { success: true, configPath: OMO_CONFIG }
|
||||
writeFileSync(omoConfigPath, JSON.stringify(newConfig, null, 2) + "\n")
|
||||
return { success: true, configPath: omoConfigPath }
|
||||
}
|
||||
|
||||
delete existing.agents
|
||||
const merged = deepMerge(existing, newConfig)
|
||||
writeFileSync(OMO_CONFIG, JSON.stringify(merged, null, 2) + "\n")
|
||||
writeFileSync(omoConfigPath, JSON.stringify(merged, null, 2) + "\n")
|
||||
} catch (parseErr) {
|
||||
if (parseErr instanceof SyntaxError) {
|
||||
writeFileSync(OMO_CONFIG, JSON.stringify(newConfig, null, 2) + "\n")
|
||||
return { success: true, configPath: OMO_CONFIG }
|
||||
writeFileSync(omoConfigPath, JSON.stringify(newConfig, null, 2) + "\n")
|
||||
return { success: true, configPath: omoConfigPath }
|
||||
}
|
||||
throw parseErr
|
||||
}
|
||||
} else {
|
||||
writeFileSync(OMO_CONFIG, JSON.stringify(newConfig, null, 2) + "\n")
|
||||
writeFileSync(omoConfigPath, JSON.stringify(newConfig, null, 2) + "\n")
|
||||
}
|
||||
|
||||
return { success: true, configPath: OMO_CONFIG }
|
||||
return { success: true, configPath: omoConfigPath }
|
||||
} catch (err) {
|
||||
return { success: false, configPath: OMO_CONFIG, error: formatErrorWithSuggestion(err, "write oh-my-opencode config") }
|
||||
return { success: false, configPath: omoConfigPath, error: formatErrorWithSuggestion(err, "write oh-my-opencode config") }
|
||||
}
|
||||
}
|
||||
|
||||
interface OpenCodeBinaryResult {
|
||||
binary: string
|
||||
binary: OpenCodeBinaryType
|
||||
version: string
|
||||
}
|
||||
|
||||
@@ -327,7 +376,9 @@ async function findOpenCodeBinaryWithVersion(): Promise<OpenCodeBinaryResult | n
|
||||
const output = await new Response(proc.stdout).text()
|
||||
await proc.exited
|
||||
if (proc.exitCode === 0) {
|
||||
return { binary, version: output.trim() }
|
||||
const version = output.trim()
|
||||
initConfigContext(binary, version)
|
||||
return { binary, version }
|
||||
}
|
||||
} catch {
|
||||
continue
|
||||
@@ -350,7 +401,7 @@ export async function addAuthPlugins(config: InstallConfig): Promise<ConfigMerge
|
||||
try {
|
||||
ensureConfigDir()
|
||||
} catch (err) {
|
||||
return { success: false, configPath: OPENCODE_CONFIG_DIR, error: formatErrorWithSuggestion(err, "create config directory") }
|
||||
return { success: false, configPath: getConfigDir(), error: formatErrorWithSuggestion(err, "create config directory") }
|
||||
}
|
||||
|
||||
const { format, path } = detectConfigFormat()
|
||||
@@ -390,46 +441,6 @@ export async function addAuthPlugins(config: InstallConfig): Promise<ConfigMerge
|
||||
}
|
||||
}
|
||||
|
||||
export function setupChatGPTHotfix(): ConfigMergeResult {
|
||||
try {
|
||||
ensureConfigDir()
|
||||
} catch (err) {
|
||||
return { success: false, configPath: OPENCODE_CONFIG_DIR, error: formatErrorWithSuggestion(err, "create config directory") }
|
||||
}
|
||||
|
||||
try {
|
||||
let packageJson: Record<string, unknown> = {}
|
||||
if (existsSync(OPENCODE_PACKAGE_JSON)) {
|
||||
try {
|
||||
const stat = statSync(OPENCODE_PACKAGE_JSON)
|
||||
const content = readFileSync(OPENCODE_PACKAGE_JSON, "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(OPENCODE_PACKAGE_JSON, JSON.stringify(packageJson, null, 2) + "\n")
|
||||
return { success: true, configPath: OPENCODE_PACKAGE_JSON }
|
||||
} catch (err) {
|
||||
return { success: false, configPath: OPENCODE_PACKAGE_JSON, error: formatErrorWithSuggestion(err, "setup ChatGPT hotfix in package.json") }
|
||||
}
|
||||
}
|
||||
|
||||
export interface BunInstallResult {
|
||||
success: boolean
|
||||
timedOut?: boolean
|
||||
@@ -444,7 +455,7 @@ export async function runBunInstall(): Promise<boolean> {
|
||||
export async function runBunInstallWithDetails(): Promise<BunInstallResult> {
|
||||
try {
|
||||
const proc = Bun.spawn(["bun", "install"], {
|
||||
cwd: OPENCODE_CONFIG_DIR,
|
||||
cwd: getConfigDir(),
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
@@ -488,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"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -534,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" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -548,7 +594,7 @@ export function addProviderConfig(config: InstallConfig): ConfigMergeResult {
|
||||
try {
|
||||
ensureConfigDir()
|
||||
} catch (err) {
|
||||
return { success: false, configPath: OPENCODE_CONFIG_DIR, error: formatErrorWithSuggestion(err, "create config directory") }
|
||||
return { success: false, configPath: getConfigDir(), error: formatErrorWithSuggestion(err, "create config directory") }
|
||||
}
|
||||
|
||||
const { format, path } = detectConfigFormat()
|
||||
@@ -622,17 +668,18 @@ export function detectCurrentConfig(): DetectedConfig {
|
||||
result.hasGemini = plugins.some((p) => p.startsWith("opencode-antigravity-auth"))
|
||||
result.hasChatGPT = plugins.some((p) => p.startsWith("opencode-openai-codex-auth"))
|
||||
|
||||
if (!existsSync(OMO_CONFIG)) {
|
||||
const omoConfigPath = getOmoConfig()
|
||||
if (!existsSync(omoConfigPath)) {
|
||||
return result
|
||||
}
|
||||
|
||||
try {
|
||||
const stat = statSync(OMO_CONFIG)
|
||||
const stat = statSync(omoConfigPath)
|
||||
if (stat.size === 0) {
|
||||
return result
|
||||
}
|
||||
|
||||
const content = readFileSync(OMO_CONFIG, "utf-8")
|
||||
const content = readFileSync(omoConfigPath, "utf-8")
|
||||
if (isEmptyOrWhitespace(content)) {
|
||||
return result
|
||||
}
|
||||
@@ -644,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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
import { existsSync, readFileSync } from "node:fs"
|
||||
import { homedir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import type { CheckResult, CheckDefinition, PluginInfo } from "../types"
|
||||
import { CHECK_IDS, CHECK_NAMES, PACKAGE_NAME } from "../constants"
|
||||
import { parseJsonc } from "../../../shared"
|
||||
|
||||
const OPENCODE_CONFIG_DIR = join(homedir(), ".config", "opencode")
|
||||
const OPENCODE_JSON = join(OPENCODE_CONFIG_DIR, "opencode.json")
|
||||
const OPENCODE_JSONC = join(OPENCODE_CONFIG_DIR, "opencode.jsonc")
|
||||
import { parseJsonc, getOpenCodeConfigPaths } from "../../../shared"
|
||||
|
||||
function detectConfigPath(): { path: string; format: "json" | "jsonc" } | null {
|
||||
if (existsSync(OPENCODE_JSONC)) {
|
||||
return { path: OPENCODE_JSONC, format: "jsonc" }
|
||||
const paths = getOpenCodeConfigPaths({ binary: "opencode", version: null })
|
||||
|
||||
if (existsSync(paths.configJsonc)) {
|
||||
return { path: paths.configJsonc, format: "jsonc" }
|
||||
}
|
||||
if (existsSync(OPENCODE_JSON)) {
|
||||
return { path: OPENCODE_JSON, format: "json" }
|
||||
if (existsSync(paths.configJson)) {
|
||||
return { path: paths.configJson, format: "json" }
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -81,13 +77,14 @@ export async function checkPluginRegistration(): Promise<CheckResult> {
|
||||
const info = getPluginInfo()
|
||||
|
||||
if (!info.configPath) {
|
||||
const expectedPaths = getOpenCodeConfigPaths({ binary: "opencode", version: null })
|
||||
return {
|
||||
name: CHECK_NAMES[CHECK_IDS.PLUGIN_REGISTRATION],
|
||||
status: "fail",
|
||||
message: "OpenCode config file not found",
|
||||
details: [
|
||||
"Run: bunx oh-my-opencode install",
|
||||
`Expected: ${OPENCODE_JSON} or ${OPENCODE_JSONC}`,
|
||||
`Expected: ${expectedPaths.configJson} or ${expectedPaths.configJsonc}`,
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" }
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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
136
src/config/schema.test.ts
Normal 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",
|
||||
])
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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"])
|
||||
|
||||
@@ -74,6 +74,7 @@ export const HookNameSchema = z.enum([
|
||||
"compaction-context-injector",
|
||||
"claude-code-hooks",
|
||||
"auto-slash-command",
|
||||
"edit-error-recovery",
|
||||
])
|
||||
|
||||
export const BuiltinCommandNameSchema = z.enum([
|
||||
@@ -231,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(),
|
||||
@@ -247,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>
|
||||
@@ -264,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"
|
||||
|
||||
351
src/features/background-agent/concurrency.test.ts
Normal file
351
src/features/background-agent/concurrency.test.ts
Normal file
@@ -0,0 +1,351 @@
|
||||
import { describe, test, expect, beforeEach } from "bun:test"
|
||||
import { ConcurrencyManager } from "./concurrency"
|
||||
import type { BackgroundTaskConfig } from "../../config/schema"
|
||||
|
||||
describe("ConcurrencyManager.getConcurrencyLimit", () => {
|
||||
test("should return model-specific limit when modelConcurrency is set", () => {
|
||||
// #given
|
||||
const config: BackgroundTaskConfig = {
|
||||
modelConcurrency: { "anthropic/claude-sonnet-4-5": 5 }
|
||||
}
|
||||
const manager = new ConcurrencyManager(config)
|
||||
|
||||
// #when
|
||||
const limit = manager.getConcurrencyLimit("anthropic/claude-sonnet-4-5")
|
||||
|
||||
// #then
|
||||
expect(limit).toBe(5)
|
||||
})
|
||||
|
||||
test("should return provider limit when providerConcurrency is set for model provider", () => {
|
||||
// #given
|
||||
const config: BackgroundTaskConfig = {
|
||||
providerConcurrency: { anthropic: 3 }
|
||||
}
|
||||
const manager = new ConcurrencyManager(config)
|
||||
|
||||
// #when
|
||||
const limit = manager.getConcurrencyLimit("anthropic/claude-sonnet-4-5")
|
||||
|
||||
// #then
|
||||
expect(limit).toBe(3)
|
||||
})
|
||||
|
||||
test("should return provider limit even when modelConcurrency exists but doesn't match", () => {
|
||||
// #given
|
||||
const config: BackgroundTaskConfig = {
|
||||
modelConcurrency: { "google/gemini-3-pro": 5 },
|
||||
providerConcurrency: { anthropic: 3 }
|
||||
}
|
||||
const manager = new ConcurrencyManager(config)
|
||||
|
||||
// #when
|
||||
const limit = manager.getConcurrencyLimit("anthropic/claude-sonnet-4-5")
|
||||
|
||||
// #then
|
||||
expect(limit).toBe(3)
|
||||
})
|
||||
|
||||
test("should return default limit when defaultConcurrency is set", () => {
|
||||
// #given
|
||||
const config: BackgroundTaskConfig = {
|
||||
defaultConcurrency: 2
|
||||
}
|
||||
const manager = new ConcurrencyManager(config)
|
||||
|
||||
// #when
|
||||
const limit = manager.getConcurrencyLimit("anthropic/claude-sonnet-4-5")
|
||||
|
||||
// #then
|
||||
expect(limit).toBe(2)
|
||||
})
|
||||
|
||||
test("should return default 5 when no config provided", () => {
|
||||
// #given
|
||||
const manager = new ConcurrencyManager()
|
||||
|
||||
// #when
|
||||
const limit = manager.getConcurrencyLimit("anthropic/claude-sonnet-4-5")
|
||||
|
||||
// #then
|
||||
expect(limit).toBe(5)
|
||||
})
|
||||
|
||||
test("should return default 5 when config exists but no concurrency settings", () => {
|
||||
// #given
|
||||
const config: BackgroundTaskConfig = {}
|
||||
const manager = new ConcurrencyManager(config)
|
||||
|
||||
// #when
|
||||
const limit = manager.getConcurrencyLimit("anthropic/claude-sonnet-4-5")
|
||||
|
||||
// #then
|
||||
expect(limit).toBe(5)
|
||||
})
|
||||
|
||||
test("should prioritize model-specific over provider-specific over default", () => {
|
||||
// #given
|
||||
const config: BackgroundTaskConfig = {
|
||||
modelConcurrency: { "anthropic/claude-sonnet-4-5": 10 },
|
||||
providerConcurrency: { anthropic: 5 },
|
||||
defaultConcurrency: 2
|
||||
}
|
||||
const manager = new ConcurrencyManager(config)
|
||||
|
||||
// #when
|
||||
const modelLimit = manager.getConcurrencyLimit("anthropic/claude-sonnet-4-5")
|
||||
const providerLimit = manager.getConcurrencyLimit("anthropic/claude-opus-4-5")
|
||||
const defaultLimit = manager.getConcurrencyLimit("google/gemini-3-pro")
|
||||
|
||||
// #then
|
||||
expect(modelLimit).toBe(10)
|
||||
expect(providerLimit).toBe(5)
|
||||
expect(defaultLimit).toBe(2)
|
||||
})
|
||||
|
||||
test("should handle models without provider part", () => {
|
||||
// #given
|
||||
const config: BackgroundTaskConfig = {
|
||||
providerConcurrency: { "custom-model": 4 }
|
||||
}
|
||||
const manager = new ConcurrencyManager(config)
|
||||
|
||||
// #when
|
||||
const limit = manager.getConcurrencyLimit("custom-model")
|
||||
|
||||
// #then
|
||||
expect(limit).toBe(4)
|
||||
})
|
||||
|
||||
test("should return Infinity when defaultConcurrency is 0", () => {
|
||||
// #given
|
||||
const config: BackgroundTaskConfig = { defaultConcurrency: 0 }
|
||||
const manager = new ConcurrencyManager(config)
|
||||
|
||||
// #when
|
||||
const limit = manager.getConcurrencyLimit("any-model")
|
||||
|
||||
// #then
|
||||
expect(limit).toBe(Infinity)
|
||||
})
|
||||
|
||||
test("should return Infinity when providerConcurrency is 0", () => {
|
||||
// #given
|
||||
const config: BackgroundTaskConfig = {
|
||||
providerConcurrency: { anthropic: 0 }
|
||||
}
|
||||
const manager = new ConcurrencyManager(config)
|
||||
|
||||
// #when
|
||||
const limit = manager.getConcurrencyLimit("anthropic/claude-sonnet-4-5")
|
||||
|
||||
// #then
|
||||
expect(limit).toBe(Infinity)
|
||||
})
|
||||
|
||||
test("should return Infinity when modelConcurrency is 0", () => {
|
||||
// #given
|
||||
const config: BackgroundTaskConfig = {
|
||||
modelConcurrency: { "anthropic/claude-sonnet-4-5": 0 }
|
||||
}
|
||||
const manager = new ConcurrencyManager(config)
|
||||
|
||||
// #when
|
||||
const limit = manager.getConcurrencyLimit("anthropic/claude-sonnet-4-5")
|
||||
|
||||
// #then
|
||||
expect(limit).toBe(Infinity)
|
||||
})
|
||||
})
|
||||
|
||||
describe("ConcurrencyManager.acquire/release", () => {
|
||||
let manager: ConcurrencyManager
|
||||
|
||||
beforeEach(() => {
|
||||
// #given
|
||||
const config: BackgroundTaskConfig = {}
|
||||
manager = new ConcurrencyManager(config)
|
||||
})
|
||||
|
||||
test("should allow acquiring up to limit", async () => {
|
||||
// #given
|
||||
const config: BackgroundTaskConfig = { defaultConcurrency: 2 }
|
||||
manager = new ConcurrencyManager(config)
|
||||
|
||||
// #when
|
||||
await manager.acquire("model-a")
|
||||
await manager.acquire("model-a")
|
||||
|
||||
// #then - both resolved without waiting
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
|
||||
test("should allow acquires up to default limit of 5", async () => {
|
||||
// #given - no config = default limit of 5
|
||||
|
||||
// #when
|
||||
await manager.acquire("model-a")
|
||||
await manager.acquire("model-a")
|
||||
await manager.acquire("model-a")
|
||||
await manager.acquire("model-a")
|
||||
await manager.acquire("model-a")
|
||||
|
||||
// #then - all 5 resolved
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
|
||||
test("should queue when limit reached", async () => {
|
||||
// #given
|
||||
const config: BackgroundTaskConfig = { defaultConcurrency: 1 }
|
||||
manager = new ConcurrencyManager(config)
|
||||
await manager.acquire("model-a")
|
||||
|
||||
// #when
|
||||
let resolved = false
|
||||
const waitPromise = manager.acquire("model-a").then(() => { resolved = true })
|
||||
|
||||
// Give microtask queue a chance to run
|
||||
await Promise.resolve()
|
||||
|
||||
// #then - should still be waiting
|
||||
expect(resolved).toBe(false)
|
||||
|
||||
// #when - release
|
||||
manager.release("model-a")
|
||||
await waitPromise
|
||||
|
||||
// #then - now resolved
|
||||
expect(resolved).toBe(true)
|
||||
})
|
||||
|
||||
test("should queue multiple tasks and process in order", async () => {
|
||||
// #given
|
||||
const config: BackgroundTaskConfig = { defaultConcurrency: 1 }
|
||||
manager = new ConcurrencyManager(config)
|
||||
await manager.acquire("model-a")
|
||||
|
||||
// #when
|
||||
const order: string[] = []
|
||||
const task1 = manager.acquire("model-a").then(() => { order.push("1") })
|
||||
const task2 = manager.acquire("model-a").then(() => { order.push("2") })
|
||||
const task3 = manager.acquire("model-a").then(() => { order.push("3") })
|
||||
|
||||
// Give microtask queue a chance to run
|
||||
await Promise.resolve()
|
||||
|
||||
// #then - none resolved yet
|
||||
expect(order).toEqual([])
|
||||
|
||||
// #when - release one at a time
|
||||
manager.release("model-a")
|
||||
await task1
|
||||
expect(order).toEqual(["1"])
|
||||
|
||||
manager.release("model-a")
|
||||
await task2
|
||||
expect(order).toEqual(["1", "2"])
|
||||
|
||||
manager.release("model-a")
|
||||
await task3
|
||||
expect(order).toEqual(["1", "2", "3"])
|
||||
})
|
||||
|
||||
test("should handle independent models separately", async () => {
|
||||
// #given
|
||||
const config: BackgroundTaskConfig = { defaultConcurrency: 1 }
|
||||
manager = new ConcurrencyManager(config)
|
||||
await manager.acquire("model-a")
|
||||
|
||||
// #when - acquire different model
|
||||
const resolved = await Promise.race([
|
||||
manager.acquire("model-b").then(() => "resolved"),
|
||||
Promise.resolve("timeout").then(() => "timeout")
|
||||
])
|
||||
|
||||
// #then - different model should resolve immediately
|
||||
expect(resolved).toBe("resolved")
|
||||
})
|
||||
|
||||
test("should allow re-acquiring after release", async () => {
|
||||
// #given
|
||||
const config: BackgroundTaskConfig = { defaultConcurrency: 1 }
|
||||
manager = new ConcurrencyManager(config)
|
||||
|
||||
// #when
|
||||
await manager.acquire("model-a")
|
||||
manager.release("model-a")
|
||||
await manager.acquire("model-a")
|
||||
|
||||
// #then
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
|
||||
test("should handle release when no acquire", () => {
|
||||
// #given
|
||||
const config: BackgroundTaskConfig = { defaultConcurrency: 2 }
|
||||
manager = new ConcurrencyManager(config)
|
||||
|
||||
// #when - release without acquire
|
||||
manager.release("model-a")
|
||||
|
||||
// #then - should not throw
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
|
||||
test("should handle release when no prior acquire", () => {
|
||||
// #given - default config
|
||||
|
||||
// #when - release without acquire
|
||||
manager.release("model-a")
|
||||
|
||||
// #then - should not throw
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
|
||||
test("should handle multiple acquires and releases correctly", async () => {
|
||||
// #given
|
||||
const config: BackgroundTaskConfig = { defaultConcurrency: 3 }
|
||||
manager = new ConcurrencyManager(config)
|
||||
|
||||
// #when
|
||||
await manager.acquire("model-a")
|
||||
await manager.acquire("model-a")
|
||||
await manager.acquire("model-a")
|
||||
|
||||
// Release all
|
||||
manager.release("model-a")
|
||||
manager.release("model-a")
|
||||
manager.release("model-a")
|
||||
|
||||
// Should be able to acquire again
|
||||
await manager.acquire("model-a")
|
||||
|
||||
// #then
|
||||
expect(true).toBe(true)
|
||||
})
|
||||
|
||||
test("should use model-specific limit for acquire", async () => {
|
||||
// #given
|
||||
const config: BackgroundTaskConfig = {
|
||||
modelConcurrency: { "anthropic/claude-sonnet-4-5": 2 },
|
||||
defaultConcurrency: 5
|
||||
}
|
||||
manager = new ConcurrencyManager(config)
|
||||
await manager.acquire("anthropic/claude-sonnet-4-5")
|
||||
await manager.acquire("anthropic/claude-sonnet-4-5")
|
||||
|
||||
// #when
|
||||
let resolved = false
|
||||
const waitPromise = manager.acquire("anthropic/claude-sonnet-4-5").then(() => { resolved = true })
|
||||
|
||||
// Give microtask queue a chance to run
|
||||
await Promise.resolve()
|
||||
|
||||
// #then - should be waiting (model-specific limit is 2)
|
||||
expect(resolved).toBe(false)
|
||||
|
||||
// Cleanup
|
||||
manager.release("anthropic/claude-sonnet-4-5")
|
||||
await waitPromise
|
||||
})
|
||||
})
|
||||
66
src/features/background-agent/concurrency.ts
Normal file
66
src/features/background-agent/concurrency.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import type { BackgroundTaskConfig } from "../../config/schema"
|
||||
|
||||
export class ConcurrencyManager {
|
||||
private config?: BackgroundTaskConfig
|
||||
private counts: Map<string, number> = new Map()
|
||||
private queues: Map<string, Array<() => void>> = new Map()
|
||||
|
||||
constructor(config?: BackgroundTaskConfig) {
|
||||
this.config = config
|
||||
}
|
||||
|
||||
getConcurrencyLimit(model: string): number {
|
||||
const modelLimit = this.config?.modelConcurrency?.[model]
|
||||
if (modelLimit !== undefined) {
|
||||
return modelLimit === 0 ? Infinity : modelLimit
|
||||
}
|
||||
const provider = model.split('/')[0]
|
||||
const providerLimit = this.config?.providerConcurrency?.[provider]
|
||||
if (providerLimit !== undefined) {
|
||||
return providerLimit === 0 ? Infinity : providerLimit
|
||||
}
|
||||
const defaultLimit = this.config?.defaultConcurrency
|
||||
if (defaultLimit !== undefined) {
|
||||
return defaultLimit === 0 ? Infinity : defaultLimit
|
||||
}
|
||||
return 5
|
||||
}
|
||||
|
||||
async acquire(model: string): Promise<void> {
|
||||
const limit = this.getConcurrencyLimit(model)
|
||||
if (limit === Infinity) {
|
||||
return
|
||||
}
|
||||
|
||||
const current = this.counts.get(model) ?? 0
|
||||
if (current < limit) {
|
||||
this.counts.set(model, current + 1)
|
||||
return
|
||||
}
|
||||
|
||||
return new Promise<void>((resolve) => {
|
||||
const queue = this.queues.get(model) ?? []
|
||||
queue.push(resolve)
|
||||
this.queues.set(model, queue)
|
||||
})
|
||||
}
|
||||
|
||||
release(model: string): void {
|
||||
const limit = this.getConcurrencyLimit(model)
|
||||
if (limit === Infinity) {
|
||||
return
|
||||
}
|
||||
|
||||
const queue = this.queues.get(model)
|
||||
if (queue && queue.length > 0) {
|
||||
const next = queue.shift()!
|
||||
this.counts.set(model, this.counts.get(model) ?? 0)
|
||||
next()
|
||||
} else {
|
||||
const current = this.counts.get(model) ?? 0
|
||||
if (current > 0) {
|
||||
this.counts.set(model, current - 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from "./types"
|
||||
export { BackgroundManager } from "./manager"
|
||||
export { ConcurrencyManager } from "./concurrency"
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { describe, test, expect, beforeEach } from "bun:test"
|
||||
import type { BackgroundTask } from "./types"
|
||||
|
||||
const TASK_TTL_MS = 30 * 60 * 1000
|
||||
|
||||
class MockBackgroundManager {
|
||||
private tasks: Map<string, BackgroundTask> = new Map()
|
||||
private notifications: Map<string, BackgroundTask[]> = new Map()
|
||||
|
||||
addTask(task: BackgroundTask): void {
|
||||
this.tasks.set(task.id, task)
|
||||
@@ -34,6 +37,74 @@ class MockBackgroundManager {
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
markForNotification(task: BackgroundTask): void {
|
||||
const queue = this.notifications.get(task.parentSessionID) ?? []
|
||||
queue.push(task)
|
||||
this.notifications.set(task.parentSessionID, queue)
|
||||
}
|
||||
|
||||
getPendingNotifications(sessionID: string): BackgroundTask[] {
|
||||
return this.notifications.get(sessionID) ?? []
|
||||
}
|
||||
|
||||
private clearNotificationsForTask(taskId: string): void {
|
||||
for (const [sessionID, tasks] of this.notifications.entries()) {
|
||||
const filtered = tasks.filter((t) => t.id !== taskId)
|
||||
if (filtered.length === 0) {
|
||||
this.notifications.delete(sessionID)
|
||||
} else {
|
||||
this.notifications.set(sessionID, filtered)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pruneStaleTasksAndNotifications(): { prunedTasks: string[]; prunedNotifications: number } {
|
||||
const now = Date.now()
|
||||
const prunedTasks: string[] = []
|
||||
let prunedNotifications = 0
|
||||
|
||||
for (const [taskId, task] of this.tasks.entries()) {
|
||||
const age = now - task.startedAt.getTime()
|
||||
if (age > TASK_TTL_MS) {
|
||||
prunedTasks.push(taskId)
|
||||
this.clearNotificationsForTask(taskId)
|
||||
this.tasks.delete(taskId)
|
||||
}
|
||||
}
|
||||
|
||||
for (const [sessionID, notifications] of this.notifications.entries()) {
|
||||
if (notifications.length === 0) {
|
||||
this.notifications.delete(sessionID)
|
||||
continue
|
||||
}
|
||||
const validNotifications = notifications.filter((task) => {
|
||||
const age = now - task.startedAt.getTime()
|
||||
return age <= TASK_TTL_MS
|
||||
})
|
||||
const removed = notifications.length - validNotifications.length
|
||||
prunedNotifications += removed
|
||||
if (validNotifications.length === 0) {
|
||||
this.notifications.delete(sessionID)
|
||||
} else if (validNotifications.length !== notifications.length) {
|
||||
this.notifications.set(sessionID, validNotifications)
|
||||
}
|
||||
}
|
||||
|
||||
return { prunedTasks, prunedNotifications }
|
||||
}
|
||||
|
||||
getTaskCount(): number {
|
||||
return this.tasks.size
|
||||
}
|
||||
|
||||
getNotificationCount(): number {
|
||||
let count = 0
|
||||
for (const notifications of this.notifications.values()) {
|
||||
count += notifications.length
|
||||
}
|
||||
return count
|
||||
}
|
||||
}
|
||||
|
||||
function createMockTask(overrides: Partial<BackgroundTask> & { id: string; sessionID: string; parentSessionID: string }): BackgroundTask {
|
||||
@@ -230,3 +301,184 @@ describe("BackgroundManager.getAllDescendantTasks", () => {
|
||||
expect(result[0].id).toBe("task-b")
|
||||
})
|
||||
})
|
||||
|
||||
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
|
||||
|
||||
beforeEach(() => {
|
||||
// #given
|
||||
manager = new MockBackgroundManager()
|
||||
})
|
||||
|
||||
test("should not prune fresh tasks", () => {
|
||||
// #given
|
||||
const task = createMockTask({
|
||||
id: "task-fresh",
|
||||
sessionID: "session-fresh",
|
||||
parentSessionID: "session-parent",
|
||||
startedAt: new Date(),
|
||||
})
|
||||
manager.addTask(task)
|
||||
|
||||
// #when
|
||||
const result = manager.pruneStaleTasksAndNotifications()
|
||||
|
||||
// #then
|
||||
expect(result.prunedTasks).toHaveLength(0)
|
||||
expect(manager.getTaskCount()).toBe(1)
|
||||
})
|
||||
|
||||
test("should prune tasks older than 30 minutes", () => {
|
||||
// #given
|
||||
const staleDate = new Date(Date.now() - 31 * 60 * 1000)
|
||||
const task = createMockTask({
|
||||
id: "task-stale",
|
||||
sessionID: "session-stale",
|
||||
parentSessionID: "session-parent",
|
||||
startedAt: staleDate,
|
||||
})
|
||||
manager.addTask(task)
|
||||
|
||||
// #when
|
||||
const result = manager.pruneStaleTasksAndNotifications()
|
||||
|
||||
// #then
|
||||
expect(result.prunedTasks).toContain("task-stale")
|
||||
expect(manager.getTaskCount()).toBe(0)
|
||||
})
|
||||
|
||||
test("should prune stale notifications", () => {
|
||||
// #given
|
||||
const staleDate = new Date(Date.now() - 31 * 60 * 1000)
|
||||
const task = createMockTask({
|
||||
id: "task-stale",
|
||||
sessionID: "session-stale",
|
||||
parentSessionID: "session-parent",
|
||||
startedAt: staleDate,
|
||||
})
|
||||
manager.markForNotification(task)
|
||||
|
||||
// #when
|
||||
const result = manager.pruneStaleTasksAndNotifications()
|
||||
|
||||
// #then
|
||||
expect(result.prunedNotifications).toBe(1)
|
||||
expect(manager.getNotificationCount()).toBe(0)
|
||||
})
|
||||
|
||||
test("should clean up notifications when task is pruned", () => {
|
||||
// #given
|
||||
const staleDate = new Date(Date.now() - 31 * 60 * 1000)
|
||||
const task = createMockTask({
|
||||
id: "task-stale",
|
||||
sessionID: "session-stale",
|
||||
parentSessionID: "session-parent",
|
||||
startedAt: staleDate,
|
||||
})
|
||||
manager.addTask(task)
|
||||
manager.markForNotification(task)
|
||||
|
||||
// #when
|
||||
manager.pruneStaleTasksAndNotifications()
|
||||
|
||||
// #then
|
||||
expect(manager.getTaskCount()).toBe(0)
|
||||
expect(manager.getNotificationCount()).toBe(0)
|
||||
})
|
||||
|
||||
test("should keep fresh tasks while pruning stale ones", () => {
|
||||
// #given
|
||||
const staleDate = new Date(Date.now() - 31 * 60 * 1000)
|
||||
const staleTask = createMockTask({
|
||||
id: "task-stale",
|
||||
sessionID: "session-stale",
|
||||
parentSessionID: "session-parent",
|
||||
startedAt: staleDate,
|
||||
})
|
||||
const freshTask = createMockTask({
|
||||
id: "task-fresh",
|
||||
sessionID: "session-fresh",
|
||||
parentSessionID: "session-parent",
|
||||
startedAt: new Date(),
|
||||
})
|
||||
manager.addTask(staleTask)
|
||||
manager.addTask(freshTask)
|
||||
|
||||
// #when
|
||||
const result = manager.pruneStaleTasksAndNotifications()
|
||||
|
||||
// #then
|
||||
expect(result.prunedTasks).toHaveLength(1)
|
||||
expect(result.prunedTasks).toContain("task-stale")
|
||||
expect(manager.getTaskCount()).toBe(1)
|
||||
expect(manager.getTask("task-fresh")).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,12 +6,16 @@ import type {
|
||||
LaunchInput,
|
||||
} from "./types"
|
||||
import { log } from "../../shared/logger"
|
||||
import { ConcurrencyManager } from "./concurrency"
|
||||
import type { BackgroundTaskConfig } from "../../config/schema"
|
||||
import {
|
||||
findNearestMessageWithFields,
|
||||
MESSAGE_STORAGE,
|
||||
} from "../hook-message-injector"
|
||||
import { subagentSessions } from "../claude-code-session-state"
|
||||
|
||||
const TASK_TTL_MS = 30 * 60 * 1000
|
||||
|
||||
type OpencodeClient = PluginInput["client"]
|
||||
|
||||
interface MessagePartInfo {
|
||||
@@ -58,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> {
|
||||
@@ -71,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}`)
|
||||
}
|
||||
|
||||
@@ -100,6 +114,7 @@ export class BackgroundManager {
|
||||
lastUpdate: new Date(),
|
||||
},
|
||||
parentModel: input.parentModel,
|
||||
model,
|
||||
}
|
||||
|
||||
this.tasks.set(task.id, task)
|
||||
@@ -114,6 +129,7 @@ export class BackgroundManager {
|
||||
tools: {
|
||||
task: false,
|
||||
background_task: false,
|
||||
call_omo_agent: false,
|
||||
},
|
||||
parts: [{ type: "text", text: input.prompt }],
|
||||
},
|
||||
@@ -129,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)
|
||||
}
|
||||
@@ -250,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)
|
||||
@@ -327,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
|
||||
@@ -345,11 +371,11 @@ export class BackgroundManager {
|
||||
},
|
||||
query: { directory: this.directory },
|
||||
})
|
||||
this.clearNotificationsForTask(taskId)
|
||||
log("[background-agent] Successfully sent prompt to parent session:", { parentSessionID: task.parentSessionID })
|
||||
} catch (error) {
|
||||
log("[background-agent] prompt failed:", String(error))
|
||||
} finally {
|
||||
this.clearNotificationsForTask(taskId)
|
||||
this.tasks.delete(taskId)
|
||||
log("[background-agent] Removed completed task from memory:", taskId)
|
||||
}
|
||||
@@ -377,7 +403,45 @@ export class BackgroundManager {
|
||||
return false
|
||||
}
|
||||
|
||||
private pruneStaleTasksAndNotifications(): void {
|
||||
const now = Date.now()
|
||||
|
||||
for (const [taskId, task] of this.tasks.entries()) {
|
||||
const age = now - task.startedAt.getTime()
|
||||
if (age > TASK_TTL_MS) {
|
||||
log("[background-agent] Pruning stale task:", { taskId, age: Math.round(age / 1000) + "s" })
|
||||
task.status = "error"
|
||||
task.error = "Task timed out after 30 minutes"
|
||||
task.completedAt = new Date()
|
||||
if (task.model) {
|
||||
this.concurrencyManager.release(task.model)
|
||||
}
|
||||
this.clearNotificationsForTask(taskId)
|
||||
this.tasks.delete(taskId)
|
||||
subagentSessions.delete(task.sessionID)
|
||||
}
|
||||
}
|
||||
|
||||
for (const [sessionID, notifications] of this.notifications.entries()) {
|
||||
if (notifications.length === 0) {
|
||||
this.notifications.delete(sessionID)
|
||||
continue
|
||||
}
|
||||
const validNotifications = notifications.filter((task) => {
|
||||
const age = now - task.startedAt.getTime()
|
||||
return age <= TASK_TTL_MS
|
||||
})
|
||||
if (validNotifications.length === 0) {
|
||||
this.notifications.delete(sessionID)
|
||||
} else if (validNotifications.length !== notifications.length) {
|
||||
this.notifications.set(sessionID, validNotifications)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async pollRunningTasks(): Promise<void> {
|
||||
this.pruneStaleTasksAndNotifications()
|
||||
|
||||
const statusResult = await this.client.session.status()
|
||||
const allStatuses = (statusResult.data ?? {}) as Record<string, { type: string }>
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ export interface BackgroundTask {
|
||||
error?: string
|
||||
progress?: TaskProgress
|
||||
parentModel?: { providerID: string; modelID: string }
|
||||
model?: string
|
||||
}
|
||||
|
||||
export interface LaunchInput {
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { CommandDefinition } from "../claude-code-command-loader"
|
||||
import type { BuiltinCommandName, BuiltinCommands } from "./types"
|
||||
import { INIT_DEEP_TEMPLATE } from "./templates/init-deep"
|
||||
import { RALPH_LOOP_TEMPLATE, CANCEL_RALPH_TEMPLATE } from "./templates/ralph-loop"
|
||||
import { REFACTOR_TEMPLATE } from "./templates/refactor"
|
||||
|
||||
const BUILTIN_COMMAND_DEFINITIONS: Record<BuiltinCommandName, Omit<CommandDefinition, "name">> = {
|
||||
"init-deep": {
|
||||
@@ -32,6 +33,14 @@ $ARGUMENTS
|
||||
${CANCEL_RALPH_TEMPLATE}
|
||||
</command-instruction>`,
|
||||
},
|
||||
refactor: {
|
||||
description:
|
||||
"(builtin) Intelligent refactoring command with LSP, AST-grep, architecture analysis, codemap, and TDD verification.",
|
||||
template: `<command-instruction>
|
||||
${REFACTOR_TEMPLATE}
|
||||
</command-instruction>`,
|
||||
argumentHint: "<refactoring-target> [--scope=<file|module|project>] [--strategy=<safe|aggressive>]",
|
||||
},
|
||||
}
|
||||
|
||||
export function loadBuiltinCommands(
|
||||
|
||||
624
src/features/builtin-commands/templates/refactor.ts
Normal file
624
src/features/builtin-commands/templates/refactor.ts
Normal file
@@ -0,0 +1,624 @@
|
||||
export const REFACTOR_TEMPLATE = `# Intelligent Refactor Command
|
||||
|
||||
## Usage
|
||||
\`\`\`
|
||||
/refactor <refactoring-target> [--scope=<file|module|project>] [--strategy=<safe|aggressive>]
|
||||
|
||||
Arguments:
|
||||
refactoring-target: What to refactor. Can be:
|
||||
- File path: src/auth/handler.ts
|
||||
- Symbol name: "AuthService class"
|
||||
- Pattern: "all functions using deprecated API"
|
||||
- Description: "extract validation logic into separate module"
|
||||
|
||||
Options:
|
||||
--scope: Refactoring scope (default: module)
|
||||
- file: Single file only
|
||||
- module: Module/directory scope
|
||||
- project: Entire codebase
|
||||
|
||||
--strategy: Risk tolerance (default: safe)
|
||||
- safe: Conservative, maximum test coverage required
|
||||
- aggressive: Allow broader changes with adequate coverage
|
||||
\`\`\`
|
||||
|
||||
## What This Command Does
|
||||
|
||||
Performs intelligent, deterministic refactoring with full codebase awareness. Unlike blind search-and-replace, this command:
|
||||
|
||||
1. **Understands your intent** - Analyzes what you actually want to achieve
|
||||
2. **Maps the codebase** - Builds a definitive codemap before touching anything
|
||||
3. **Assesses risk** - Evaluates test coverage and determines verification strategy
|
||||
4. **Plans meticulously** - Creates a detailed plan with Plan agent
|
||||
5. **Executes precisely** - Step-by-step refactoring with LSP and AST-grep
|
||||
6. **Verifies constantly** - Runs tests after each change to ensure zero regression
|
||||
|
||||
---
|
||||
|
||||
# PHASE 0: INTENT GATE (MANDATORY FIRST STEP)
|
||||
|
||||
**BEFORE ANY ACTION, classify and validate the request.**
|
||||
|
||||
## Step 0.1: Parse Request Type
|
||||
|
||||
| Signal | Classification | Action |
|
||||
|--------|----------------|--------|
|
||||
| Specific file/symbol | Explicit | Proceed to codebase analysis |
|
||||
| "Refactor X to Y" | Clear transformation | Proceed to codebase analysis |
|
||||
| "Improve", "Clean up" | Open-ended | **MUST ask**: "What specific improvement?" |
|
||||
| Ambiguous scope | Uncertain | **MUST ask**: "Which modules/files?" |
|
||||
| Missing context | Incomplete | **MUST ask**: "What's the desired outcome?" |
|
||||
|
||||
## Step 0.2: Validate Understanding
|
||||
|
||||
Before proceeding, confirm:
|
||||
- [ ] Target is clearly identified
|
||||
- [ ] Desired outcome is understood
|
||||
- [ ] Scope is defined (file/module/project)
|
||||
- [ ] Success criteria can be articulated
|
||||
|
||||
**If ANY of above is unclear, ASK CLARIFYING QUESTION:**
|
||||
|
||||
\`\`\`
|
||||
I want to make sure I understand the refactoring goal correctly.
|
||||
|
||||
**What I understood**: [interpretation]
|
||||
**What I'm unsure about**: [specific ambiguity]
|
||||
|
||||
Options I see:
|
||||
1. [Option A] - [implications]
|
||||
2. [Option B] - [implications]
|
||||
|
||||
**My recommendation**: [suggestion with reasoning]
|
||||
|
||||
Should I proceed with [recommendation], or would you prefer differently?
|
||||
\`\`\`
|
||||
|
||||
## Step 0.3: Create Initial Todos
|
||||
|
||||
**IMMEDIATELY after understanding the request, create todos:**
|
||||
|
||||
\`\`\`
|
||||
TodoWrite([
|
||||
{"id": "phase-1", "content": "PHASE 1: Codebase Analysis - launch parallel explore agents", "status": "pending", "priority": "high"},
|
||||
{"id": "phase-2", "content": "PHASE 2: Build Codemap - map dependencies and impact zones", "status": "pending", "priority": "high"},
|
||||
{"id": "phase-3", "content": "PHASE 3: Test Assessment - analyze test coverage and verification strategy", "status": "pending", "priority": "high"},
|
||||
{"id": "phase-4", "content": "PHASE 4: Plan Generation - invoke Plan agent for detailed refactoring plan", "status": "pending", "priority": "high"},
|
||||
{"id": "phase-5", "content": "PHASE 5: Execute Refactoring - step-by-step with continuous verification", "status": "pending", "priority": "high"},
|
||||
{"id": "phase-6", "content": "PHASE 6: Final Verification - full test suite and regression check", "status": "pending", "priority": "high"}
|
||||
])
|
||||
\`\`\`
|
||||
|
||||
---
|
||||
|
||||
# PHASE 1: CODEBASE ANALYSIS (PARALLEL EXPLORATION)
|
||||
|
||||
**Mark phase-1 as in_progress.**
|
||||
|
||||
## 1.1: Launch Parallel Explore Agents (BACKGROUND)
|
||||
|
||||
Fire ALL of these simultaneously using \`call_omo_agent\`:
|
||||
|
||||
\`\`\`
|
||||
// Agent 1: Find the refactoring target
|
||||
call_omo_agent(
|
||||
subagent_type="explore",
|
||||
run_in_background=true,
|
||||
prompt="Find all occurrences and definitions of [TARGET].
|
||||
Report: file paths, line numbers, usage patterns."
|
||||
)
|
||||
|
||||
// Agent 2: Find related code
|
||||
call_omo_agent(
|
||||
subagent_type="explore",
|
||||
run_in_background=true,
|
||||
prompt="Find all code that imports, uses, or depends on [TARGET].
|
||||
Report: dependency chains, import graphs."
|
||||
)
|
||||
|
||||
// Agent 3: Find similar patterns
|
||||
call_omo_agent(
|
||||
subagent_type="explore",
|
||||
run_in_background=true,
|
||||
prompt="Find similar code patterns to [TARGET] in the codebase.
|
||||
Report: analogous implementations, established conventions."
|
||||
)
|
||||
|
||||
// Agent 4: Find tests
|
||||
call_omo_agent(
|
||||
subagent_type="explore",
|
||||
run_in_background=true,
|
||||
prompt="Find all test files related to [TARGET].
|
||||
Report: test file paths, test case names, coverage indicators."
|
||||
)
|
||||
|
||||
// Agent 5: Architecture context
|
||||
call_omo_agent(
|
||||
subagent_type="explore",
|
||||
run_in_background=true,
|
||||
prompt="Find architectural patterns and module organization around [TARGET].
|
||||
Report: module boundaries, layer structure, design patterns in use."
|
||||
)
|
||||
\`\`\`
|
||||
|
||||
## 1.2: Direct Tool Exploration (WHILE AGENTS RUN)
|
||||
|
||||
While background agents are running, use direct tools:
|
||||
|
||||
### LSP Tools for Precise Analysis:
|
||||
|
||||
\`\`\`typescript
|
||||
// Get symbol information at target location
|
||||
lsp_hover(filePath, line, character) // Type info, docs, signatures
|
||||
|
||||
// Find definition(s)
|
||||
lsp_goto_definition(filePath, line, character) // Where is it defined?
|
||||
|
||||
// Find ALL usages across workspace
|
||||
lsp_find_references(filePath, line, character, includeDeclaration=true)
|
||||
|
||||
// Get file structure
|
||||
lsp_document_symbols(filePath) // Hierarchical outline
|
||||
|
||||
// Search symbols by name
|
||||
lsp_workspace_symbols(filePath, query="[target_symbol]")
|
||||
|
||||
// Get current diagnostics
|
||||
lsp_diagnostics(filePath) // Errors, warnings before we start
|
||||
\`\`\`
|
||||
|
||||
### AST-Grep for Pattern Analysis:
|
||||
|
||||
\`\`\`typescript
|
||||
// Find structural patterns
|
||||
ast_grep_search(
|
||||
pattern="function $NAME($$$) { $$$ }", // or relevant pattern
|
||||
lang="typescript", // or relevant language
|
||||
paths=["src/"]
|
||||
)
|
||||
|
||||
// Preview refactoring (DRY RUN)
|
||||
ast_grep_replace(
|
||||
pattern="[old_pattern]",
|
||||
rewrite="[new_pattern]",
|
||||
lang="[language]",
|
||||
dryRun=true // ALWAYS preview first
|
||||
)
|
||||
\`\`\`
|
||||
|
||||
### Grep for Text Patterns:
|
||||
|
||||
\`\`\`
|
||||
grep(pattern="[search_term]", path="src/", include="*.ts")
|
||||
\`\`\`
|
||||
|
||||
## 1.3: Collect Background Results
|
||||
|
||||
\`\`\`
|
||||
background_output(task_id="[agent_1_id]")
|
||||
background_output(task_id="[agent_2_id]")
|
||||
...
|
||||
\`\`\`
|
||||
|
||||
**Mark phase-1 as completed after all results collected.**
|
||||
|
||||
---
|
||||
|
||||
# PHASE 2: BUILD CODEMAP (DEPENDENCY MAPPING)
|
||||
|
||||
**Mark phase-2 as in_progress.**
|
||||
|
||||
## 2.1: Construct Definitive Codemap
|
||||
|
||||
Based on Phase 1 results, build:
|
||||
|
||||
\`\`\`
|
||||
## CODEMAP: [TARGET]
|
||||
|
||||
### Core Files (Direct Impact)
|
||||
- \`path/to/file.ts:L10-L50\` - Primary definition
|
||||
- \`path/to/file2.ts:L25\` - Key usage
|
||||
|
||||
### Dependency Graph
|
||||
\`\`\`
|
||||
[TARGET]
|
||||
├── imports from:
|
||||
│ ├── module-a (types)
|
||||
│ └── module-b (utils)
|
||||
├── imported by:
|
||||
│ ├── consumer-1.ts
|
||||
│ ├── consumer-2.ts
|
||||
│ └── consumer-3.ts
|
||||
└── used by:
|
||||
├── handler.ts (direct call)
|
||||
└── service.ts (dependency injection)
|
||||
\`\`\`
|
||||
|
||||
### Impact Zones
|
||||
| Zone | Risk Level | Files Affected | Test Coverage |
|
||||
|------|------------|----------------|---------------|
|
||||
| Core | HIGH | 3 files | 85% covered |
|
||||
| Consumers | MEDIUM | 8 files | 70% covered |
|
||||
| Edge | LOW | 2 files | 50% covered |
|
||||
|
||||
### Established Patterns
|
||||
- Pattern A: [description] - used in N places
|
||||
- Pattern B: [description] - established convention
|
||||
\`\`\`
|
||||
|
||||
## 2.2: Identify Refactoring Constraints
|
||||
|
||||
Based on codemap:
|
||||
- **MUST follow**: [existing patterns identified]
|
||||
- **MUST NOT break**: [critical dependencies]
|
||||
- **Safe to change**: [isolated code zones]
|
||||
- **Requires migration**: [breaking changes impact]
|
||||
|
||||
**Mark phase-2 as completed.**
|
||||
|
||||
---
|
||||
|
||||
# PHASE 3: TEST ASSESSMENT (VERIFICATION STRATEGY)
|
||||
|
||||
**Mark phase-3 as in_progress.**
|
||||
|
||||
## 3.1: Detect Test Infrastructure
|
||||
|
||||
\`\`\`bash
|
||||
# Check for test commands
|
||||
cat package.json | jq '.scripts | keys[] | select(test("test"))'
|
||||
|
||||
# Or for Python
|
||||
ls -la pytest.ini pyproject.toml setup.cfg
|
||||
|
||||
# Or for Go
|
||||
ls -la *_test.go
|
||||
\`\`\`
|
||||
|
||||
## 3.2: Analyze Test Coverage
|
||||
|
||||
\`\`\`
|
||||
// Find all tests related to target
|
||||
call_omo_agent(
|
||||
subagent_type="explore",
|
||||
run_in_background=false, // Need this synchronously
|
||||
prompt="Analyze test coverage for [TARGET]:
|
||||
1. Which test files cover this code?
|
||||
2. What test cases exist?
|
||||
3. Are there integration tests?
|
||||
4. What edge cases are tested?
|
||||
5. Estimated coverage percentage?"
|
||||
)
|
||||
\`\`\`
|
||||
|
||||
## 3.3: Determine Verification Strategy
|
||||
|
||||
Based on test analysis:
|
||||
|
||||
| Coverage Level | Strategy |
|
||||
|----------------|----------|
|
||||
| HIGH (>80%) | Run existing tests after each step |
|
||||
| MEDIUM (50-80%) | Run tests + add safety assertions |
|
||||
| LOW (<50%) | **PAUSE**: Propose adding tests first |
|
||||
| NONE | **BLOCK**: Refuse aggressive refactoring |
|
||||
|
||||
**If coverage is LOW or NONE, ask user:**
|
||||
|
||||
\`\`\`
|
||||
Test coverage for [TARGET] is [LEVEL].
|
||||
|
||||
**Risk Assessment**: Refactoring without adequate tests is dangerous.
|
||||
|
||||
Options:
|
||||
1. Add tests first, then refactor (RECOMMENDED)
|
||||
2. Proceed with extra caution, manual verification required
|
||||
3. Abort refactoring
|
||||
|
||||
Which approach do you prefer?
|
||||
\`\`\`
|
||||
|
||||
## 3.4: Document Verification Plan
|
||||
|
||||
\`\`\`
|
||||
## VERIFICATION PLAN
|
||||
|
||||
### Test Commands
|
||||
- Unit: \`bun test\` / \`npm test\` / \`pytest\` / etc.
|
||||
- Integration: [command if exists]
|
||||
- Type check: \`tsc --noEmit\` / \`pyright\` / etc.
|
||||
|
||||
### Verification Checkpoints
|
||||
After each refactoring step:
|
||||
1. lsp_diagnostics → zero new errors
|
||||
2. Run test command → all pass
|
||||
3. Type check → clean
|
||||
|
||||
### Regression Indicators
|
||||
- [Specific test that must pass]
|
||||
- [Behavior that must be preserved]
|
||||
- [API contract that must not change]
|
||||
\`\`\`
|
||||
|
||||
**Mark phase-3 as completed.**
|
||||
|
||||
---
|
||||
|
||||
# PHASE 4: PLAN GENERATION (PLAN AGENT)
|
||||
|
||||
**Mark phase-4 as in_progress.**
|
||||
|
||||
## 4.1: Invoke Plan Agent
|
||||
|
||||
\`\`\`
|
||||
Task(
|
||||
subagent_type="plan",
|
||||
prompt="Create a detailed refactoring plan:
|
||||
|
||||
## Refactoring Goal
|
||||
[User's original request]
|
||||
|
||||
## Codemap (from Phase 2)
|
||||
[Insert codemap here]
|
||||
|
||||
## Test Coverage (from Phase 3)
|
||||
[Insert verification plan here]
|
||||
|
||||
## Constraints
|
||||
- MUST follow existing patterns: [list]
|
||||
- MUST NOT break: [critical paths]
|
||||
- MUST run tests after each step
|
||||
|
||||
## Requirements
|
||||
1. Break down into atomic refactoring steps
|
||||
2. Each step must be independently verifiable
|
||||
3. Order steps by dependency (what must happen first)
|
||||
4. Specify exact files and line ranges for each step
|
||||
5. Include rollback strategy for each step
|
||||
6. Define commit checkpoints"
|
||||
)
|
||||
\`\`\`
|
||||
|
||||
## 4.2: Review and Validate Plan
|
||||
|
||||
After receiving plan from Plan agent:
|
||||
|
||||
1. **Verify completeness**: All identified files addressed?
|
||||
2. **Verify safety**: Each step reversible?
|
||||
3. **Verify order**: Dependencies respected?
|
||||
4. **Verify verification**: Test commands specified?
|
||||
|
||||
## 4.3: Register Detailed Todos
|
||||
|
||||
Convert Plan agent output into granular todos:
|
||||
|
||||
\`\`\`
|
||||
TodoWrite([
|
||||
// Each step from the plan becomes a todo
|
||||
{"id": "refactor-1", "content": "Step 1: [description]", "status": "pending", "priority": "high"},
|
||||
{"id": "verify-1", "content": "Verify Step 1: run tests", "status": "pending", "priority": "high"},
|
||||
{"id": "refactor-2", "content": "Step 2: [description]", "status": "pending", "priority": "medium"},
|
||||
{"id": "verify-2", "content": "Verify Step 2: run tests", "status": "pending", "priority": "medium"},
|
||||
// ... continue for all steps
|
||||
])
|
||||
\`\`\`
|
||||
|
||||
**Mark phase-4 as completed.**
|
||||
|
||||
---
|
||||
|
||||
# PHASE 5: EXECUTE REFACTORING (DETERMINISTIC EXECUTION)
|
||||
|
||||
**Mark phase-5 as in_progress.**
|
||||
|
||||
## 5.1: Execution Protocol
|
||||
|
||||
For EACH refactoring step:
|
||||
|
||||
### Pre-Step
|
||||
1. Mark step todo as \`in_progress\`
|
||||
2. Read current file state
|
||||
3. Verify lsp_diagnostics is baseline
|
||||
|
||||
### Execute Step
|
||||
Use appropriate tool:
|
||||
|
||||
**For Symbol Renames:**
|
||||
\`\`\`typescript
|
||||
lsp_prepare_rename(filePath, line, character) // Validate rename is possible
|
||||
lsp_rename(filePath, line, character, newName) // Execute rename
|
||||
\`\`\`
|
||||
|
||||
**For Pattern Transformations:**
|
||||
\`\`\`typescript
|
||||
// Preview first
|
||||
ast_grep_replace(pattern, rewrite, lang, dryRun=true)
|
||||
|
||||
// If preview looks good, execute
|
||||
ast_grep_replace(pattern, rewrite, lang, dryRun=false)
|
||||
\`\`\`
|
||||
|
||||
**For Structural Changes:**
|
||||
\`\`\`typescript
|
||||
// Use Edit tool for precise changes
|
||||
edit(filePath, oldString, newString)
|
||||
\`\`\`
|
||||
|
||||
### Post-Step Verification (MANDATORY)
|
||||
|
||||
\`\`\`typescript
|
||||
// 1. Check diagnostics
|
||||
lsp_diagnostics(filePath) // Must be clean or same as baseline
|
||||
|
||||
// 2. Run tests
|
||||
bash("bun test") // Or appropriate test command
|
||||
|
||||
// 3. Type check
|
||||
bash("tsc --noEmit") // Or appropriate type check
|
||||
\`\`\`
|
||||
|
||||
### Step Completion
|
||||
1. If verification passes → Mark step todo as \`completed\`
|
||||
2. If verification fails → **STOP AND FIX**
|
||||
|
||||
## 5.2: Failure Recovery Protocol
|
||||
|
||||
If ANY verification fails:
|
||||
|
||||
1. **STOP** immediately
|
||||
2. **REVERT** the failed change
|
||||
3. **DIAGNOSE** what went wrong
|
||||
4. **OPTIONS**:
|
||||
- Fix the issue and retry
|
||||
- Skip this step (if optional)
|
||||
- Consult oracle agent for help
|
||||
- Ask user for guidance
|
||||
|
||||
**NEVER proceed to next step with broken tests.**
|
||||
|
||||
## 5.3: Commit Checkpoints
|
||||
|
||||
After each logical group of changes:
|
||||
|
||||
\`\`\`bash
|
||||
git add [changed-files]
|
||||
git commit -m "refactor(scope): description
|
||||
|
||||
[details of what was changed and why]"
|
||||
\`\`\`
|
||||
|
||||
**Mark phase-5 as completed when all refactoring steps done.**
|
||||
|
||||
---
|
||||
|
||||
# PHASE 6: FINAL VERIFICATION (REGRESSION CHECK)
|
||||
|
||||
**Mark phase-6 as in_progress.**
|
||||
|
||||
## 6.1: Full Test Suite
|
||||
|
||||
\`\`\`bash
|
||||
# Run complete test suite
|
||||
bun test # or npm test, pytest, go test, etc.
|
||||
\`\`\`
|
||||
|
||||
## 6.2: Type Check
|
||||
|
||||
\`\`\`bash
|
||||
# Full type check
|
||||
tsc --noEmit # or equivalent
|
||||
\`\`\`
|
||||
|
||||
## 6.3: Lint Check
|
||||
|
||||
\`\`\`bash
|
||||
# Run linter
|
||||
eslint . # or equivalent
|
||||
\`\`\`
|
||||
|
||||
## 6.4: Build Verification (if applicable)
|
||||
|
||||
\`\`\`bash
|
||||
# Ensure build still works
|
||||
bun run build # or npm run build, etc.
|
||||
\`\`\`
|
||||
|
||||
## 6.5: Final Diagnostics
|
||||
|
||||
\`\`\`typescript
|
||||
// Check all changed files
|
||||
for (file of changedFiles) {
|
||||
lsp_diagnostics(file) // Must all be clean
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
## 6.6: Generate Summary
|
||||
|
||||
\`\`\`markdown
|
||||
## Refactoring Complete
|
||||
|
||||
### What Changed
|
||||
- [List of changes made]
|
||||
|
||||
### Files Modified
|
||||
- \`path/to/file.ts\` - [what changed]
|
||||
- \`path/to/file2.ts\` - [what changed]
|
||||
|
||||
### Verification Results
|
||||
- Tests: PASSED (X/Y passing)
|
||||
- Type Check: CLEAN
|
||||
- Lint: CLEAN
|
||||
- Build: SUCCESS
|
||||
|
||||
### No Regressions Detected
|
||||
All existing tests pass. No new errors introduced.
|
||||
\`\`\`
|
||||
|
||||
**Mark phase-6 as completed.**
|
||||
|
||||
---
|
||||
|
||||
# CRITICAL RULES
|
||||
|
||||
## NEVER DO
|
||||
- Skip lsp_diagnostics check after changes
|
||||
- Proceed with failing tests
|
||||
- Make changes without understanding impact
|
||||
- Use \`as any\`, \`@ts-ignore\`, \`@ts-expect-error\`
|
||||
- Delete tests to make them pass
|
||||
- Commit broken code
|
||||
- Refactor without understanding existing patterns
|
||||
|
||||
## ALWAYS DO
|
||||
- Understand before changing
|
||||
- Preview before applying (ast_grep dryRun=true)
|
||||
- Verify after every change
|
||||
- Follow existing codebase patterns
|
||||
- Keep todos updated in real-time
|
||||
- Commit at logical checkpoints
|
||||
- Report issues immediately
|
||||
|
||||
## ABORT CONDITIONS
|
||||
If any of these occur, **STOP and consult user**:
|
||||
- Test coverage is zero for target code
|
||||
- Changes would break public API
|
||||
- Refactoring scope is unclear
|
||||
- 3 consecutive verification failures
|
||||
- User-defined constraints violated
|
||||
|
||||
---
|
||||
|
||||
# Tool Usage Philosophy
|
||||
|
||||
You already know these tools. Use them intelligently:
|
||||
|
||||
## LSP Tools
|
||||
Leverage the full LSP toolset (\`lsp_*\`) for precision analysis. Key patterns:
|
||||
- **Understand before changing**: \`lsp_hover\`, \`lsp_goto_definition\` to grasp context
|
||||
- **Impact analysis**: \`lsp_find_references\` to map all usages before modification
|
||||
- **Safe refactoring**: \`lsp_prepare_rename\` → \`lsp_rename\` for symbol renames
|
||||
- **Continuous verification**: \`lsp_diagnostics\` after every change
|
||||
|
||||
## AST-Grep
|
||||
Use \`ast_grep_search\` and \`ast_grep_replace\` for structural transformations.
|
||||
**Critical**: Always \`dryRun=true\` first, review, then execute.
|
||||
|
||||
## Agents
|
||||
- \`explore\`: Parallel codebase pattern discovery
|
||||
- \`plan\`: Detailed refactoring plan generation
|
||||
- \`oracle\`: Consult for complex architectural decisions
|
||||
- \`librarian\`: **Use proactively** when encountering deprecated methods or library migration tasks. Query official docs and OSS examples for modern replacements.
|
||||
|
||||
## Deprecated Code & Library Migration
|
||||
When you encounter deprecated methods/APIs during refactoring:
|
||||
1. Fire \`librarian\` to find the recommended modern alternative
|
||||
2. **DO NOT auto-upgrade to latest version** unless user explicitly requests migration
|
||||
3. If user requests library migration, use \`librarian\` to fetch latest API docs before making changes
|
||||
|
||||
---
|
||||
|
||||
**Remember: Refactoring without tests is reckless. Refactoring without understanding is destructive. This command ensures you do neither.**
|
||||
|
||||
<user-request>
|
||||
$ARGUMENTS
|
||||
</user-request>
|
||||
`
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { CommandDefinition } from "../claude-code-command-loader"
|
||||
|
||||
export type BuiltinCommandName = "init-deep" | "ralph-loop" | "cancel-ralph"
|
||||
export type BuiltinCommandName = "init-deep" | "ralph-loop" | "cancel-ralph" | "refactor"
|
||||
|
||||
export interface BuiltinCommandConfig {
|
||||
disabled_commands?: BuiltinCommandName[]
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { BuiltinSkill } from "./types"
|
||||
|
||||
const playwrightSkill: BuiltinSkill = {
|
||||
name: "playwright",
|
||||
description: "Browser automation with Playwright MCP. Use for web scraping, testing, screenshots, and browser interactions.",
|
||||
description: "MUST USE for any browser-related tasks. Browser automation via Playwright MCP - verification, browsing, information gathering, web scraping, testing, screenshots, and all browser interactions.",
|
||||
template: `# Playwright Browser Automation
|
||||
|
||||
This skill provides browser automation capabilities via the Playwright MCP server.`,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { existsSync, readdirSync, readFileSync, realpathSync, type Dirent } 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"
|
||||
@@ -7,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 []
|
||||
@@ -32,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 []
|
||||
@@ -45,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
|
||||
}
|
||||
|
||||
@@ -56,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>
|
||||
@@ -105,27 +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)
|
||||
}
|
||||
|
||||
export async function loadAllCommands(): Promise<Record<string, CommandDefinition>> {
|
||||
const [user, project, global, projectOpencode] = await Promise.all([
|
||||
loadUserCommands(),
|
||||
loadProjectCommands(),
|
||||
loadOpencodeGlobalCommands(),
|
||||
loadOpencodeProjectCommands(),
|
||||
])
|
||||
return { ...projectOpencode, ...global, ...project, ...user }
|
||||
}
|
||||
|
||||
@@ -464,11 +464,13 @@ export interface PluginComponentsResult {
|
||||
export async function loadAllPluginComponents(options?: PluginLoaderOptions): Promise<PluginComponentsResult> {
|
||||
const { plugins, errors } = discoverInstalledPlugins(options)
|
||||
|
||||
const commands = loadPluginCommands(plugins)
|
||||
const skills = loadPluginSkillsAsCommands(plugins)
|
||||
const agents = loadPluginAgents(plugins)
|
||||
const mcpServers = await loadPluginMcpServers(plugins)
|
||||
const hooksConfigs = loadPluginHooksConfigs(plugins)
|
||||
const [commands, skills, agents, mcpServers, hooksConfigs] = await Promise.all([
|
||||
Promise.resolve(loadPluginCommands(plugins)),
|
||||
Promise.resolve(loadPluginSkillsAsCommands(plugins)),
|
||||
Promise.resolve(loadPluginAgents(plugins)),
|
||||
loadPluginMcpServers(plugins),
|
||||
Promise.resolve(loadPluginHooksConfigs(plugins)),
|
||||
])
|
||||
|
||||
log(`Loaded ${plugins.length} plugins with ${Object.keys(commands).length} commands, ${Object.keys(skills).length} skills, ${Object.keys(agents).length} agents, ${Object.keys(mcpServers).length} MCP servers`)
|
||||
|
||||
|
||||
330
src/features/context-injector/collector.test.ts
Normal file
330
src/features/context-injector/collector.test.ts
Normal file
@@ -0,0 +1,330 @@
|
||||
import { describe, it, expect, beforeEach } from "bun:test"
|
||||
import { ContextCollector } from "./collector"
|
||||
import type { ContextPriority, ContextSourceType } from "./types"
|
||||
|
||||
describe("ContextCollector", () => {
|
||||
let collector: ContextCollector
|
||||
|
||||
beforeEach(() => {
|
||||
collector = new ContextCollector()
|
||||
})
|
||||
|
||||
describe("register", () => {
|
||||
it("registers context for a session", () => {
|
||||
// #given
|
||||
const sessionID = "ses_test1"
|
||||
const options = {
|
||||
id: "ulw-context",
|
||||
source: "keyword-detector" as ContextSourceType,
|
||||
content: "Ultrawork mode activated",
|
||||
}
|
||||
|
||||
// #when
|
||||
collector.register(sessionID, options)
|
||||
|
||||
// #then
|
||||
const pending = collector.getPending(sessionID)
|
||||
expect(pending.hasContent).toBe(true)
|
||||
expect(pending.entries).toHaveLength(1)
|
||||
expect(pending.entries[0].content).toBe("Ultrawork mode activated")
|
||||
})
|
||||
|
||||
it("assigns default priority of 'normal' when not specified", () => {
|
||||
// #given
|
||||
const sessionID = "ses_test2"
|
||||
|
||||
// #when
|
||||
collector.register(sessionID, {
|
||||
id: "test",
|
||||
source: "keyword-detector",
|
||||
content: "test content",
|
||||
})
|
||||
|
||||
// #then
|
||||
const pending = collector.getPending(sessionID)
|
||||
expect(pending.entries[0].priority).toBe("normal")
|
||||
})
|
||||
|
||||
it("uses specified priority", () => {
|
||||
// #given
|
||||
const sessionID = "ses_test3"
|
||||
|
||||
// #when
|
||||
collector.register(sessionID, {
|
||||
id: "critical-context",
|
||||
source: "keyword-detector",
|
||||
content: "critical content",
|
||||
priority: "critical",
|
||||
})
|
||||
|
||||
// #then
|
||||
const pending = collector.getPending(sessionID)
|
||||
expect(pending.entries[0].priority).toBe("critical")
|
||||
})
|
||||
|
||||
it("deduplicates by source + id combination", () => {
|
||||
// #given
|
||||
const sessionID = "ses_test4"
|
||||
const options = {
|
||||
id: "ulw-context",
|
||||
source: "keyword-detector" as ContextSourceType,
|
||||
content: "First content",
|
||||
}
|
||||
|
||||
// #when
|
||||
collector.register(sessionID, options)
|
||||
collector.register(sessionID, { ...options, content: "Updated content" })
|
||||
|
||||
// #then
|
||||
const pending = collector.getPending(sessionID)
|
||||
expect(pending.entries).toHaveLength(1)
|
||||
expect(pending.entries[0].content).toBe("Updated content")
|
||||
})
|
||||
|
||||
it("allows same id from different sources", () => {
|
||||
// #given
|
||||
const sessionID = "ses_test5"
|
||||
|
||||
// #when
|
||||
collector.register(sessionID, {
|
||||
id: "context-1",
|
||||
source: "keyword-detector",
|
||||
content: "From keyword-detector",
|
||||
})
|
||||
collector.register(sessionID, {
|
||||
id: "context-1",
|
||||
source: "rules-injector",
|
||||
content: "From rules-injector",
|
||||
})
|
||||
|
||||
// #then
|
||||
const pending = collector.getPending(sessionID)
|
||||
expect(pending.entries).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getPending", () => {
|
||||
it("returns empty result for session with no context", () => {
|
||||
// #given
|
||||
const sessionID = "ses_empty"
|
||||
|
||||
// #when
|
||||
const pending = collector.getPending(sessionID)
|
||||
|
||||
// #then
|
||||
expect(pending.hasContent).toBe(false)
|
||||
expect(pending.entries).toHaveLength(0)
|
||||
expect(pending.merged).toBe("")
|
||||
})
|
||||
|
||||
it("merges multiple contexts with separator", () => {
|
||||
// #given
|
||||
const sessionID = "ses_merge"
|
||||
collector.register(sessionID, {
|
||||
id: "ctx-1",
|
||||
source: "keyword-detector",
|
||||
content: "First context",
|
||||
})
|
||||
collector.register(sessionID, {
|
||||
id: "ctx-2",
|
||||
source: "rules-injector",
|
||||
content: "Second context",
|
||||
})
|
||||
|
||||
// #when
|
||||
const pending = collector.getPending(sessionID)
|
||||
|
||||
// #then
|
||||
expect(pending.hasContent).toBe(true)
|
||||
expect(pending.merged).toContain("First context")
|
||||
expect(pending.merged).toContain("Second context")
|
||||
})
|
||||
|
||||
it("orders contexts by priority (critical > high > normal > low)", () => {
|
||||
// #given
|
||||
const sessionID = "ses_priority"
|
||||
collector.register(sessionID, {
|
||||
id: "low",
|
||||
source: "custom",
|
||||
content: "LOW",
|
||||
priority: "low",
|
||||
})
|
||||
collector.register(sessionID, {
|
||||
id: "critical",
|
||||
source: "custom",
|
||||
content: "CRITICAL",
|
||||
priority: "critical",
|
||||
})
|
||||
collector.register(sessionID, {
|
||||
id: "normal",
|
||||
source: "custom",
|
||||
content: "NORMAL",
|
||||
priority: "normal",
|
||||
})
|
||||
collector.register(sessionID, {
|
||||
id: "high",
|
||||
source: "custom",
|
||||
content: "HIGH",
|
||||
priority: "high",
|
||||
})
|
||||
|
||||
// #when
|
||||
const pending = collector.getPending(sessionID)
|
||||
|
||||
// #then
|
||||
const order = pending.entries.map((e) => e.priority)
|
||||
expect(order).toEqual(["critical", "high", "normal", "low"])
|
||||
})
|
||||
|
||||
it("maintains registration order within same priority", () => {
|
||||
// #given
|
||||
const sessionID = "ses_order"
|
||||
collector.register(sessionID, {
|
||||
id: "first",
|
||||
source: "custom",
|
||||
content: "First",
|
||||
priority: "normal",
|
||||
})
|
||||
collector.register(sessionID, {
|
||||
id: "second",
|
||||
source: "custom",
|
||||
content: "Second",
|
||||
priority: "normal",
|
||||
})
|
||||
collector.register(sessionID, {
|
||||
id: "third",
|
||||
source: "custom",
|
||||
content: "Third",
|
||||
priority: "normal",
|
||||
})
|
||||
|
||||
// #when
|
||||
const pending = collector.getPending(sessionID)
|
||||
|
||||
// #then
|
||||
const ids = pending.entries.map((e) => e.id)
|
||||
expect(ids).toEqual(["first", "second", "third"])
|
||||
})
|
||||
})
|
||||
|
||||
describe("consume", () => {
|
||||
it("clears pending context for session", () => {
|
||||
// #given
|
||||
const sessionID = "ses_consume"
|
||||
collector.register(sessionID, {
|
||||
id: "ctx",
|
||||
source: "keyword-detector",
|
||||
content: "test",
|
||||
})
|
||||
|
||||
// #when
|
||||
collector.consume(sessionID)
|
||||
|
||||
// #then
|
||||
const pending = collector.getPending(sessionID)
|
||||
expect(pending.hasContent).toBe(false)
|
||||
})
|
||||
|
||||
it("returns the consumed context", () => {
|
||||
// #given
|
||||
const sessionID = "ses_consume_return"
|
||||
collector.register(sessionID, {
|
||||
id: "ctx",
|
||||
source: "keyword-detector",
|
||||
content: "test content",
|
||||
})
|
||||
|
||||
// #when
|
||||
const consumed = collector.consume(sessionID)
|
||||
|
||||
// #then
|
||||
expect(consumed.hasContent).toBe(true)
|
||||
expect(consumed.entries[0].content).toBe("test content")
|
||||
})
|
||||
|
||||
it("does not affect other sessions", () => {
|
||||
// #given
|
||||
const session1 = "ses_1"
|
||||
const session2 = "ses_2"
|
||||
collector.register(session1, {
|
||||
id: "ctx",
|
||||
source: "keyword-detector",
|
||||
content: "session 1",
|
||||
})
|
||||
collector.register(session2, {
|
||||
id: "ctx",
|
||||
source: "keyword-detector",
|
||||
content: "session 2",
|
||||
})
|
||||
|
||||
// #when
|
||||
collector.consume(session1)
|
||||
|
||||
// #then
|
||||
expect(collector.getPending(session1).hasContent).toBe(false)
|
||||
expect(collector.getPending(session2).hasContent).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("clear", () => {
|
||||
it("removes all context for a session", () => {
|
||||
// #given
|
||||
const sessionID = "ses_clear"
|
||||
collector.register(sessionID, {
|
||||
id: "ctx-1",
|
||||
source: "keyword-detector",
|
||||
content: "test 1",
|
||||
})
|
||||
collector.register(sessionID, {
|
||||
id: "ctx-2",
|
||||
source: "rules-injector",
|
||||
content: "test 2",
|
||||
})
|
||||
|
||||
// #when
|
||||
collector.clear(sessionID)
|
||||
|
||||
// #then
|
||||
expect(collector.getPending(sessionID).hasContent).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("hasPending", () => {
|
||||
it("returns true when session has pending context", () => {
|
||||
// #given
|
||||
const sessionID = "ses_has"
|
||||
collector.register(sessionID, {
|
||||
id: "ctx",
|
||||
source: "keyword-detector",
|
||||
content: "test",
|
||||
})
|
||||
|
||||
// #when / #then
|
||||
expect(collector.hasPending(sessionID)).toBe(true)
|
||||
})
|
||||
|
||||
it("returns false when session has no pending context", () => {
|
||||
// #given
|
||||
const sessionID = "ses_empty"
|
||||
|
||||
// #when / #then
|
||||
expect(collector.hasPending(sessionID)).toBe(false)
|
||||
})
|
||||
|
||||
it("returns false after consume", () => {
|
||||
// #given
|
||||
const sessionID = "ses_after_consume"
|
||||
collector.register(sessionID, {
|
||||
id: "ctx",
|
||||
source: "keyword-detector",
|
||||
content: "test",
|
||||
})
|
||||
|
||||
// #when
|
||||
collector.consume(sessionID)
|
||||
|
||||
// #then
|
||||
expect(collector.hasPending(sessionID)).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
85
src/features/context-injector/collector.ts
Normal file
85
src/features/context-injector/collector.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import type {
|
||||
ContextEntry,
|
||||
ContextPriority,
|
||||
PendingContext,
|
||||
RegisterContextOptions,
|
||||
} from "./types"
|
||||
|
||||
const PRIORITY_ORDER: Record<ContextPriority, number> = {
|
||||
critical: 0,
|
||||
high: 1,
|
||||
normal: 2,
|
||||
low: 3,
|
||||
}
|
||||
|
||||
const CONTEXT_SEPARATOR = "\n\n---\n\n"
|
||||
|
||||
export class ContextCollector {
|
||||
private sessions: Map<string, Map<string, ContextEntry>> = new Map()
|
||||
|
||||
register(sessionID: string, options: RegisterContextOptions): void {
|
||||
if (!this.sessions.has(sessionID)) {
|
||||
this.sessions.set(sessionID, new Map())
|
||||
}
|
||||
|
||||
const sessionMap = this.sessions.get(sessionID)!
|
||||
const key = `${options.source}:${options.id}`
|
||||
|
||||
const entry: ContextEntry = {
|
||||
id: options.id,
|
||||
source: options.source,
|
||||
content: options.content,
|
||||
priority: options.priority ?? "normal",
|
||||
timestamp: Date.now(),
|
||||
metadata: options.metadata,
|
||||
}
|
||||
|
||||
sessionMap.set(key, entry)
|
||||
}
|
||||
|
||||
getPending(sessionID: string): PendingContext {
|
||||
const sessionMap = this.sessions.get(sessionID)
|
||||
|
||||
if (!sessionMap || sessionMap.size === 0) {
|
||||
return {
|
||||
merged: "",
|
||||
entries: [],
|
||||
hasContent: false,
|
||||
}
|
||||
}
|
||||
|
||||
const entries = this.sortEntries([...sessionMap.values()])
|
||||
const merged = entries.map((e) => e.content).join(CONTEXT_SEPARATOR)
|
||||
|
||||
return {
|
||||
merged,
|
||||
entries,
|
||||
hasContent: entries.length > 0,
|
||||
}
|
||||
}
|
||||
|
||||
consume(sessionID: string): PendingContext {
|
||||
const pending = this.getPending(sessionID)
|
||||
this.clear(sessionID)
|
||||
return pending
|
||||
}
|
||||
|
||||
clear(sessionID: string): void {
|
||||
this.sessions.delete(sessionID)
|
||||
}
|
||||
|
||||
hasPending(sessionID: string): boolean {
|
||||
const sessionMap = this.sessions.get(sessionID)
|
||||
return sessionMap !== undefined && sessionMap.size > 0
|
||||
}
|
||||
|
||||
private sortEntries(entries: ContextEntry[]): ContextEntry[] {
|
||||
return entries.sort((a, b) => {
|
||||
const priorityDiff = PRIORITY_ORDER[a.priority] - PRIORITY_ORDER[b.priority]
|
||||
if (priorityDiff !== 0) return priorityDiff
|
||||
return a.timestamp - b.timestamp
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const contextCollector = new ContextCollector()
|
||||
16
src/features/context-injector/index.ts
Normal file
16
src/features/context-injector/index.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
export { ContextCollector, contextCollector } from "./collector"
|
||||
export {
|
||||
injectPendingContext,
|
||||
createContextInjectorHook,
|
||||
createContextInjectorMessagesTransformHook,
|
||||
} from "./injector"
|
||||
export type {
|
||||
ContextSourceType,
|
||||
ContextPriority,
|
||||
ContextEntry,
|
||||
RegisterContextOptions,
|
||||
PendingContext,
|
||||
MessageContext,
|
||||
OutputParts,
|
||||
InjectionStrategy,
|
||||
} from "./types"
|
||||
292
src/features/context-injector/injector.test.ts
Normal file
292
src/features/context-injector/injector.test.ts
Normal file
@@ -0,0 +1,292 @@
|
||||
import { describe, it, expect, beforeEach } from "bun:test"
|
||||
import { ContextCollector } from "./collector"
|
||||
import {
|
||||
injectPendingContext,
|
||||
createContextInjectorHook,
|
||||
createContextInjectorMessagesTransformHook,
|
||||
} from "./injector"
|
||||
|
||||
describe("injectPendingContext", () => {
|
||||
let collector: ContextCollector
|
||||
|
||||
beforeEach(() => {
|
||||
collector = new ContextCollector()
|
||||
})
|
||||
|
||||
describe("when parts have text content", () => {
|
||||
it("prepends context to first text part", () => {
|
||||
// #given
|
||||
const sessionID = "ses_inject1"
|
||||
collector.register(sessionID, {
|
||||
id: "ulw",
|
||||
source: "keyword-detector",
|
||||
content: "Ultrawork mode activated",
|
||||
})
|
||||
const parts = [{ type: "text", text: "User message" }]
|
||||
|
||||
// #when
|
||||
const result = injectPendingContext(collector, sessionID, parts)
|
||||
|
||||
// #then
|
||||
expect(result.injected).toBe(true)
|
||||
expect(parts[0].text).toContain("Ultrawork mode activated")
|
||||
expect(parts[0].text).toContain("User message")
|
||||
})
|
||||
|
||||
it("uses separator between context and original message", () => {
|
||||
// #given
|
||||
const sessionID = "ses_inject2"
|
||||
collector.register(sessionID, {
|
||||
id: "ctx",
|
||||
source: "keyword-detector",
|
||||
content: "Context content",
|
||||
})
|
||||
const parts = [{ type: "text", text: "Original message" }]
|
||||
|
||||
// #when
|
||||
injectPendingContext(collector, sessionID, parts)
|
||||
|
||||
// #then
|
||||
expect(parts[0].text).toBe("Context content\n\n---\n\nOriginal message")
|
||||
})
|
||||
|
||||
it("consumes context after injection", () => {
|
||||
// #given
|
||||
const sessionID = "ses_inject3"
|
||||
collector.register(sessionID, {
|
||||
id: "ctx",
|
||||
source: "keyword-detector",
|
||||
content: "Context",
|
||||
})
|
||||
const parts = [{ type: "text", text: "Message" }]
|
||||
|
||||
// #when
|
||||
injectPendingContext(collector, sessionID, parts)
|
||||
|
||||
// #then
|
||||
expect(collector.hasPending(sessionID)).toBe(false)
|
||||
})
|
||||
|
||||
it("returns injected=false when no pending context", () => {
|
||||
// #given
|
||||
const sessionID = "ses_empty"
|
||||
const parts = [{ type: "text", text: "Message" }]
|
||||
|
||||
// #when
|
||||
const result = injectPendingContext(collector, sessionID, parts)
|
||||
|
||||
// #then
|
||||
expect(result.injected).toBe(false)
|
||||
expect(parts[0].text).toBe("Message")
|
||||
})
|
||||
})
|
||||
|
||||
describe("when parts have no text content", () => {
|
||||
it("does not inject and preserves context", () => {
|
||||
// #given
|
||||
const sessionID = "ses_notext"
|
||||
collector.register(sessionID, {
|
||||
id: "ctx",
|
||||
source: "keyword-detector",
|
||||
content: "Context",
|
||||
})
|
||||
const parts = [{ type: "image", url: "https://example.com/img.png" }]
|
||||
|
||||
// #when
|
||||
const result = injectPendingContext(collector, sessionID, parts)
|
||||
|
||||
// #then
|
||||
expect(result.injected).toBe(false)
|
||||
expect(collector.hasPending(sessionID)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("with multiple text parts", () => {
|
||||
it("injects into first text part only", () => {
|
||||
// #given
|
||||
const sessionID = "ses_multi"
|
||||
collector.register(sessionID, {
|
||||
id: "ctx",
|
||||
source: "keyword-detector",
|
||||
content: "Context",
|
||||
})
|
||||
const parts = [
|
||||
{ type: "text", text: "First" },
|
||||
{ type: "text", text: "Second" },
|
||||
]
|
||||
|
||||
// #when
|
||||
injectPendingContext(collector, sessionID, parts)
|
||||
|
||||
// #then
|
||||
expect(parts[0].text).toContain("Context")
|
||||
expect(parts[1].text).toBe("Second")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("createContextInjectorHook", () => {
|
||||
let collector: ContextCollector
|
||||
|
||||
beforeEach(() => {
|
||||
collector = new ContextCollector()
|
||||
})
|
||||
|
||||
describe("chat.message handler", () => {
|
||||
it("is a no-op (context injection moved to messages transform)", async () => {
|
||||
// #given
|
||||
const hook = createContextInjectorHook(collector)
|
||||
const sessionID = "ses_hook1"
|
||||
collector.register(sessionID, {
|
||||
id: "ctx",
|
||||
source: "keyword-detector",
|
||||
content: "Hook context",
|
||||
})
|
||||
const input = { sessionID }
|
||||
const output = {
|
||||
message: {},
|
||||
parts: [{ type: "text", text: "User message" }],
|
||||
}
|
||||
|
||||
// #when
|
||||
await hook["chat.message"](input, output)
|
||||
|
||||
// #then
|
||||
expect(output.parts[0].text).toBe("User message")
|
||||
expect(collector.hasPending(sessionID)).toBe(true)
|
||||
})
|
||||
|
||||
it("does nothing when no pending context", async () => {
|
||||
// #given
|
||||
const hook = createContextInjectorHook(collector)
|
||||
const sessionID = "ses_hook2"
|
||||
const input = { sessionID }
|
||||
const output = {
|
||||
message: {},
|
||||
parts: [{ type: "text", text: "User message" }],
|
||||
}
|
||||
|
||||
// #when
|
||||
await hook["chat.message"](input, output)
|
||||
|
||||
// #then
|
||||
expect(output.parts[0].text).toBe("User message")
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("createContextInjectorMessagesTransformHook", () => {
|
||||
let collector: ContextCollector
|
||||
|
||||
beforeEach(() => {
|
||||
collector = new ContextCollector()
|
||||
})
|
||||
|
||||
const createMockMessage = (
|
||||
role: "user" | "assistant",
|
||||
text: string,
|
||||
sessionID: string
|
||||
) => ({
|
||||
info: {
|
||||
id: `msg_${Date.now()}_${Math.random()}`,
|
||||
sessionID,
|
||||
role,
|
||||
time: { created: Date.now() },
|
||||
agent: "Sisyphus",
|
||||
model: { providerID: "test", modelID: "test" },
|
||||
path: { cwd: "/", root: "/" },
|
||||
},
|
||||
parts: [
|
||||
{
|
||||
id: `part_${Date.now()}`,
|
||||
sessionID,
|
||||
messageID: `msg_${Date.now()}`,
|
||||
type: "text" as const,
|
||||
text,
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
it("inserts synthetic message before last user message", async () => {
|
||||
// #given
|
||||
const hook = createContextInjectorMessagesTransformHook(collector)
|
||||
const sessionID = "ses_transform1"
|
||||
collector.register(sessionID, {
|
||||
id: "ulw",
|
||||
source: "keyword-detector",
|
||||
content: "Ultrawork context",
|
||||
})
|
||||
const messages = [
|
||||
createMockMessage("user", "First message", sessionID),
|
||||
createMockMessage("assistant", "Response", sessionID),
|
||||
createMockMessage("user", "Second message", sessionID),
|
||||
]
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const output = { messages } as any
|
||||
|
||||
// #when
|
||||
await hook["experimental.chat.messages.transform"]!({}, output)
|
||||
|
||||
// #then
|
||||
expect(output.messages.length).toBe(4)
|
||||
expect(output.messages[2].parts[0].text).toBe("Ultrawork context")
|
||||
expect(output.messages[2].parts[0].synthetic).toBe(true)
|
||||
expect(output.messages[3].parts[0].text).toBe("Second message")
|
||||
})
|
||||
|
||||
it("does nothing when no pending context", async () => {
|
||||
// #given
|
||||
const hook = createContextInjectorMessagesTransformHook(collector)
|
||||
const sessionID = "ses_transform2"
|
||||
const messages = [createMockMessage("user", "Hello world", sessionID)]
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const output = { messages } as any
|
||||
|
||||
// #when
|
||||
await hook["experimental.chat.messages.transform"]!({}, output)
|
||||
|
||||
// #then
|
||||
expect(output.messages.length).toBe(1)
|
||||
})
|
||||
|
||||
it("does nothing when no user messages", async () => {
|
||||
// #given
|
||||
const hook = createContextInjectorMessagesTransformHook(collector)
|
||||
const sessionID = "ses_transform3"
|
||||
collector.register(sessionID, {
|
||||
id: "ctx",
|
||||
source: "keyword-detector",
|
||||
content: "Context",
|
||||
})
|
||||
const messages = [createMockMessage("assistant", "Response", sessionID)]
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const output = { messages } as any
|
||||
|
||||
// #when
|
||||
await hook["experimental.chat.messages.transform"]!({}, output)
|
||||
|
||||
// #then
|
||||
expect(output.messages.length).toBe(1)
|
||||
expect(collector.hasPending(sessionID)).toBe(true)
|
||||
})
|
||||
|
||||
it("consumes context after injection", async () => {
|
||||
// #given
|
||||
const hook = createContextInjectorMessagesTransformHook(collector)
|
||||
const sessionID = "ses_transform4"
|
||||
collector.register(sessionID, {
|
||||
id: "ctx",
|
||||
source: "keyword-detector",
|
||||
content: "Context",
|
||||
})
|
||||
const messages = [createMockMessage("user", "Message", sessionID)]
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const output = { messages } as any
|
||||
|
||||
// #when
|
||||
await hook["experimental.chat.messages.transform"]!({}, output)
|
||||
|
||||
// #then
|
||||
expect(collector.hasPending(sessionID)).toBe(false)
|
||||
})
|
||||
})
|
||||
156
src/features/context-injector/injector.ts
Normal file
156
src/features/context-injector/injector.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import type { ContextCollector } from "./collector"
|
||||
import type { Message, Part } from "@opencode-ai/sdk"
|
||||
import { log } from "../../shared"
|
||||
|
||||
interface OutputPart {
|
||||
type: string
|
||||
text?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
interface InjectionResult {
|
||||
injected: boolean
|
||||
contextLength: number
|
||||
}
|
||||
|
||||
export function injectPendingContext(
|
||||
collector: ContextCollector,
|
||||
sessionID: string,
|
||||
parts: OutputPart[]
|
||||
): InjectionResult {
|
||||
if (!collector.hasPending(sessionID)) {
|
||||
return { injected: false, contextLength: 0 }
|
||||
}
|
||||
|
||||
const textPartIndex = parts.findIndex((p) => p.type === "text" && p.text !== undefined)
|
||||
if (textPartIndex === -1) {
|
||||
return { injected: false, contextLength: 0 }
|
||||
}
|
||||
|
||||
const pending = collector.consume(sessionID)
|
||||
const originalText = parts[textPartIndex].text ?? ""
|
||||
parts[textPartIndex].text = `${pending.merged}\n\n---\n\n${originalText}`
|
||||
|
||||
return {
|
||||
injected: true,
|
||||
contextLength: pending.merged.length,
|
||||
}
|
||||
}
|
||||
|
||||
interface ChatMessageInput {
|
||||
sessionID: string
|
||||
agent?: string
|
||||
model?: { providerID: string; modelID: string }
|
||||
messageID?: string
|
||||
}
|
||||
|
||||
interface ChatMessageOutput {
|
||||
message: Record<string, unknown>
|
||||
parts: OutputPart[]
|
||||
}
|
||||
|
||||
export function createContextInjectorHook(collector: ContextCollector) {
|
||||
return {
|
||||
"chat.message": async (
|
||||
_input: ChatMessageInput,
|
||||
_output: ChatMessageOutput
|
||||
): Promise<void> => {
|
||||
void collector
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
interface MessageWithParts {
|
||||
info: Message
|
||||
parts: Part[]
|
||||
}
|
||||
|
||||
type MessagesTransformHook = {
|
||||
"experimental.chat.messages.transform"?: (
|
||||
input: Record<string, never>,
|
||||
output: { messages: MessageWithParts[] }
|
||||
) => Promise<void>
|
||||
}
|
||||
|
||||
export function createContextInjectorMessagesTransformHook(
|
||||
collector: ContextCollector
|
||||
): MessagesTransformHook {
|
||||
return {
|
||||
"experimental.chat.messages.transform": async (_input, output) => {
|
||||
const { messages } = output
|
||||
if (messages.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
let lastUserMessageIndex = -1
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
if (messages[i].info.role === "user") {
|
||||
lastUserMessageIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (lastUserMessageIndex === -1) {
|
||||
return
|
||||
}
|
||||
|
||||
const lastUserMessage = messages[lastUserMessageIndex]
|
||||
const sessionID = (lastUserMessage.info as unknown as { sessionID?: string }).sessionID
|
||||
if (!sessionID) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!collector.hasPending(sessionID)) {
|
||||
return
|
||||
}
|
||||
|
||||
const pending = collector.consume(sessionID)
|
||||
if (!pending.hasContent) {
|
||||
return
|
||||
}
|
||||
|
||||
const refInfo = lastUserMessage.info as unknown as {
|
||||
sessionID?: string
|
||||
agent?: string
|
||||
model?: { providerID?: string; modelID?: string }
|
||||
path?: { cwd?: string; root?: string }
|
||||
}
|
||||
|
||||
const syntheticMessageId = `synthetic_ctx_${Date.now()}`
|
||||
const syntheticPartId = `synthetic_ctx_part_${Date.now()}`
|
||||
const now = Date.now()
|
||||
|
||||
const syntheticMessage: MessageWithParts = {
|
||||
info: {
|
||||
id: syntheticMessageId,
|
||||
sessionID: sessionID,
|
||||
role: "user",
|
||||
time: { created: now },
|
||||
agent: refInfo.agent ?? "Sisyphus",
|
||||
model: refInfo.model ?? { providerID: "unknown", modelID: "unknown" },
|
||||
path: refInfo.path ?? { cwd: "/", root: "/" },
|
||||
} as unknown as Message,
|
||||
parts: [
|
||||
{
|
||||
id: syntheticPartId,
|
||||
sessionID: sessionID,
|
||||
messageID: syntheticMessageId,
|
||||
type: "text",
|
||||
text: pending.merged,
|
||||
synthetic: true,
|
||||
time: { start: now, end: now },
|
||||
} as Part,
|
||||
],
|
||||
}
|
||||
|
||||
messages.splice(lastUserMessageIndex, 0, syntheticMessage)
|
||||
|
||||
log("[context-injector] Injected synthetic message from collector", {
|
||||
sessionID,
|
||||
insertIndex: lastUserMessageIndex,
|
||||
contextLength: pending.merged.length,
|
||||
newMessageCount: messages.length,
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
91
src/features/context-injector/types.ts
Normal file
91
src/features/context-injector/types.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Source identifier for context injection
|
||||
* Each source registers context that will be merged and injected together
|
||||
*/
|
||||
export type ContextSourceType =
|
||||
| "keyword-detector"
|
||||
| "rules-injector"
|
||||
| "directory-agents"
|
||||
| "directory-readme"
|
||||
| "custom"
|
||||
|
||||
/**
|
||||
* Priority levels for context ordering
|
||||
* Higher priority contexts appear first in the merged output
|
||||
*/
|
||||
export type ContextPriority = "critical" | "high" | "normal" | "low"
|
||||
|
||||
/**
|
||||
* A single context entry registered by a source
|
||||
*/
|
||||
export interface ContextEntry {
|
||||
/** Unique identifier for this entry within the source */
|
||||
id: string
|
||||
/** The source that registered this context */
|
||||
source: ContextSourceType
|
||||
/** The actual context content to inject */
|
||||
content: string
|
||||
/** Priority for ordering (default: normal) */
|
||||
priority: ContextPriority
|
||||
/** Timestamp when registered */
|
||||
timestamp: number
|
||||
/** Optional metadata for debugging/logging */
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for registering context
|
||||
*/
|
||||
export interface RegisterContextOptions {
|
||||
/** Unique ID for this context entry (used for deduplication) */
|
||||
id: string
|
||||
/** Source identifier */
|
||||
source: ContextSourceType
|
||||
/** The content to inject */
|
||||
content: string
|
||||
/** Priority for ordering (default: normal) */
|
||||
priority?: ContextPriority
|
||||
/** Optional metadata */
|
||||
metadata?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of getting pending context for a session
|
||||
*/
|
||||
export interface PendingContext {
|
||||
/** Merged context string, ready for injection */
|
||||
merged: string
|
||||
/** Individual entries that were merged */
|
||||
entries: ContextEntry[]
|
||||
/** Whether there's any content to inject */
|
||||
hasContent: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Message context from the original user message
|
||||
* Used when injecting to match the message format
|
||||
*/
|
||||
export interface MessageContext {
|
||||
agent?: string
|
||||
model?: {
|
||||
providerID?: string
|
||||
modelID?: string
|
||||
}
|
||||
path?: {
|
||||
cwd?: string
|
||||
root?: string
|
||||
}
|
||||
tools?: Record<string, boolean>
|
||||
}
|
||||
|
||||
/**
|
||||
* Output parts from chat.message hook
|
||||
*/
|
||||
export interface OutputParts {
|
||||
parts: Array<{ type: string; text?: string; [key: string]: unknown }>
|
||||
}
|
||||
|
||||
/**
|
||||
* Injection strategy
|
||||
*/
|
||||
export type InjectionStrategy = "prepend-parts" | "storage" | "auto"
|
||||
448
src/features/opencode-skill-loader/async-loader.test.ts
Normal file
448
src/features/opencode-skill-loader/async-loader.test.ts
Normal file
@@ -0,0 +1,448 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "bun:test"
|
||||
import { mkdirSync, writeFileSync, rmSync, chmodSync } from "fs"
|
||||
import { join } from "path"
|
||||
import { tmpdir } from "os"
|
||||
import type { LoadedSkill } from "./types"
|
||||
|
||||
const TEST_DIR = join(tmpdir(), "async-loader-test-" + Date.now())
|
||||
const SKILLS_DIR = join(TEST_DIR, ".opencode", "skill")
|
||||
|
||||
function createTestSkill(name: string, content: string, mcpJson?: object): string {
|
||||
const skillDir = join(SKILLS_DIR, name)
|
||||
mkdirSync(skillDir, { recursive: true })
|
||||
const skillPath = join(skillDir, "SKILL.md")
|
||||
writeFileSync(skillPath, content)
|
||||
if (mcpJson) {
|
||||
writeFileSync(join(skillDir, "mcp.json"), JSON.stringify(mcpJson, null, 2))
|
||||
}
|
||||
return skillDir
|
||||
}
|
||||
|
||||
function createDirectSkill(name: string, content: string): string {
|
||||
mkdirSync(SKILLS_DIR, { recursive: true })
|
||||
const skillPath = join(SKILLS_DIR, `${name}.md`)
|
||||
writeFileSync(skillPath, content)
|
||||
return skillPath
|
||||
}
|
||||
|
||||
describe("async-loader", () => {
|
||||
beforeEach(() => {
|
||||
mkdirSync(TEST_DIR, { recursive: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(TEST_DIR, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe("discoverSkillsInDirAsync", () => {
|
||||
it("returns empty array for non-existent directory", async () => {
|
||||
// #given - non-existent directory
|
||||
const nonExistentDir = join(TEST_DIR, "does-not-exist")
|
||||
|
||||
// #when
|
||||
const { discoverSkillsInDirAsync } = await import("./async-loader")
|
||||
const skills = await discoverSkillsInDirAsync(nonExistentDir)
|
||||
|
||||
// #then - should return empty array, not throw
|
||||
expect(skills).toEqual([])
|
||||
})
|
||||
|
||||
it("discovers skills from SKILL.md in directory", async () => {
|
||||
// #given
|
||||
const skillContent = `---
|
||||
name: test-skill
|
||||
description: A test skill
|
||||
---
|
||||
This is the skill body.
|
||||
`
|
||||
createTestSkill("test-skill", skillContent)
|
||||
|
||||
// #when
|
||||
const { discoverSkillsInDirAsync } = await import("./async-loader")
|
||||
const skills = await discoverSkillsInDirAsync(SKILLS_DIR)
|
||||
|
||||
// #then
|
||||
expect(skills).toHaveLength(1)
|
||||
expect(skills[0].name).toBe("test-skill")
|
||||
expect(skills[0].definition.description).toContain("A test skill")
|
||||
})
|
||||
|
||||
it("discovers skills from {name}.md pattern in directory", async () => {
|
||||
// #given
|
||||
const skillContent = `---
|
||||
name: named-skill
|
||||
description: Named pattern skill
|
||||
---
|
||||
Skill body.
|
||||
`
|
||||
const skillDir = join(SKILLS_DIR, "named-skill")
|
||||
mkdirSync(skillDir, { recursive: true })
|
||||
writeFileSync(join(skillDir, "named-skill.md"), skillContent)
|
||||
|
||||
// #when
|
||||
const { discoverSkillsInDirAsync } = await import("./async-loader")
|
||||
const skills = await discoverSkillsInDirAsync(SKILLS_DIR)
|
||||
|
||||
// #then
|
||||
expect(skills).toHaveLength(1)
|
||||
expect(skills[0].name).toBe("named-skill")
|
||||
})
|
||||
|
||||
it("discovers direct .md files", async () => {
|
||||
// #given
|
||||
const skillContent = `---
|
||||
name: direct-skill
|
||||
description: Direct markdown file
|
||||
---
|
||||
Direct skill.
|
||||
`
|
||||
createDirectSkill("direct-skill", skillContent)
|
||||
|
||||
// #when
|
||||
const { discoverSkillsInDirAsync } = await import("./async-loader")
|
||||
const skills = await discoverSkillsInDirAsync(SKILLS_DIR)
|
||||
|
||||
// #then
|
||||
expect(skills).toHaveLength(1)
|
||||
expect(skills[0].name).toBe("direct-skill")
|
||||
})
|
||||
|
||||
it("skips entries starting with dot", async () => {
|
||||
// #given
|
||||
const validContent = `---
|
||||
name: valid-skill
|
||||
---
|
||||
Valid.
|
||||
`
|
||||
const hiddenContent = `---
|
||||
name: hidden-skill
|
||||
---
|
||||
Hidden.
|
||||
`
|
||||
createTestSkill("valid-skill", validContent)
|
||||
createTestSkill(".hidden-skill", hiddenContent)
|
||||
|
||||
// #when
|
||||
const { discoverSkillsInDirAsync } = await import("./async-loader")
|
||||
const skills = await discoverSkillsInDirAsync(SKILLS_DIR)
|
||||
|
||||
// #then - only valid-skill should be discovered
|
||||
expect(skills).toHaveLength(1)
|
||||
expect(skills[0]?.name).toBe("valid-skill")
|
||||
})
|
||||
|
||||
it("skips invalid files and continues with valid ones", async () => {
|
||||
// #given - one valid, one invalid (unreadable)
|
||||
const validContent = `---
|
||||
name: valid-skill
|
||||
---
|
||||
Valid skill.
|
||||
`
|
||||
const invalidContent = `---
|
||||
name: invalid-skill
|
||||
---
|
||||
Invalid skill.
|
||||
`
|
||||
createTestSkill("valid-skill", validContent)
|
||||
const invalidDir = createTestSkill("invalid-skill", invalidContent)
|
||||
const invalidFile = join(invalidDir, "SKILL.md")
|
||||
|
||||
// Make file unreadable on Unix systems
|
||||
if (process.platform !== "win32") {
|
||||
chmodSync(invalidFile, 0o000)
|
||||
}
|
||||
|
||||
// #when
|
||||
const { discoverSkillsInDirAsync } = await import("./async-loader")
|
||||
const skills = await discoverSkillsInDirAsync(SKILLS_DIR)
|
||||
|
||||
// #then - should skip invalid and return only valid
|
||||
expect(skills.length).toBeGreaterThanOrEqual(1)
|
||||
expect(skills.some((s: LoadedSkill) => s.name === "valid-skill")).toBe(true)
|
||||
|
||||
// Cleanup: restore permissions before cleanup
|
||||
if (process.platform !== "win32") {
|
||||
chmodSync(invalidFile, 0o644)
|
||||
}
|
||||
})
|
||||
|
||||
it("discovers multiple skills correctly", async () => {
|
||||
// #given
|
||||
const skill1 = `---
|
||||
name: skill-one
|
||||
description: First skill
|
||||
---
|
||||
Skill one.
|
||||
`
|
||||
const skill2 = `---
|
||||
name: skill-two
|
||||
description: Second skill
|
||||
---
|
||||
Skill two.
|
||||
`
|
||||
createTestSkill("skill-one", skill1)
|
||||
createTestSkill("skill-two", skill2)
|
||||
|
||||
// #when
|
||||
const { discoverSkillsInDirAsync } = await import("./async-loader")
|
||||
const asyncSkills = await discoverSkillsInDirAsync(SKILLS_DIR)
|
||||
|
||||
// #then
|
||||
expect(asyncSkills.length).toBe(2)
|
||||
expect(asyncSkills.map((s: LoadedSkill) => s.name).sort()).toEqual(["skill-one", "skill-two"])
|
||||
|
||||
const skill1Result = asyncSkills.find((s: LoadedSkill) => s.name === "skill-one")
|
||||
expect(skill1Result?.definition.description).toContain("First skill")
|
||||
})
|
||||
|
||||
it("loads MCP config from frontmatter", async () => {
|
||||
// #given
|
||||
const skillContent = `---
|
||||
name: mcp-skill
|
||||
description: Skill with MCP
|
||||
mcp:
|
||||
sqlite:
|
||||
command: uvx
|
||||
args: [mcp-server-sqlite]
|
||||
---
|
||||
MCP skill.
|
||||
`
|
||||
createTestSkill("mcp-skill", skillContent)
|
||||
|
||||
// #when
|
||||
const { discoverSkillsInDirAsync } = await import("./async-loader")
|
||||
const skills = await discoverSkillsInDirAsync(SKILLS_DIR)
|
||||
|
||||
// #then
|
||||
const skill = skills.find((s: LoadedSkill) => s.name === "mcp-skill")
|
||||
expect(skill?.mcpConfig).toBeDefined()
|
||||
expect(skill?.mcpConfig?.sqlite).toBeDefined()
|
||||
expect(skill?.mcpConfig?.sqlite?.command).toBe("uvx")
|
||||
})
|
||||
|
||||
it("loads MCP config from mcp.json file", async () => {
|
||||
// #given
|
||||
const skillContent = `---
|
||||
name: json-mcp-skill
|
||||
description: Skill with mcp.json
|
||||
---
|
||||
Skill body.
|
||||
`
|
||||
const mcpJson = {
|
||||
mcpServers: {
|
||||
playwright: {
|
||||
command: "npx",
|
||||
args: ["@playwright/mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
createTestSkill("json-mcp-skill", skillContent, mcpJson)
|
||||
|
||||
// #when
|
||||
const { discoverSkillsInDirAsync } = await import("./async-loader")
|
||||
const skills = await discoverSkillsInDirAsync(SKILLS_DIR)
|
||||
|
||||
// #then
|
||||
const skill = skills.find((s: LoadedSkill) => s.name === "json-mcp-skill")
|
||||
expect(skill?.mcpConfig?.playwright).toBeDefined()
|
||||
expect(skill?.mcpConfig?.playwright?.command).toBe("npx")
|
||||
})
|
||||
|
||||
it("prioritizes mcp.json over frontmatter MCP", async () => {
|
||||
// #given
|
||||
const skillContent = `---
|
||||
name: priority-test
|
||||
mcp:
|
||||
from-yaml:
|
||||
command: yaml-cmd
|
||||
---
|
||||
Skill.
|
||||
`
|
||||
const mcpJson = {
|
||||
mcpServers: {
|
||||
"from-json": {
|
||||
command: "json-cmd"
|
||||
}
|
||||
}
|
||||
}
|
||||
createTestSkill("priority-test", skillContent, mcpJson)
|
||||
|
||||
// #when
|
||||
const { discoverSkillsInDirAsync } = await import("./async-loader")
|
||||
const skills = await discoverSkillsInDirAsync(SKILLS_DIR)
|
||||
|
||||
// #then - mcp.json should take priority
|
||||
const skill = skills.find((s: LoadedSkill) => s.name === "priority-test")
|
||||
expect(skill?.mcpConfig?.["from-json"]).toBeDefined()
|
||||
expect(skill?.mcpConfig?.["from-yaml"]).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("mapWithConcurrency", () => {
|
||||
it("processes items with concurrency limit", async () => {
|
||||
// #given
|
||||
const { mapWithConcurrency } = await import("./async-loader")
|
||||
const items = Array.from({ length: 50 }, (_, i) => i)
|
||||
let maxConcurrent = 0
|
||||
let currentConcurrent = 0
|
||||
|
||||
const mapper = async (item: number) => {
|
||||
currentConcurrent++
|
||||
maxConcurrent = Math.max(maxConcurrent, currentConcurrent)
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
currentConcurrent--
|
||||
return item * 2
|
||||
}
|
||||
|
||||
// #when
|
||||
const results = await mapWithConcurrency(items, mapper, 16)
|
||||
|
||||
// #then
|
||||
expect(results).toEqual(items.map(i => i * 2))
|
||||
expect(maxConcurrent).toBeLessThanOrEqual(16)
|
||||
expect(maxConcurrent).toBeGreaterThan(1) // Should actually run concurrently
|
||||
})
|
||||
|
||||
it("handles empty array", async () => {
|
||||
// #given
|
||||
const { mapWithConcurrency } = await import("./async-loader")
|
||||
|
||||
// #when
|
||||
const results = await mapWithConcurrency([], async (x: number) => x * 2, 16)
|
||||
|
||||
// #then
|
||||
expect(results).toEqual([])
|
||||
})
|
||||
|
||||
it("handles single item", async () => {
|
||||
// #given
|
||||
const { mapWithConcurrency } = await import("./async-loader")
|
||||
|
||||
// #when
|
||||
const results = await mapWithConcurrency([42], async (x: number) => x * 2, 16)
|
||||
|
||||
// #then
|
||||
expect(results).toEqual([84])
|
||||
})
|
||||
})
|
||||
|
||||
describe("loadSkillFromPathAsync", () => {
|
||||
it("loads skill from valid path", async () => {
|
||||
// #given
|
||||
const skillContent = `---
|
||||
name: path-skill
|
||||
description: Loaded from path
|
||||
---
|
||||
Path skill.
|
||||
`
|
||||
const skillDir = createTestSkill("path-skill", skillContent)
|
||||
const skillPath = join(skillDir, "SKILL.md")
|
||||
|
||||
// #when
|
||||
const { loadSkillFromPathAsync } = await import("./async-loader")
|
||||
const skill = await loadSkillFromPathAsync(skillPath, skillDir, "path-skill", "opencode-project")
|
||||
|
||||
// #then
|
||||
expect(skill).not.toBeNull()
|
||||
expect(skill?.name).toBe("path-skill")
|
||||
expect(skill?.scope).toBe("opencode-project")
|
||||
})
|
||||
|
||||
it("returns null for invalid path", async () => {
|
||||
// #given
|
||||
const invalidPath = join(TEST_DIR, "nonexistent.md")
|
||||
|
||||
// #when
|
||||
const { loadSkillFromPathAsync } = await import("./async-loader")
|
||||
const skill = await loadSkillFromPathAsync(invalidPath, TEST_DIR, "invalid", "opencode")
|
||||
|
||||
// #then
|
||||
expect(skill).toBeNull()
|
||||
})
|
||||
|
||||
it("returns null for malformed skill file", async () => {
|
||||
// #given
|
||||
const malformedContent = "This is not valid frontmatter content\nNo YAML here!"
|
||||
mkdirSync(SKILLS_DIR, { recursive: true })
|
||||
const malformedPath = join(SKILLS_DIR, "malformed.md")
|
||||
writeFileSync(malformedPath, malformedContent)
|
||||
|
||||
// #when
|
||||
const { loadSkillFromPathAsync } = await import("./async-loader")
|
||||
const skill = await loadSkillFromPathAsync(malformedPath, SKILLS_DIR, "malformed", "user")
|
||||
|
||||
// #then
|
||||
expect(skill).not.toBeNull() // parseFrontmatter handles missing frontmatter gracefully
|
||||
})
|
||||
})
|
||||
|
||||
describe("loadMcpJsonFromDirAsync", () => {
|
||||
it("loads mcp.json with mcpServers format", async () => {
|
||||
// #given
|
||||
mkdirSync(SKILLS_DIR, { recursive: true })
|
||||
const mcpJson = {
|
||||
mcpServers: {
|
||||
test: {
|
||||
command: "test-cmd",
|
||||
args: ["arg1"]
|
||||
}
|
||||
}
|
||||
}
|
||||
writeFileSync(join(SKILLS_DIR, "mcp.json"), JSON.stringify(mcpJson))
|
||||
|
||||
// #when
|
||||
const { loadMcpJsonFromDirAsync } = await import("./async-loader")
|
||||
const config = await loadMcpJsonFromDirAsync(SKILLS_DIR)
|
||||
|
||||
// #then
|
||||
expect(config).toBeDefined()
|
||||
expect(config?.test).toBeDefined()
|
||||
expect(config?.test?.command).toBe("test-cmd")
|
||||
})
|
||||
|
||||
it("returns undefined for non-existent mcp.json", async () => {
|
||||
// #given
|
||||
mkdirSync(SKILLS_DIR, { recursive: true })
|
||||
|
||||
// #when
|
||||
const { loadMcpJsonFromDirAsync } = await import("./async-loader")
|
||||
const config = await loadMcpJsonFromDirAsync(SKILLS_DIR)
|
||||
|
||||
// #then
|
||||
expect(config).toBeUndefined()
|
||||
})
|
||||
|
||||
it("returns undefined for invalid JSON", async () => {
|
||||
// #given
|
||||
mkdirSync(SKILLS_DIR, { recursive: true })
|
||||
writeFileSync(join(SKILLS_DIR, "mcp.json"), "{ invalid json }")
|
||||
|
||||
// #when
|
||||
const { loadMcpJsonFromDirAsync } = await import("./async-loader")
|
||||
const config = await loadMcpJsonFromDirAsync(SKILLS_DIR)
|
||||
|
||||
// #then
|
||||
expect(config).toBeUndefined()
|
||||
})
|
||||
|
||||
it("supports direct format without mcpServers", async () => {
|
||||
// #given
|
||||
mkdirSync(SKILLS_DIR, { recursive: true })
|
||||
const mcpJson = {
|
||||
direct: {
|
||||
command: "direct-cmd",
|
||||
args: ["arg"]
|
||||
}
|
||||
}
|
||||
writeFileSync(join(SKILLS_DIR, "mcp.json"), JSON.stringify(mcpJson))
|
||||
|
||||
// #when
|
||||
const { loadMcpJsonFromDirAsync } = await import("./async-loader")
|
||||
const config = await loadMcpJsonFromDirAsync(SKILLS_DIR)
|
||||
|
||||
// #then
|
||||
expect(config?.direct).toBeDefined()
|
||||
expect(config?.direct?.command).toBe("direct-cmd")
|
||||
})
|
||||
})
|
||||
})
|
||||
180
src/features/opencode-skill-loader/async-loader.ts
Normal file
180
src/features/opencode-skill-loader/async-loader.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { readFile, readdir } from "fs/promises"
|
||||
import type { Dirent } from "fs"
|
||||
import { join, basename } from "path"
|
||||
import yaml from "js-yaml"
|
||||
import { parseFrontmatter } from "../../shared/frontmatter"
|
||||
import { sanitizeModelField } from "../../shared/model-sanitizer"
|
||||
import { resolveSymlink, isMarkdownFile } from "../../shared/file-utils"
|
||||
import type { CommandDefinition } from "../claude-code-command-loader/types"
|
||||
import type { SkillScope, SkillMetadata, LoadedSkill } from "./types"
|
||||
import type { SkillMcpConfig } from "../skill-mcp-manager/types"
|
||||
|
||||
export async function mapWithConcurrency<T, R>(
|
||||
items: T[],
|
||||
mapper: (item: T) => Promise<R>,
|
||||
concurrency: number
|
||||
): Promise<R[]> {
|
||||
const results: R[] = new Array(items.length)
|
||||
let index = 0
|
||||
|
||||
const worker = async () => {
|
||||
while (index < items.length) {
|
||||
const currentIndex = index++
|
||||
results[currentIndex] = await mapper(items[currentIndex])
|
||||
}
|
||||
}
|
||||
|
||||
const workers = Array.from({ length: Math.min(concurrency, items.length) }, () => worker())
|
||||
await Promise.all(workers)
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
function parseSkillMcpConfigFromFrontmatter(content: string): SkillMcpConfig | undefined {
|
||||
const frontmatterMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/)
|
||||
if (!frontmatterMatch) return undefined
|
||||
|
||||
try {
|
||||
const parsed = yaml.load(frontmatterMatch[1]) as Record<string, unknown>
|
||||
if (parsed && typeof parsed === "object" && "mcp" in parsed && parsed.mcp) {
|
||||
return parsed.mcp as SkillMcpConfig
|
||||
}
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export async function loadMcpJsonFromDirAsync(skillDir: string): Promise<SkillMcpConfig | undefined> {
|
||||
const mcpJsonPath = join(skillDir, "mcp.json")
|
||||
|
||||
try {
|
||||
const content = await readFile(mcpJsonPath, "utf-8")
|
||||
const parsed = JSON.parse(content) as Record<string, unknown>
|
||||
|
||||
if (parsed && typeof parsed === "object" && "mcpServers" in parsed && parsed.mcpServers) {
|
||||
return parsed.mcpServers as SkillMcpConfig
|
||||
}
|
||||
|
||||
if (parsed && typeof parsed === "object" && !("mcpServers" in parsed)) {
|
||||
const hasCommandField = Object.values(parsed).some(
|
||||
(v) => v && typeof v === "object" && "command" in (v as Record<string, unknown>)
|
||||
)
|
||||
if (hasCommandField) {
|
||||
return parsed as SkillMcpConfig
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
export async function loadSkillFromPathAsync(
|
||||
skillPath: string,
|
||||
resolvedPath: string,
|
||||
defaultName: string,
|
||||
scope: SkillScope
|
||||
): Promise<LoadedSkill | null> {
|
||||
try {
|
||||
const content = await readFile(skillPath, "utf-8")
|
||||
const { data, body, parseError } = parseFrontmatter<SkillMetadata>(content)
|
||||
if (parseError) return null
|
||||
|
||||
const frontmatterMcp = parseSkillMcpConfigFromFrontmatter(content)
|
||||
const mcpJsonMcp = await loadMcpJsonFromDirAsync(resolvedPath)
|
||||
const mcpConfig = mcpJsonMcp || frontmatterMcp
|
||||
|
||||
const skillName = data.name || defaultName
|
||||
const originalDescription = data.description || ""
|
||||
const isOpencodeSource = scope === "opencode" || scope === "opencode-project"
|
||||
const formattedDescription = `(${scope} - Skill) ${originalDescription}`
|
||||
|
||||
const wrappedTemplate = `<skill-instruction>
|
||||
Base directory for this skill: ${resolvedPath}/
|
||||
File references (@path) in this skill are relative to this directory.
|
||||
|
||||
${body.trim()}
|
||||
</skill-instruction>
|
||||
|
||||
<user-request>
|
||||
$ARGUMENTS
|
||||
</user-request>`
|
||||
|
||||
const definition: CommandDefinition = {
|
||||
name: skillName,
|
||||
description: formattedDescription,
|
||||
template: wrappedTemplate,
|
||||
model: sanitizeModelField(data.model, isOpencodeSource ? "opencode" : "claude-code"),
|
||||
agent: data.agent,
|
||||
subtask: data.subtask,
|
||||
argumentHint: data["argument-hint"],
|
||||
}
|
||||
|
||||
return {
|
||||
name: skillName,
|
||||
path: skillPath,
|
||||
resolvedPath,
|
||||
definition,
|
||||
scope,
|
||||
license: data.license,
|
||||
compatibility: data.compatibility,
|
||||
metadata: data.metadata,
|
||||
allowedTools: parseAllowedTools(data["allowed-tools"]),
|
||||
mcpConfig,
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function parseAllowedTools(allowedTools: string | undefined): string[] | undefined {
|
||||
if (!allowedTools) return undefined
|
||||
return allowedTools.split(/\s+/).filter(Boolean)
|
||||
}
|
||||
|
||||
export async function discoverSkillsInDirAsync(skillsDir: string): Promise<LoadedSkill[]> {
|
||||
try {
|
||||
const entries = await readdir(skillsDir, { withFileTypes: true })
|
||||
|
||||
const processEntry = async (entry: Dirent): Promise<LoadedSkill | null> => {
|
||||
if (entry.name.startsWith(".")) return null
|
||||
|
||||
const entryPath = join(skillsDir, entry.name)
|
||||
|
||||
if (entry.isDirectory() || entry.isSymbolicLink()) {
|
||||
const resolvedPath = resolveSymlink(entryPath)
|
||||
const dirName = entry.name
|
||||
|
||||
const skillMdPath = join(resolvedPath, "SKILL.md")
|
||||
try {
|
||||
await readFile(skillMdPath, "utf-8")
|
||||
return await loadSkillFromPathAsync(skillMdPath, resolvedPath, dirName, "opencode-project")
|
||||
} catch {
|
||||
const namedSkillMdPath = join(resolvedPath, `${dirName}.md`)
|
||||
try {
|
||||
await readFile(namedSkillMdPath, "utf-8")
|
||||
return await loadSkillFromPathAsync(namedSkillMdPath, resolvedPath, dirName, "opencode-project")
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isMarkdownFile(entry)) {
|
||||
const skillName = basename(entry.name, ".md")
|
||||
return await loadSkillFromPathAsync(entryPath, skillsDir, skillName, "opencode-project")
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const skillPromises = await mapWithConcurrency(entries, processEntry, 16)
|
||||
return skillPromises.filter((skill): skill is LoadedSkill => skill !== null)
|
||||
} catch (error: unknown) {
|
||||
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
|
||||
return []
|
||||
}
|
||||
return []
|
||||
}
|
||||
}
|
||||
210
src/features/opencode-skill-loader/blocking.test.ts
Normal file
210
src/features/opencode-skill-loader/blocking.test.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "bun:test"
|
||||
import { mkdirSync, writeFileSync, rmSync } from "fs"
|
||||
import { join } from "path"
|
||||
import { tmpdir } from "os"
|
||||
import { discoverAllSkillsBlocking } from "./blocking"
|
||||
import type { SkillScope } from "./types"
|
||||
|
||||
const TEST_DIR = join(tmpdir(), `blocking-test-${Date.now()}`)
|
||||
|
||||
beforeEach(() => {
|
||||
mkdirSync(TEST_DIR, { recursive: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(TEST_DIR, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe("discoverAllSkillsBlocking", () => {
|
||||
it("returns skills synchronously from valid directories", () => {
|
||||
// #given valid skill directory
|
||||
const skillDir = join(TEST_DIR, "skills")
|
||||
mkdirSync(skillDir, { recursive: true })
|
||||
|
||||
const skillMdPath = join(skillDir, "test-skill.md")
|
||||
writeFileSync(
|
||||
skillMdPath,
|
||||
`---
|
||||
name: test-skill
|
||||
description: A test skill
|
||||
---
|
||||
This is test skill content.`
|
||||
)
|
||||
|
||||
const dirs = [skillDir]
|
||||
const scopes: SkillScope[] = ["opencode-project"]
|
||||
|
||||
// #when discoverAllSkillsBlocking called
|
||||
const skills = discoverAllSkillsBlocking(dirs, scopes)
|
||||
|
||||
// #then returns skills synchronously
|
||||
expect(skills).toBeArray()
|
||||
expect(skills.length).toBe(1)
|
||||
expect(skills[0].name).toBe("test-skill")
|
||||
expect(skills[0].definition.description).toContain("test skill")
|
||||
})
|
||||
|
||||
it("returns empty array for empty directories", () => {
|
||||
// #given empty directory
|
||||
const emptyDir = join(TEST_DIR, "empty")
|
||||
mkdirSync(emptyDir, { recursive: true })
|
||||
|
||||
const dirs = [emptyDir]
|
||||
const scopes: SkillScope[] = ["opencode-project"]
|
||||
|
||||
// #when discoverAllSkillsBlocking called
|
||||
const skills = discoverAllSkillsBlocking(dirs, scopes)
|
||||
|
||||
// #then returns empty array
|
||||
expect(skills).toBeArray()
|
||||
expect(skills.length).toBe(0)
|
||||
})
|
||||
|
||||
it("returns empty array for non-existent directories", () => {
|
||||
// #given non-existent directory
|
||||
const nonExistentDir = join(TEST_DIR, "does-not-exist")
|
||||
|
||||
const dirs = [nonExistentDir]
|
||||
const scopes: SkillScope[] = ["opencode-project"]
|
||||
|
||||
// #when discoverAllSkillsBlocking called
|
||||
const skills = discoverAllSkillsBlocking(dirs, scopes)
|
||||
|
||||
// #then returns empty array (no throw)
|
||||
expect(skills).toBeArray()
|
||||
expect(skills.length).toBe(0)
|
||||
})
|
||||
|
||||
it("handles multiple directories with mixed content", () => {
|
||||
// #given multiple directories with valid and invalid skills
|
||||
const dir1 = join(TEST_DIR, "dir1")
|
||||
const dir2 = join(TEST_DIR, "dir2")
|
||||
mkdirSync(dir1, { recursive: true })
|
||||
mkdirSync(dir2, { recursive: true })
|
||||
|
||||
writeFileSync(
|
||||
join(dir1, "skill1.md"),
|
||||
`---
|
||||
name: skill1
|
||||
description: First skill
|
||||
---
|
||||
Skill 1 content.`
|
||||
)
|
||||
|
||||
writeFileSync(
|
||||
join(dir2, "skill2.md"),
|
||||
`---
|
||||
name: skill2
|
||||
description: Second skill
|
||||
---
|
||||
Skill 2 content.`
|
||||
)
|
||||
|
||||
const dirs = [dir1, dir2]
|
||||
const scopes: SkillScope[] = ["opencode-project"]
|
||||
|
||||
// #when discoverAllSkillsBlocking called
|
||||
const skills = discoverAllSkillsBlocking(dirs, scopes)
|
||||
|
||||
// #then returns all valid skills
|
||||
expect(skills).toBeArray()
|
||||
expect(skills.length).toBe(2)
|
||||
|
||||
const skillNames = skills.map(s => s.name).sort()
|
||||
expect(skillNames).toEqual(["skill1", "skill2"])
|
||||
})
|
||||
|
||||
it("skips invalid YAML files", () => {
|
||||
// #given directory with invalid YAML
|
||||
const skillDir = join(TEST_DIR, "skills")
|
||||
mkdirSync(skillDir, { recursive: true })
|
||||
|
||||
const validSkillPath = join(skillDir, "valid.md")
|
||||
writeFileSync(
|
||||
validSkillPath,
|
||||
`---
|
||||
name: valid-skill
|
||||
description: Valid skill
|
||||
---
|
||||
Valid skill content.`
|
||||
)
|
||||
|
||||
const invalidSkillPath = join(skillDir, "invalid.md")
|
||||
writeFileSync(
|
||||
invalidSkillPath,
|
||||
`---
|
||||
name: invalid skill
|
||||
description: [ invalid yaml
|
||||
---
|
||||
Invalid content.`
|
||||
)
|
||||
|
||||
const dirs = [skillDir]
|
||||
const scopes: SkillScope[] = ["opencode-project"]
|
||||
|
||||
// #when discoverAllSkillsBlocking called
|
||||
const skills = discoverAllSkillsBlocking(dirs, scopes)
|
||||
|
||||
// #then skips invalid, returns valid
|
||||
expect(skills).toBeArray()
|
||||
expect(skills.length).toBe(1)
|
||||
expect(skills[0].name).toBe("valid-skill")
|
||||
})
|
||||
|
||||
it("handles directory-based skills with SKILL.md", () => {
|
||||
// #given directory-based skill structure
|
||||
const skillsDir = join(TEST_DIR, "skills")
|
||||
const mySkillDir = join(skillsDir, "my-skill")
|
||||
mkdirSync(mySkillDir, { recursive: true })
|
||||
|
||||
const skillMdPath = join(mySkillDir, "SKILL.md")
|
||||
writeFileSync(
|
||||
skillMdPath,
|
||||
`---
|
||||
name: my-skill
|
||||
description: Directory-based skill
|
||||
---
|
||||
This is a directory-based skill.`
|
||||
)
|
||||
|
||||
const dirs = [skillsDir]
|
||||
const scopes: SkillScope[] = ["opencode-project"]
|
||||
|
||||
// #when discoverAllSkillsBlocking called
|
||||
const skills = discoverAllSkillsBlocking(dirs, scopes)
|
||||
|
||||
// #then returns skill from SKILL.md
|
||||
expect(skills).toBeArray()
|
||||
expect(skills.length).toBe(1)
|
||||
expect(skills[0].name).toBe("my-skill")
|
||||
})
|
||||
|
||||
it("processes large skill sets without timeout", () => {
|
||||
// #given directory with many skills (20+)
|
||||
const skillDir = join(TEST_DIR, "many-skills")
|
||||
mkdirSync(skillDir, { recursive: true })
|
||||
|
||||
const skillCount = 25
|
||||
for (let i = 0; i < skillCount; i++) {
|
||||
const skillPath = join(skillDir, `skill-${i}.md`)
|
||||
writeFileSync(
|
||||
skillPath,
|
||||
`---
|
||||
name: skill-${i}
|
||||
description: Skill number ${i}
|
||||
---
|
||||
Content for skill ${i}.`
|
||||
)
|
||||
}
|
||||
|
||||
const dirs = [skillDir]
|
||||
const scopes: SkillScope[] = ["opencode-project"]
|
||||
|
||||
// #when discoverAllSkillsBlocking called
|
||||
const skills = discoverAllSkillsBlocking(dirs, scopes)
|
||||
|
||||
// #then completes without timeout
|
||||
expect(skills).toBeArray()
|
||||
expect(skills.length).toBe(skillCount)
|
||||
})
|
||||
})
|
||||
62
src/features/opencode-skill-loader/blocking.ts
Normal file
62
src/features/opencode-skill-loader/blocking.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { Worker, MessageChannel, receiveMessageOnPort } from "worker_threads"
|
||||
import type { LoadedSkill, SkillScope } from "./types"
|
||||
|
||||
interface WorkerInput {
|
||||
dirs: string[]
|
||||
scopes: SkillScope[]
|
||||
}
|
||||
|
||||
interface WorkerOutputSuccess {
|
||||
ok: true
|
||||
skills: LoadedSkill[]
|
||||
}
|
||||
|
||||
interface WorkerOutputError {
|
||||
ok: false
|
||||
error: { message: string; stack?: string }
|
||||
}
|
||||
|
||||
type WorkerOutput = WorkerOutputSuccess | WorkerOutputError
|
||||
|
||||
const TIMEOUT_MS = 30000
|
||||
|
||||
export function discoverAllSkillsBlocking(dirs: string[], scopes: SkillScope[]): LoadedSkill[] {
|
||||
const signal = new Int32Array(new SharedArrayBuffer(4))
|
||||
const { port1, port2 } = new MessageChannel()
|
||||
|
||||
const worker = new Worker(new URL("./discover-worker.ts", import.meta.url), {
|
||||
workerData: { signal }
|
||||
})
|
||||
|
||||
worker.postMessage({ port: port2 }, [port2])
|
||||
|
||||
const input: WorkerInput = { dirs, scopes }
|
||||
port1.postMessage(input)
|
||||
|
||||
const waitResult = Atomics.wait(signal, 0, 0, TIMEOUT_MS)
|
||||
|
||||
if (waitResult === "timed-out") {
|
||||
worker.terminate()
|
||||
port1.close()
|
||||
throw new Error(`Worker timeout after ${TIMEOUT_MS}ms`)
|
||||
}
|
||||
|
||||
const message = receiveMessageOnPort(port1)
|
||||
|
||||
worker.terminate()
|
||||
port1.close()
|
||||
|
||||
if (!message) {
|
||||
throw new Error("Worker did not return result")
|
||||
}
|
||||
|
||||
const output = message.message as WorkerOutput
|
||||
|
||||
if (output.ok === false) {
|
||||
const error = new Error(output.error.message)
|
||||
error.stack = output.error.stack
|
||||
throw error
|
||||
}
|
||||
|
||||
return output.skills
|
||||
}
|
||||
59
src/features/opencode-skill-loader/discover-worker.ts
Normal file
59
src/features/opencode-skill-loader/discover-worker.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { workerData, parentPort } from "worker_threads"
|
||||
import type { MessagePort } from "worker_threads"
|
||||
import { discoverSkillsInDirAsync } from "./async-loader"
|
||||
import type { LoadedSkill, SkillScope } from "./types"
|
||||
|
||||
interface WorkerInput {
|
||||
dirs: string[]
|
||||
scopes: SkillScope[]
|
||||
}
|
||||
|
||||
interface WorkerOutputSuccess {
|
||||
ok: true
|
||||
skills: LoadedSkill[]
|
||||
}
|
||||
|
||||
interface WorkerOutputError {
|
||||
ok: false
|
||||
error: { message: string; stack?: string }
|
||||
}
|
||||
|
||||
type WorkerOutput = WorkerOutputSuccess | WorkerOutputError
|
||||
|
||||
const { signal } = workerData as { signal: Int32Array }
|
||||
|
||||
if (!parentPort) {
|
||||
throw new Error("Worker must be run with parentPort")
|
||||
}
|
||||
|
||||
parentPort.once("message", (data: { port: MessagePort }) => {
|
||||
const { port } = data
|
||||
|
||||
port.on("message", async (input: WorkerInput) => {
|
||||
try {
|
||||
const results = await Promise.all(
|
||||
input.dirs.map(dir => discoverSkillsInDirAsync(dir))
|
||||
)
|
||||
|
||||
const skills = results.flat()
|
||||
|
||||
const output: WorkerOutputSuccess = { ok: true, skills }
|
||||
|
||||
port.postMessage(output)
|
||||
Atomics.store(signal, 0, 1)
|
||||
Atomics.notify(signal, 0)
|
||||
} catch (error: unknown) {
|
||||
const output: WorkerOutputError = {
|
||||
ok: false,
|
||||
error: {
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
},
|
||||
}
|
||||
|
||||
port.postMessage(output)
|
||||
Atomics.store(signal, 0, 1)
|
||||
Atomics.notify(signal, 0)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { existsSync, readdirSync, readFileSync } from "fs"
|
||||
import { join, basename, dirname } from "path"
|
||||
import { promises as fs } from "fs"
|
||||
import { join, basename } from "path"
|
||||
import { homedir } from "os"
|
||||
import yaml from "js-yaml"
|
||||
import { parseFrontmatter } from "../../shared/frontmatter"
|
||||
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 } from "./types"
|
||||
import type { SkillScope, SkillMetadata, LoadedSkill, LazyContentLoader } from "./types"
|
||||
import type { SkillMcpConfig } from "../skill-mcp-manager/types"
|
||||
|
||||
function parseSkillMcpConfigFromFrontmatter(content: string): SkillMcpConfig | undefined {
|
||||
@@ -25,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>)
|
||||
@@ -58,17 +55,17 @@ function parseAllowedTools(allowedTools: string | undefined): string[] | undefin
|
||||
return allowedTools.split(/\s+/).filter(Boolean)
|
||||
}
|
||||
|
||||
function loadSkillFromPath(
|
||||
async function loadSkillFromPath(
|
||||
skillPath: string,
|
||||
resolvedPath: string,
|
||||
defaultName: string,
|
||||
scope: SkillScope
|
||||
): LoadedSkill | null {
|
||||
): Promise<LoadedSkill | null> {
|
||||
try {
|
||||
const content = readFileSync(skillPath, "utf-8")
|
||||
const { data, body } = parseFrontmatter<SkillMetadata>(content)
|
||||
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
|
||||
@@ -76,7 +73,14 @@ function loadSkillFromPath(
|
||||
const isOpencodeSource = scope === "opencode" || scope === "opencode-project"
|
||||
const formattedDescription = `(${scope} - Skill) ${originalDescription}`
|
||||
|
||||
const wrappedTemplate = `<skill-instruction>
|
||||
const lazyContent: LazyContentLoader = {
|
||||
loaded: false,
|
||||
content: undefined,
|
||||
load: async () => {
|
||||
if (!lazyContent.loaded) {
|
||||
const fileContent = await fs.readFile(skillPath, "utf-8")
|
||||
const { body } = parseFrontmatter<SkillMetadata>(fileContent)
|
||||
lazyContent.content = `<skill-instruction>
|
||||
Base directory for this skill: ${resolvedPath}/
|
||||
File references (@path) in this skill are relative to this directory.
|
||||
|
||||
@@ -86,11 +90,16 @@ ${body.trim()}
|
||||
<user-request>
|
||||
$ARGUMENTS
|
||||
</user-request>`
|
||||
lazyContent.loaded = true
|
||||
}
|
||||
return lazyContent.content!
|
||||
},
|
||||
}
|
||||
|
||||
const definition: CommandDefinition = {
|
||||
name: skillName,
|
||||
description: formattedDescription,
|
||||
template: wrappedTemplate,
|
||||
template: "",
|
||||
model: sanitizeModelField(data.model, isOpencodeSource ? "opencode" : "claude-code"),
|
||||
agent: data.agent,
|
||||
subtask: data.subtask,
|
||||
@@ -108,24 +117,15 @@ $ARGUMENTS
|
||||
metadata: data.metadata,
|
||||
allowedTools: parseAllowedTools(data["allowed-tools"]),
|
||||
mcpConfig,
|
||||
lazyContent,
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 })
|
||||
async function loadSkillsFromDir(skillsDir: string, scope: SkillScope): Promise<LoadedSkill[]> {
|
||||
const entries = await fs.readdir(skillsDir, { withFileTypes: true }).catch(() => [])
|
||||
const skills: LoadedSkill[] = []
|
||||
|
||||
for (const entry of entries) {
|
||||
@@ -134,21 +134,25 @@ function loadSkillsFromDir(skillsDir: string, scope: SkillScope): LoadedSkill[]
|
||||
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")
|
||||
if (existsSync(skillMdPath)) {
|
||||
const skill = loadSkillFromPath(skillMdPath, resolvedPath, dirName, scope)
|
||||
try {
|
||||
await fs.access(skillMdPath)
|
||||
const skill = await loadSkillFromPath(skillMdPath, resolvedPath, dirName, scope)
|
||||
if (skill) skills.push(skill)
|
||||
continue
|
||||
} catch {
|
||||
}
|
||||
|
||||
const namedSkillMdPath = join(resolvedPath, `${dirName}.md`)
|
||||
if (existsSync(namedSkillMdPath)) {
|
||||
const skill = loadSkillFromPath(namedSkillMdPath, resolvedPath, dirName, scope)
|
||||
try {
|
||||
await fs.access(namedSkillMdPath)
|
||||
const skill = await loadSkillFromPath(namedSkillMdPath, resolvedPath, dirName, scope)
|
||||
if (skill) skills.push(skill)
|
||||
continue
|
||||
} catch {
|
||||
}
|
||||
|
||||
continue
|
||||
@@ -156,7 +160,7 @@ function loadSkillsFromDir(skillsDir: string, scope: SkillScope): LoadedSkill[]
|
||||
|
||||
if (isMarkdownFile(entry)) {
|
||||
const skillName = basename(entry.name, ".md")
|
||||
const skill = loadSkillFromPath(entryPath, skillsDir, skillName, scope)
|
||||
const skill = await loadSkillFromPath(entryPath, skillsDir, skillName, scope)
|
||||
if (skill) skills.push(skill)
|
||||
}
|
||||
}
|
||||
@@ -173,116 +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[] {
|
||||
export async function discoverAllSkills(): Promise<LoadedSkill[]> {
|
||||
const [opencodeProjectSkills, projectSkills, opencodeGlobalSkills, userSkills] = await Promise.all([
|
||||
discoverOpencodeProjectSkills(),
|
||||
discoverProjectClaudeSkills(),
|
||||
discoverOpencodeGlobalSkills(),
|
||||
discoverUserClaudeSkills(),
|
||||
])
|
||||
|
||||
return [...opencodeProjectSkills, ...projectSkills, ...opencodeGlobalSkills, ...userSkills]
|
||||
}
|
||||
|
||||
export async function discoverSkills(options: DiscoverSkillsOptions = {}): Promise<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")
|
||||
const [opencodeProjectSkills, opencodeGlobalSkills] = await Promise.all([
|
||||
discoverOpencodeProjectSkills(),
|
||||
discoverOpencodeGlobalSkills(),
|
||||
])
|
||||
|
||||
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")
|
||||
const [projectSkills, userSkills] = await Promise.all([
|
||||
discoverProjectClaudeSkills(),
|
||||
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)
|
||||
export async function getSkillByName(name: string, options: DiscoverSkillsOptions = {}): Promise<LoadedSkill | undefined> {
|
||||
const skills = await discoverSkills(options)
|
||||
return skills.find(s => s.name === name)
|
||||
}
|
||||
|
||||
export function discoverUserClaudeSkills(): LoadedSkill[] {
|
||||
export async function discoverUserClaudeSkills(): Promise<LoadedSkill[]> {
|
||||
const userSkillsDir = join(getClaudeConfigDir(), "skills")
|
||||
return loadSkillsFromDir(userSkillsDir, "user")
|
||||
}
|
||||
|
||||
export function discoverProjectClaudeSkills(): LoadedSkill[] {
|
||||
export async function discoverProjectClaudeSkills(): Promise<LoadedSkill[]> {
|
||||
const projectSkillsDir = join(process.cwd(), ".claude", "skills")
|
||||
return loadSkillsFromDir(projectSkillsDir, "project")
|
||||
}
|
||||
|
||||
export function discoverOpencodeGlobalSkills(): LoadedSkill[] {
|
||||
export async function discoverOpencodeGlobalSkills(): Promise<LoadedSkill[]> {
|
||||
const opencodeSkillsDir = join(homedir(), ".config", "opencode", "skill")
|
||||
return loadSkillsFromDir(opencodeSkillsDir, "opencode")
|
||||
}
|
||||
|
||||
export function discoverOpencodeProjectSkills(): LoadedSkill[] {
|
||||
export async function discoverOpencodeProjectSkills(): Promise<LoadedSkill[]> {
|
||||
const opencodeProjectDir = join(process.cwd(), ".opencode", "skill")
|
||||
return loadSkillsFromDir(opencodeProjectDir, "opencode-project")
|
||||
}
|
||||
|
||||
@@ -17,6 +17,12 @@ export interface SkillMetadata {
|
||||
mcp?: SkillMcpConfig
|
||||
}
|
||||
|
||||
export interface LazyContentLoader {
|
||||
loaded: boolean
|
||||
content?: string
|
||||
load: () => Promise<string>
|
||||
}
|
||||
|
||||
export interface LoadedSkill {
|
||||
name: string
|
||||
path?: string
|
||||
@@ -28,4 +34,5 @@ export interface LoadedSkill {
|
||||
metadata?: Record<string, string>
|
||||
allowedTools?: string[]
|
||||
mcpConfig?: SkillMcpConfig
|
||||
lazyContent?: LazyContentLoader
|
||||
}
|
||||
|
||||
201
src/features/skill-mcp-manager/env-cleaner.test.ts
Normal file
201
src/features/skill-mcp-manager/env-cleaner.test.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from "bun:test"
|
||||
import { createCleanMcpEnvironment, EXCLUDED_ENV_PATTERNS } from "./env-cleaner"
|
||||
|
||||
describe("createCleanMcpEnvironment", () => {
|
||||
// Store original env to restore after tests
|
||||
const originalEnv = { ...process.env }
|
||||
|
||||
afterEach(() => {
|
||||
// Restore original environment
|
||||
for (const key of Object.keys(process.env)) {
|
||||
if (!(key in originalEnv)) {
|
||||
delete process.env[key]
|
||||
}
|
||||
}
|
||||
for (const [key, value] of Object.entries(originalEnv)) {
|
||||
process.env[key] = value
|
||||
}
|
||||
})
|
||||
|
||||
describe("NPM_CONFIG_* filtering", () => {
|
||||
it("filters out uppercase NPM_CONFIG_* variables", () => {
|
||||
// #given
|
||||
process.env.NPM_CONFIG_REGISTRY = "https://private.registry.com"
|
||||
process.env.NPM_CONFIG_CACHE = "/some/cache/path"
|
||||
process.env.NPM_CONFIG_PREFIX = "/some/prefix"
|
||||
process.env.PATH = "/usr/bin"
|
||||
|
||||
// #when
|
||||
const cleanEnv = createCleanMcpEnvironment()
|
||||
|
||||
// #then
|
||||
expect(cleanEnv.NPM_CONFIG_REGISTRY).toBeUndefined()
|
||||
expect(cleanEnv.NPM_CONFIG_CACHE).toBeUndefined()
|
||||
expect(cleanEnv.NPM_CONFIG_PREFIX).toBeUndefined()
|
||||
expect(cleanEnv.PATH).toBe("/usr/bin")
|
||||
})
|
||||
|
||||
it("filters out lowercase npm_config_* variables", () => {
|
||||
// #given
|
||||
process.env.npm_config_registry = "https://private.registry.com"
|
||||
process.env.npm_config_cache = "/some/cache/path"
|
||||
process.env.npm_config_https_proxy = "http://proxy:8080"
|
||||
process.env.npm_config_proxy = "http://proxy:8080"
|
||||
process.env.HOME = "/home/user"
|
||||
|
||||
// #when
|
||||
const cleanEnv = createCleanMcpEnvironment()
|
||||
|
||||
// #then
|
||||
expect(cleanEnv.npm_config_registry).toBeUndefined()
|
||||
expect(cleanEnv.npm_config_cache).toBeUndefined()
|
||||
expect(cleanEnv.npm_config_https_proxy).toBeUndefined()
|
||||
expect(cleanEnv.npm_config_proxy).toBeUndefined()
|
||||
expect(cleanEnv.HOME).toBe("/home/user")
|
||||
})
|
||||
})
|
||||
|
||||
describe("YARN_* filtering", () => {
|
||||
it("filters out YARN_* variables", () => {
|
||||
// #given
|
||||
process.env.YARN_CACHE_FOLDER = "/yarn/cache"
|
||||
process.env.YARN_ENABLE_IMMUTABLE_INSTALLS = "true"
|
||||
process.env.YARN_REGISTRY = "https://yarn.registry.com"
|
||||
process.env.NODE_ENV = "production"
|
||||
|
||||
// #when
|
||||
const cleanEnv = createCleanMcpEnvironment()
|
||||
|
||||
// #then
|
||||
expect(cleanEnv.YARN_CACHE_FOLDER).toBeUndefined()
|
||||
expect(cleanEnv.YARN_ENABLE_IMMUTABLE_INSTALLS).toBeUndefined()
|
||||
expect(cleanEnv.YARN_REGISTRY).toBeUndefined()
|
||||
expect(cleanEnv.NODE_ENV).toBe("production")
|
||||
})
|
||||
})
|
||||
|
||||
describe("PNPM_* filtering", () => {
|
||||
it("filters out PNPM_* variables", () => {
|
||||
// #given
|
||||
process.env.PNPM_HOME = "/pnpm/home"
|
||||
process.env.PNPM_STORE_DIR = "/pnpm/store"
|
||||
process.env.USER = "testuser"
|
||||
|
||||
// #when
|
||||
const cleanEnv = createCleanMcpEnvironment()
|
||||
|
||||
// #then
|
||||
expect(cleanEnv.PNPM_HOME).toBeUndefined()
|
||||
expect(cleanEnv.PNPM_STORE_DIR).toBeUndefined()
|
||||
expect(cleanEnv.USER).toBe("testuser")
|
||||
})
|
||||
})
|
||||
|
||||
describe("NO_UPDATE_NOTIFIER filtering", () => {
|
||||
it("filters out NO_UPDATE_NOTIFIER variable", () => {
|
||||
// #given
|
||||
process.env.NO_UPDATE_NOTIFIER = "1"
|
||||
process.env.SHELL = "/bin/bash"
|
||||
|
||||
// #when
|
||||
const cleanEnv = createCleanMcpEnvironment()
|
||||
|
||||
// #then
|
||||
expect(cleanEnv.NO_UPDATE_NOTIFIER).toBeUndefined()
|
||||
expect(cleanEnv.SHELL).toBe("/bin/bash")
|
||||
})
|
||||
})
|
||||
|
||||
describe("custom environment overlay", () => {
|
||||
it("merges custom env on top of clean process.env", () => {
|
||||
// #given
|
||||
process.env.PATH = "/usr/bin"
|
||||
process.env.NPM_CONFIG_REGISTRY = "https://private.registry.com"
|
||||
const customEnv = {
|
||||
MCP_API_KEY: "secret-key",
|
||||
CUSTOM_VAR: "custom-value",
|
||||
}
|
||||
|
||||
// #when
|
||||
const cleanEnv = createCleanMcpEnvironment(customEnv)
|
||||
|
||||
// #then
|
||||
expect(cleanEnv.PATH).toBe("/usr/bin")
|
||||
expect(cleanEnv.NPM_CONFIG_REGISTRY).toBeUndefined()
|
||||
expect(cleanEnv.MCP_API_KEY).toBe("secret-key")
|
||||
expect(cleanEnv.CUSTOM_VAR).toBe("custom-value")
|
||||
})
|
||||
|
||||
it("custom env can override process.env values", () => {
|
||||
// #given
|
||||
process.env.NODE_ENV = "development"
|
||||
const customEnv = {
|
||||
NODE_ENV: "production",
|
||||
}
|
||||
|
||||
// #when
|
||||
const cleanEnv = createCleanMcpEnvironment(customEnv)
|
||||
|
||||
// #then
|
||||
expect(cleanEnv.NODE_ENV).toBe("production")
|
||||
})
|
||||
})
|
||||
|
||||
describe("undefined value handling", () => {
|
||||
it("skips undefined values from process.env", () => {
|
||||
// #given - process.env can have undefined values in TypeScript
|
||||
const envWithUndefined = { ...process.env, UNDEFINED_VAR: undefined }
|
||||
Object.assign(process.env, envWithUndefined)
|
||||
|
||||
// #when
|
||||
const cleanEnv = createCleanMcpEnvironment()
|
||||
|
||||
// #then - should not throw and should not include undefined values
|
||||
expect(cleanEnv.UNDEFINED_VAR).toBeUndefined()
|
||||
expect(Object.values(cleanEnv).every((v) => v !== undefined)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("mixed case handling", () => {
|
||||
it("filters both uppercase and lowercase npm config variants", () => {
|
||||
// #given - pnpm/yarn can set both cases simultaneously
|
||||
process.env.NPM_CONFIG_CACHE = "/uppercase/cache"
|
||||
process.env.npm_config_cache = "/lowercase/cache"
|
||||
process.env.NPM_CONFIG_REGISTRY = "https://uppercase.registry.com"
|
||||
process.env.npm_config_registry = "https://lowercase.registry.com"
|
||||
|
||||
// #when
|
||||
const cleanEnv = createCleanMcpEnvironment()
|
||||
|
||||
// #then
|
||||
expect(cleanEnv.NPM_CONFIG_CACHE).toBeUndefined()
|
||||
expect(cleanEnv.npm_config_cache).toBeUndefined()
|
||||
expect(cleanEnv.NPM_CONFIG_REGISTRY).toBeUndefined()
|
||||
expect(cleanEnv.npm_config_registry).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("EXCLUDED_ENV_PATTERNS", () => {
|
||||
it("contains patterns for npm, yarn, and pnpm configs", () => {
|
||||
// #given / #when / #then
|
||||
expect(EXCLUDED_ENV_PATTERNS.length).toBeGreaterThanOrEqual(4)
|
||||
|
||||
// Test that patterns match expected strings
|
||||
const testCases = [
|
||||
{ pattern: "NPM_CONFIG_REGISTRY", shouldMatch: true },
|
||||
{ pattern: "npm_config_registry", shouldMatch: true },
|
||||
{ pattern: "YARN_CACHE_FOLDER", shouldMatch: true },
|
||||
{ pattern: "PNPM_HOME", shouldMatch: true },
|
||||
{ pattern: "NO_UPDATE_NOTIFIER", shouldMatch: true },
|
||||
{ pattern: "PATH", shouldMatch: false },
|
||||
{ pattern: "HOME", shouldMatch: false },
|
||||
{ pattern: "NODE_ENV", shouldMatch: false },
|
||||
]
|
||||
|
||||
for (const { pattern, shouldMatch } of testCases) {
|
||||
const matches = EXCLUDED_ENV_PATTERNS.some((regex: RegExp) => regex.test(pattern))
|
||||
expect(matches).toBe(shouldMatch)
|
||||
}
|
||||
})
|
||||
})
|
||||
27
src/features/skill-mcp-manager/env-cleaner.ts
Normal file
27
src/features/skill-mcp-manager/env-cleaner.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
// Filters npm/pnpm/yarn config env vars that break MCP servers in pnpm projects (#456)
|
||||
export const EXCLUDED_ENV_PATTERNS: RegExp[] = [
|
||||
/^NPM_CONFIG_/i,
|
||||
/^npm_config_/,
|
||||
/^YARN_/,
|
||||
/^PNPM_/,
|
||||
/^NO_UPDATE_NOTIFIER$/,
|
||||
]
|
||||
|
||||
export function createCleanMcpEnvironment(
|
||||
customEnv: Record<string, string> = {}
|
||||
): Record<string, string> {
|
||||
const cleanEnv: Record<string, string> = {}
|
||||
|
||||
for (const [key, value] of Object.entries(process.env)) {
|
||||
if (value === undefined) continue
|
||||
|
||||
const shouldExclude = EXCLUDED_ENV_PATTERNS.some((pattern) => pattern.test(key))
|
||||
if (!shouldExclude) {
|
||||
cleanEnv[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
Object.assign(cleanEnv, customEnv)
|
||||
|
||||
return cleanEnv
|
||||
}
|
||||
@@ -3,21 +3,68 @@ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
|
||||
import type { Tool, Resource, Prompt } from "@modelcontextprotocol/sdk/types.js"
|
||||
import type { ClaudeCodeMcpServer } from "../claude-code-mcp-loader/types"
|
||||
import { expandEnvVarsInObject } from "../claude-code-mcp-loader/env-expander"
|
||||
import { createCleanMcpEnvironment } from "./env-cleaner"
|
||||
import type { SkillMcpClientInfo, SkillMcpServerContext } from "./types"
|
||||
|
||||
interface ManagedClient {
|
||||
client: Client
|
||||
transport: StdioClientTransport
|
||||
skillName: string
|
||||
lastUsedAt: number
|
||||
}
|
||||
|
||||
export class SkillMcpManager {
|
||||
private clients: Map<string, ManagedClient> = new Map()
|
||||
private pendingConnections: Map<string, Promise<Client>> = new Map()
|
||||
private cleanupRegistered = false
|
||||
private cleanupInterval: ReturnType<typeof setInterval> | null = null
|
||||
private readonly IDLE_TIMEOUT = 5 * 60 * 1000
|
||||
|
||||
private getClientKey(info: SkillMcpClientInfo): string {
|
||||
return `${info.sessionID}:${info.skillName}:${info.serverName}`
|
||||
}
|
||||
|
||||
private registerProcessCleanup(): void {
|
||||
if (this.cleanupRegistered) return
|
||||
this.cleanupRegistered = true
|
||||
|
||||
const cleanup = async () => {
|
||||
for (const [, managed] of this.clients) {
|
||||
try {
|
||||
await managed.client.close()
|
||||
} catch {
|
||||
// Ignore errors during cleanup
|
||||
}
|
||||
try {
|
||||
await managed.transport.close()
|
||||
} catch {
|
||||
// Transport may already be terminated
|
||||
}
|
||||
}
|
||||
this.clients.clear()
|
||||
this.pendingConnections.clear()
|
||||
}
|
||||
|
||||
// Note: 'exit' event is synchronous-only in Node.js, so we use 'beforeExit' for async cleanup
|
||||
// However, 'beforeExit' is not emitted on explicit process.exit() calls
|
||||
// Signal handlers are made async to properly await cleanup
|
||||
|
||||
process.on("SIGINT", async () => {
|
||||
await cleanup()
|
||||
process.exit(0)
|
||||
})
|
||||
process.on("SIGTERM", async () => {
|
||||
await cleanup()
|
||||
process.exit(0)
|
||||
})
|
||||
if (process.platform === "win32") {
|
||||
process.on("SIGBREAK", async () => {
|
||||
await cleanup()
|
||||
process.exit(0)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async getOrCreateClient(
|
||||
info: SkillMcpClientInfo,
|
||||
config: ClaudeCodeMcpServer
|
||||
@@ -26,12 +73,26 @@ export class SkillMcpManager {
|
||||
const existing = this.clients.get(key)
|
||||
|
||||
if (existing) {
|
||||
existing.lastUsedAt = Date.now()
|
||||
return existing.client
|
||||
}
|
||||
|
||||
// Prevent race condition: if a connection is already in progress, wait for it
|
||||
const pending = this.pendingConnections.get(key)
|
||||
if (pending) {
|
||||
return pending
|
||||
}
|
||||
|
||||
const expandedConfig = expandEnvVarsInObject(config)
|
||||
const client = await this.createClient(info, expandedConfig)
|
||||
return client
|
||||
const connectionPromise = this.createClient(info, expandedConfig)
|
||||
this.pendingConnections.set(key, connectionPromise)
|
||||
|
||||
try {
|
||||
const client = await connectionPromise
|
||||
return client
|
||||
} finally {
|
||||
this.pendingConnections.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
private async createClient(
|
||||
@@ -55,15 +116,9 @@ export class SkillMcpManager {
|
||||
const command = config.command
|
||||
const args = config.args || []
|
||||
|
||||
// Always inherit parent process environment
|
||||
const mergedEnv: Record<string, string> = {}
|
||||
for (const [key, value] of Object.entries(process.env)) {
|
||||
if (value !== undefined) mergedEnv[key] = value
|
||||
}
|
||||
// Overlay with skill-specific env vars if present
|
||||
if (config.env) {
|
||||
Object.assign(mergedEnv, config.env)
|
||||
}
|
||||
const mergedEnv = createCleanMcpEnvironment(config.env)
|
||||
|
||||
this.registerProcessCleanup()
|
||||
|
||||
const transport = new StdioClientTransport({
|
||||
command,
|
||||
@@ -80,6 +135,12 @@ export class SkillMcpManager {
|
||||
try {
|
||||
await client.connect(transport)
|
||||
} catch (error) {
|
||||
// Close transport to prevent orphaned MCP process on connection failure
|
||||
try {
|
||||
await transport.close()
|
||||
} catch {
|
||||
// Process may already be terminated
|
||||
}
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
throw new Error(
|
||||
`Failed to connect to MCP server "${info.serverName}".\n\n` +
|
||||
@@ -92,7 +153,8 @@ export class SkillMcpManager {
|
||||
)
|
||||
}
|
||||
|
||||
this.clients.set(key, { client, transport, skillName: info.skillName })
|
||||
this.clients.set(key, { client, transport, skillName: info.skillName, lastUsedAt: Date.now() })
|
||||
this.startCleanupTimer()
|
||||
return client
|
||||
}
|
||||
|
||||
@@ -102,26 +164,64 @@ export class SkillMcpManager {
|
||||
for (const [key, managed] of this.clients.entries()) {
|
||||
if (key.startsWith(`${sessionID}:`)) {
|
||||
keysToRemove.push(key)
|
||||
// Delete from map first to prevent re-entrancy during async close
|
||||
this.clients.delete(key)
|
||||
try {
|
||||
await managed.client.close()
|
||||
} catch {
|
||||
// Ignore close errors - process may already be terminated
|
||||
}
|
||||
try {
|
||||
await managed.transport.close()
|
||||
} catch {
|
||||
// Transport may already be terminated
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const key of keysToRemove) {
|
||||
this.clients.delete(key)
|
||||
}
|
||||
}
|
||||
|
||||
async disconnectAll(): Promise<void> {
|
||||
for (const [, managed] of this.clients.entries()) {
|
||||
this.stopCleanupTimer()
|
||||
const clients = Array.from(this.clients.values())
|
||||
this.clients.clear()
|
||||
for (const managed of clients) {
|
||||
try {
|
||||
await managed.client.close()
|
||||
} catch { /* process may already be terminated */ }
|
||||
try {
|
||||
await managed.transport.close()
|
||||
} catch { /* transport may already be terminated */ }
|
||||
}
|
||||
}
|
||||
|
||||
private startCleanupTimer(): void {
|
||||
if (this.cleanupInterval) return
|
||||
this.cleanupInterval = setInterval(() => {
|
||||
this.cleanupIdleClients()
|
||||
}, 60_000)
|
||||
this.cleanupInterval.unref()
|
||||
}
|
||||
|
||||
private stopCleanupTimer(): void {
|
||||
if (this.cleanupInterval) {
|
||||
clearInterval(this.cleanupInterval)
|
||||
this.cleanupInterval = null
|
||||
}
|
||||
}
|
||||
|
||||
private async cleanupIdleClients(): Promise<void> {
|
||||
const now = Date.now()
|
||||
for (const [key, managed] of this.clients) {
|
||||
if (now - managed.lastUsedAt > this.IDLE_TIMEOUT) {
|
||||
this.clients.delete(key)
|
||||
try {
|
||||
await managed.client.close()
|
||||
} catch { /* process may already be terminated */ }
|
||||
try {
|
||||
await managed.transport.close()
|
||||
} catch { /* transport may already be terminated */ }
|
||||
}
|
||||
}
|
||||
this.clients.clear()
|
||||
}
|
||||
|
||||
async listTools(
|
||||
@@ -193,10 +293,13 @@ export class SkillMcpManager {
|
||||
const key = this.getClientKey(info)
|
||||
const existing = this.clients.get(key)
|
||||
if (existing) {
|
||||
this.clients.delete(key)
|
||||
try {
|
||||
await existing.client.close()
|
||||
} catch { /* process may already be terminated */ }
|
||||
this.clients.delete(key)
|
||||
try {
|
||||
await existing.transport.close()
|
||||
} catch { /* transport may already be terminated */ }
|
||||
return await this.getOrCreateClient(info, config)
|
||||
}
|
||||
throw error
|
||||
|
||||
@@ -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",
|
||||
]);
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ type Client = {
|
||||
query: { directory: string };
|
||||
}) => Promise<unknown>;
|
||||
prompt_async: (opts: {
|
||||
path: { sessionID: string };
|
||||
path: { id: string };
|
||||
body: { parts: Array<{ type: string; text: string }> };
|
||||
query: { directory: string };
|
||||
}) => Promise<unknown>;
|
||||
@@ -408,7 +408,7 @@ export async function executeCompact(
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await (client as Client).session.prompt_async({
|
||||
path: { sessionID },
|
||||
path: { id: sessionID },
|
||||
body: { parts: [{ type: "text", text: "Continue" }] },
|
||||
query: { directory },
|
||||
});
|
||||
@@ -506,7 +506,7 @@ export async function executeCompact(
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await (client as Client).session.prompt_async({
|
||||
path: { sessionID },
|
||||
path: { id: sessionID },
|
||||
body: { parts: [{ type: "text", text: "Continue" }] },
|
||||
query: { directory },
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -28,6 +28,7 @@ import { recordToolUse, recordToolResult, getTranscriptPath, recordUserMessage }
|
||||
import type { PluginConfig } from "./types"
|
||||
import { log, isHookDisabled } from "../../shared"
|
||||
import { injectHookMessage } from "../../features/hook-message-injector"
|
||||
import { detectKeywordsWithType, removeCodeBlocks } from "../keyword-detector"
|
||||
|
||||
const sessionFirstMessageProcessed = new Set<string>()
|
||||
const sessionErrorState = new Map<string, { hasError: boolean; errorMessage?: string }>()
|
||||
@@ -137,9 +138,21 @@ export function createClaudeCodeHooksHook(ctx: PluginInput, config: PluginConfig
|
||||
return
|
||||
}
|
||||
|
||||
if (result.messages.length > 0) {
|
||||
const hookContent = result.messages.join("\n\n")
|
||||
log(`[claude-code-hooks] Injecting ${result.messages.length} hook messages`, { sessionID: input.sessionID, contentLength: hookContent.length, isFirstMessage })
|
||||
const detectedKeywords = detectKeywordsWithType(removeCodeBlocks(prompt), input.agent)
|
||||
const keywordMessages = detectedKeywords.map((k) => k.message)
|
||||
|
||||
if (keywordMessages.length > 0) {
|
||||
log("[claude-code-hooks] Detected keywords", {
|
||||
sessionID: input.sessionID,
|
||||
types: detectedKeywords.map((k) => k.type),
|
||||
})
|
||||
}
|
||||
|
||||
const allMessages = [...keywordMessages, ...result.messages]
|
||||
|
||||
if (allMessages.length > 0) {
|
||||
const hookContent = allMessages.join("\n\n")
|
||||
log(`[claude-code-hooks] Injecting ${allMessages.length} messages (${keywordMessages.length} keyword + ${result.messages.length} hook)`, { sessionID: input.sessionID, contentLength: hookContent.length, isFirstMessage })
|
||||
|
||||
if (isFirstMessage) {
|
||||
const idx = output.parts.findIndex((p) => p.type === "text" && p.text)
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
|
||||
const ANTHROPIC_DISPLAY_LIMIT = 1_000_000
|
||||
const ANTHROPIC_ACTUAL_LIMIT = 200_000
|
||||
const ANTHROPIC_ACTUAL_LIMIT =
|
||||
process.env.ANTHROPIC_1M_CONTEXT === "true" ||
|
||||
process.env.VERTEX_ANTHROPIC_1M_CONTEXT === "true"
|
||||
? 1_000_000
|
||||
: 200_000
|
||||
const CONTEXT_WARNING_THRESHOLD = 0.70
|
||||
|
||||
const CONTEXT_REMINDER = `[SYSTEM REMINDER - 1M Context Window]
|
||||
|
||||
126
src/hooks/edit-error-recovery/index.test.ts
Normal file
126
src/hooks/edit-error-recovery/index.test.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { describe, it, expect, beforeEach } from "bun:test"
|
||||
import { createEditErrorRecoveryHook, EDIT_ERROR_REMINDER, EDIT_ERROR_PATTERNS } from "./index"
|
||||
|
||||
describe("createEditErrorRecoveryHook", () => {
|
||||
let hook: ReturnType<typeof createEditErrorRecoveryHook>
|
||||
|
||||
beforeEach(() => {
|
||||
hook = createEditErrorRecoveryHook({} as any)
|
||||
})
|
||||
|
||||
describe("tool.execute.after", () => {
|
||||
const createInput = (tool: string) => ({
|
||||
tool,
|
||||
sessionID: "test-session",
|
||||
callID: "test-call-id",
|
||||
})
|
||||
|
||||
const createOutput = (outputText: string) => ({
|
||||
title: "Edit",
|
||||
output: outputText,
|
||||
metadata: {},
|
||||
})
|
||||
|
||||
describe("#given Edit tool with oldString/newString same error", () => {
|
||||
describe("#when the error message is detected", () => {
|
||||
it("#then should append the recovery reminder", async () => {
|
||||
const input = createInput("Edit")
|
||||
const output = createOutput("Error: oldString and newString must be different")
|
||||
|
||||
await hook["tool.execute.after"](input, output)
|
||||
|
||||
expect(output.output).toContain(EDIT_ERROR_REMINDER)
|
||||
expect(output.output).toContain("oldString and newString must be different")
|
||||
})
|
||||
})
|
||||
|
||||
describe("#when the error appears without Error prefix", () => {
|
||||
it("#then should still detect and append reminder", async () => {
|
||||
const input = createInput("Edit")
|
||||
const output = createOutput("oldString and newString must be different")
|
||||
|
||||
await hook["tool.execute.after"](input, output)
|
||||
|
||||
expect(output.output).toContain(EDIT_ERROR_REMINDER)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given Edit tool with oldString not found error", () => {
|
||||
describe("#when oldString not found in content", () => {
|
||||
it("#then should append the recovery reminder", async () => {
|
||||
const input = createInput("Edit")
|
||||
const output = createOutput("Error: oldString not found in content")
|
||||
|
||||
await hook["tool.execute.after"](input, output)
|
||||
|
||||
expect(output.output).toContain(EDIT_ERROR_REMINDER)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given Edit tool with multiple matches error", () => {
|
||||
describe("#when oldString found multiple times", () => {
|
||||
it("#then should append the recovery reminder", async () => {
|
||||
const input = createInput("Edit")
|
||||
const output = createOutput(
|
||||
"Error: oldString found multiple times and requires more code context to uniquely identify the intended match"
|
||||
)
|
||||
|
||||
await hook["tool.execute.after"](input, output)
|
||||
|
||||
expect(output.output).toContain(EDIT_ERROR_REMINDER)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given non-Edit tool", () => {
|
||||
describe("#when tool is not Edit", () => {
|
||||
it("#then should not modify output", async () => {
|
||||
const input = createInput("Read")
|
||||
const originalOutput = "some output"
|
||||
const output = createOutput(originalOutput)
|
||||
|
||||
await hook["tool.execute.after"](input, output)
|
||||
|
||||
expect(output.output).toBe(originalOutput)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given Edit tool with successful output", () => {
|
||||
describe("#when no error in output", () => {
|
||||
it("#then should not modify output", async () => {
|
||||
const input = createInput("Edit")
|
||||
const originalOutput = "File edited successfully"
|
||||
const output = createOutput(originalOutput)
|
||||
|
||||
await hook["tool.execute.after"](input, output)
|
||||
|
||||
expect(output.output).toBe(originalOutput)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given case insensitive tool name", () => {
|
||||
describe("#when tool is 'edit' lowercase", () => {
|
||||
it("#then should still detect and append reminder", async () => {
|
||||
const input = createInput("edit")
|
||||
const output = createOutput("oldString and newString must be different")
|
||||
|
||||
await hook["tool.execute.after"](input, output)
|
||||
|
||||
expect(output.output).toContain(EDIT_ERROR_REMINDER)
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("EDIT_ERROR_PATTERNS", () => {
|
||||
it("#then should contain all known Edit error patterns", () => {
|
||||
expect(EDIT_ERROR_PATTERNS).toContain("oldString and newString must be different")
|
||||
expect(EDIT_ERROR_PATTERNS).toContain("oldString not found")
|
||||
expect(EDIT_ERROR_PATTERNS).toContain("oldString found multiple times")
|
||||
})
|
||||
})
|
||||
})
|
||||
57
src/hooks/edit-error-recovery/index.ts
Normal file
57
src/hooks/edit-error-recovery/index.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
|
||||
/**
|
||||
* Known Edit tool error patterns that indicate the AI made a mistake
|
||||
*/
|
||||
export const EDIT_ERROR_PATTERNS = [
|
||||
"oldString and newString must be different",
|
||||
"oldString not found",
|
||||
"oldString found multiple times",
|
||||
] as const
|
||||
|
||||
/**
|
||||
* System reminder injected when Edit tool fails due to AI mistake
|
||||
* Short, direct, and commanding - forces immediate corrective action
|
||||
*/
|
||||
export const EDIT_ERROR_REMINDER = `
|
||||
[EDIT ERROR - IMMEDIATE ACTION REQUIRED]
|
||||
|
||||
You made an Edit mistake. STOP and do this NOW:
|
||||
|
||||
1. READ the file immediately to see its ACTUAL current state
|
||||
2. VERIFY what the content really looks like (your assumption was wrong)
|
||||
3. APOLOGIZE briefly to the user for the error
|
||||
4. CONTINUE with corrected action based on the real file content
|
||||
|
||||
DO NOT attempt another edit until you've read and verified the file state.
|
||||
`
|
||||
|
||||
/**
|
||||
* Detects Edit tool errors caused by AI mistakes and injects a recovery reminder
|
||||
*
|
||||
* This hook catches common Edit tool failures:
|
||||
* - oldString and newString must be different (trying to "edit" to same content)
|
||||
* - oldString not found (wrong assumption about file content)
|
||||
* - oldString found multiple times (ambiguous match, need more context)
|
||||
*
|
||||
* @see https://github.com/sst/opencode/issues/4718
|
||||
*/
|
||||
export function createEditErrorRecoveryHook(_ctx: PluginInput) {
|
||||
return {
|
||||
"tool.execute.after": async (
|
||||
input: { tool: string; sessionID: string; callID: string },
|
||||
output: { title: string; output: string; metadata: unknown }
|
||||
) => {
|
||||
if (input.tool.toLowerCase() !== "edit") return
|
||||
|
||||
const outputLower = output.output.toLowerCase()
|
||||
const hasEditError = EDIT_ERROR_PATTERNS.some((pattern) =>
|
||||
outputLower.includes(pattern.toLowerCase())
|
||||
)
|
||||
|
||||
if (hasEditError) {
|
||||
output.output += `\n${EDIT_ERROR_REMINDER}`
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -24,3 +24,4 @@ export { createEmptyMessageSanitizerHook } from "./empty-message-sanitizer";
|
||||
export { createThinkingBlockValidatorHook } from "./thinking-block-validator";
|
||||
export { createRalphLoopHook, type RalphLoopHook } from "./ralph-loop";
|
||||
export { createAutoSlashCommandHook } from "./auto-slash-command";
|
||||
export { createEditErrorRecoveryHook } from "./edit-error-recovery";
|
||||
|
||||
@@ -1,11 +1,92 @@
|
||||
export const CODE_BLOCK_PATTERN = /```[\s\S]*?```/g
|
||||
export const INLINE_CODE_PATTERN = /`[^`]+`/g
|
||||
|
||||
export const KEYWORD_DETECTORS: Array<{ pattern: RegExp; message: string }> = [
|
||||
// ULTRAWORK: ulw, ultrawork
|
||||
{
|
||||
pattern: /(ultrawork|ulw)/i,
|
||||
message: `<ultrawork-mode>
|
||||
const ULTRAWORK_PLANNER_SECTION = `## CRITICAL: YOU ARE A PLANNER, NOT AN IMPLEMENTER
|
||||
|
||||
**IDENTITY CONSTRAINT (NON-NEGOTIABLE):**
|
||||
You ARE the planner. You ARE NOT an implementer. You DO NOT write code. You DO NOT execute tasks.
|
||||
|
||||
**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 | - |
|
||||
|
||||
**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
|
||||
|
||||
**YOUR ONLY WRITABLE PATHS:**
|
||||
- \`.sisyphus/plans/*.md\` - Final work plans
|
||||
- \`.sisyphus/drafts/*.md\` - Working drafts during interview
|
||||
|
||||
**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."
|
||||
|
||||
---
|
||||
|
||||
## CONTEXT GATHERING (MANDATORY BEFORE PLANNING)
|
||||
|
||||
You ARE the planner. Your job: create bulletproof work plans.
|
||||
**Before drafting ANY plan, gather context via explore/librarian agents.**
|
||||
|
||||
### 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
|
||||
|
||||
### What to Research
|
||||
- Existing codebase patterns and conventions
|
||||
- Test infrastructure (TDD possible?)
|
||||
- External library APIs and constraints
|
||||
- Similar implementations in OSS (via librarian)
|
||||
|
||||
**NEVER plan blind. Context first, plan second.**`
|
||||
|
||||
/**
|
||||
* 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"
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
|
||||
if (isPlanner) {
|
||||
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.
|
||||
|
||||
${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.
|
||||
@@ -31,15 +112,66 @@ TELL THE USER WHAT AGENTS YOU WILL LEVERAGE NOW TO SATISFY USER'S REQUEST.
|
||||
3. Always Use Plan agent with gathered context to create detailed work breakdown
|
||||
4. Execute with continuous verification against original requirements
|
||||
|
||||
## TDD (if test infrastructure exists)
|
||||
## VERIFICATION GUARANTEE (NON-NEGOTIABLE)
|
||||
|
||||
1. Write spec (requirements)
|
||||
2. Write tests (failing)
|
||||
3. RED: tests fail
|
||||
4. Implement minimal code
|
||||
5. GREEN: tests pass
|
||||
6. Refactor if needed (must stay green)
|
||||
7. Next feature, repeat
|
||||
**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
|
||||
@@ -55,7 +187,13 @@ THE USER ASKED FOR X. DELIVER EXACTLY X. NOT A SUBSET. NOT A DEMO. NOT A STARTIN
|
||||
|
||||
---
|
||||
|
||||
`,
|
||||
`
|
||||
}
|
||||
|
||||
export const KEYWORD_DETECTORS: Array<{ pattern: RegExp; message: string | ((agentName?: string) => string) }> = [
|
||||
{
|
||||
pattern: /(ultrawork|ulw)/i,
|
||||
message: getUltraworkMessage,
|
||||
},
|
||||
// SEARCH: EN/KO/JP/CN/VN
|
||||
{
|
||||
|
||||
@@ -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 }))
|
||||
|
||||
125
src/hooks/keyword-detector/index.test.ts
Normal file
125
src/hooks/keyword-detector/index.test.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { describe, expect, test, beforeEach, afterEach, spyOn } from "bun:test"
|
||||
import { createKeywordDetectorHook } from "./index"
|
||||
import { setMainSession } from "../../features/claude-code-session-state"
|
||||
import * as sharedModule from "../../shared"
|
||||
|
||||
describe("keyword-detector session filtering", () => {
|
||||
let logCalls: Array<{ msg: string; data?: unknown }>
|
||||
|
||||
beforeEach(() => {
|
||||
setMainSession(undefined)
|
||||
logCalls = []
|
||||
spyOn(sharedModule, "log").mockImplementation((msg: string, data?: unknown) => {
|
||||
logCalls.push({ msg, data })
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
setMainSession(undefined)
|
||||
})
|
||||
|
||||
function createMockPluginInput(options: { toastCalls?: string[] } = {}) {
|
||||
const toastCalls = options.toastCalls ?? []
|
||||
return {
|
||||
client: {
|
||||
tui: {
|
||||
showToast: async (opts: any) => {
|
||||
toastCalls.push(opts.body.title)
|
||||
},
|
||||
},
|
||||
},
|
||||
} as any
|
||||
}
|
||||
|
||||
test("should skip non-ultrawork keywords in non-main session (using mainSessionID check)", async () => {
|
||||
// #given - main session is set, different session submits search keyword
|
||||
const mainSessionID = "main-123"
|
||||
const subagentSessionID = "subagent-456"
|
||||
setMainSession(mainSessionID)
|
||||
|
||||
const hook = createKeywordDetectorHook(createMockPluginInput())
|
||||
const output = {
|
||||
message: {} as Record<string, unknown>,
|
||||
parts: [{ type: "text", text: "search mode 찾아줘" }],
|
||||
}
|
||||
|
||||
// #when - non-main session triggers keyword detection
|
||||
await hook["chat.message"](
|
||||
{ sessionID: subagentSessionID },
|
||||
output
|
||||
)
|
||||
|
||||
// #then - search keyword should be filtered out based on mainSessionID comparison
|
||||
const skipLog = logCalls.find(c => c.msg.includes("Skipping non-ultrawork keywords in non-main session"))
|
||||
expect(skipLog).toBeDefined()
|
||||
})
|
||||
|
||||
test("should allow ultrawork keywords in non-main session", async () => {
|
||||
// #given - main session is set, different session submits ultrawork keyword
|
||||
const mainSessionID = "main-123"
|
||||
const subagentSessionID = "subagent-456"
|
||||
setMainSession(mainSessionID)
|
||||
|
||||
const toastCalls: string[] = []
|
||||
const hook = createKeywordDetectorHook(createMockPluginInput({ toastCalls }))
|
||||
const output = {
|
||||
message: {} as Record<string, unknown>,
|
||||
parts: [{ type: "text", text: "ultrawork mode" }],
|
||||
}
|
||||
|
||||
// #when - non-main session triggers ultrawork keyword
|
||||
await hook["chat.message"](
|
||||
{ sessionID: subagentSessionID },
|
||||
output
|
||||
)
|
||||
|
||||
// #then - ultrawork should still work (variant set to max)
|
||||
expect(output.message.variant).toBe("max")
|
||||
expect(toastCalls).toContain("Ultrawork Mode Activated")
|
||||
})
|
||||
|
||||
test("should allow all keywords in main session", async () => {
|
||||
// #given - main session submits search keyword
|
||||
const mainSessionID = "main-123"
|
||||
setMainSession(mainSessionID)
|
||||
|
||||
const hook = createKeywordDetectorHook(createMockPluginInput())
|
||||
const output = {
|
||||
message: {} as Record<string, unknown>,
|
||||
parts: [{ type: "text", text: "search mode 찾아줘" }],
|
||||
}
|
||||
|
||||
// #when - main session triggers keyword detection
|
||||
await hook["chat.message"](
|
||||
{ sessionID: mainSessionID },
|
||||
output
|
||||
)
|
||||
|
||||
// #then - search keyword should be detected (output unchanged but detection happens)
|
||||
// Note: search keywords don't set variant, they inject messages via context-injector
|
||||
// This test verifies the detection logic runs without filtering
|
||||
expect(output.message.variant).toBeUndefined() // search doesn't set variant
|
||||
})
|
||||
|
||||
test("should allow all keywords when mainSessionID is not set", async () => {
|
||||
// #given - no main session set (early startup or standalone mode)
|
||||
setMainSession(undefined)
|
||||
|
||||
const toastCalls: string[] = []
|
||||
const hook = createKeywordDetectorHook(createMockPluginInput({ toastCalls }))
|
||||
const output = {
|
||||
message: {} as Record<string, unknown>,
|
||||
parts: [{ type: "text", text: "ultrawork search" }],
|
||||
}
|
||||
|
||||
// #when - any session triggers keyword detection
|
||||
await hook["chat.message"](
|
||||
{ sessionID: "any-session" },
|
||||
output
|
||||
)
|
||||
|
||||
// #then - all keywords should work
|
||||
expect(output.message.variant).toBe("max")
|
||||
expect(toastCalls).toContain("Ultrawork Mode Activated")
|
||||
})
|
||||
})
|
||||
@@ -1,15 +1,12 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { detectKeywords, detectKeywordsWithType, extractPromptText } from "./detector"
|
||||
import { detectKeywordsWithType, extractPromptText, removeCodeBlocks } from "./detector"
|
||||
import { log } from "../../shared"
|
||||
import { injectHookMessage } from "../../features/hook-message-injector"
|
||||
import { getMainSessionID } from "../../features/claude-code-session-state"
|
||||
|
||||
export * from "./detector"
|
||||
export * from "./constants"
|
||||
export * from "./types"
|
||||
|
||||
const sessionFirstMessageProcessed = new Set<string>()
|
||||
const sessionUltraworkNotified = new Set<string>()
|
||||
|
||||
export function createKeywordDetectorHook(ctx: PluginInput) {
|
||||
return {
|
||||
"chat.message": async (
|
||||
@@ -24,65 +21,53 @@ export function createKeywordDetectorHook(ctx: PluginInput) {
|
||||
parts: Array<{ type: string; text?: string; [key: string]: unknown }>
|
||||
}
|
||||
): Promise<void> => {
|
||||
const isFirstMessage = !sessionFirstMessageProcessed.has(input.sessionID)
|
||||
sessionFirstMessageProcessed.add(input.sessionID)
|
||||
|
||||
const promptText = extractPromptText(output.parts)
|
||||
const detectedKeywords = detectKeywordsWithType(promptText)
|
||||
const messages = detectedKeywords.map((k) => k.message)
|
||||
let detectedKeywords = detectKeywordsWithType(removeCodeBlocks(promptText), input.agent)
|
||||
|
||||
if (messages.length === 0) {
|
||||
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 && !sessionUltraworkNotified.has(input.sessionID)) {
|
||||
sessionUltraworkNotified.add(input.sessionID)
|
||||
if (hasUltrawork) {
|
||||
log(`[keyword-detector] Ultrawork mode activated`, { sessionID: input.sessionID })
|
||||
|
||||
ctx.client.tui.showToast({
|
||||
body: {
|
||||
title: "Ultrawork Mode Activated",
|
||||
message: "Maximum precision engaged. All agents at your disposal.",
|
||||
variant: "success" as const,
|
||||
duration: 3000,
|
||||
},
|
||||
}).catch((err) => log(`[keyword-detector] Failed to show toast`, { error: err, sessionID: input.sessionID }))
|
||||
|
||||
output.message.variant = "max"
|
||||
|
||||
ctx.client.tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: "Ultrawork Mode Activated",
|
||||
message: "Maximum precision engaged. All agents at your disposal.",
|
||||
variant: "success" as const,
|
||||
duration: 3000,
|
||||
},
|
||||
})
|
||||
.catch((err) =>
|
||||
log(`[keyword-detector] Failed to show toast`, { error: err, sessionID: input.sessionID })
|
||||
)
|
||||
}
|
||||
|
||||
const context = messages.join("\n")
|
||||
|
||||
// First message: transform parts directly (for title generation compatibility)
|
||||
if (isFirstMessage) {
|
||||
log(`Keywords detected on first message, transforming parts directly`, { sessionID: input.sessionID, keywordCount: messages.length })
|
||||
const idx = output.parts.findIndex((p) => p.type === "text" && p.text)
|
||||
if (idx >= 0) {
|
||||
output.parts[idx].text = `${context}\n\n---\n\n${output.parts[idx].text ?? ""}`
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Subsequent messages: inject as separate message
|
||||
log(`Keywords detected: ${messages.length}`, { sessionID: input.sessionID })
|
||||
|
||||
const message = output.message as {
|
||||
agent?: string
|
||||
model?: { modelID?: string; providerID?: string }
|
||||
path?: { cwd?: string; root?: string }
|
||||
tools?: Record<string, boolean>
|
||||
}
|
||||
|
||||
log(`[keyword-detector] Injecting context for ${messages.length} keywords`, { sessionID: input.sessionID, contextLength: context.length })
|
||||
const success = injectHookMessage(input.sessionID, context, {
|
||||
agent: message.agent,
|
||||
model: message.model,
|
||||
path: message.path,
|
||||
tools: message.tools,
|
||||
log(`[keyword-detector] Detected ${detectedKeywords.length} keywords`, {
|
||||
sessionID: input.sessionID,
|
||||
types: detectedKeywords.map((k) => k.type),
|
||||
})
|
||||
|
||||
if (success) {
|
||||
log("Keyword context injected", { sessionID: input.sessionID })
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
150
src/hooks/non-interactive-env/index.test.ts
Normal file
150
src/hooks/non-interactive-env/index.test.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { describe, test, expect } from "bun:test"
|
||||
import { createNonInteractiveEnvHook, NON_INTERACTIVE_ENV } from "./index"
|
||||
|
||||
describe("non-interactive-env hook", () => {
|
||||
const mockCtx = {} as Parameters<typeof createNonInteractiveEnvHook>[0]
|
||||
|
||||
describe("git command modification", () => {
|
||||
test("#given git command #when hook executes #then prepends export statement", async () => {
|
||||
const hook = createNonInteractiveEnvHook(mockCtx)
|
||||
const output: { args: Record<string, unknown>; message?: string } = {
|
||||
args: { command: "git commit -m 'test'" },
|
||||
}
|
||||
|
||||
await hook["tool.execute.before"](
|
||||
{ tool: "bash", sessionID: "test", callID: "1" },
|
||||
output
|
||||
)
|
||||
|
||||
const cmd = output.args.command as string
|
||||
expect(cmd).toStartWith("export ")
|
||||
expect(cmd).toContain("GIT_EDITOR=:")
|
||||
expect(cmd).toContain("EDITOR=:")
|
||||
expect(cmd).toContain("PAGER=cat")
|
||||
expect(cmd).toContain("; git commit -m 'test'")
|
||||
})
|
||||
|
||||
test("#given chained git commands #when hook executes #then export applies to all", async () => {
|
||||
const hook = createNonInteractiveEnvHook(mockCtx)
|
||||
const output: { args: Record<string, unknown>; message?: string } = {
|
||||
args: { command: "git add file && git rebase --continue" },
|
||||
}
|
||||
|
||||
await hook["tool.execute.before"](
|
||||
{ tool: "bash", sessionID: "test", callID: "1" },
|
||||
output
|
||||
)
|
||||
|
||||
const cmd = output.args.command as string
|
||||
expect(cmd).toStartWith("export ")
|
||||
expect(cmd).toContain("; git add file && git rebase --continue")
|
||||
})
|
||||
|
||||
test("#given non-git bash command #when hook executes #then command unchanged", async () => {
|
||||
const hook = createNonInteractiveEnvHook(mockCtx)
|
||||
const output: { args: Record<string, unknown>; message?: string } = {
|
||||
args: { command: "ls -la" },
|
||||
}
|
||||
|
||||
await hook["tool.execute.before"](
|
||||
{ tool: "bash", sessionID: "test", callID: "1" },
|
||||
output
|
||||
)
|
||||
|
||||
expect(output.args.command).toBe("ls -la")
|
||||
})
|
||||
|
||||
test("#given non-bash tool #when hook executes #then command unchanged", async () => {
|
||||
const hook = createNonInteractiveEnvHook(mockCtx)
|
||||
const output: { args: Record<string, unknown>; message?: string } = {
|
||||
args: { command: "git status" },
|
||||
}
|
||||
|
||||
await hook["tool.execute.before"](
|
||||
{ tool: "Read", sessionID: "test", callID: "1" },
|
||||
output
|
||||
)
|
||||
|
||||
expect(output.args.command).toBe("git status")
|
||||
})
|
||||
|
||||
test("#given empty command #when hook executes #then no error", async () => {
|
||||
const hook = createNonInteractiveEnvHook(mockCtx)
|
||||
const output: { args: Record<string, unknown>; message?: string } = {
|
||||
args: {},
|
||||
}
|
||||
|
||||
await hook["tool.execute.before"](
|
||||
{ tool: "bash", sessionID: "test", callID: "1" },
|
||||
output
|
||||
)
|
||||
|
||||
expect(output.args.command).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("shell escaping", () => {
|
||||
test("#given git command #when building prefix #then VISUAL properly escaped", async () => {
|
||||
const hook = createNonInteractiveEnvHook(mockCtx)
|
||||
const output: { args: Record<string, unknown>; message?: string } = {
|
||||
args: { command: "git status" },
|
||||
}
|
||||
|
||||
await hook["tool.execute.before"](
|
||||
{ tool: "bash", sessionID: "test", callID: "1" },
|
||||
output
|
||||
)
|
||||
|
||||
const cmd = output.args.command as string
|
||||
expect(cmd).toContain("VISUAL=''")
|
||||
})
|
||||
|
||||
test("#given git command #when building prefix #then all NON_INTERACTIVE_ENV vars included", async () => {
|
||||
const hook = createNonInteractiveEnvHook(mockCtx)
|
||||
const output: { args: Record<string, unknown>; message?: string } = {
|
||||
args: { command: "git log" },
|
||||
}
|
||||
|
||||
await hook["tool.execute.before"](
|
||||
{ tool: "bash", sessionID: "test", callID: "1" },
|
||||
output
|
||||
)
|
||||
|
||||
const cmd = output.args.command as string
|
||||
for (const key of Object.keys(NON_INTERACTIVE_ENV)) {
|
||||
expect(cmd).toContain(`${key}=`)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe("banned command detection", () => {
|
||||
test("#given vim command #when hook executes #then warning message set", async () => {
|
||||
const hook = createNonInteractiveEnvHook(mockCtx)
|
||||
const output: { args: Record<string, unknown>; message?: string } = {
|
||||
args: { command: "vim file.txt" },
|
||||
}
|
||||
|
||||
await hook["tool.execute.before"](
|
||||
{ tool: "bash", sessionID: "test", callID: "1" },
|
||||
output
|
||||
)
|
||||
|
||||
expect(output.message).toContain("vim")
|
||||
expect(output.message).toContain("interactive")
|
||||
})
|
||||
|
||||
test("#given safe command #when hook executes #then no warning", async () => {
|
||||
const hook = createNonInteractiveEnvHook(mockCtx)
|
||||
const output: { args: Record<string, unknown>; message?: string } = {
|
||||
args: { command: "ls -la" },
|
||||
}
|
||||
|
||||
await hook["tool.execute.before"](
|
||||
{ tool: "bash", sessionID: "test", callID: "1" },
|
||||
output
|
||||
)
|
||||
|
||||
expect(output.message).toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -19,6 +19,35 @@ function detectBannedCommand(command: string): string | undefined {
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Shell-escape a value for use in VAR=value prefix.
|
||||
* Wraps in single quotes if contains special chars.
|
||||
*/
|
||||
function shellEscape(value: string): string {
|
||||
// Empty string needs quotes
|
||||
if (value === "") return "''"
|
||||
// If contains special chars, wrap in single quotes (escape existing single quotes)
|
||||
if (/[^a-zA-Z0-9_\-.:\/]/.test(value)) {
|
||||
return `'${value.replace(/'/g, "'\\''")}'`
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* Build export statement for environment variables.
|
||||
* Uses `export VAR1=val1 VAR2=val2;` format to ensure variables
|
||||
* apply to ALL commands in a chain (e.g., `cmd1 && cmd2`).
|
||||
*
|
||||
* Previous approach used VAR=value prefix which only applies to the first command.
|
||||
* OpenCode's bash tool ignores args.env, so we must prepend to command.
|
||||
*/
|
||||
function buildEnvPrefix(env: Record<string, string>): string {
|
||||
const exports = Object.entries(env)
|
||||
.map(([key, value]) => `${key}=${shellEscape(value)}`)
|
||||
.join(" ")
|
||||
return `export ${exports};`
|
||||
}
|
||||
|
||||
export function createNonInteractiveEnvHook(_ctx: PluginInput) {
|
||||
return {
|
||||
"tool.execute.before": async (
|
||||
@@ -34,20 +63,27 @@ export function createNonInteractiveEnvHook(_ctx: PluginInput) {
|
||||
return
|
||||
}
|
||||
|
||||
output.args.env = {
|
||||
...process.env,
|
||||
...(output.args.env as Record<string, string> | undefined),
|
||||
...NON_INTERACTIVE_ENV,
|
||||
}
|
||||
|
||||
const bannedCmd = detectBannedCommand(command)
|
||||
if (bannedCmd) {
|
||||
output.message = `⚠️ Warning: '${bannedCmd}' is an interactive command that may hang in non-interactive environments.`
|
||||
}
|
||||
|
||||
log(`[${HOOK_NAME}] Set non-interactive environment variables`, {
|
||||
// Only prepend env vars for git commands (editor blocking, pager, etc.)
|
||||
const isGitCommand = /\bgit\b/.test(command)
|
||||
if (!isGitCommand) {
|
||||
return
|
||||
}
|
||||
|
||||
// OpenCode's bash tool uses hardcoded `...process.env` in spawn(),
|
||||
// ignoring any args.env we might set. Prepend export statement to command.
|
||||
// Uses `export VAR=val;` format to ensure variables apply to ALL commands
|
||||
// in a chain (e.g., `git add file && git rebase --continue`).
|
||||
const envPrefix = buildEnvPrefix(NON_INTERACTIVE_ENV)
|
||||
output.args.command = `${envPrefix} ${command}`
|
||||
|
||||
log(`[${HOOK_NAME}] Prepended non-interactive env vars to git command`, {
|
||||
sessionID: input.sessionID,
|
||||
env: NON_INTERACTIVE_ENV,
|
||||
envPrefix,
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
@@ -48,7 +48,11 @@ interface MessageWrapper {
|
||||
}
|
||||
|
||||
const CLAUDE_MODEL_PATTERN = /claude-(opus|sonnet|haiku)/i
|
||||
const CLAUDE_DEFAULT_CONTEXT_LIMIT = 200_000
|
||||
const CLAUDE_DEFAULT_CONTEXT_LIMIT =
|
||||
process.env.ANTHROPIC_1M_CONTEXT === "true" ||
|
||||
process.env.VERTEX_ANTHROPIC_1M_CONTEXT === "true"
|
||||
? 1_000_000
|
||||
: 200_000
|
||||
|
||||
function isSupportedModel(modelID: string): boolean {
|
||||
return CLAUDE_MODEL_PATTERN.test(modelID)
|
||||
|
||||
@@ -302,6 +302,77 @@ describe("ralph-loop", () => {
|
||||
expect(promptCalls.length).toBe(0)
|
||||
})
|
||||
|
||||
test("should clear orphaned state when original session no longer exists", async () => {
|
||||
// #given - state file exists from a previous session that no longer exists
|
||||
const state: RalphLoopState = {
|
||||
active: true,
|
||||
iteration: 3,
|
||||
max_iterations: 50,
|
||||
completion_promise: "DONE",
|
||||
started_at: "2025-12-30T01:00:00Z",
|
||||
prompt: "Build something",
|
||||
session_id: "orphaned-session-999", // This session no longer exists
|
||||
}
|
||||
writeState(TEST_DIR, state)
|
||||
|
||||
// Mock sessionExists to return false for the orphaned session
|
||||
const hook = createRalphLoopHook(createMockPluginInput(), {
|
||||
checkSessionExists: async (sessionID: string) => {
|
||||
// Orphaned session doesn't exist, current session does
|
||||
return sessionID !== "orphaned-session-999"
|
||||
},
|
||||
})
|
||||
|
||||
// #when - a new session goes idle (different from the orphaned session in state)
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: "new-session-456" },
|
||||
},
|
||||
})
|
||||
|
||||
// #then - orphaned state should be cleared
|
||||
expect(hook.getState()).toBeNull()
|
||||
// #then - no continuation injected (state was cleared, not resumed)
|
||||
expect(promptCalls.length).toBe(0)
|
||||
})
|
||||
|
||||
test("should NOT clear state when original session still exists (different active session)", async () => {
|
||||
// #given - state file exists from a session that still exists
|
||||
const state: RalphLoopState = {
|
||||
active: true,
|
||||
iteration: 2,
|
||||
max_iterations: 50,
|
||||
completion_promise: "DONE",
|
||||
started_at: "2025-12-30T01:00:00Z",
|
||||
prompt: "Build something",
|
||||
session_id: "active-session-123", // This session still exists
|
||||
}
|
||||
writeState(TEST_DIR, state)
|
||||
|
||||
// Mock sessionExists to return true for the active session
|
||||
const hook = createRalphLoopHook(createMockPluginInput(), {
|
||||
checkSessionExists: async (sessionID: string) => {
|
||||
// Original session still exists
|
||||
return sessionID === "active-session-123" || sessionID === "new-session-456"
|
||||
},
|
||||
})
|
||||
|
||||
// #when - a different session goes idle
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "session.idle",
|
||||
properties: { sessionID: "new-session-456" },
|
||||
},
|
||||
})
|
||||
|
||||
// #then - state should NOT be cleared (original session still active)
|
||||
expect(hook.getState()).not.toBeNull()
|
||||
expect(hook.getState()?.session_id).toBe("active-session-123")
|
||||
// #then - no continuation injected (it's a different session's loop)
|
||||
expect(promptCalls.length).toBe(0)
|
||||
})
|
||||
|
||||
test("should use default config values", () => {
|
||||
// #given - hook with config
|
||||
const hook = createRalphLoopHook(createMockPluginInput(), {
|
||||
@@ -423,5 +494,162 @@ describe("ralph-loop", () => {
|
||||
expect(promptCalls[0].text).toContain("Create a calculator app")
|
||||
expect(promptCalls[0].text).toContain("<promise>CALCULATOR_DONE</promise>")
|
||||
})
|
||||
|
||||
test("should clear loop state on user abort (MessageAbortedError)", async () => {
|
||||
// #given - active loop
|
||||
const hook = createRalphLoopHook(createMockPluginInput())
|
||||
hook.startLoop("session-123", "Build something")
|
||||
expect(hook.getState()).not.toBeNull()
|
||||
|
||||
// #when - user aborts (Ctrl+C)
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "session.error",
|
||||
properties: {
|
||||
sessionID: "session-123",
|
||||
error: { name: "MessageAbortedError", message: "User aborted" },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// #then - loop state should be cleared immediately
|
||||
expect(hook.getState()).toBeNull()
|
||||
})
|
||||
|
||||
test("should NOT set recovery mode on user abort", async () => {
|
||||
// #given - active loop
|
||||
const hook = createRalphLoopHook(createMockPluginInput())
|
||||
hook.startLoop("session-123", "Build something")
|
||||
|
||||
// #when - user aborts (Ctrl+C)
|
||||
await hook.event({
|
||||
event: {
|
||||
type: "session.error",
|
||||
properties: {
|
||||
sessionID: "session-123",
|
||||
error: { name: "MessageAbortedError" },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Start a new loop
|
||||
hook.startLoop("session-123", "New task")
|
||||
|
||||
// #when - session goes idle immediately (should work, no recovery mode)
|
||||
await hook.event({
|
||||
event: { type: "session.idle", properties: { sessionID: "session-123" } },
|
||||
})
|
||||
|
||||
// #then - continuation should be injected (not blocked by recovery)
|
||||
expect(promptCalls.length).toBe(1)
|
||||
})
|
||||
|
||||
test("should only check LAST assistant message for completion", async () => {
|
||||
// #given - multiple assistant messages, only first has completion promise
|
||||
mockSessionMessages = [
|
||||
{ info: { role: "user" }, parts: [{ type: "text", text: "Start task" }] },
|
||||
{ info: { role: "assistant" }, parts: [{ type: "text", text: "I'll work on it. <promise>DONE</promise>" }] },
|
||||
{ info: { role: "user" }, parts: [{ type: "text", text: "Continue" }] },
|
||||
{ info: { role: "assistant" }, parts: [{ type: "text", text: "Working on more features..." }] },
|
||||
]
|
||||
const hook = createRalphLoopHook(createMockPluginInput(), {
|
||||
getTranscriptPath: () => join(TEST_DIR, "nonexistent.jsonl"),
|
||||
})
|
||||
hook.startLoop("session-123", "Build something", { completionPromise: "DONE" })
|
||||
|
||||
// #when - session goes idle
|
||||
await hook.event({
|
||||
event: { type: "session.idle", properties: { sessionID: "session-123" } },
|
||||
})
|
||||
|
||||
// #then - loop should continue (last message has no completion promise)
|
||||
expect(promptCalls.length).toBe(1)
|
||||
expect(hook.getState()?.iteration).toBe(2)
|
||||
})
|
||||
|
||||
test("should detect completion only in LAST assistant message", async () => {
|
||||
// #given - last assistant message has completion promise
|
||||
mockSessionMessages = [
|
||||
{ info: { role: "user" }, parts: [{ type: "text", text: "Start task" }] },
|
||||
{ info: { role: "assistant" }, parts: [{ type: "text", text: "Starting work..." }] },
|
||||
{ info: { role: "user" }, parts: [{ type: "text", text: "Continue" }] },
|
||||
{ info: { role: "assistant" }, parts: [{ type: "text", text: "Task complete! <promise>DONE</promise>" }] },
|
||||
]
|
||||
const hook = createRalphLoopHook(createMockPluginInput(), {
|
||||
getTranscriptPath: () => join(TEST_DIR, "nonexistent.jsonl"),
|
||||
})
|
||||
hook.startLoop("session-123", "Build something", { completionPromise: "DONE" })
|
||||
|
||||
// #when - session goes idle
|
||||
await hook.event({
|
||||
event: { type: "session.idle", properties: { sessionID: "session-123" } },
|
||||
})
|
||||
|
||||
// #then - loop should complete (last message has completion promise)
|
||||
expect(promptCalls.length).toBe(0)
|
||||
expect(toastCalls.some((t) => t.title === "Ralph Loop Complete!")).toBe(true)
|
||||
expect(hook.getState()).toBeNull()
|
||||
})
|
||||
|
||||
test("should check transcript BEFORE API to optimize performance", async () => {
|
||||
// #given - transcript has completion promise
|
||||
const transcriptPath = join(TEST_DIR, "transcript.jsonl")
|
||||
writeFileSync(transcriptPath, JSON.stringify({ content: "<promise>DONE</promise>" }))
|
||||
mockSessionMessages = [
|
||||
{ info: { role: "assistant" }, parts: [{ type: "text", text: "No promise here" }] },
|
||||
]
|
||||
const hook = createRalphLoopHook(createMockPluginInput(), {
|
||||
getTranscriptPath: () => transcriptPath,
|
||||
})
|
||||
hook.startLoop("session-123", "Build something", { completionPromise: "DONE" })
|
||||
|
||||
// #when - session goes idle
|
||||
await hook.event({
|
||||
event: { type: "session.idle", properties: { sessionID: "session-123" } },
|
||||
})
|
||||
|
||||
// #then - should complete via transcript (API not called when transcript succeeds)
|
||||
expect(promptCalls.length).toBe(0)
|
||||
expect(hook.getState()).toBeNull()
|
||||
// API should NOT be called since transcript found completion
|
||||
expect(messagesCalls.length).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe("API timeout protection", () => {
|
||||
test("should not hang when session.messages() times out", async () => {
|
||||
// #given - slow API that takes longer than timeout
|
||||
const slowMock = {
|
||||
...createMockPluginInput(),
|
||||
client: {
|
||||
...createMockPluginInput().client,
|
||||
session: {
|
||||
...createMockPluginInput().client.session,
|
||||
messages: async () => {
|
||||
// Simulate slow API (would hang without timeout)
|
||||
await new Promise((resolve) => setTimeout(resolve, 10000))
|
||||
return { data: [] }
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
const hook = createRalphLoopHook(slowMock as any, {
|
||||
getTranscriptPath: () => join(TEST_DIR, "nonexistent.jsonl"),
|
||||
apiTimeout: 100, // 100ms timeout for test
|
||||
})
|
||||
hook.startLoop("session-123", "Build something")
|
||||
|
||||
// #when - session goes idle (API will timeout)
|
||||
const startTime = Date.now()
|
||||
await hook.event({
|
||||
event: { type: "session.idle", properties: { sessionID: "session-123" } },
|
||||
})
|
||||
const elapsed = Date.now() - startTime
|
||||
|
||||
// #then - should complete within timeout + buffer (not hang for 10s)
|
||||
expect(elapsed).toBeLessThan(500)
|
||||
// #then - loop should continue (API timeout = no completion detected)
|
||||
expect(promptCalls.length).toBe(1)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -53,6 +53,8 @@ export interface RalphLoopHook {
|
||||
getState: () => RalphLoopState | null
|
||||
}
|
||||
|
||||
const DEFAULT_API_TIMEOUT = 3000
|
||||
|
||||
export function createRalphLoopHook(
|
||||
ctx: PluginInput,
|
||||
options?: RalphLoopOptions
|
||||
@@ -61,6 +63,8 @@ export function createRalphLoopHook(
|
||||
const config = options?.config
|
||||
const stateDir = config?.state_dir
|
||||
const getTranscriptPath = options?.getTranscriptPath ?? getDefaultTranscriptPath
|
||||
const apiTimeout = options?.apiTimeout ?? DEFAULT_API_TIMEOUT
|
||||
const checkSessionExists = options?.checkSessionExists
|
||||
|
||||
function getSessionState(sessionID: string): SessionState {
|
||||
let state = sessions.get(sessionID)
|
||||
@@ -97,32 +101,34 @@ export function createRalphLoopHook(
|
||||
promise: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const response = await ctx.client.session.messages({
|
||||
path: { id: sessionID },
|
||||
query: { directory: ctx.directory },
|
||||
})
|
||||
const response = await Promise.race([
|
||||
ctx.client.session.messages({
|
||||
path: { id: sessionID },
|
||||
query: { directory: ctx.directory },
|
||||
}),
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(() => reject(new Error("API timeout")), apiTimeout)
|
||||
),
|
||||
])
|
||||
|
||||
const messages = (response as { data?: unknown[] }).data ?? []
|
||||
|
||||
if (!Array.isArray(messages)) return false
|
||||
|
||||
const assistantMessages = (messages as OpenCodeSessionMessage[]).filter(
|
||||
(msg) => msg.info?.role === "assistant"
|
||||
)
|
||||
const lastAssistant = assistantMessages[assistantMessages.length - 1]
|
||||
if (!lastAssistant?.parts) return false
|
||||
|
||||
const pattern = new RegExp(`<promise>\\s*${escapeRegex(promise)}\\s*</promise>`, "is")
|
||||
const responseText = lastAssistant.parts
|
||||
.filter((p) => p.type === "text")
|
||||
.map((p) => p.text ?? "")
|
||||
.join("\n")
|
||||
|
||||
for (const msg of messages as OpenCodeSessionMessage[]) {
|
||||
if (msg.info?.role !== "assistant") continue
|
||||
|
||||
for (const part of msg.parts || []) {
|
||||
if (part.type === "text" && part.text) {
|
||||
if (pattern.test(part.text)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
return pattern.test(responseText)
|
||||
} catch (err) {
|
||||
log(`[${HOOK_NAME}] Failed to fetch session messages`, { sessionID, error: String(err) })
|
||||
log(`[${HOOK_NAME}] Session messages check failed`, { sessionID, error: String(err) })
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -194,23 +200,40 @@ export function createRalphLoopHook(
|
||||
}
|
||||
|
||||
if (state.session_id && state.session_id !== sessionID) {
|
||||
if (checkSessionExists) {
|
||||
try {
|
||||
const originalSessionExists = await checkSessionExists(state.session_id)
|
||||
if (!originalSessionExists) {
|
||||
clearState(ctx.directory, stateDir)
|
||||
log(`[${HOOK_NAME}] Cleared orphaned state from deleted session`, {
|
||||
orphanedSessionId: state.session_id,
|
||||
currentSessionId: sessionID,
|
||||
})
|
||||
return
|
||||
}
|
||||
} catch (err) {
|
||||
log(`[${HOOK_NAME}] Failed to check session existence`, {
|
||||
sessionId: state.session_id,
|
||||
error: String(err),
|
||||
})
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const completionDetectedViaApi = await detectCompletionInSessionMessages(
|
||||
sessionID,
|
||||
state.completion_promise
|
||||
)
|
||||
|
||||
const transcriptPath = getTranscriptPath(sessionID)
|
||||
const completionDetectedViaTranscript = detectCompletionPromise(transcriptPath, state.completion_promise)
|
||||
|
||||
if (completionDetectedViaApi || completionDetectedViaTranscript) {
|
||||
const completionDetectedViaApi = completionDetectedViaTranscript
|
||||
? false
|
||||
: await detectCompletionInSessionMessages(sessionID, state.completion_promise)
|
||||
|
||||
if (completionDetectedViaTranscript || completionDetectedViaApi) {
|
||||
log(`[${HOOK_NAME}] Completion detected!`, {
|
||||
sessionID,
|
||||
iteration: state.iteration,
|
||||
promise: state.completion_promise,
|
||||
detectedVia: completionDetectedViaApi ? "session_messages_api" : "transcript_file",
|
||||
detectedVia: completionDetectedViaTranscript ? "transcript_file" : "session_messages_api",
|
||||
})
|
||||
clearState(ctx.directory, stateDir)
|
||||
|
||||
@@ -308,6 +331,20 @@ export function createRalphLoopHook(
|
||||
|
||||
if (event.type === "session.error") {
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
const error = props?.error as { name?: string } | undefined
|
||||
|
||||
if (error?.name === "MessageAbortedError") {
|
||||
if (sessionID) {
|
||||
const state = readState(ctx.directory, stateDir)
|
||||
if (state?.session_id === sessionID) {
|
||||
clearState(ctx.directory, stateDir)
|
||||
log(`[${HOOK_NAME}] User aborted, loop cleared`, { sessionID })
|
||||
}
|
||||
sessions.delete(sessionID)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (sessionID) {
|
||||
const sessionState = getSessionState(sessionID)
|
||||
sessionState.isRecovering = true
|
||||
|
||||
@@ -13,4 +13,6 @@ export interface RalphLoopState {
|
||||
export interface RalphLoopOptions {
|
||||
config?: RalphLoopConfig
|
||||
getTranscriptPath?: (sessionId: string) => string
|
||||
apiTimeout?: number
|
||||
checkSessionExists?: (sessionId: string) => Promise<boolean>
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { describe, expect, test, beforeEach, afterEach, spyOn } from "bun:test"
|
||||
import { describe, expect, test, beforeEach, afterEach, spyOn, mock } from "bun:test"
|
||||
import { EventEmitter } from "node:events"
|
||||
import * as childProcess from "node:child_process"
|
||||
|
||||
import { createSessionNotification } from "./session-notification"
|
||||
import { setMainSession, subagentSessions } from "../features/claude-code-session-state"
|
||||
@@ -6,20 +8,11 @@ import * as utils from "./session-notification-utils"
|
||||
|
||||
describe("session-notification", () => {
|
||||
let notificationCalls: string[]
|
||||
let spawnMock: ReturnType<typeof spyOn>
|
||||
|
||||
function createMockPluginInput() {
|
||||
return {
|
||||
$: async (cmd: TemplateStringsArray | string, ...values: any[]) => {
|
||||
// #given - track notification commands (osascript, notify-send, powershell)
|
||||
const cmdStr = typeof cmd === "string"
|
||||
? cmd
|
||||
: cmd.reduce((acc, part, i) => acc + part + (values[i] ?? ""), "")
|
||||
|
||||
if (cmdStr.includes("osascript") || cmdStr.includes("notify-send") || cmdStr.includes("powershell")) {
|
||||
notificationCalls.push(cmdStr)
|
||||
}
|
||||
return { stdout: "", stderr: "", exitCode: 0 }
|
||||
},
|
||||
$: async () => ({ stdout: "", stderr: "", exitCode: 0 }),
|
||||
client: {
|
||||
session: {
|
||||
todo: async () => ({ data: [] }),
|
||||
@@ -32,6 +25,18 @@ describe("session-notification", () => {
|
||||
beforeEach(() => {
|
||||
notificationCalls = []
|
||||
|
||||
// Mock spawn to track notification commands
|
||||
// Uses node:child_process.spawn instead of Bun shell to avoid GC crash
|
||||
spawnMock = spyOn(childProcess, "spawn").mockImplementation(((cmd: string, args?: string[]) => {
|
||||
// Track notification commands (osascript, notify-send, powershell)
|
||||
if (cmd.includes("osascript") || cmd.includes("notify-send") || cmd.includes("powershell")) {
|
||||
notificationCalls.push(`${cmd} ${(args ?? []).join(" ")}`)
|
||||
}
|
||||
const emitter = new EventEmitter()
|
||||
setTimeout(() => emitter.emit("close", 0), 0)
|
||||
return emitter as any
|
||||
}) as typeof childProcess.spawn)
|
||||
|
||||
spyOn(utils, "getOsascriptPath").mockResolvedValue("/usr/bin/osascript")
|
||||
spyOn(utils, "getNotifySendPath").mockResolvedValue("/usr/bin/notify-send")
|
||||
spyOn(utils, "getPowershellPath").mockResolvedValue("powershell")
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { platform } from "os"
|
||||
import { spawn } from "node:child_process"
|
||||
import { subagentSessions, getMainSessionID } from "../features/claude-code-session-state"
|
||||
import {
|
||||
getOsascriptPath,
|
||||
@@ -11,6 +12,21 @@ import {
|
||||
startBackgroundCheck,
|
||||
} from "./session-notification-utils"
|
||||
|
||||
/**
|
||||
* Execute a command using node:child_process instead of Bun shell.
|
||||
* This avoids Bun's ShellInterpreter GC bug on Windows (oven-sh/bun#23177, #24368).
|
||||
*/
|
||||
function execCommand(command: string, args: string[]): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const proc = spawn(command, args, {
|
||||
stdio: "ignore",
|
||||
detached: false,
|
||||
})
|
||||
proc.on("close", () => resolve())
|
||||
proc.on("error", () => resolve())
|
||||
})
|
||||
}
|
||||
|
||||
interface Todo {
|
||||
content: string
|
||||
status: string
|
||||
@@ -65,14 +81,17 @@ async function sendNotification(
|
||||
|
||||
const esTitle = title.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
|
||||
const esMessage = message.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
|
||||
await ctx.$`${osascriptPath} -e ${"display notification \"" + esMessage + "\" with title \"" + esTitle + "\""}`.catch(() => {})
|
||||
const script = `display notification "${esMessage}" with title "${esTitle}"`
|
||||
// Use node:child_process instead of Bun shell to avoid potential GC issues
|
||||
await execCommand(osascriptPath, ["-e", script]).catch(() => {})
|
||||
break
|
||||
}
|
||||
case "linux": {
|
||||
const notifySendPath = await getNotifySendPath()
|
||||
if (!notifySendPath) return
|
||||
|
||||
await ctx.$`${notifySendPath} ${title} ${message} 2>/dev/null`.catch(() => {})
|
||||
// Use node:child_process instead of Bun shell to avoid potential GC issues
|
||||
await execCommand(notifySendPath, [title, message]).catch(() => {})
|
||||
break
|
||||
}
|
||||
case "win32": {
|
||||
@@ -93,7 +112,8 @@ $Toast = [Windows.UI.Notifications.ToastNotification]::new($SerializedXml)
|
||||
$Notifier = [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('OpenCode')
|
||||
$Notifier.Show($Toast)
|
||||
`.trim().replace(/\n/g, "; ")
|
||||
await ctx.$`${powershellPath} -Command ${toastScript}`.catch(() => {})
|
||||
// Use node:child_process instead of Bun shell to avoid GC crash (oven-sh/bun#23177)
|
||||
await execCommand(powershellPath, ["-Command", toastScript]).catch(() => {})
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -104,17 +124,19 @@ async function playSound(ctx: PluginInput, p: Platform, soundPath: string): Prom
|
||||
case "darwin": {
|
||||
const afplayPath = await getAfplayPath()
|
||||
if (!afplayPath) return
|
||||
ctx.$`${afplayPath} ${soundPath}`.catch(() => {})
|
||||
// Use node:child_process instead of Bun shell to avoid potential GC issues
|
||||
execCommand(afplayPath, [soundPath]).catch(() => {})
|
||||
break
|
||||
}
|
||||
case "linux": {
|
||||
const paplayPath = await getPaplayPath()
|
||||
if (paplayPath) {
|
||||
ctx.$`${paplayPath} ${soundPath} 2>/dev/null`.catch(() => {})
|
||||
// Use node:child_process instead of Bun shell to avoid potential GC issues
|
||||
execCommand(paplayPath, [soundPath]).catch(() => {})
|
||||
} else {
|
||||
const aplayPath = await getAplayPath()
|
||||
if (aplayPath) {
|
||||
ctx.$`${aplayPath} ${soundPath} 2>/dev/null`.catch(() => {})
|
||||
execCommand(aplayPath, [soundPath]).catch(() => {})
|
||||
}
|
||||
}
|
||||
break
|
||||
@@ -122,7 +144,9 @@ async function playSound(ctx: PluginInput, p: Platform, soundPath: string): Prom
|
||||
case "win32": {
|
||||
const powershellPath = await getPowershellPath()
|
||||
if (!powershellPath) return
|
||||
ctx.$`${powershellPath} -Command ${"(New-Object Media.SoundPlayer '" + soundPath + "').PlaySync()"}`.catch(() => {})
|
||||
// Use node:child_process instead of Bun shell to avoid GC crash (oven-sh/bun#23177)
|
||||
const soundScript = `(New-Object Media.SoundPlayer '${soundPath.replace(/'/g, "''")}').PlaySync()`
|
||||
execCommand(powershellPath, ["-Command", soundScript]).catch(() => {})
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
203
src/hooks/session-recovery/index.test.ts
Normal file
203
src/hooks/session-recovery/index.test.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import { describe, expect, it } from "bun:test"
|
||||
import { detectErrorType } from "./index"
|
||||
|
||||
describe("detectErrorType", () => {
|
||||
describe("thinking_block_order errors", () => {
|
||||
it("should detect 'first block' error pattern", () => {
|
||||
// #given an error about thinking being the first block
|
||||
const error = {
|
||||
message: "messages.0: thinking block must not be the first block",
|
||||
}
|
||||
|
||||
// #when detectErrorType is called
|
||||
const result = detectErrorType(error)
|
||||
|
||||
// #then should return thinking_block_order
|
||||
expect(result).toBe("thinking_block_order")
|
||||
})
|
||||
|
||||
it("should detect 'must start with' error pattern", () => {
|
||||
// #given an error about message must start with something
|
||||
const error = {
|
||||
message: "messages.5: thinking must start with text or tool_use",
|
||||
}
|
||||
|
||||
// #when detectErrorType is called
|
||||
const result = detectErrorType(error)
|
||||
|
||||
// #then should return thinking_block_order
|
||||
expect(result).toBe("thinking_block_order")
|
||||
})
|
||||
|
||||
it("should detect 'preceeding' error pattern", () => {
|
||||
// #given an error about preceeding block
|
||||
const error = {
|
||||
message: "messages.10: thinking requires preceeding text block",
|
||||
}
|
||||
|
||||
// #when detectErrorType is called
|
||||
const result = detectErrorType(error)
|
||||
|
||||
// #then should return thinking_block_order
|
||||
expect(result).toBe("thinking_block_order")
|
||||
})
|
||||
|
||||
it("should detect 'expected/found' error pattern", () => {
|
||||
// #given an error about expected vs found
|
||||
const error = {
|
||||
message: "messages.3: thinking block expected text but found tool_use",
|
||||
}
|
||||
|
||||
// #when detectErrorType is called
|
||||
const result = detectErrorType(error)
|
||||
|
||||
// #then should return thinking_block_order
|
||||
expect(result).toBe("thinking_block_order")
|
||||
})
|
||||
|
||||
it("should detect 'final block cannot be thinking' error pattern", () => {
|
||||
// #given an error about final block cannot be thinking
|
||||
const error = {
|
||||
message:
|
||||
"messages.125: The final block in an assistant message cannot be thinking.",
|
||||
}
|
||||
|
||||
// #when detectErrorType is called
|
||||
const result = detectErrorType(error)
|
||||
|
||||
// #then should return thinking_block_order
|
||||
expect(result).toBe("thinking_block_order")
|
||||
})
|
||||
|
||||
it("should detect 'final block' variant error pattern", () => {
|
||||
// #given an error mentioning final block with thinking
|
||||
const error = {
|
||||
message:
|
||||
"messages.17: thinking in the final block is not allowed in assistant messages",
|
||||
}
|
||||
|
||||
// #when detectErrorType is called
|
||||
const result = detectErrorType(error)
|
||||
|
||||
// #then should return thinking_block_order
|
||||
expect(result).toBe("thinking_block_order")
|
||||
})
|
||||
|
||||
it("should detect 'cannot be thinking' error pattern", () => {
|
||||
// #given an error using 'cannot be thinking' phrasing
|
||||
const error = {
|
||||
message:
|
||||
"messages.219: The last block in an assistant message cannot be thinking content",
|
||||
}
|
||||
|
||||
// #when detectErrorType is called
|
||||
const result = detectErrorType(error)
|
||||
|
||||
// #then should return thinking_block_order
|
||||
expect(result).toBe("thinking_block_order")
|
||||
})
|
||||
})
|
||||
|
||||
describe("tool_result_missing errors", () => {
|
||||
it("should detect tool_use/tool_result mismatch", () => {
|
||||
// #given an error about tool_use without tool_result
|
||||
const error = {
|
||||
message: "tool_use block requires corresponding tool_result",
|
||||
}
|
||||
|
||||
// #when detectErrorType is called
|
||||
const result = detectErrorType(error)
|
||||
|
||||
// #then should return tool_result_missing
|
||||
expect(result).toBe("tool_result_missing")
|
||||
})
|
||||
})
|
||||
|
||||
describe("thinking_disabled_violation errors", () => {
|
||||
it("should detect thinking disabled violation", () => {
|
||||
// #given an error about thinking being disabled
|
||||
const error = {
|
||||
message:
|
||||
"thinking is disabled for this model and cannot contain thinking blocks",
|
||||
}
|
||||
|
||||
// #when detectErrorType is called
|
||||
const result = detectErrorType(error)
|
||||
|
||||
// #then should return thinking_disabled_violation
|
||||
expect(result).toBe("thinking_disabled_violation")
|
||||
})
|
||||
})
|
||||
|
||||
describe("unrecognized errors", () => {
|
||||
it("should return null for unrecognized error patterns", () => {
|
||||
// #given an unrelated error
|
||||
const error = {
|
||||
message: "Rate limit exceeded",
|
||||
}
|
||||
|
||||
// #when detectErrorType is called
|
||||
const result = detectErrorType(error)
|
||||
|
||||
// #then should return null
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it("should return null for empty error", () => {
|
||||
// #given an empty error
|
||||
const error = {}
|
||||
|
||||
// #when detectErrorType is called
|
||||
const result = detectErrorType(error)
|
||||
|
||||
// #then should return null
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it("should return null for null error", () => {
|
||||
// #given a null error
|
||||
const error = null
|
||||
|
||||
// #when detectErrorType is called
|
||||
const result = detectErrorType(error)
|
||||
|
||||
// #then should return null
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("nested error objects", () => {
|
||||
it("should detect error in data.error.message path", () => {
|
||||
// #given an error with nested structure
|
||||
const error = {
|
||||
data: {
|
||||
error: {
|
||||
message:
|
||||
"messages.163: The final block in an assistant message cannot be thinking.",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// #when detectErrorType is called
|
||||
const result = detectErrorType(error)
|
||||
|
||||
// #then should return thinking_block_order
|
||||
expect(result).toBe("thinking_block_order")
|
||||
})
|
||||
|
||||
it("should detect error in error.message path", () => {
|
||||
// #given an error with error.message structure
|
||||
const error = {
|
||||
error: {
|
||||
message: "messages.169: final block cannot be thinking",
|
||||
},
|
||||
}
|
||||
|
||||
// #when detectErrorType is called
|
||||
const result = detectErrorType(error)
|
||||
|
||||
// #then should return thinking_block_order
|
||||
expect(result).toBe("thinking_block_order")
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -122,7 +122,7 @@ function extractMessageIndex(error: unknown): number | null {
|
||||
return match ? parseInt(match[1], 10) : null
|
||||
}
|
||||
|
||||
function detectErrorType(error: unknown): RecoveryErrorType {
|
||||
export function detectErrorType(error: unknown): RecoveryErrorType {
|
||||
const message = getErrorMessage(error)
|
||||
|
||||
if (message.includes("tool_use") && message.includes("tool_result")) {
|
||||
@@ -134,6 +134,8 @@ function detectErrorType(error: unknown): RecoveryErrorType {
|
||||
(message.includes("first block") ||
|
||||
message.includes("must start with") ||
|
||||
message.includes("preceeding") ||
|
||||
message.includes("final block") ||
|
||||
message.includes("cannot be thinking") ||
|
||||
(message.includes("expected") && message.includes("found")))
|
||||
) {
|
||||
return "thinking_block_order"
|
||||
|
||||
@@ -322,4 +322,140 @@ describe("think-mode switcher", () => {
|
||||
expect(config.maxTokens).toBe(64000)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Custom provider prefixes support", () => {
|
||||
describe("getHighVariant with prefixes", () => {
|
||||
it("should preserve vertex_ai/ prefix when getting high variant", () => {
|
||||
// #given a model ID with vertex_ai/ prefix
|
||||
const variant = getHighVariant("vertex_ai/claude-sonnet-4-5")
|
||||
|
||||
// #then should return high variant with prefix preserved
|
||||
expect(variant).toBe("vertex_ai/claude-sonnet-4-5-high")
|
||||
})
|
||||
|
||||
it("should preserve openai/ prefix when getting high variant", () => {
|
||||
// #given a model ID with openai/ prefix
|
||||
const variant = getHighVariant("openai/gpt-5-2")
|
||||
|
||||
// #then should return high variant with prefix preserved
|
||||
expect(variant).toBe("openai/gpt-5-2-high")
|
||||
})
|
||||
|
||||
it("should handle prefixes with dots in version numbers", () => {
|
||||
// #given a model ID with prefix and dots
|
||||
const variant = getHighVariant("vertex_ai/claude-opus-4.5")
|
||||
|
||||
// #then should normalize dots and preserve prefix
|
||||
expect(variant).toBe("vertex_ai/claude-opus-4-5-high")
|
||||
})
|
||||
|
||||
it("should handle multiple different prefixes", () => {
|
||||
// #given various custom prefixes
|
||||
expect(getHighVariant("azure/gpt-5")).toBe("azure/gpt-5-high")
|
||||
expect(getHighVariant("bedrock/claude-sonnet-4-5")).toBe("bedrock/claude-sonnet-4-5-high")
|
||||
expect(getHighVariant("custom-llm/gemini-3-pro")).toBe("custom-llm/gemini-3-pro-high")
|
||||
})
|
||||
|
||||
it("should return null for prefixed models without high variant mapping", () => {
|
||||
// #given prefixed model IDs without high variant mapping
|
||||
expect(getHighVariant("vertex_ai/unknown-model")).toBeNull()
|
||||
expect(getHighVariant("custom/llama-3-70b")).toBeNull()
|
||||
})
|
||||
|
||||
it("should return null for already-high prefixed models", () => {
|
||||
// #given prefixed model IDs that are already high
|
||||
expect(getHighVariant("vertex_ai/claude-opus-4-5-high")).toBeNull()
|
||||
expect(getHighVariant("openai/gpt-5-2-high")).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("isAlreadyHighVariant with prefixes", () => {
|
||||
it("should detect -high suffix in prefixed models", () => {
|
||||
// #given prefixed model IDs with -high suffix
|
||||
expect(isAlreadyHighVariant("vertex_ai/claude-opus-4-5-high")).toBe(true)
|
||||
expect(isAlreadyHighVariant("openai/gpt-5-2-high")).toBe(true)
|
||||
expect(isAlreadyHighVariant("custom/gemini-3-pro-high")).toBe(true)
|
||||
})
|
||||
|
||||
it("should return false for prefixed base models", () => {
|
||||
// #given prefixed base model IDs without -high suffix
|
||||
expect(isAlreadyHighVariant("vertex_ai/claude-opus-4-5")).toBe(false)
|
||||
expect(isAlreadyHighVariant("openai/gpt-5-2")).toBe(false)
|
||||
})
|
||||
|
||||
it("should handle prefixed models with dots", () => {
|
||||
// #given prefixed model IDs with dots
|
||||
expect(isAlreadyHighVariant("vertex_ai/gpt-5.2")).toBe(false)
|
||||
expect(isAlreadyHighVariant("vertex_ai/gpt-5.2-high")).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getThinkingConfig with prefixes", () => {
|
||||
it("should return null for custom providers (not in THINKING_CONFIGS)", () => {
|
||||
// #given custom provider with prefixed Claude model
|
||||
const config = getThinkingConfig("dia-llm", "vertex_ai/claude-sonnet-4-5")
|
||||
|
||||
// #then should return null (custom provider not in THINKING_CONFIGS)
|
||||
expect(config).toBeNull()
|
||||
})
|
||||
|
||||
it("should work with prefixed models on known providers", () => {
|
||||
// #given known provider (anthropic) with prefixed model
|
||||
// This tests that the base model name is correctly extracted for capability check
|
||||
const config = getThinkingConfig("anthropic", "custom-prefix/claude-opus-4-5")
|
||||
|
||||
// #then should return thinking config (base model is capable)
|
||||
expect(config).not.toBeNull()
|
||||
expect(config?.thinking).toBeDefined()
|
||||
})
|
||||
|
||||
it("should return null for prefixed models that are already high", () => {
|
||||
// #given prefixed already-high model
|
||||
const config = getThinkingConfig("anthropic", "vertex_ai/claude-opus-4-5-high")
|
||||
|
||||
// #then should return null
|
||||
expect(config).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe("Real-world custom provider scenario", () => {
|
||||
it("should handle LLM proxy with vertex_ai prefix correctly", () => {
|
||||
// #given a custom LLM proxy provider using vertex_ai/ prefix
|
||||
const providerID = "dia-llm"
|
||||
const modelID = "vertex_ai/claude-sonnet-4-5"
|
||||
|
||||
// #when getting high variant
|
||||
const highVariant = getHighVariant(modelID)
|
||||
|
||||
// #then should preserve the prefix
|
||||
expect(highVariant).toBe("vertex_ai/claude-sonnet-4-5-high")
|
||||
|
||||
// #and when checking if already high
|
||||
expect(isAlreadyHighVariant(modelID)).toBe(false)
|
||||
expect(isAlreadyHighVariant(highVariant!)).toBe(true)
|
||||
|
||||
// #and when getting thinking config for custom provider
|
||||
const config = getThinkingConfig(providerID, modelID)
|
||||
|
||||
// #then should return null (custom provider, not anthropic)
|
||||
// This prevents applying incompatible thinking configs to custom providers
|
||||
expect(config).toBeNull()
|
||||
})
|
||||
|
||||
it("should not break when switching to high variant in think mode", () => {
|
||||
// #given think mode switching vertex_ai/claude model to high variant
|
||||
const original = "vertex_ai/claude-opus-4-5"
|
||||
const high = getHighVariant(original)
|
||||
|
||||
// #then the high variant should be valid
|
||||
expect(high).toBe("vertex_ai/claude-opus-4-5-high")
|
||||
|
||||
// #and should be recognized as already high
|
||||
expect(isAlreadyHighVariant(high!)).toBe(true)
|
||||
|
||||
// #and switching again should return null (already high)
|
||||
expect(getHighVariant(high!)).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -16,6 +16,26 @@
|
||||
* inconsistencies defensively while maintaining backwards compatibility.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Extracts provider-specific prefix from model ID (if present).
|
||||
* Custom providers may use prefixes for routing (e.g., vertex_ai/, openai/).
|
||||
*
|
||||
* @example
|
||||
* extractModelPrefix("vertex_ai/claude-sonnet-4-5") // { prefix: "vertex_ai/", base: "claude-sonnet-4-5" }
|
||||
* extractModelPrefix("claude-sonnet-4-5") // { prefix: "", base: "claude-sonnet-4-5" }
|
||||
* extractModelPrefix("openai/gpt-5.2") // { prefix: "openai/", base: "gpt-5.2" }
|
||||
*/
|
||||
function extractModelPrefix(modelID: string): { prefix: string; base: string } {
|
||||
const slashIndex = modelID.indexOf("/")
|
||||
if (slashIndex === -1) {
|
||||
return { prefix: "", base: modelID }
|
||||
}
|
||||
return {
|
||||
prefix: modelID.slice(0, slashIndex + 1),
|
||||
base: modelID.slice(slashIndex + 1),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes model IDs to use consistent hyphen formatting.
|
||||
* GitHub Copilot may use dots (claude-opus-4.5) but our maps use hyphens (claude-opus-4-5).
|
||||
@@ -25,6 +45,7 @@
|
||||
* normalizeModelID("claude-opus-4.5") // "claude-opus-4-5"
|
||||
* normalizeModelID("gemini-3.5-pro") // "gemini-3-5-pro"
|
||||
* normalizeModelID("gpt-5.2") // "gpt-5-2"
|
||||
* normalizeModelID("vertex_ai/claude-opus-4.5") // "vertex_ai/claude-opus-4-5"
|
||||
*/
|
||||
function normalizeModelID(modelID: string): string {
|
||||
// Replace dots with hyphens when followed by a digit
|
||||
@@ -142,16 +163,27 @@ const THINKING_CAPABLE_MODELS = {
|
||||
|
||||
export function getHighVariant(modelID: string): string | null {
|
||||
const normalized = normalizeModelID(modelID)
|
||||
const { prefix, base } = extractModelPrefix(normalized)
|
||||
|
||||
if (ALREADY_HIGH.has(normalized)) {
|
||||
// Check if already high variant (with or without prefix)
|
||||
if (ALREADY_HIGH.has(base) || base.endsWith("-high")) {
|
||||
return null
|
||||
}
|
||||
return HIGH_VARIANT_MAP[normalized] ?? null
|
||||
|
||||
// Look up high variant for base model
|
||||
const highBase = HIGH_VARIANT_MAP[base]
|
||||
if (!highBase) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Preserve prefix in the high variant
|
||||
return prefix + highBase
|
||||
}
|
||||
|
||||
export function isAlreadyHighVariant(modelID: string): boolean {
|
||||
const normalized = normalizeModelID(modelID)
|
||||
return ALREADY_HIGH.has(normalized) || normalized.endsWith("-high")
|
||||
const { base } = extractModelPrefix(normalized)
|
||||
return ALREADY_HIGH.has(base) || base.endsWith("-high")
|
||||
}
|
||||
|
||||
type ThinkingProvider = keyof typeof THINKING_CONFIGS
|
||||
@@ -165,6 +197,7 @@ export function getThinkingConfig(
|
||||
modelID: string
|
||||
): Record<string, unknown> | null {
|
||||
const normalized = normalizeModelID(modelID)
|
||||
const { base } = extractModelPrefix(normalized)
|
||||
|
||||
if (isAlreadyHighVariant(normalized)) {
|
||||
return null
|
||||
@@ -179,9 +212,10 @@ export function getThinkingConfig(
|
||||
const config = THINKING_CONFIGS[resolvedProvider]
|
||||
const capablePatterns = THINKING_CAPABLE_MODELS[resolvedProvider]
|
||||
|
||||
const modelLower = normalized.toLowerCase()
|
||||
// Check capability using base model name (without prefix)
|
||||
const baseLower = base.toLowerCase()
|
||||
const isCapable = capablePatterns.some((pattern) =>
|
||||
modelLower.includes(pattern.toLowerCase())
|
||||
baseLower.includes(pattern.toLowerCase())
|
||||
)
|
||||
|
||||
return isCapable ? config : null
|
||||
|
||||
@@ -8,6 +8,16 @@ describe("todo-continuation-enforcer", () => {
|
||||
let promptCalls: Array<{ sessionID: string; agent?: string; model?: { providerID?: string; modelID?: string }; text: string }>
|
||||
let toastCalls: Array<{ title: string; message: string }>
|
||||
|
||||
interface MockMessage {
|
||||
info: {
|
||||
id: string
|
||||
role: "user" | "assistant"
|
||||
error?: { name: string; data?: { message: string } }
|
||||
}
|
||||
}
|
||||
|
||||
let mockMessages: MockMessage[] = []
|
||||
|
||||
function createMockPluginInput() {
|
||||
return {
|
||||
client: {
|
||||
@@ -16,6 +26,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
{ id: "1", content: "Task 1", status: "pending", priority: "high" },
|
||||
{ id: "2", content: "Task 2", status: "completed", priority: "medium" },
|
||||
]}),
|
||||
messages: async () => ({ data: mockMessages }),
|
||||
prompt: async (opts: any) => {
|
||||
promptCalls.push({
|
||||
sessionID: opts.path.id,
|
||||
@@ -51,6 +62,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
beforeEach(() => {
|
||||
promptCalls = []
|
||||
toastCalls = []
|
||||
mockMessages = []
|
||||
setMainSession(undefined)
|
||||
subagentSessions.clear()
|
||||
})
|
||||
@@ -165,58 +177,9 @@ describe("todo-continuation-enforcer", () => {
|
||||
expect(promptCalls[0].sessionID).toBe(bgTaskSession)
|
||||
})
|
||||
|
||||
test("should skip injection when abort error occurs immediately before idle", async () => {
|
||||
// #given - session that just had an abort error
|
||||
const sessionID = "main-error"
|
||||
setMainSession(sessionID)
|
||||
|
||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||
|
||||
// #when - abort error occurs
|
||||
await hook.handler({
|
||||
event: { type: "session.error", properties: { sessionID, error: { name: "AbortError", message: "aborted" } } },
|
||||
})
|
||||
|
||||
// #when - session goes idle immediately after abort
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 3000))
|
||||
|
||||
// #then - no continuation injected (abort was immediately before idle)
|
||||
expect(promptCalls).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("should clear abort state on user message and allow injection", async () => {
|
||||
// #given - session with abort error, then user clears it
|
||||
const sessionID = "main-error-clear"
|
||||
setMainSession(sessionID)
|
||||
|
||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||
|
||||
// #when - abort error occurs
|
||||
await hook.handler({
|
||||
event: { type: "session.error", properties: { sessionID, error: { message: "aborted" } } },
|
||||
})
|
||||
|
||||
// #when - user sends message (clears abort state)
|
||||
await hook.handler({
|
||||
event: { type: "message.updated", properties: { info: { sessionID, role: "user" } } },
|
||||
})
|
||||
|
||||
// #when - session goes idle
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 2500))
|
||||
|
||||
// #then - continuation injected (abort state was cleared by user message)
|
||||
expect(promptCalls.length).toBe(1)
|
||||
})
|
||||
|
||||
test("should cancel countdown on user message", async () => {
|
||||
test("should cancel countdown on user message after grace period", async () => {
|
||||
// #given - session starting countdown
|
||||
const sessionID = "main-cancel"
|
||||
setMainSession(sessionID)
|
||||
@@ -228,7 +191,8 @@ describe("todo-continuation-enforcer", () => {
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
// #when - user sends message immediately (before 2s countdown)
|
||||
// #when - wait past grace period (500ms), then user sends message
|
||||
await new Promise(r => setTimeout(r, 600))
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "message.updated",
|
||||
@@ -236,11 +200,37 @@ describe("todo-continuation-enforcer", () => {
|
||||
},
|
||||
})
|
||||
|
||||
// #then - wait past countdown time and verify no injection
|
||||
// #then - wait past countdown time and verify no injection (countdown was cancelled)
|
||||
await new Promise(r => setTimeout(r, 2500))
|
||||
expect(promptCalls).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("should ignore user message within grace period", async () => {
|
||||
// #given - session starting countdown
|
||||
const sessionID = "main-grace"
|
||||
setMainSession(sessionID)
|
||||
|
||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||
|
||||
// #when - session goes idle
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
// #when - user message arrives within grace period (immediately)
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "message.updated",
|
||||
properties: { info: { sessionID, role: "user" } }
|
||||
},
|
||||
})
|
||||
|
||||
// #then - countdown should continue (message was ignored)
|
||||
// wait past 2s countdown and verify injection happens
|
||||
await new Promise(r => setTimeout(r, 2500))
|
||||
expect(promptCalls).toHaveLength(1)
|
||||
})
|
||||
|
||||
test("should cancel countdown on assistant activity", async () => {
|
||||
// #given - session starting countdown
|
||||
const sessionID = "main-assistant"
|
||||
@@ -388,7 +378,7 @@ describe("todo-continuation-enforcer", () => {
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
await new Promise(r => setTimeout(r, 2500))
|
||||
await new Promise(r => setTimeout(r, 3500))
|
||||
|
||||
// #then - first injection happened
|
||||
expect(promptCalls.length).toBe(1)
|
||||
@@ -397,118 +387,17 @@ describe("todo-continuation-enforcer", () => {
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
await new Promise(r => setTimeout(r, 2500))
|
||||
await new Promise(r => setTimeout(r, 3500))
|
||||
|
||||
// #then - second injection also happened (no throttle blocking)
|
||||
expect(promptCalls.length).toBe(2)
|
||||
}, { timeout: 10000 })
|
||||
}, { timeout: 15000 })
|
||||
|
||||
// ============================================================
|
||||
// ABORT "IMMEDIATELY BEFORE" DETECTION TESTS
|
||||
// These tests verify that abort errors only block continuation
|
||||
// when they occur IMMEDIATELY before session.idle, not based
|
||||
// on a time-based cooldown.
|
||||
// ============================================================
|
||||
|
||||
test("should skip injection ONLY when abort error occurs immediately before idle", async () => {
|
||||
// #given - session with incomplete todos
|
||||
const sessionID = "main-abort-immediate"
|
||||
setMainSession(sessionID)
|
||||
|
||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||
|
||||
// #when - abort error occurs (with abort-specific error)
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "session.error",
|
||||
properties: {
|
||||
sessionID,
|
||||
error: { name: "MessageAbortedError", message: "The operation was aborted" }
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// #when - session goes idle IMMEDIATELY after abort (no other events in between)
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 3000))
|
||||
|
||||
// #then - no continuation injected (abort was immediately before idle)
|
||||
expect(promptCalls).toHaveLength(0)
|
||||
})
|
||||
|
||||
test("should inject normally when abort error is followed by assistant activity before idle", async () => {
|
||||
// #given - session with incomplete todos
|
||||
const sessionID = "main-abort-then-assistant"
|
||||
setMainSession(sessionID)
|
||||
|
||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||
|
||||
// #when - abort error occurs
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "session.error",
|
||||
properties: {
|
||||
sessionID,
|
||||
error: { name: "MessageAbortedError", message: "The operation was aborted" }
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// #when - assistant sends a message (intervening event clears abort state)
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "message.updated",
|
||||
properties: { info: { sessionID, role: "assistant" } }
|
||||
},
|
||||
})
|
||||
|
||||
// #when - session goes idle (abort is no longer "immediately before")
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 2500))
|
||||
|
||||
// #then - continuation injected (abort was NOT immediately before idle)
|
||||
expect(promptCalls.length).toBe(1)
|
||||
})
|
||||
|
||||
test("should inject normally when abort error is followed by tool execution before idle", async () => {
|
||||
// #given - session with incomplete todos
|
||||
const sessionID = "main-abort-then-tool"
|
||||
setMainSession(sessionID)
|
||||
|
||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||
|
||||
// #when - abort error occurs
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "session.error",
|
||||
properties: {
|
||||
sessionID,
|
||||
error: { message: "aborted" }
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// #when - tool execution happens (intervening event)
|
||||
await hook.handler({
|
||||
event: { type: "tool.execute.after", properties: { sessionID } },
|
||||
})
|
||||
|
||||
// #when - session goes idle
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 2500))
|
||||
|
||||
// #then - continuation injected (abort was NOT immediately before idle)
|
||||
expect(promptCalls.length).toBe(1)
|
||||
})
|
||||
|
||||
test("should NOT skip for non-abort errors even if immediately before idle", async () => {
|
||||
// #given - session with incomplete todos
|
||||
@@ -539,74 +428,105 @@ describe("todo-continuation-enforcer", () => {
|
||||
expect(promptCalls.length).toBe(1)
|
||||
})
|
||||
|
||||
test("should inject after abort if time passes and new idle event occurs", async () => {
|
||||
// #given - session with incomplete todos, abort happened previously
|
||||
const sessionID = "main-abort-time-passed"
|
||||
|
||||
|
||||
|
||||
|
||||
// ============================================================
|
||||
// API-BASED ABORT DETECTION TESTS
|
||||
// These tests verify that abort is detected by checking
|
||||
// the last assistant message's error field via session.messages API
|
||||
// ============================================================
|
||||
|
||||
test("should skip injection when last assistant message has MessageAbortedError", async () => {
|
||||
// #given - session where last assistant message was aborted
|
||||
const sessionID = "main-api-abort"
|
||||
setMainSession(sessionID)
|
||||
|
||||
mockMessages = [
|
||||
{ info: { id: "msg-1", role: "user" } },
|
||||
{ info: { id: "msg-2", role: "assistant", error: { name: "MessageAbortedError", data: { message: "The operation was aborted" } } } },
|
||||
]
|
||||
|
||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||
|
||||
// #when - abort error occurs
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "session.error",
|
||||
properties: {
|
||||
sessionID,
|
||||
error: { name: "AbortError", message: "cancelled" }
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// #when - first idle (immediately after abort) - should be skipped
|
||||
// #when - session goes idle
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 3000))
|
||||
|
||||
// #then - no continuation (last message was aborted)
|
||||
expect(promptCalls).toHaveLength(0)
|
||||
})
|
||||
|
||||
// #when - second idle event occurs (abort is no longer "immediately before")
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 2500))
|
||||
|
||||
// #then - continuation injected on second idle (abort state was consumed)
|
||||
expect(promptCalls.length).toBe(1)
|
||||
}, { timeout: 10000 })
|
||||
|
||||
test("should handle multiple abort errors correctly - only last one matters", async () => {
|
||||
// #given - session with incomplete todos
|
||||
const sessionID = "main-multi-abort"
|
||||
test("should inject when last assistant message has no error", async () => {
|
||||
// #given - session where last assistant message completed normally
|
||||
const sessionID = "main-api-no-error"
|
||||
setMainSession(sessionID)
|
||||
|
||||
mockMessages = [
|
||||
{ info: { id: "msg-1", role: "user" } },
|
||||
{ info: { id: "msg-2", role: "assistant" } },
|
||||
]
|
||||
|
||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||
|
||||
// #when - first abort error
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "session.error",
|
||||
properties: { sessionID, error: { message: "aborted" } }
|
||||
},
|
||||
})
|
||||
|
||||
// #when - second abort error (immediately before idle)
|
||||
await hook.handler({
|
||||
event: {
|
||||
type: "session.error",
|
||||
properties: { sessionID, error: { message: "interrupted" } }
|
||||
},
|
||||
})
|
||||
|
||||
// #when - idle immediately after second abort
|
||||
// #when - session goes idle
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 3000))
|
||||
|
||||
// #then - no continuation (abort was immediately before)
|
||||
// #then - continuation injected (no abort)
|
||||
expect(promptCalls.length).toBe(1)
|
||||
})
|
||||
|
||||
test("should inject when last message is from user (not assistant)", async () => {
|
||||
// #given - session where last message is from user
|
||||
const sessionID = "main-api-user-last"
|
||||
setMainSession(sessionID)
|
||||
|
||||
mockMessages = [
|
||||
{ info: { id: "msg-1", role: "assistant" } },
|
||||
{ info: { id: "msg-2", role: "user" } },
|
||||
]
|
||||
|
||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||
|
||||
// #when - session goes idle
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 3000))
|
||||
|
||||
// #then - continuation injected (last message is user, not aborted assistant)
|
||||
expect(promptCalls.length).toBe(1)
|
||||
})
|
||||
|
||||
test("should skip when last assistant message has any abort-like error", async () => {
|
||||
// #given - session where last assistant message has AbortError (DOMException style)
|
||||
const sessionID = "main-api-abort-dom"
|
||||
setMainSession(sessionID)
|
||||
|
||||
mockMessages = [
|
||||
{ info: { id: "msg-1", role: "user" } },
|
||||
{ info: { id: "msg-2", role: "assistant", error: { name: "AbortError" } } },
|
||||
]
|
||||
|
||||
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
|
||||
|
||||
// #when - session goes idle
|
||||
await hook.handler({
|
||||
event: { type: "session.idle", properties: { sessionID } },
|
||||
})
|
||||
|
||||
await new Promise(r => setTimeout(r, 3000))
|
||||
|
||||
// #then - no continuation (abort error detected)
|
||||
expect(promptCalls).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -29,10 +29,10 @@ interface Todo {
|
||||
}
|
||||
|
||||
interface SessionState {
|
||||
lastEventWasAbortError?: boolean
|
||||
countdownTimer?: ReturnType<typeof setTimeout>
|
||||
countdownInterval?: ReturnType<typeof setInterval>
|
||||
isRecovering?: boolean
|
||||
countdownStartedAt?: number
|
||||
}
|
||||
|
||||
const CONTINUATION_PROMPT = `[SYSTEM REMINDER - TODO CONTINUATION]
|
||||
@@ -45,6 +45,7 @@ Incomplete tasks remain in your todo list. Continue working on the next pending
|
||||
|
||||
const COUNTDOWN_SECONDS = 2
|
||||
const TOAST_DURATION_MS = 900
|
||||
const COUNTDOWN_GRACE_PERIOD_MS = 500
|
||||
|
||||
function getMessageDir(sessionID: string): string | null {
|
||||
if (!existsSync(MESSAGE_STORAGE)) return null
|
||||
@@ -60,31 +61,30 @@ function getMessageDir(sessionID: string): string | null {
|
||||
return null
|
||||
}
|
||||
|
||||
function isAbortError(error: unknown): boolean {
|
||||
if (!error) return false
|
||||
|
||||
if (typeof error === "object") {
|
||||
const errObj = error as Record<string, unknown>
|
||||
const name = errObj.name as string | undefined
|
||||
const message = (errObj.message as string | undefined)?.toLowerCase() ?? ""
|
||||
|
||||
if (name === "MessageAbortedError" || name === "AbortError") return true
|
||||
if (name === "DOMException" && message.includes("abort")) return true
|
||||
if (message.includes("aborted") || message.includes("cancelled") || message.includes("interrupted")) return true
|
||||
}
|
||||
|
||||
if (typeof error === "string") {
|
||||
const lower = error.toLowerCase()
|
||||
return lower.includes("abort") || lower.includes("cancel") || lower.includes("interrupt")
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
function getIncompleteCount(todos: Todo[]): number {
|
||||
return todos.filter(t => t.status !== "completed" && t.status !== "cancelled").length
|
||||
}
|
||||
|
||||
interface MessageInfo {
|
||||
id?: string
|
||||
role?: string
|
||||
error?: { name?: string; data?: unknown }
|
||||
}
|
||||
|
||||
function isLastAssistantMessageAborted(messages: Array<{ info?: MessageInfo }>): boolean {
|
||||
if (!messages || messages.length === 0) return false
|
||||
|
||||
const assistantMessages = messages.filter(m => m.info?.role === "assistant")
|
||||
if (assistantMessages.length === 0) return false
|
||||
|
||||
const lastAssistant = assistantMessages[assistantMessages.length - 1]
|
||||
const errorName = lastAssistant.info?.error?.name
|
||||
|
||||
if (!errorName) return false
|
||||
|
||||
return errorName === "MessageAbortedError" || errorName === "AbortError"
|
||||
}
|
||||
|
||||
export function createTodoContinuationEnforcer(
|
||||
ctx: PluginInput,
|
||||
options: TodoContinuationEnforcerOptions = {}
|
||||
@@ -113,6 +113,7 @@ export function createTodoContinuationEnforcer(
|
||||
clearInterval(state.countdownInterval)
|
||||
state.countdownInterval = undefined
|
||||
}
|
||||
state.countdownStartedAt = undefined
|
||||
}
|
||||
|
||||
function cleanup(sessionID: string): void {
|
||||
@@ -228,6 +229,7 @@ export function createTodoContinuationEnforcer(
|
||||
|
||||
let secondsRemaining = COUNTDOWN_SECONDS
|
||||
showCountdownToast(secondsRemaining, incompleteCount)
|
||||
state.countdownStartedAt = Date.now()
|
||||
|
||||
state.countdownInterval = setInterval(() => {
|
||||
secondsRemaining--
|
||||
@@ -251,12 +253,8 @@ export function createTodoContinuationEnforcer(
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
if (!sessionID) return
|
||||
|
||||
const state = getState(sessionID)
|
||||
const isAbort = isAbortError(props?.error)
|
||||
state.lastEventWasAbortError = isAbort
|
||||
cancelCountdown(sessionID)
|
||||
|
||||
log(`[${HOOK_NAME}] session.error`, { sessionID, isAbort })
|
||||
log(`[${HOOK_NAME}] session.error`, { sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
@@ -282,12 +280,6 @@ export function createTodoContinuationEnforcer(
|
||||
return
|
||||
}
|
||||
|
||||
if (state.lastEventWasAbortError) {
|
||||
state.lastEventWasAbortError = false
|
||||
log(`[${HOOK_NAME}] Skipped: abort error immediately before idle`, { sessionID })
|
||||
return
|
||||
}
|
||||
|
||||
const hasRunningBgTasks = backgroundManager
|
||||
? backgroundManager.getTasksByParentSession(sessionID).some(t => t.status === "running")
|
||||
: false
|
||||
@@ -297,6 +289,21 @@ export function createTodoContinuationEnforcer(
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const messagesResp = await ctx.client.session.messages({
|
||||
path: { id: sessionID },
|
||||
query: { directory: ctx.directory },
|
||||
})
|
||||
const messages = (messagesResp as { data?: Array<{ info?: MessageInfo }> }).data ?? []
|
||||
|
||||
if (isLastAssistantMessageAborted(messages)) {
|
||||
log(`[${HOOK_NAME}] Skipped: last assistant message was aborted`, { sessionID })
|
||||
return
|
||||
}
|
||||
} catch (err) {
|
||||
log(`[${HOOK_NAME}] Messages fetch failed, continuing`, { sessionID, error: String(err) })
|
||||
}
|
||||
|
||||
let todos: Todo[] = []
|
||||
try {
|
||||
const response = await ctx.client.session.todo({ path: { id: sessionID } })
|
||||
@@ -328,14 +335,16 @@ export function createTodoContinuationEnforcer(
|
||||
|
||||
if (!sessionID) return
|
||||
|
||||
const state = sessions.get(sessionID)
|
||||
if (state) {
|
||||
state.lastEventWasAbortError = false
|
||||
}
|
||||
|
||||
if (role === "user") {
|
||||
const state = sessions.get(sessionID)
|
||||
if (state?.countdownStartedAt) {
|
||||
const elapsed = Date.now() - state.countdownStartedAt
|
||||
if (elapsed < COUNTDOWN_GRACE_PERIOD_MS) {
|
||||
log(`[${HOOK_NAME}] Ignoring user message in grace period`, { sessionID, elapsed })
|
||||
return
|
||||
}
|
||||
}
|
||||
cancelCountdown(sessionID)
|
||||
log(`[${HOOK_NAME}] User message: cleared abort state`, { sessionID })
|
||||
}
|
||||
|
||||
if (role === "assistant") {
|
||||
@@ -350,10 +359,6 @@ export function createTodoContinuationEnforcer(
|
||||
const role = info?.role as string | undefined
|
||||
|
||||
if (sessionID && role === "assistant") {
|
||||
const state = sessions.get(sessionID)
|
||||
if (state) {
|
||||
state.lastEventWasAbortError = false
|
||||
}
|
||||
cancelCountdown(sessionID)
|
||||
}
|
||||
return
|
||||
@@ -362,10 +367,6 @@ export function createTodoContinuationEnforcer(
|
||||
if (event.type === "tool.execute.before" || event.type === "tool.execute.after") {
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
if (sessionID) {
|
||||
const state = sessions.get(sessionID)
|
||||
if (state) {
|
||||
state.lastEventWasAbortError = false
|
||||
}
|
||||
cancelCountdown(sessionID)
|
||||
}
|
||||
return
|
||||
|
||||
168
src/hooks/tool-output-truncator.test.ts
Normal file
168
src/hooks/tool-output-truncator.test.ts
Normal file
@@ -0,0 +1,168 @@
|
||||
import { describe, it, expect, beforeEach, mock, spyOn } from "bun:test"
|
||||
import { createToolOutputTruncatorHook } from "./tool-output-truncator"
|
||||
import * as dynamicTruncator from "../shared/dynamic-truncator"
|
||||
|
||||
describe("createToolOutputTruncatorHook", () => {
|
||||
let hook: ReturnType<typeof createToolOutputTruncatorHook>
|
||||
let truncateSpy: ReturnType<typeof spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
truncateSpy = spyOn(dynamicTruncator, "createDynamicTruncator").mockReturnValue({
|
||||
truncate: mock(async (_sessionID: string, output: string, options?: { targetMaxTokens?: number }) => ({
|
||||
result: output,
|
||||
truncated: false,
|
||||
targetMaxTokens: options?.targetMaxTokens,
|
||||
})),
|
||||
getUsage: mock(async () => null),
|
||||
truncateSync: mock(() => ({ result: "", truncated: false })),
|
||||
})
|
||||
hook = createToolOutputTruncatorHook({} as never)
|
||||
})
|
||||
|
||||
describe("tool.execute.after", () => {
|
||||
const createInput = (tool: string) => ({
|
||||
tool,
|
||||
sessionID: "test-session",
|
||||
callID: "test-call-id",
|
||||
})
|
||||
|
||||
const createOutput = (outputText: string) => ({
|
||||
title: "Result",
|
||||
output: outputText,
|
||||
metadata: {},
|
||||
})
|
||||
|
||||
describe("#given webfetch tool", () => {
|
||||
describe("#when output is processed", () => {
|
||||
it("#then should use aggressive truncation limit (10k tokens)", async () => {
|
||||
const truncateMock = mock(async (_sessionID: string, _output: string, options?: { targetMaxTokens?: number }) => ({
|
||||
result: "truncated",
|
||||
truncated: true,
|
||||
targetMaxTokens: options?.targetMaxTokens,
|
||||
}))
|
||||
truncateSpy.mockReturnValue({
|
||||
truncate: truncateMock,
|
||||
getUsage: mock(async () => null),
|
||||
truncateSync: mock(() => ({ result: "", truncated: false })),
|
||||
})
|
||||
hook = createToolOutputTruncatorHook({} as never)
|
||||
|
||||
const input = createInput("webfetch")
|
||||
const output = createOutput("large content")
|
||||
|
||||
await hook["tool.execute.after"](input, output)
|
||||
|
||||
expect(truncateMock).toHaveBeenCalledWith(
|
||||
"test-session",
|
||||
"large content",
|
||||
{ targetMaxTokens: 10_000 }
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("#when using WebFetch variant", () => {
|
||||
it("#then should also use aggressive truncation limit", async () => {
|
||||
const truncateMock = mock(async (_sessionID: string, _output: string, options?: { targetMaxTokens?: number }) => ({
|
||||
result: "truncated",
|
||||
truncated: true,
|
||||
}))
|
||||
truncateSpy.mockReturnValue({
|
||||
truncate: truncateMock,
|
||||
getUsage: mock(async () => null),
|
||||
truncateSync: mock(() => ({ result: "", truncated: false })),
|
||||
})
|
||||
hook = createToolOutputTruncatorHook({} as never)
|
||||
|
||||
const input = createInput("WebFetch")
|
||||
const output = createOutput("large content")
|
||||
|
||||
await hook["tool.execute.after"](input, output)
|
||||
|
||||
expect(truncateMock).toHaveBeenCalledWith(
|
||||
"test-session",
|
||||
"large content",
|
||||
{ targetMaxTokens: 10_000 }
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given grep tool", () => {
|
||||
describe("#when output is processed", () => {
|
||||
it("#then should use default truncation limit (50k tokens)", async () => {
|
||||
const truncateMock = mock(async (_sessionID: string, _output: string, options?: { targetMaxTokens?: number }) => ({
|
||||
result: "truncated",
|
||||
truncated: true,
|
||||
}))
|
||||
truncateSpy.mockReturnValue({
|
||||
truncate: truncateMock,
|
||||
getUsage: mock(async () => null),
|
||||
truncateSync: mock(() => ({ result: "", truncated: false })),
|
||||
})
|
||||
hook = createToolOutputTruncatorHook({} as never)
|
||||
|
||||
const input = createInput("grep")
|
||||
const output = createOutput("grep output")
|
||||
|
||||
await hook["tool.execute.after"](input, output)
|
||||
|
||||
expect(truncateMock).toHaveBeenCalledWith(
|
||||
"test-session",
|
||||
"grep output",
|
||||
{ targetMaxTokens: 50_000 }
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given non-truncatable tool", () => {
|
||||
describe("#when tool is not in TRUNCATABLE_TOOLS list", () => {
|
||||
it("#then should not call truncator", async () => {
|
||||
const truncateMock = mock(async () => ({
|
||||
result: "truncated",
|
||||
truncated: true,
|
||||
}))
|
||||
truncateSpy.mockReturnValue({
|
||||
truncate: truncateMock,
|
||||
getUsage: mock(async () => null),
|
||||
truncateSync: mock(() => ({ result: "", truncated: false })),
|
||||
})
|
||||
hook = createToolOutputTruncatorHook({} as never)
|
||||
|
||||
const input = createInput("Read")
|
||||
const output = createOutput("file content")
|
||||
|
||||
await hook["tool.execute.after"](input, output)
|
||||
|
||||
expect(truncateMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("#given truncate_all_tool_outputs enabled", () => {
|
||||
describe("#when any tool output is processed", () => {
|
||||
it("#then should truncate non-listed tools too", async () => {
|
||||
const truncateMock = mock(async (_sessionID: string, _output: string, options?: { targetMaxTokens?: number }) => ({
|
||||
result: "truncated",
|
||||
truncated: true,
|
||||
}))
|
||||
truncateSpy.mockReturnValue({
|
||||
truncate: truncateMock,
|
||||
getUsage: mock(async () => null),
|
||||
truncateSync: mock(() => ({ result: "", truncated: false })),
|
||||
})
|
||||
hook = createToolOutputTruncatorHook({} as never, {
|
||||
experimental: { truncate_all_tool_outputs: true },
|
||||
})
|
||||
|
||||
const input = createInput("Read")
|
||||
const output = createOutput("file content")
|
||||
|
||||
await hook["tool.execute.after"](input, output)
|
||||
|
||||
expect(truncateMock).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -2,6 +2,9 @@ import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import type { ExperimentalConfig } from "../config/schema"
|
||||
import { createDynamicTruncator } from "../shared/dynamic-truncator"
|
||||
|
||||
const DEFAULT_MAX_TOKENS = 50_000 // ~200k chars
|
||||
const WEBFETCH_MAX_TOKENS = 10_000 // ~40k chars - web pages need aggressive truncation
|
||||
|
||||
const TRUNCATABLE_TOOLS = [
|
||||
"grep",
|
||||
"Grep",
|
||||
@@ -21,6 +24,11 @@ const TRUNCATABLE_TOOLS = [
|
||||
"WebFetch",
|
||||
]
|
||||
|
||||
const TOOL_SPECIFIC_MAX_TOKENS: Record<string, number> = {
|
||||
webfetch: WEBFETCH_MAX_TOKENS,
|
||||
WebFetch: WEBFETCH_MAX_TOKENS,
|
||||
}
|
||||
|
||||
interface ToolOutputTruncatorOptions {
|
||||
experimental?: ExperimentalConfig
|
||||
}
|
||||
@@ -36,7 +44,12 @@ export function createToolOutputTruncatorHook(ctx: PluginInput, options?: ToolOu
|
||||
if (!truncateAll && !TRUNCATABLE_TOOLS.includes(input.tool)) return
|
||||
|
||||
try {
|
||||
const { result, truncated } = await truncator.truncate(input.sessionID, output.output)
|
||||
const targetMaxTokens = TOOL_SPECIFIC_MAX_TOKENS[input.tool] ?? DEFAULT_MAX_TOKENS
|
||||
const { result, truncated } = await truncator.truncate(
|
||||
input.sessionID,
|
||||
output.output,
|
||||
{ targetMaxTokens }
|
||||
)
|
||||
if (truncated) {
|
||||
output.output = result
|
||||
}
|
||||
|
||||
66
src/index.ts
66
src/index.ts
@@ -25,7 +25,13 @@ import {
|
||||
createThinkingBlockValidatorHook,
|
||||
createRalphLoopHook,
|
||||
createAutoSlashCommandHook,
|
||||
createEditErrorRecoveryHook,
|
||||
} from "./hooks";
|
||||
import {
|
||||
contextCollector,
|
||||
createContextInjectorHook,
|
||||
createContextInjectorMessagesTransformHook,
|
||||
} from "./features/context-injector";
|
||||
import { createGoogleAntigravityAuthPlugin } from "./auth/antigravity";
|
||||
import {
|
||||
discoverUserClaudeSkills,
|
||||
@@ -47,18 +53,24 @@ import {
|
||||
createLookAt,
|
||||
createSkillTool,
|
||||
createSkillMcpTool,
|
||||
createSlashcommandTool,
|
||||
discoverCommandsSync,
|
||||
sessionExists,
|
||||
interactive_bash,
|
||||
getTmuxPath,
|
||||
startTmuxCheck,
|
||||
} from "./tools";
|
||||
import { BackgroundManager } from "./features/background-agent";
|
||||
import { SkillMcpManager } from "./features/skill-mcp-manager";
|
||||
import { type OhMyOpenCodeConfig, type HookName } from "./config";
|
||||
import { type HookName } from "./config";
|
||||
import { log } from "./shared";
|
||||
import { loadPluginConfig } from "./plugin-config";
|
||||
import { createModelCacheState, getModelLimit } from "./plugin-state";
|
||||
import { createConfigHandler } from "./plugin-handlers";
|
||||
|
||||
const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
// Start background tmux check immediately
|
||||
startTmuxCheck();
|
||||
|
||||
const pluginConfig = loadPluginConfig(ctx.directory, ctx);
|
||||
const disabledHooks = new Set(pluginConfig.disabled_hooks ?? []);
|
||||
const isHookEnabled = (hookName: HookName) => !disabledHooks.has(hookName);
|
||||
@@ -128,6 +140,9 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
const keywordDetector = isHookEnabled("keyword-detector")
|
||||
? createKeywordDetectorHook(ctx)
|
||||
: null;
|
||||
const contextInjector = createContextInjectorHook(contextCollector);
|
||||
const contextInjectorMessagesTransform =
|
||||
createContextInjectorMessagesTransformHook(contextCollector);
|
||||
const agentUsageReminder = isHookEnabled("agent-usage-reminder")
|
||||
? createAgentUsageReminderHook(ctx)
|
||||
: null;
|
||||
@@ -145,11 +160,14 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
: null;
|
||||
|
||||
const ralphLoop = isHookEnabled("ralph-loop")
|
||||
? createRalphLoopHook(ctx, { config: pluginConfig.ralph_loop })
|
||||
? createRalphLoopHook(ctx, {
|
||||
config: pluginConfig.ralph_loop,
|
||||
checkSessionExists: async (sessionId) => sessionExists(sessionId),
|
||||
})
|
||||
: null;
|
||||
|
||||
const autoSlashCommand = isHookEnabled("auto-slash-command")
|
||||
? createAutoSlashCommandHook()
|
||||
const editErrorRecovery = isHookEnabled("edit-error-recovery")
|
||||
? createEditErrorRecoveryHook(ctx)
|
||||
: null;
|
||||
|
||||
const backgroundManager = new BackgroundManager(ctx);
|
||||
@@ -184,13 +202,19 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
return true;
|
||||
});
|
||||
const includeClaudeSkills = pluginConfig.claude_code?.skills !== false;
|
||||
const [userSkills, globalSkills, projectSkills, opencodeProjectSkills] = await Promise.all([
|
||||
includeClaudeSkills ? discoverUserClaudeSkills() : Promise.resolve([]),
|
||||
discoverOpencodeGlobalSkills(),
|
||||
includeClaudeSkills ? discoverProjectClaudeSkills() : Promise.resolve([]),
|
||||
discoverOpencodeProjectSkills(),
|
||||
]);
|
||||
const mergedSkills = mergeSkills(
|
||||
builtinSkills,
|
||||
pluginConfig.skills,
|
||||
includeClaudeSkills ? discoverUserClaudeSkills() : [],
|
||||
discoverOpencodeGlobalSkills(),
|
||||
includeClaudeSkills ? discoverProjectClaudeSkills() : [],
|
||||
discoverOpencodeProjectSkills()
|
||||
userSkills,
|
||||
globalSkills,
|
||||
projectSkills,
|
||||
opencodeProjectSkills
|
||||
);
|
||||
const skillMcpManager = new SkillMcpManager();
|
||||
const getSessionIDForMcp = () => getMainSessionID() || "";
|
||||
@@ -205,12 +229,19 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
getSessionID: getSessionIDForMcp,
|
||||
});
|
||||
|
||||
const googleAuthHooks =
|
||||
pluginConfig.google_auth !== false
|
||||
? await createGoogleAntigravityAuthPlugin(ctx)
|
||||
: null;
|
||||
const commands = discoverCommandsSync();
|
||||
const slashcommandTool = createSlashcommandTool({
|
||||
commands,
|
||||
skills: mergedSkills,
|
||||
});
|
||||
|
||||
const tmuxAvailable = await getTmuxPath();
|
||||
const autoSlashCommand = isHookEnabled("auto-slash-command")
|
||||
? createAutoSlashCommandHook({ skills: mergedSkills })
|
||||
: null;
|
||||
|
||||
const googleAuthHooks = pluginConfig.google_auth !== false
|
||||
? await createGoogleAntigravityAuthPlugin(ctx)
|
||||
: null;
|
||||
|
||||
const configHandler = createConfigHandler({
|
||||
ctx,
|
||||
@@ -228,12 +259,14 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
look_at: lookAt,
|
||||
skill: skillTool,
|
||||
skill_mcp: skillMcpTool,
|
||||
...(tmuxAvailable ? { interactive_bash } : {}),
|
||||
slashcommand: slashcommandTool,
|
||||
interactive_bash,
|
||||
},
|
||||
|
||||
"chat.message": async (input, output) => {
|
||||
await claudeCodeHooks["chat.message"]?.(input, output);
|
||||
await keywordDetector?.["chat.message"]?.(input, output);
|
||||
await contextInjector["chat.message"]?.(input, output);
|
||||
await autoSlashCommand?.["chat.message"]?.(input, output);
|
||||
|
||||
if (ralphLoop) {
|
||||
@@ -294,6 +327,8 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
input: Record<string, never>,
|
||||
output: { messages: Array<{ info: unknown; parts: unknown[] }> }
|
||||
) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await contextInjectorMessagesTransform?.["experimental.chat.messages.transform"]?.(input, output as any);
|
||||
await thinkingBlockValidator?.[
|
||||
"experimental.chat.messages.transform"
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -436,6 +471,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
await emptyTaskResponseDetector?.["tool.execute.after"](input, output);
|
||||
await agentUsageReminder?.["tool.execute.after"](input, output);
|
||||
await interactiveBashSession?.["tool.execute.after"](input, output);
|
||||
await editErrorRecovery?.["tool.execute.after"](input, output);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
86
src/mcp/index.test.ts
Normal file
86
src/mcp/index.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
@@ -21,6 +21,7 @@ import { loadAllPluginComponents } from "../features/claude-code-plugin-loader";
|
||||
import { createBuiltinMcps } from "../mcp";
|
||||
import type { OhMyOpenCodeConfig } from "../config";
|
||||
import { log } from "../shared";
|
||||
import { migrateAgentConfig } from "../shared/permission-compat";
|
||||
import { PLAN_SYSTEM_PROMPT, PLAN_PERMISSION } from "../agents/plan-prompt";
|
||||
import type { ModelCacheState } from "../plugin-state";
|
||||
|
||||
@@ -95,13 +96,24 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
|
||||
config.model as string | undefined
|
||||
);
|
||||
|
||||
// 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 projectAgents = (pluginConfig.claude_code?.agents ?? true)
|
||||
? loadProjectAgents()
|
||||
: {};
|
||||
const pluginAgents = pluginComponents.agents;
|
||||
|
||||
// Plugin agents: Apply permission migration for compatibility
|
||||
const rawPluginAgents = pluginComponents.agents;
|
||||
const pluginAgents = Object.fromEntries(
|
||||
Object.entries(rawPluginAgents).map(([k, v]) => [
|
||||
k,
|
||||
v ? migrateAgentConfig(v as Record<string, unknown>) : v,
|
||||
])
|
||||
);
|
||||
|
||||
const isSisyphusEnabled = pluginConfig.sisyphus_agent?.disabled !== true;
|
||||
const builderEnabled =
|
||||
@@ -132,10 +144,13 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
|
||||
if (builderEnabled) {
|
||||
const { name: _buildName, ...buildConfigWithoutName } =
|
||||
configAgent?.build ?? {};
|
||||
const migratedBuildConfig = migrateAgentConfig(
|
||||
buildConfigWithoutName as Record<string, unknown>
|
||||
);
|
||||
const openCodeBuilderOverride =
|
||||
pluginConfig.agents?.["OpenCode-Builder"];
|
||||
const openCodeBuilderBase = {
|
||||
...buildConfigWithoutName,
|
||||
...migratedBuildConfig,
|
||||
description: `${configAgent?.build?.description ?? "Build agent"} (OpenCode default)`,
|
||||
};
|
||||
|
||||
@@ -145,12 +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 = {
|
||||
...planConfigWithoutName,
|
||||
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)`,
|
||||
@@ -162,16 +182,29 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
|
||||
: plannerSisyphusBase;
|
||||
}
|
||||
|
||||
const filteredConfigAgents = configAgent
|
||||
? Object.fromEntries(
|
||||
Object.entries(configAgent).filter(([key]) => {
|
||||
const filteredConfigAgents = configAgent
|
||||
? Object.fromEntries(
|
||||
Object.entries(configAgent)
|
||||
.filter(([key]) => {
|
||||
if (key === "build") return false;
|
||||
if (key === "plan" && replacePlan) return false;
|
||||
return true;
|
||||
})
|
||||
)
|
||||
.map(([key, value]) => [
|
||||
key,
|
||||
value ? migrateAgentConfig(value as Record<string, unknown>) : value,
|
||||
])
|
||||
)
|
||||
: {};
|
||||
|
||||
const migratedBuild = configAgent?.build
|
||||
? migrateAgentConfig(configAgent.build as Record<string, unknown>)
|
||||
: {};
|
||||
|
||||
const planDemoteConfig = replacePlan
|
||||
? { mode: "subagent" as const, hidden: true }
|
||||
: undefined;
|
||||
|
||||
config.agent = {
|
||||
...agentConfig,
|
||||
...Object.fromEntries(
|
||||
@@ -181,10 +214,8 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
|
||||
...projectAgents,
|
||||
...pluginAgents,
|
||||
...filteredConfigAgents,
|
||||
build: { ...configAgent?.build, mode: "subagent" },
|
||||
...(replacePlan
|
||||
? { plan: { ...configAgent?.plan, mode: "subagent" } }
|
||||
: {}),
|
||||
build: { ...migratedBuild, mode: "subagent", hidden: true },
|
||||
...(planDemoteConfig ? { plan: planDemoteConfig } : {}),
|
||||
};
|
||||
} else {
|
||||
config.agent = {
|
||||
@@ -243,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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin";
|
||||
|
||||
const ANTHROPIC_ACTUAL_LIMIT = 200_000;
|
||||
const ANTHROPIC_ACTUAL_LIMIT =
|
||||
process.env.ANTHROPIC_1M_CONTEXT === "true" ||
|
||||
process.env.VERTEX_ANTHROPIC_1M_CONTEXT === "true"
|
||||
? 1_000_000
|
||||
: 200_000;
|
||||
const CHARS_PER_TOKEN_ESTIMATE = 4;
|
||||
const DEFAULT_TARGET_MAX_TOKENS = 50_000;
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user