fix: resolve 25 pre-publish blockers

- postinstall.mjs: fix alias package detection
- migrate-legacy-plugin-entry: dedupe + regression tests
- task_system: default consistency across runtime paths
- task() contract: consistent tool behavior
- runtime model selection, tool cap, stale-task cancellation
- recovery sanitization, context-limit gating
- Ralph semantic DONE hardening, Atlas fallback persistence
- native-skill description/content, skill path traversal guard
- publish workflow: platform awaited via reusable workflow job
- release: version edits reapplied before commit/tag
- JSONC plugin migration: top-level plugin key safety
- cold-cache: user fallback models skip disconnected providers
- docs/version/release framing updates

Verified: bun test (4599 pass), tsc --noEmit clean, bun run build clean
This commit is contained in:
YeonGyu-Kim
2026-03-28 15:24:18 +09:00
parent 44b039bef6
commit d2c576c510
62 changed files with 1264 additions and 292 deletions

View File

@@ -56,10 +56,33 @@ jobs:
env: env:
BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/napi" BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/napi"
- name: Validate release inputs
id: validate
env:
INPUT_VERSION: ${{ inputs.version }}
INPUT_DIST_TAG: ${{ inputs.dist_tag }}
run: |
VERSION="$INPUT_VERSION"
DIST_TAG="$INPUT_DIST_TAG"
if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z]+(\.[0-9A-Za-z]+)*)?$ ]]; then
echo "::error::Invalid version: $VERSION"
exit 1
fi
if [ -n "$DIST_TAG" ] && ! [[ "$DIST_TAG" =~ ^[a-z][a-z0-9-]*$ ]]; then
echo "::error::Invalid dist_tag: $DIST_TAG"
exit 1
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "dist_tag=$DIST_TAG" >> $GITHUB_OUTPUT
- name: Check if already published - name: Check if already published
id: check id: check
env:
VERSION: ${{ steps.validate.outputs.version }}
run: | run: |
VERSION="${{ inputs.version }}"
PLATFORM_KEY="${{ matrix.platform }}" PLATFORM_KEY="${{ matrix.platform }}"
PLATFORM_KEY="${PLATFORM_KEY//-/_}" PLATFORM_KEY="${PLATFORM_KEY//-/_}"
@@ -96,15 +119,18 @@ jobs:
- name: Update version in package.json - name: Update version in package.json
if: steps.check.outputs.skip != 'true' if: steps.check.outputs.skip != 'true'
env:
VERSION: ${{ steps.validate.outputs.version }}
run: | run: |
VERSION="${{ inputs.version }}"
cd packages/${{ matrix.platform }} cd packages/${{ matrix.platform }}
jq --arg v "$VERSION" '.version = $v' package.json > tmp.json && mv tmp.json package.json jq --arg v "$VERSION" '.version = $v' package.json > tmp.json && mv tmp.json package.json
- name: Set root package version - name: Set root package version
if: steps.check.outputs.skip != 'true' if: steps.check.outputs.skip != 'true'
env:
VERSION: ${{ steps.validate.outputs.version }}
run: | run: |
jq --arg v "${{ inputs.version }}" '.version = $v' package.json > tmp.json && mv tmp.json package.json jq --arg v "$VERSION" '.version = $v' package.json > tmp.json && mv tmp.json package.json
- name: Pre-download baseline compile target - name: Pre-download baseline compile target
if: steps.check.outputs.skip != 'true' && endsWith(matrix.platform, '-baseline') if: steps.check.outputs.skip != 'true' && endsWith(matrix.platform, '-baseline')
@@ -226,11 +252,33 @@ jobs:
matrix: matrix:
platform: [darwin-arm64, darwin-x64, darwin-x64-baseline, linux-x64, linux-x64-baseline, linux-arm64, linux-x64-musl, linux-x64-musl-baseline, linux-arm64-musl, windows-x64, windows-x64-baseline] platform: [darwin-arm64, darwin-x64, darwin-x64-baseline, linux-x64, linux-x64-baseline, linux-arm64, linux-x64-musl, linux-x64-musl-baseline, linux-arm64-musl, windows-x64, windows-x64-baseline]
steps: steps:
- name: Validate release inputs
id: validate
env:
INPUT_VERSION: ${{ inputs.version }}
INPUT_DIST_TAG: ${{ inputs.dist_tag }}
run: |
VERSION="$INPUT_VERSION"
DIST_TAG="$INPUT_DIST_TAG"
if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z]+(\.[0-9A-Za-z]+)*)?$ ]]; then
echo "::error::Invalid version: $VERSION"
exit 1
fi
if [ -n "$DIST_TAG" ] && ! [[ "$DIST_TAG" =~ ^[a-z][a-z0-9-]*$ ]]; then
echo "::error::Invalid dist_tag: $DIST_TAG"
exit 1
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "dist_tag=$DIST_TAG" >> $GITHUB_OUTPUT
- name: Check if already published - name: Check if already published
id: check id: check
env:
VERSION: ${{ steps.validate.outputs.version }}
run: | run: |
VERSION="${{ inputs.version }}"
OC_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://registry.npmjs.org/oh-my-opencode-${{ matrix.platform }}/${VERSION}") OC_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://registry.npmjs.org/oh-my-opencode-${{ matrix.platform }}/${VERSION}")
OA_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://registry.npmjs.org/oh-my-openagent-${{ matrix.platform }}/${VERSION}") OA_STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://registry.npmjs.org/oh-my-openagent-${{ matrix.platform }}/${VERSION}")
@@ -288,22 +336,26 @@ jobs:
- name: Publish oh-my-opencode-${{ matrix.platform }} - name: Publish oh-my-opencode-${{ matrix.platform }}
if: steps.check.outputs.skip_opencode != 'true' && steps.download.outcome == 'success' if: steps.check.outputs.skip_opencode != 'true' && steps.download.outcome == 'success'
env:
DIST_TAG: ${{ steps.validate.outputs.dist_tag }}
NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }}
NPM_CONFIG_PROVENANCE: true
run: | run: |
cd packages/${{ matrix.platform }} cd packages/${{ matrix.platform }}
TAG_ARG="" if [ -n "$DIST_TAG" ]; then
if [ -n "${{ inputs.dist_tag }}" ]; then npm publish --access public --provenance --tag "$DIST_TAG"
TAG_ARG="--tag ${{ inputs.dist_tag }}" else
npm publish --access public --provenance
fi fi
npm publish --access public --provenance $TAG_ARG
env:
NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }}
NPM_CONFIG_PROVENANCE: true
timeout-minutes: 15 timeout-minutes: 15
- name: Publish oh-my-openagent-${{ matrix.platform }} - name: Publish oh-my-openagent-${{ matrix.platform }}
if: steps.check.outputs.skip_openagent != 'true' && steps.download.outcome == 'success' if: steps.check.outputs.skip_openagent != 'true' && steps.download.outcome == 'success'
env:
DIST_TAG: ${{ steps.validate.outputs.dist_tag }}
NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }}
NPM_CONFIG_PROVENANCE: true
run: | run: |
cd packages/${{ matrix.platform }} cd packages/${{ matrix.platform }}
@@ -313,13 +365,9 @@ jobs:
'.name = $name | .description = $desc | .bin = {"oh-my-openagent": (.bin | to_entries | .[0].value)}' \ '.name = $name | .description = $desc | .bin = {"oh-my-openagent": (.bin | to_entries | .[0].value)}' \
package.json > tmp.json && mv tmp.json package.json package.json > tmp.json && mv tmp.json package.json
TAG_ARG="" if [ -n "$DIST_TAG" ]; then
if [ -n "${{ inputs.dist_tag }}" ]; then npm publish --access public --provenance --tag "$DIST_TAG"
TAG_ARG="--tag ${{ inputs.dist_tag }}" else
npm publish --access public --provenance
fi fi
npm publish --access public --provenance $TAG_ARG
env:
NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }}
NPM_CONFIG_PROVENANCE: true
timeout-minutes: 15 timeout-minutes: 15

View File

@@ -167,22 +167,35 @@ jobs:
- name: Calculate version - name: Calculate version
id: version id: version
env:
RAW_VERSION: ${{ inputs.version }}
BUMP: ${{ inputs.bump }}
run: | run: |
VERSION="${{ inputs.version }}" VERSION="$RAW_VERSION"
if [ -z "$VERSION" ]; then if [ -z "$VERSION" ]; then
PREV=$(curl -s https://registry.npmjs.org/oh-my-opencode/latest | jq -r '.version // "0.0.0"') PREV=$(curl -s https://registry.npmjs.org/oh-my-opencode/latest | jq -r '.version // "0.0.0"')
BASE="${PREV%%-*}" BASE="${PREV%%-*}"
IFS='.' read -r MAJOR MINOR PATCH <<< "$BASE" IFS='.' read -r MAJOR MINOR PATCH <<< "$BASE"
case "${{ inputs.bump }}" in case "$BUMP" in
major) VERSION="$((MAJOR+1)).0.0" ;; major) VERSION="$((MAJOR+1)).0.0" ;;
minor) VERSION="${MAJOR}.$((MINOR+1)).0" ;; minor) VERSION="${MAJOR}.$((MINOR+1)).0" ;;
*) VERSION="${MAJOR}.${MINOR}.$((PATCH+1))" ;; *) VERSION="${MAJOR}.${MINOR}.$((PATCH+1))" ;;
esac esac
fi fi
if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z]+(\.[0-9A-Za-z]+)*)?$ ]]; then
echo "::error::Invalid version: $VERSION"
exit 1
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT echo "version=$VERSION" >> $GITHUB_OUTPUT
if [[ "$VERSION" == *"-"* ]]; then if [[ "$VERSION" == *"-"* ]]; then
DIST_TAG=$(echo "$VERSION" | cut -d'-' -f2 | cut -d'.' -f1) DIST_TAG=$(printf '%s' "$VERSION" | cut -d'-' -f2 | cut -d'.' -f1)
if ! [[ "$DIST_TAG" =~ ^[a-z][a-z0-9-]*$ ]]; then
echo "::error::Invalid dist_tag: $DIST_TAG"
exit 1
fi
echo "dist_tag=${DIST_TAG:-next}" >> $GITHUB_OUTPUT echo "dist_tag=${DIST_TAG:-next}" >> $GITHUB_OUTPUT
else else
echo "dist_tag=" >> $GITHUB_OUTPUT echo "dist_tag=" >> $GITHUB_OUTPUT
@@ -192,8 +205,9 @@ jobs:
- name: Check if already published - name: Check if already published
id: check id: check
env:
VERSION: ${{ steps.version.outputs.version }}
run: | run: |
VERSION="${{ steps.version.outputs.version }}"
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://registry.npmjs.org/oh-my-opencode/${VERSION}") STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://registry.npmjs.org/oh-my-opencode/${VERSION}")
if [ "$STATUS" = "200" ]; then if [ "$STATUS" = "200" ]; then
echo "skip=true" >> $GITHUB_OUTPUT echo "skip=true" >> $GITHUB_OUTPUT
@@ -204,8 +218,9 @@ jobs:
- name: Update version - name: Update version
if: steps.check.outputs.skip != 'true' if: steps.check.outputs.skip != 'true'
env:
VERSION: ${{ steps.version.outputs.version }}
run: | run: |
VERSION="${{ steps.version.outputs.version }}"
jq --arg v "$VERSION" '.version = $v' package.json > tmp.json && mv tmp.json package.json jq --arg v "$VERSION" '.version = $v' package.json > tmp.json && mv tmp.json package.json
for platform in darwin-arm64 darwin-x64 darwin-x64-baseline linux-x64 linux-x64-baseline linux-arm64 linux-x64-musl linux-x64-musl-baseline linux-arm64-musl windows-x64 windows-x64-baseline; do for platform in darwin-arm64 darwin-x64 darwin-x64-baseline linux-x64 linux-x64-baseline linux-arm64 linux-x64-musl linux-x64-musl-baseline linux-arm64-musl windows-x64 windows-x64-baseline; do
@@ -225,33 +240,22 @@ jobs:
- name: Publish oh-my-opencode - name: Publish oh-my-opencode
if: steps.check.outputs.skip != 'true' if: steps.check.outputs.skip != 'true'
run: |
TAG_ARG=""
if [ -n "${{ steps.version.outputs.dist_tag }}" ]; then
TAG_ARG="--tag ${{ steps.version.outputs.dist_tag }}"
fi
npm publish --access public --provenance $TAG_ARG
env: env:
DIST_TAG: ${{ steps.version.outputs.dist_tag }}
NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }} NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }}
NPM_CONFIG_PROVENANCE: true NPM_CONFIG_PROVENANCE: true
- name: Commit version bump
if: steps.check.outputs.skip != 'true'
run: | run: |
git config user.email "github-actions[bot]@users.noreply.github.com" if [ -n "$DIST_TAG" ]; then
git config user.name "github-actions[bot]" npm publish --access public --provenance --tag "$DIST_TAG"
git add package.json packages/*/package.json else
git diff --cached --quiet || git commit -m "release: v${{ steps.version.outputs.version }}" npm publish --access public --provenance
git tag -f "v${{ steps.version.outputs.version }}" fi
git push origin --tags --force
git push origin HEAD
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Check if oh-my-openagent already published - name: Check if oh-my-openagent already published
id: check-openagent id: check-openagent
env:
VERSION: ${{ steps.version.outputs.version }}
run: | run: |
VERSION="${{ steps.version.outputs.version }}"
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://registry.npmjs.org/oh-my-openagent/${VERSION}") STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://registry.npmjs.org/oh-my-openagent/${VERSION}")
if [ "$STATUS" = "200" ]; then if [ "$STATUS" = "200" ]; then
echo "skip=true" >> $GITHUB_OUTPUT echo "skip=true" >> $GITHUB_OUTPUT
@@ -262,9 +266,12 @@ jobs:
- name: Publish oh-my-openagent - name: Publish oh-my-openagent
if: steps.check-openagent.outputs.skip != 'true' if: steps.check-openagent.outputs.skip != 'true'
env:
VERSION: ${{ steps.version.outputs.version }}
DIST_TAG: ${{ steps.version.outputs.dist_tag }}
NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }}
NPM_CONFIG_PROVENANCE: true
run: | run: |
VERSION="${{ steps.version.outputs.version }}"
# Update package name, version, and optionalDependencies for oh-my-openagent # Update package name, version, and optionalDependencies for oh-my-openagent
jq --arg v "$VERSION" ' jq --arg v "$VERSION" '
.name = "oh-my-openagent" | .name = "oh-my-openagent" |
@@ -276,38 +283,30 @@ jobs:
) )
' package.json > tmp.json && mv tmp.json package.json ' package.json > tmp.json && mv tmp.json package.json
TAG_ARG="" if [ -n "$DIST_TAG" ]; then
if [ -n "${{ steps.version.outputs.dist_tag }}" ]; then npm publish --access public --provenance --tag "$DIST_TAG"
TAG_ARG="--tag ${{ steps.version.outputs.dist_tag }}" else
npm publish --access public --provenance
fi fi
npm publish --access public --provenance $TAG_ARG || echo "::warning::oh-my-openagent publish failed"
env:
NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }}
NPM_CONFIG_PROVENANCE: true
- name: Restore package.json - name: Restore package.json
if: steps.check-openagent.outputs.skip != 'true' if: always() && steps.check-openagent.outputs.skip != 'true'
run: | run: |
git checkout -- package.json git checkout -- package.json
trigger-platform: publish-platform:
runs-on: ubuntu-latest
needs: publish-main needs: publish-main
if: inputs.skip_platform != true if: inputs.skip_platform != true
steps: uses: ./.github/workflows/publish-platform.yml
- name: Trigger platform publish workflow with:
run: | version: ${{ needs.publish-main.outputs.version }}
gh workflow run publish-platform.yml \ dist_tag: ${{ needs.publish-main.outputs.dist_tag }}
--repo ${{ github.repository }} \ secrets: inherit
--ref ${{ github.ref }} \
-f version=${{ needs.publish-main.outputs.version }} \
-f dist_tag=${{ needs.publish-main.outputs.dist_tag }}
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
release: release:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: publish-main needs: [publish-main, publish-platform]
if: always() && needs.publish-main.result == 'success' && (inputs.skip_platform == true || needs.publish-platform.result == 'success')
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
with: with:
@@ -331,13 +330,53 @@ jobs:
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Create GitHub release - name: Apply release version to source tree
env:
VERSION: ${{ needs.publish-main.outputs.version }}
run: |
jq --arg v "$VERSION" '.version = $v' package.json > tmp.json && mv tmp.json package.json
for platform in darwin-arm64 darwin-x64 darwin-x64-baseline linux-x64 linux-x64-baseline linux-arm64 linux-x64-musl linux-x64-musl-baseline linux-arm64-musl windows-x64 windows-x64-baseline; do
jq --arg v "$VERSION" '.version = $v' "packages/${platform}/package.json" > tmp.json
mv tmp.json "packages/${platform}/package.json"
done
jq --arg v "$VERSION" '.optionalDependencies = (.optionalDependencies | to_entries | map(.value = $v) | from_entries)' package.json > tmp.json && mv tmp.json package.json
- name: Commit version bump
env:
VERSION: ${{ needs.publish-main.outputs.version }}
run: |
git config user.email "github-actions[bot]@users.noreply.github.com"
git config user.name "github-actions[bot]"
git add package.json packages/*/package.json
git diff --cached --quiet || git commit -m "release: v${VERSION}"
- name: Create release tag
env:
VERSION: ${{ needs.publish-main.outputs.version }}
run: |
if git rev-parse "v${VERSION}" >/dev/null 2>&1; then
echo "::error::Tag v${VERSION} already exists"
exit 1
fi
git tag "v${VERSION}"
- name: Push release state
env:
VERSION: ${{ needs.publish-main.outputs.version }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
git push origin HEAD
git push origin "v${VERSION}"
- name: Create GitHub release
env:
VERSION: ${{ needs.publish-main.outputs.version }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: | run: |
VERSION="${{ needs.publish-main.outputs.version }}"
gh release view "v${VERSION}" >/dev/null 2>&1 || \ gh release view "v${VERSION}" >/dev/null 2>&1 || \
gh release create "v${VERSION}" --title "v${VERSION}" --notes-file /tmp/changelog.md gh release create "v${VERSION}" --title "v${VERSION}" --notes-file /tmp/changelog.md
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Delete draft release - name: Delete draft release
run: gh release delete next --yes 2>/dev/null || true run: gh release delete next --yes 2>/dev/null || true
@@ -346,13 +385,13 @@ jobs:
- name: Merge to master - name: Merge to master
continue-on-error: true continue-on-error: true
env:
VERSION: ${{ needs.publish-main.outputs.version }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: | run: |
git config user.name "github-actions[bot]" git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com" git config user.email "github-actions[bot]@users.noreply.github.com"
VERSION="${{ needs.publish-main.outputs.version }}"
git stash --include-untracked || true git stash --include-untracked || true
git checkout master git checkout master
git reset --hard "v${VERSION}" git reset --hard "v${VERSION}"
git push -f origin master || echo "::warning::Failed to push to master" git push -f origin master || echo "::warning::Failed to push to master"
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -4885,6 +4885,11 @@
"additionalProperties": false "additionalProperties": false
}, },
"git_master": { "git_master": {
"default": {
"commit_footer": true,
"include_co_authored_by": true,
"git_env_prefix": "GIT_MASTER=1"
},
"type": "object", "type": "object",
"properties": { "properties": {
"commit_footer": { "commit_footer": {
@@ -5035,5 +5040,8 @@
} }
} }
}, },
"required": [
"git_master"
],
"additionalProperties": false "additionalProperties": false
} }

View File

@@ -92,8 +92,8 @@ These agents do grep, search, and retrieval. They intentionally use the fastest,
| Agent | Role | Fallback Chain | Notes | | Agent | Role | Fallback Chain | Notes |
| --------------------- | ------------------ | ---------------------------------------------- | ----------------------------------------------------- | | --------------------- | ------------------ | ---------------------------------------------- | ----------------------------------------------------- |
| **Explore** | Fast codebase grep | Grok Code Fast → opencode-go/minimax-m2.7 → opencode/minimax-m2.5 → Haiku → GPT-5-Nano | Speed is everything. Fire 10 in parallel. Uses opencode-go/minimax-m2.7 where the provider catalog exposes it, falling back to opencode/minimax-m2.5. | | **Explore** | Fast codebase grep | Grok Code Fast → opencode-go/minimax-m2.7-highspeed → opencode/minimax-m2.7 → Haiku → GPT-5-Nano | Speed is everything. Fire 10 in parallel. Uses the high-speed OpenCode Go MiniMax entry first, then the standard OpenCode Zen MiniMax fallback. |
| **Librarian** | Docs/code search | opencode-go/minimax-m2.7 → opencode/minimax-m2.5 → Haiku → GPT-5-Nano | Doc retrieval doesn't need deep reasoning. Uses opencode-go/minimax-m2.7 where the provider catalog exposes it, falling back to opencode/minimax-m2.5. | | **Librarian** | Docs/code search | opencode-go/minimax-m2.7 → opencode/minimax-m2.7-highspeed → Haiku → GPT-5-Nano | Doc retrieval doesn't need deep reasoning. Uses OpenCode Go MiniMax first, then the OpenCode Zen high-speed MiniMax fallback. |
| **Multimodal Looker** | Vision/screenshots | GPT-5.4 → opencode-go/kimi-k2.5 → GLM-4.6v → GPT-5-Nano | Uses the first available multimodal-capable fallback. | | **Multimodal Looker** | Vision/screenshots | GPT-5.4 → opencode-go/kimi-k2.5 → GLM-4.6v → GPT-5-Nano | Uses the first available multimodal-capable fallback. |
| **Sisyphus-Junior** | Category executor | Claude Sonnet → opencode-go/kimi-k2.5 → GPT-5.4 → MiniMax M2.7 → Big Pickle | Handles delegated category tasks. Sonnet-tier default. | | **Sisyphus-Junior** | Category executor | Claude Sonnet → opencode-go/kimi-k2.5 → GPT-5.4 → MiniMax M2.7 → Big Pickle | Handles delegated category tasks. Sonnet-tier default. |
@@ -131,8 +131,8 @@ Principle-driven, explicit reasoning, deep technical capability. Best for agents
| **Gemini 3.1 Pro** | Excels at visual/frontend tasks. Different reasoning style. Default for `visual-engineering` and `artistry`. | | **Gemini 3.1 Pro** | Excels at visual/frontend tasks. Different reasoning style. Default for `visual-engineering` and `artistry`. |
| **Gemini 3 Flash** | Fast. Good for doc search and light tasks. | | **Gemini 3 Flash** | Fast. Good for doc search and light tasks. |
| **Grok Code Fast 1** | Blazing fast code grep. Default for Explore agent. | | **Grok Code Fast 1** | Blazing fast code grep. Default for Explore agent. |
| **MiniMax M2.7** | Fast and smart. Used where provider catalogs expose the newer MiniMax line, especially through OpenCode Go. | | **MiniMax M2.7** | Fast and smart. Used in OpenCode Go and OpenCode Zen utility fallback chains. |
| **MiniMax M2.5** | Legacy OpenCode catalog entry still used in some fallback chains for compatibility. | | **MiniMax M2.7 Highspeed** | High-speed OpenCode catalog entry used in utility fallback chains that prefer the fastest available MiniMax path. |
### OpenCode Go ### OpenCode Go
@@ -144,7 +144,8 @@ A premium subscription tier ($10/month) that provides reliable access to Chinese
| ------------------------ | --------------------------------------------------------------------- | | ------------------------ | --------------------------------------------------------------------- |
| **opencode-go/kimi-k2.5** | Vision-capable, Claude-like reasoning. Used by Sisyphus, Atlas, Sisyphus-Junior, Multimodal Looker. | | **opencode-go/kimi-k2.5** | Vision-capable, Claude-like reasoning. Used by Sisyphus, Atlas, Sisyphus-Junior, Multimodal Looker. |
| **opencode-go/glm-5** | Text-only orchestration model. Used by Oracle, Prometheus, Metis, Momus. | | **opencode-go/glm-5** | Text-only orchestration model. Used by Oracle, Prometheus, Metis, Momus. |
| **opencode-go/minimax-m2.7** | Ultra-cheap, fast responses. Used by Librarian, Explore, Atlas, and Sisyphus-Junior for utility work. | | **opencode-go/minimax-m2.7** | Ultra-cheap, fast responses. Used by Librarian, Atlas, and Sisyphus-Junior for utility work. |
| **opencode-go/minimax-m2.7-highspeed** | Even faster OpenCode Go MiniMax entry used by Explore when the high-speed catalog entry is available. |
**When It Gets Used:** **When It Gets Used:**
@@ -156,7 +157,7 @@ Some model identifiers like `k2p5` (paid Kimi K2.5) and `glm-5` may only be avai
### About Free-Tier Fallbacks ### About Free-Tier Fallbacks
You may see model names like `kimi-k2.5-free`, `minimax-m2.7`, `minimax-m2.5`, or `big-pickle` (GLM 4.6) in the source code or logs. These are provider-specific or speed-optimized entries in fallback chains. The exact MiniMax model can differ by provider catalog. You may see model names like `kimi-k2.5-free`, `minimax-m2.7`, `minimax-m2.7-highspeed`, or `big-pickle` (GLM 4.6) in the source code or logs. These are provider-specific or speed-optimized entries in fallback chains.
You don't need to configure them. The system includes them so it degrades gracefully when you don't have every paid subscription. If you have the paid version, the paid version is always preferred. You don't need to configure them. The system includes them so it degrades gracefully when you don't have every paid subscription. If you have the paid version, the paid version is always preferred.

View File

@@ -238,7 +238,7 @@ If Z.ai is your main provider, the most important fallbacks are:
#### OpenCode Zen #### OpenCode Zen
OpenCode Zen provides access to `opencode/` prefixed models including `opencode/claude-opus-4-6`, `opencode/gpt-5.4`, `opencode/gpt-5.3-codex`, `opencode/gpt-5-nano`, `opencode/glm-5`, `opencode/big-pickle`, and `opencode/minimax-m2.5`. OpenCode Zen provides access to `opencode/` prefixed models including `opencode/claude-opus-4-6`, `opencode/gpt-5.4`, `opencode/gpt-5.3-codex`, `opencode/gpt-5-nano`, `opencode/glm-5`, `opencode/big-pickle`, `opencode/minimax-m2.7`, and `opencode/minimax-m2.7-highspeed`.
When OpenCode Zen is the best available provider, these are the most relevant source-backed examples: When OpenCode Zen is the best available provider, these are the most relevant source-backed examples:
@@ -250,26 +250,21 @@ When OpenCode Zen is the best available provider, these are the most relevant so
##### Setup ##### Setup
Run the installer and select "Yes" for GitHub Copilot: Run the installer and select "Yes" for OpenCode Zen:
```bash ```bash
bunx oh-my-opencode install bunx oh-my-opencode install
# Select your subscriptions (Claude, ChatGPT, Gemini) # Select your subscriptions (Claude, ChatGPT, Gemini, OpenCode Zen, etc.)
# When prompted: "Do you have a GitHub Copilot subscription?" → Select "Yes" # When prompted: "Do you have access to OpenCode Zen (opencode/ models)?" → Select "Yes"
``` ```
Or use non-interactive mode: Or use non-interactive mode:
```bash ```bash
bunx oh-my-opencode install --no-tui --claude=no --openai=no --gemini=no --copilot=yes bunx oh-my-opencode install --no-tui --claude=no --openai=no --gemini=no --opencode-zen=yes
``` ```
Then authenticate with GitHub: This provider uses the `opencode/` model catalog. If your OpenCode environment prompts for provider authentication, follow the OpenCode provider flow for `opencode/` models instead of reusing the fallback-provider auth steps above.
```bash
opencode auth login
# Select: GitHub → Authenticate via OAuth
```
### Step 5: Understand Your Model Setup ### Step 5: Understand Your Model Setup
@@ -306,8 +301,8 @@ Not all models behave the same way. Understanding which models are "similar" hel
| --------------------- | -------------------------------- | ----------------------------------------------------------- | | --------------------- | -------------------------------- | ----------------------------------------------------------- |
| **Gemini 3.1 Pro** | google, github-copilot, opencode | Excels at visual/frontend tasks. Different reasoning style. | | **Gemini 3.1 Pro** | google, github-copilot, opencode | Excels at visual/frontend tasks. Different reasoning style. |
| **Gemini 3 Flash** | google, github-copilot, opencode | Fast, good for doc search and light tasks. | | **Gemini 3 Flash** | google, github-copilot, opencode | Fast, good for doc search and light tasks. |
| **MiniMax M2.7** | venice, opencode-go | Fast and smart. Good for utility tasks where the provider catalog exposes M2.7. | | **MiniMax M2.7** | opencode-go, opencode | Fast and smart. Utility fallbacks use `minimax-m2.7` or `minimax-m2.7-highspeed` depending on the chain. |
| **MiniMax M2.5** | opencode | Legacy OpenCode catalog entry still used in some fallback chains for compatibility. | | **MiniMax M2.7 Highspeed** | opencode | Faster OpenCode Zen variant used in utility fallback chains where the runtime prefers the high-speed catalog entry. |
**Speed-Focused Models**: **Speed-Focused Models**:
@@ -315,7 +310,7 @@ Not all models behave the same way. Understanding which models are "similar" hel
| ----------------------- | ---------------------- | -------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | | ----------------------- | ---------------------- | -------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| **Grok Code Fast 1** | github-copilot, venice | Very fast | Optimized for code grep/search. Default for Explore. | | **Grok Code Fast 1** | github-copilot, venice | Very fast | Optimized for code grep/search. Default for Explore. |
| **Claude Haiku 4.5** | anthropic, opencode | Fast | Good balance of speed and intelligence. | | **Claude Haiku 4.5** | anthropic, opencode | Fast | Good balance of speed and intelligence. |
| **MiniMax M2.5** | opencode | Very fast | Legacy OpenCode catalog entry that still appears in some utility fallback chains. | | **MiniMax M2.7 Highspeed** | opencode | Very fast | OpenCode Zen high-speed utility fallback used by runtime chains such as Librarian. |
| **GPT-5.3-codex-spark** | openai | Extremely fast | Blazing fast but compacts so aggressively that oh-my-openagent's context management doesn't work well with it. Not recommended for omo agents. | | **GPT-5.3-codex-spark** | openai | Extremely fast | Blazing fast but compacts so aggressively that oh-my-openagent's context management doesn't work well with it. Not recommended for omo agents. |
#### What Each Agent Does and Which Model It Got #### What Each Agent Does and Which Model It Got
@@ -354,8 +349,8 @@ These agents do search, grep, and retrieval. They intentionally use fast, cheap
| Agent | Role | Default Chain | Design Rationale | | Agent | Role | Default Chain | Design Rationale |
| --------------------- | ------------------ | ---------------------------------------------------------------------- | -------------------------------------------------------------- | | --------------------- | ------------------ | ---------------------------------------------------------------------- | -------------------------------------------------------------- |
| **Explore** | Fast codebase grep | Grok Code Fast → OpenCode Go MiniMax M2.7 → OpenCode MiniMax M2.5 → Haiku → GPT-5-Nano | Speed is everything. Grok is blazing fast for grep. | | **Explore** | Fast codebase grep | Grok Code Fast → OpenCode Go MiniMax M2.7 Highspeed → OpenCode MiniMax M2.7 → Haiku → GPT-5-Nano | Speed is everything. Grok is blazing fast for grep. |
| **Librarian** | Docs/code search | OpenCode Go MiniMax M2.7 → OpenCode MiniMax M2.5 → Haiku → GPT-5-Nano | Doc retrieval doesn't need deep reasoning. MiniMax is fast where the provider catalog supports it. | | **Librarian** | Docs/code search | OpenCode Go MiniMax M2.7 → OpenCode MiniMax M2.7 Highspeed → Haiku → GPT-5-Nano | Doc retrieval doesn't need deep reasoning. MiniMax is fast where the provider catalog supports it. |
| **Multimodal Looker** | Vision/screenshots | GPT-5.4 (medium) → Kimi K2.5 → GLM-4.6v → GPT-5-Nano | GPT-5.4 now leads the default vision path when available. | | **Multimodal Looker** | Vision/screenshots | GPT-5.4 (medium) → Kimi K2.5 → GLM-4.6v → GPT-5-Nano | GPT-5.4 now leads the default vision path when available. |
#### Why Different Models Need Different Prompts #### Why Different Models Need Different Prompts

View File

@@ -201,10 +201,10 @@ Manages OAuth 2.1 authentication for remote MCP servers.
bunx oh-my-opencode mcp oauth login <server-name> --server-url https://api.example.com bunx oh-my-opencode mcp oauth login <server-name> --server-url https://api.example.com
# Login with explicit client ID and scopes # Login with explicit client ID and scopes
bunx oh-my-opencode mcp oauth login my-api --server-url https://api.example.com --client-id my-client --scopes "read,write" bunx oh-my-opencode mcp oauth login my-api --server-url https://api.example.com --client-id my-client --scopes read write
# Remove stored OAuth tokens # Remove stored OAuth tokens
bunx oh-my-opencode mcp oauth logout <server-name> bunx oh-my-opencode mcp oauth logout <server-name> --server-url https://api.example.com
# Check OAuth token status # Check OAuth token status
bunx oh-my-opencode mcp oauth status [server-name] bunx oh-my-opencode mcp oauth status [server-name]
@@ -216,7 +216,7 @@ bunx oh-my-opencode mcp oauth status [server-name]
| -------------------- | ------------------------------------------------------------------------- | | -------------------- | ------------------------------------------------------------------------- |
| `--server-url <url>` | MCP server URL (required for login) | | `--server-url <url>` | MCP server URL (required for login) |
| `--client-id <id>` | OAuth client ID (optional if server supports Dynamic Client Registration) | | `--client-id <id>` | OAuth client ID (optional if server supports Dynamic Client Registration) |
| `--scopes <scopes>` | Comma-separated OAuth scopes | | `--scopes <scopes>` | OAuth scopes as separate variadic arguments (for example: `--scopes read write`) |
### Token Storage ### Token Storage

View File

@@ -358,8 +358,8 @@ Capability data comes from provider runtime metadata first. OmO also ships bundl
| **Sisyphus** | `claude-opus-4-6` | `claude-opus-4-6 (max)``kimi-k2.5` via OpenCode Go / Kimi providers → `gpt-5.4 (medium)``glm-5``big-pickle` | | **Sisyphus** | `claude-opus-4-6` | `claude-opus-4-6 (max)``kimi-k2.5` via OpenCode Go / Kimi providers → `gpt-5.4 (medium)``glm-5``big-pickle` |
| **Hephaestus** | `gpt-5.4` | `gpt-5.4 (medium)` | | **Hephaestus** | `gpt-5.4` | `gpt-5.4 (medium)` |
| **oracle** | `gpt-5.4` | `gpt-5.4 (high)``gemini-3.1-pro (high)``claude-opus-4-6 (max)``glm-5` | | **oracle** | `gpt-5.4` | `gpt-5.4 (high)``gemini-3.1-pro (high)``claude-opus-4-6 (max)``glm-5` |
| **librarian** | `minimax-m2.7` | `opencode-go/minimax-m2.7``opencode/minimax-m2.5``claude-haiku-4-5``gpt-5-nano` | | **librarian** | `minimax-m2.7` | `opencode-go/minimax-m2.7``opencode/minimax-m2.7-highspeed``claude-haiku-4-5``gpt-5-nano` |
| **explore** | `grok-code-fast-1` | `grok-code-fast-1``opencode-go/minimax-m2.7``opencode/minimax-m2.5``claude-haiku-4-5``gpt-5-nano` | | **explore** | `grok-code-fast-1` | `grok-code-fast-1``opencode-go/minimax-m2.7-highspeed``opencode/minimax-m2.7``claude-haiku-4-5``gpt-5-nano` |
| **multimodal-looker** | `gpt-5.4` | `gpt-5.4 (medium)``kimi-k2.5``glm-4.6v``gpt-5-nano` | | **multimodal-looker** | `gpt-5.4` | `gpt-5.4 (medium)``kimi-k2.5``glm-4.6v``gpt-5-nano` |
| **Prometheus** | `claude-opus-4-6` | `claude-opus-4-6 (max)``gpt-5.4 (high)``glm-5``gemini-3.1-pro` | | **Prometheus** | `claude-opus-4-6` | `claude-opus-4-6 (max)``gpt-5.4 (high)``glm-5``gemini-3.1-pro` |
| **Metis** | `claude-opus-4-6` | `claude-opus-4-6 (max)``gpt-5.4 (high)``glm-5``k2p5` | | **Metis** | `claude-opus-4-6` | `claude-opus-4-6 (max)``gpt-5.4 (high)``glm-5``k2p5` |
@@ -375,9 +375,9 @@ Capability data comes from provider runtime metadata first. OmO also ships bundl
| **deep** | `gpt-5.3-codex` | `gpt-5.3-codex``claude-opus-4-6``gemini-3.1-pro` | | **deep** | `gpt-5.3-codex` | `gpt-5.3-codex``claude-opus-4-6``gemini-3.1-pro` |
| **artistry** | `gemini-3.1-pro` | `gemini-3.1-pro``claude-opus-4-6``gpt-5.4` | | **artistry** | `gemini-3.1-pro` | `gemini-3.1-pro``claude-opus-4-6``gpt-5.4` |
| **quick** | `gpt-5.4-mini` | `gpt-5.4-mini``claude-haiku-4-5``gemini-3-flash``minimax-m2.7``gpt-5-nano` | | **quick** | `gpt-5.4-mini` | `gpt-5.4-mini``claude-haiku-4-5``gemini-3-flash``minimax-m2.7``gpt-5-nano` |
| **unspecified-low** | `claude-sonnet-4-6` | `claude-sonnet-4-6``gpt-5.3-codex``gemini-3-flash``minimax-m2.7` | | **unspecified-low** | `claude-sonnet-4-6` | `claude-sonnet-4-6``gpt-5.3-codex` `kimi-k2.5` `gemini-3-flash``minimax-m2.7` |
| **unspecified-high** | `claude-opus-4-6` | `claude-opus-4-6``gpt-5.4 (high)``glm-5``k2p5``kimi-k2.5` | | **unspecified-high** | `claude-opus-4-6` | `claude-opus-4-6``gpt-5.4 (high)``glm-5``k2p5``kimi-k2.5` |
| **writing** | `gemini-3-flash` | `gemini-3-flash``claude-sonnet-4-6``minimax-m2.7` | | **writing** | `gemini-3-flash` | `gemini-3-flash` `kimi-k2.5` `claude-sonnet-4-6``minimax-m2.7` |
Run `bunx oh-my-opencode doctor --verbose` to see effective model resolution for your config. Run `bunx oh-my-opencode doctor --verbose` to see effective model resolution for your config.
@@ -925,7 +925,7 @@ When enabled, two companion hooks are active: `hashline-read-enhancer` (annotate
"aggressive_truncation": false, "aggressive_truncation": false,
"auto_resume": false, "auto_resume": false,
"disable_omo_env": false, "disable_omo_env": false,
"task_system": false, "task_system": true,
"dynamic_context_pruning": { "dynamic_context_pruning": {
"enabled": false, "enabled": false,
"notification": "detailed", "notification": "detailed",
@@ -955,7 +955,7 @@ When enabled, two companion hooks are active: `hashline-read-enhancer` (annotate
| `aggressive_truncation` | `false` | Aggressively truncate when token limit exceeded | | `aggressive_truncation` | `false` | Aggressively truncate when token limit exceeded |
| `auto_resume` | `false` | Auto-resume after thinking block recovery | | `auto_resume` | `false` | Auto-resume after thinking block recovery |
| `disable_omo_env` | `false` | Disable auto-injected `<omo-env>` block (date/time/locale). Improves cache hit rate. | | `disable_omo_env` | `false` | Disable auto-injected `<omo-env>` block (date/time/locale). Improves cache hit rate. |
| `task_system` | `false` | Enable Sisyphus task system | | `task_system` | `true` | Enable Sisyphus task system |
| `dynamic_context_pruning.enabled` | `false` | Auto-prune old tool outputs to manage context window | | `dynamic_context_pruning.enabled` | `false` | Auto-prune old tool outputs to manage context window |
| `dynamic_context_pruning.notification` | `detailed` | Pruning notifications: `off` / `minimal` / `detailed` | | `dynamic_context_pruning.notification` | `detailed` | Pruning notifications: `off` / `minimal` / `detailed` |
| `turn_protection.turns` | `3` | Recent turns protected from pruning (110) | | `turn_protection.turns` | `3` | Recent turns protected from pruning (110) |

View File

@@ -13,8 +13,8 @@ Core-agent tab cycling is deterministic via injected runtime order field. The fi
| **Sisyphus** | `claude-opus-4-6` | The default orchestrator. Plans, delegates, and executes complex tasks using specialized subagents with aggressive parallel execution. Todo-driven workflow with extended thinking (32k budget). Fallback: `glm-5``big-pickle`. | | **Sisyphus** | `claude-opus-4-6` | The default orchestrator. Plans, delegates, and executes complex tasks using specialized subagents with aggressive parallel execution. Todo-driven workflow with extended thinking (32k budget). Fallback: `glm-5``big-pickle`. |
| **Hephaestus** | `gpt-5.4` | The Legitimate Craftsman. Autonomous deep worker inspired by AmpCode's deep mode. Goal-oriented execution with thorough research before action. Explores codebase patterns, completes tasks end-to-end without premature stopping. Named after the Greek god of forge and craftsmanship. Requires a GPT-capable provider. | | **Hephaestus** | `gpt-5.4` | The Legitimate Craftsman. Autonomous deep worker inspired by AmpCode's deep mode. Goal-oriented execution with thorough research before action. Explores codebase patterns, completes tasks end-to-end without premature stopping. Named after the Greek god of forge and craftsmanship. Requires a GPT-capable provider. |
| **Oracle** | `gpt-5.4` | Architecture decisions, code review, debugging. Read-only consultation with stellar logical reasoning and deep analysis. Inspired by AmpCode. Fallback: `gemini-3.1-pro``claude-opus-4-6`. | | **Oracle** | `gpt-5.4` | Architecture decisions, code review, debugging. Read-only consultation with stellar logical reasoning and deep analysis. Inspired by AmpCode. Fallback: `gemini-3.1-pro``claude-opus-4-6`. |
| **Librarian** | `minimax-m2.7` | Multi-repo analysis, documentation lookup, OSS implementation examples. Deep codebase understanding with evidence-based answers. Primary OpenCode Go path uses MiniMax M2.7. Other provider catalogs may still fall back to MiniMax M2.5, then `claude-haiku-4-5` and `gpt-5-nano`. | | **Librarian** | `minimax-m2.7` | Multi-repo analysis, documentation lookup, OSS implementation examples. Deep codebase understanding with evidence-based answers. Primary OpenCode Go path uses MiniMax M2.7, then falls back to OpenCode Zen `minimax-m2.7-highspeed`, then `claude-haiku-4-5` and `gpt-5-nano`. |
| **Explore** | `grok-code-fast-1` | Fast codebase exploration and contextual grep. Primary path stays on Grok Code Fast 1. MiniMax M2.7 is now used where provider catalogs expose it, while some OpenCode fallback paths still use MiniMax M2.5 for catalog compatibility. | | **Explore** | `grok-code-fast-1` | Fast codebase exploration and contextual grep. Primary path stays on Grok Code Fast 1, then uses OpenCode Go `minimax-m2.7-highspeed`, then OpenCode Zen `minimax-m2.7`, before falling through to Haiku and GPT-5-Nano. |
| **Multimodal-Looker** | `gpt-5.4` | Visual content specialist. Analyzes PDFs, images, diagrams to extract information. Fallback: `k2p5``glm-4.6v``gpt-5-nano`. | | **Multimodal-Looker** | `gpt-5.4` | Visual content specialist. Analyzes PDFs, images, diagrams to extract information. Fallback: `k2p5``glm-4.6v``gpt-5-nano`. |
### Planning Agents ### Planning Agents

View File

@@ -1,6 +1,6 @@
{ {
"name": "oh-my-opencode", "name": "oh-my-opencode",
"version": "3.11.0", "version": "3.14.0",
"description": "The Best AI Agent Harness - Batteries-Included OpenCode Plugin with Multi-Model Orchestration, Parallel Background Agents, and Crafted LSP/AST Tools", "description": "The Best AI Agent Harness - Batteries-Included OpenCode Plugin with Multi-Model Orchestration, Parallel Background Agents, and Crafted LSP/AST Tools",
"main": "dist/index.js", "main": "dist/index.js",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",
@@ -78,17 +78,17 @@
"typescript": "^5.7.3" "typescript": "^5.7.3"
}, },
"optionalDependencies": { "optionalDependencies": {
"oh-my-opencode-darwin-arm64": "3.11.0", "oh-my-opencode-darwin-arm64": "3.14.0",
"oh-my-opencode-darwin-x64": "3.11.0", "oh-my-opencode-darwin-x64": "3.14.0",
"oh-my-opencode-darwin-x64-baseline": "3.11.0", "oh-my-opencode-darwin-x64-baseline": "3.14.0",
"oh-my-opencode-linux-arm64": "3.11.0", "oh-my-opencode-linux-arm64": "3.14.0",
"oh-my-opencode-linux-arm64-musl": "3.11.0", "oh-my-opencode-linux-arm64-musl": "3.14.0",
"oh-my-opencode-linux-x64": "3.11.0", "oh-my-opencode-linux-x64": "3.14.0",
"oh-my-opencode-linux-x64-baseline": "3.11.0", "oh-my-opencode-linux-x64-baseline": "3.14.0",
"oh-my-opencode-linux-x64-musl": "3.11.0", "oh-my-opencode-linux-x64-musl": "3.14.0",
"oh-my-opencode-linux-x64-musl-baseline": "3.11.0", "oh-my-opencode-linux-x64-musl-baseline": "3.14.0",
"oh-my-opencode-windows-x64": "3.11.0", "oh-my-opencode-windows-x64": "3.14.0",
"oh-my-opencode-windows-x64-baseline": "3.11.0" "oh-my-opencode-windows-x64-baseline": "3.14.0"
}, },
"overrides": { "overrides": {
"@opencode-ai/sdk": "^1.2.24" "@opencode-ai/sdk": "^1.2.24"

View File

@@ -1,6 +1,6 @@
{ {
"name": "oh-my-opencode-darwin-arm64", "name": "oh-my-opencode-darwin-arm64",
"version": "3.11.0", "version": "3.14.0",
"description": "Platform-specific binary for oh-my-opencode (darwin-arm64)", "description": "Platform-specific binary for oh-my-opencode (darwin-arm64)",
"license": "MIT", "license": "MIT",
"repository": { "repository": {

View File

@@ -1,6 +1,6 @@
{ {
"name": "oh-my-opencode-darwin-x64-baseline", "name": "oh-my-opencode-darwin-x64-baseline",
"version": "3.11.0", "version": "3.14.0",
"description": "Platform-specific binary for oh-my-opencode (darwin-x64-baseline, no AVX2)", "description": "Platform-specific binary for oh-my-opencode (darwin-x64-baseline, no AVX2)",
"license": "MIT", "license": "MIT",
"repository": { "repository": {

View File

@@ -1,6 +1,6 @@
{ {
"name": "oh-my-opencode-darwin-x64", "name": "oh-my-opencode-darwin-x64",
"version": "3.11.0", "version": "3.14.0",
"description": "Platform-specific binary for oh-my-opencode (darwin-x64)", "description": "Platform-specific binary for oh-my-opencode (darwin-x64)",
"license": "MIT", "license": "MIT",
"repository": { "repository": {

View File

@@ -1,6 +1,6 @@
{ {
"name": "oh-my-opencode-linux-arm64-musl", "name": "oh-my-opencode-linux-arm64-musl",
"version": "3.11.0", "version": "3.14.0",
"description": "Platform-specific binary for oh-my-opencode (linux-arm64-musl)", "description": "Platform-specific binary for oh-my-opencode (linux-arm64-musl)",
"license": "MIT", "license": "MIT",
"repository": { "repository": {

View File

@@ -1,6 +1,6 @@
{ {
"name": "oh-my-opencode-linux-arm64", "name": "oh-my-opencode-linux-arm64",
"version": "3.11.0", "version": "3.14.0",
"description": "Platform-specific binary for oh-my-opencode (linux-arm64)", "description": "Platform-specific binary for oh-my-opencode (linux-arm64)",
"license": "MIT", "license": "MIT",
"repository": { "repository": {

View File

@@ -1,6 +1,6 @@
{ {
"name": "oh-my-opencode-linux-x64-baseline", "name": "oh-my-opencode-linux-x64-baseline",
"version": "3.11.0", "version": "3.14.0",
"description": "Platform-specific binary for oh-my-opencode (linux-x64-baseline, no AVX2)", "description": "Platform-specific binary for oh-my-opencode (linux-x64-baseline, no AVX2)",
"license": "MIT", "license": "MIT",
"repository": { "repository": {

View File

@@ -1,6 +1,6 @@
{ {
"name": "oh-my-opencode-linux-x64-musl-baseline", "name": "oh-my-opencode-linux-x64-musl-baseline",
"version": "3.11.0", "version": "3.14.0",
"description": "Platform-specific binary for oh-my-opencode (linux-x64-musl-baseline, no AVX2)", "description": "Platform-specific binary for oh-my-opencode (linux-x64-musl-baseline, no AVX2)",
"license": "MIT", "license": "MIT",
"repository": { "repository": {

View File

@@ -1,6 +1,6 @@
{ {
"name": "oh-my-opencode-linux-x64-musl", "name": "oh-my-opencode-linux-x64-musl",
"version": "3.11.0", "version": "3.14.0",
"description": "Platform-specific binary for oh-my-opencode (linux-x64-musl)", "description": "Platform-specific binary for oh-my-opencode (linux-x64-musl)",
"license": "MIT", "license": "MIT",
"repository": { "repository": {

View File

@@ -1,6 +1,6 @@
{ {
"name": "oh-my-opencode-linux-x64", "name": "oh-my-opencode-linux-x64",
"version": "3.11.0", "version": "3.14.0",
"description": "Platform-specific binary for oh-my-opencode (linux-x64)", "description": "Platform-specific binary for oh-my-opencode (linux-x64)",
"license": "MIT", "license": "MIT",
"repository": { "repository": {

View File

@@ -1,6 +1,6 @@
{ {
"name": "oh-my-opencode-windows-x64-baseline", "name": "oh-my-opencode-windows-x64-baseline",
"version": "3.11.0", "version": "3.14.0",
"description": "Platform-specific binary for oh-my-opencode (windows-x64-baseline, no AVX2)", "description": "Platform-specific binary for oh-my-opencode (windows-x64-baseline, no AVX2)",
"license": "MIT", "license": "MIT",
"repository": { "repository": {

View File

@@ -1,6 +1,6 @@
{ {
"name": "oh-my-opencode-windows-x64", "name": "oh-my-opencode-windows-x64",
"version": "3.11.0", "version": "3.14.0",
"description": "Platform-specific binary for oh-my-opencode (windows-x64)", "description": "Platform-specific binary for oh-my-opencode (windows-x64)",
"license": "MIT", "license": "MIT",
"repository": { "repository": {

View File

@@ -1,6 +1,7 @@
// postinstall.mjs // postinstall.mjs
// Runs after npm install to verify platform binary is available // Runs after npm install to verify platform binary is available
import { readFileSync } from "node:fs";
import { createRequire } from "node:module"; import { createRequire } from "node:module";
import { getPlatformPackageCandidates, getBinaryPath } from "./bin/platform.js"; import { getPlatformPackageCandidates, getBinaryPath } from "./bin/platform.js";
@@ -24,7 +25,7 @@ function getLibcFamily() {
function getPackageBaseName() { function getPackageBaseName() {
try { try {
const packageJson = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")); const packageJson = JSON.parse(readFileSync(new URL("./package.json", import.meta.url), "utf8"));
return packageJson.name || "oh-my-opencode"; return packageJson.name || "oh-my-opencode";
} catch { } catch {
return "oh-my-opencode"; return "oh-my-opencode";

View File

@@ -34,6 +34,72 @@ async function generateChangelog(previousTag: string): Promise<string[]> {
return notes return notes
} }
async function getChangedFiles(previousTag: string): Promise<string[]> {
try {
const diff = await $`git diff --name-only ${previousTag}..HEAD`.text()
return diff
.split("\n")
.map((line) => line.trim())
.filter(Boolean)
} catch {
return []
}
}
function touchesAnyPath(files: string[], candidates: string[]): boolean {
return files.some((file) => candidates.some((candidate) => file === candidate || file.startsWith(`${candidate}/`)))
}
function buildReleaseFraming(files: string[]): string[] {
const bullets: string[] = []
if (
touchesAnyPath(files, [
"src/index.ts",
"src/plugin-config.ts",
"bin/platform.js",
"postinstall.mjs",
"docs",
])
) {
bullets.push("Rename transition updates across package detection, plugin/config compatibility, and install surfaces.")
}
if (touchesAnyPath(files, ["src/tools/delegate-task", "src/plugin/tool-registry.ts"])) {
bullets.push("Task and tool behavior updates, including delegate-task contract and runtime registration behavior.")
}
if (
touchesAnyPath(files, [
"src/plugin/tool-registry.ts",
"src/plugin-handlers/agent-config-handler.ts",
"src/plugin-handlers/tool-config-handler.ts",
"src/hooks/tasks-todowrite-disabler",
])
) {
bullets.push("Task-system default behavior alignment so omitted configuration behaves consistently across runtime paths.")
}
if (touchesAnyPath(files, [".github/workflows", "docs/guide/installation.md", "postinstall.mjs"])) {
bullets.push("Install and publish workflow hardening, including safer release sequencing and package/install fixes.")
}
if (bullets.length === 0) {
return []
}
return [
"## Minor Compatibility and Stability Release",
"",
"This release carries compatibility-facing behavior changes and operational hardening. Read the summary below before upgrading or publishing.",
"",
...bullets.map((bullet) => `- ${bullet}`),
"",
"## Commit Summary",
"",
]
}
async function getContributors(previousTag: string): Promise<string[]> { async function getContributors(previousTag: string): Promise<string[]> {
const notes: string[] = [] const notes: string[] = []
@@ -78,9 +144,11 @@ async function main() {
process.exit(0) process.exit(0)
} }
const changedFiles = await getChangedFiles(previousTag)
const changelog = await generateChangelog(previousTag) const changelog = await generateChangelog(previousTag)
const contributors = await getContributors(previousTag) const contributors = await getContributors(previousTag)
const notes = [...changelog, ...contributors] const framing = buildReleaseFraming(changedFiles)
const notes = [...framing, ...changelog, ...contributors]
if (notes.length === 0) { if (notes.length === 0) {
console.log("No notable changes") console.log("No notable changes")

View File

@@ -3312,6 +3312,9 @@ describe("BackgroundManager.checkAndInterruptStaleTasks", () => {
prompt: async () => ({}), prompt: async () => ({}),
promptAsync: async () => ({}), promptAsync: async () => ({}),
abort: async () => ({}), abort: async () => ({}),
get: async () => {
throw new Error("missing")
},
}, },
} }
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput, { staleTimeoutMs: 180_000 }) const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput, { staleTimeoutMs: 180_000 })
@@ -3348,6 +3351,9 @@ describe("BackgroundManager.checkAndInterruptStaleTasks", () => {
prompt: async () => ({}), prompt: async () => ({}),
promptAsync: async () => ({}), promptAsync: async () => ({}),
abort: async () => ({}), abort: async () => ({}),
get: async () => {
throw new Error("missing")
},
}, },
} }
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput, { staleTimeoutMs: 180_000 }) const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput, { staleTimeoutMs: 180_000 })
@@ -3437,6 +3443,7 @@ describe("BackgroundManager.checkAndInterruptStaleTasks", () => {
status: "running", status: "running",
startedAt: new Date(Date.now() - 15 * 60 * 1000), startedAt: new Date(Date.now() - 15 * 60 * 1000),
progress: undefined, progress: undefined,
consecutiveMissedPolls: 2,
} }
getTaskMap(manager).set(task.id, task) getTaskMap(manager).set(task.id, task)
@@ -3471,6 +3478,7 @@ describe("BackgroundManager.checkAndInterruptStaleTasks", () => {
status: "running", status: "running",
startedAt: new Date(Date.now() - 15 * 60 * 1000), startedAt: new Date(Date.now() - 15 * 60 * 1000),
progress: undefined, progress: undefined,
consecutiveMissedPolls: 2,
} }
getTaskMap(manager).set(task.id, task) getTaskMap(manager).set(task.id, task)

View File

@@ -8,6 +8,7 @@ describe("checkAndInterruptStaleTasks", () => {
const mockClient = { const mockClient = {
session: { session: {
abort: mock(() => Promise.resolve()), abort: mock(() => Promise.resolve()),
get: mock(() => Promise.resolve({ data: { id: "ses-1" } })),
}, },
} }
const mockConcurrencyManager = { const mockConcurrencyManager = {
@@ -35,6 +36,11 @@ describe("checkAndInterruptStaleTasks", () => {
beforeEach(() => { beforeEach(() => {
fixedTime = Date.now() fixedTime = Date.now()
spyOn(globalThis.Date, "now").mockReturnValue(fixedTime) spyOn(globalThis.Date, "now").mockReturnValue(fixedTime)
mockClient.session.abort.mockClear()
mockClient.session.get.mockReset()
mockClient.session.get.mockResolvedValue({ data: { id: "ses-1" } })
mockConcurrencyManager.release.mockClear()
mockNotify.mockClear()
}) })
afterEach(() => { afterEach(() => {
@@ -288,6 +294,59 @@ describe("checkAndInterruptStaleTasks", () => {
expect(task.status).toBe("running") expect(task.status).toBe("running")
}) })
it("should NOT cancel healthy task on first missing status poll", async () => {
//#given — one missing poll should not be enough to declare the session gone
const task = createRunningTask({
startedAt: new Date(Date.now() - 300_000),
progress: {
toolCalls: 1,
lastUpdate: new Date(Date.now() - 120_000),
},
})
//#when
await checkAndInterruptStaleTasks({
tasks: [task],
client: mockClient as never,
config: { staleTimeoutMs: 180_000, sessionGoneTimeoutMs: 60_000 },
concurrencyManager: mockConcurrencyManager as never,
notifyParentSession: mockNotify,
sessionStatuses: {},
})
//#then
expect(task.status).toBe("running")
expect(task.consecutiveMissedPolls).toBe(1)
expect(mockClient.session.get).not.toHaveBeenCalled()
})
it("should NOT cancel task when session.get confirms the session still exists", async () => {
//#given — repeated missing polls but direct lookup still succeeds
const task = createRunningTask({
startedAt: new Date(Date.now() - 300_000),
progress: {
toolCalls: 1,
lastUpdate: new Date(Date.now() - 120_000),
},
consecutiveMissedPolls: 2,
})
//#when
await checkAndInterruptStaleTasks({
tasks: [task],
client: mockClient as never,
config: { staleTimeoutMs: 180_000, sessionGoneTimeoutMs: 60_000 },
concurrencyManager: mockConcurrencyManager as never,
notifyParentSession: mockNotify,
sessionStatuses: {},
})
//#then
expect(task.status).toBe("running")
expect(task.consecutiveMissedPolls).toBe(0)
expect(mockClient.session.get).toHaveBeenCalledWith({ path: { id: "ses-1" } })
})
it("should use session-gone timeout when session is missing from status map (with progress)", async () => { it("should use session-gone timeout when session is missing from status map (with progress)", async () => {
//#given — lastUpdate 2min ago, session completely gone from status //#given — lastUpdate 2min ago, session completely gone from status
const task = createRunningTask({ const task = createRunningTask({
@@ -296,8 +355,11 @@ describe("checkAndInterruptStaleTasks", () => {
toolCalls: 1, toolCalls: 1,
lastUpdate: new Date(Date.now() - 120_000), lastUpdate: new Date(Date.now() - 120_000),
}, },
consecutiveMissedPolls: 2,
}) })
mockClient.session.get.mockRejectedValue(new Error("missing"))
//#when — empty sessionStatuses (session gone), sessionGoneTimeoutMs = 60s //#when — empty sessionStatuses (session gone), sessionGoneTimeoutMs = 60s
await checkAndInterruptStaleTasks({ await checkAndInterruptStaleTasks({
tasks: [task], tasks: [task],
@@ -318,8 +380,11 @@ describe("checkAndInterruptStaleTasks", () => {
const task = createRunningTask({ const task = createRunningTask({
startedAt: new Date(Date.now() - 120_000), startedAt: new Date(Date.now() - 120_000),
progress: undefined, progress: undefined,
consecutiveMissedPolls: 2,
}) })
mockClient.session.get.mockRejectedValue(new Error("missing"))
//#when — session gone, sessionGoneTimeoutMs = 60s //#when — session gone, sessionGoneTimeoutMs = 60s
await checkAndInterruptStaleTasks({ await checkAndInterruptStaleTasks({
tasks: [task], tasks: [task],
@@ -343,8 +408,11 @@ describe("checkAndInterruptStaleTasks", () => {
toolCalls: 1, toolCalls: 1,
lastUpdate: new Date(Date.now() - 120_000), lastUpdate: new Date(Date.now() - 120_000),
}, },
consecutiveMissedPolls: 2,
}) })
mockClient.session.get.mockRejectedValue(new Error("missing"))
//#when — session is idle (present in map), staleTimeoutMs = 180s //#when — session is idle (present in map), staleTimeoutMs = 180s
await checkAndInterruptStaleTasks({ await checkAndInterruptStaleTasks({
tasks: [task], tasks: [task],
@@ -367,8 +435,11 @@ describe("checkAndInterruptStaleTasks", () => {
toolCalls: 1, toolCalls: 1,
lastUpdate: new Date(Date.now() - 120_000), lastUpdate: new Date(Date.now() - 120_000),
}, },
consecutiveMissedPolls: 2,
}) })
mockClient.session.get.mockRejectedValue(new Error("missing"))
//#when — no config (default sessionGoneTimeoutMs = 60_000) //#when — no config (default sessionGoneTimeoutMs = 60_000)
await checkAndInterruptStaleTasks({ await checkAndInterruptStaleTasks({
tasks: [task], tasks: [task],

View File

@@ -16,6 +16,8 @@ import {
import { removeTaskToastTracking } from "./remove-task-toast-tracking" import { removeTaskToastTracking } from "./remove-task-toast-tracking"
import { isActiveSessionStatus } from "./session-status-classifier" import { isActiveSessionStatus } from "./session-status-classifier"
const MIN_SESSION_GONE_POLLS = 3
const TERMINAL_TASK_STATUSES = new Set<BackgroundTask["status"]>([ const TERMINAL_TASK_STATUSES = new Set<BackgroundTask["status"]>([
"completed", "completed",
"error", "error",
@@ -97,6 +99,15 @@ export function pruneStaleTasksAndNotifications(args: {
export type SessionStatusMap = Record<string, { type: string }> export type SessionStatusMap = Record<string, { type: string }>
async function verifySessionExists(client: OpencodeClient, sessionID: string): Promise<boolean> {
try {
const result = await client.session.get({ path: { id: sessionID } })
return !!result.data
} catch {
return false
}
}
export async function checkAndInterruptStaleTasks(args: { export async function checkAndInterruptStaleTasks(args: {
tasks: Iterable<BackgroundTask> tasks: Iterable<BackgroundTask>
client: OpencodeClient client: OpencodeClient
@@ -130,14 +141,28 @@ export async function checkAndInterruptStaleTasks(args: {
const sessionStatus = sessionStatuses?.[sessionID]?.type const sessionStatus = sessionStatuses?.[sessionID]?.type
const sessionIsRunning = sessionStatus !== undefined && isActiveSessionStatus(sessionStatus) const sessionIsRunning = sessionStatus !== undefined && isActiveSessionStatus(sessionStatus)
const sessionGone = sessionStatuses !== undefined && sessionStatus === undefined const sessionMissing = sessionStatuses !== undefined && sessionStatus === undefined
const runtime = now - startedAt.getTime() const runtime = now - startedAt.getTime()
if (sessionMissing) {
task.consecutiveMissedPolls = (task.consecutiveMissedPolls ?? 0) + 1
} else if (sessionStatuses !== undefined) {
task.consecutiveMissedPolls = 0
}
const sessionGone = sessionMissing && (task.consecutiveMissedPolls ?? 0) >= MIN_SESSION_GONE_POLLS
if (!task.progress?.lastUpdate) { if (!task.progress?.lastUpdate) {
if (sessionIsRunning) continue if (sessionIsRunning) continue
if (sessionMissing && !sessionGone) continue
const effectiveTimeout = sessionGone ? sessionGoneTimeoutMs : messageStalenessMs const effectiveTimeout = sessionGone ? sessionGoneTimeoutMs : messageStalenessMs
if (runtime <= effectiveTimeout) continue if (runtime <= effectiveTimeout) continue
if (sessionGone && await verifySessionExists(client, sessionID)) {
task.consecutiveMissedPolls = 0
continue
}
const staleMinutes = Math.round(runtime / 60000) const staleMinutes = Math.round(runtime / 60000)
const reason = sessionGone ? "session gone from status registry" : "no activity" const reason = sessionGone ? "session gone from status registry" : "no activity"
task.status = "cancelled" task.status = "cancelled"
@@ -171,11 +196,16 @@ export async function checkAndInterruptStaleTasks(args: {
if (timeSinceLastUpdate <= effectiveStaleTimeout) continue if (timeSinceLastUpdate <= effectiveStaleTimeout) continue
if (task.status !== "running") continue if (task.status !== "running") continue
const staleMinutes = Math.round(timeSinceLastUpdate / 60000) if (sessionGone && await verifySessionExists(client, sessionID)) {
const reason = sessionGone ? "session gone from status registry" : "no activity" task.consecutiveMissedPolls = 0
task.status = "cancelled" continue
task.error = `Stale timeout (${reason} for ${staleMinutes}min). This is a FINAL cancellation - do NOT create a replacement task. If the timeout is too short, increase 'background_task.${sessionGone ? "sessionGoneTimeoutMs" : "staleTimeoutMs"}' in .opencode/oh-my-opencode.json.` }
task.completedAt = new Date()
const staleMinutes = Math.round(timeSinceLastUpdate / 60000)
const reason = sessionGone ? "session gone from status registry" : "no activity"
task.status = "cancelled"
task.error = `Stale timeout (${reason} for ${staleMinutes}min). This is a FINAL cancellation - do NOT create a replacement task. If the timeout is too short, increase 'background_task.${sessionGone ? "sessionGoneTimeoutMs" : "staleTimeoutMs"}' in .opencode/oh-my-opencode.json.`
task.completedAt = new Date()
if (task.concurrencyKey) { if (task.concurrencyKey) {
concurrencyManager.release(task.concurrencyKey) concurrencyManager.release(task.concurrencyKey)

View File

@@ -66,6 +66,8 @@ export interface BackgroundTask {
lastMsgCount?: number lastMsgCount?: number
/** Number of consecutive polls with stable message count */ /** Number of consecutive polls with stable message count */
stablePolls?: number stablePolls?: number
/** Number of consecutive polls where session was missing from status map */
consecutiveMissedPolls?: number
} }
export interface LaunchInput { export interface LaunchInput {

View File

@@ -3,9 +3,13 @@ import { parseFrontmatter } from "../../shared/frontmatter"
import type { LoadedSkill } from "./types" import type { LoadedSkill } from "./types"
export function extractSkillTemplate(skill: LoadedSkill): string { export function extractSkillTemplate(skill: LoadedSkill): string {
if (skill.path) { if (skill.scope === "config" && skill.definition.template) {
const content = readFileSync(skill.path, "utf-8") return skill.definition.template
const { body } = parseFrontmatter(content) }
if (skill.path) {
const content = readFileSync(skill.path, "utf-8")
const { body } = parseFrontmatter(content)
return body.trim() return body.trim()
} }
return skill.definition.template || "" return skill.definition.template || ""

View File

@@ -0,0 +1,105 @@
import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test"
const replaceEmptyTextPartsAsync = mock(() => Promise.resolve(false))
const injectTextPartAsync = mock(() => Promise.resolve(false))
const findMessagesWithEmptyTextPartsFromSDK = mock(() => Promise.resolve([] as string[]))
mock.module("../../shared", () => ({
normalizeSDKResponse: (response: { data?: unknown[] }) => response.data ?? [],
}))
mock.module("../../shared/logger", () => ({
log: () => {},
}))
mock.module("../../shared/opencode-storage-detection", () => ({
isSqliteBackend: () => true,
}))
mock.module("../session-recovery/storage", () => ({
findEmptyMessages: () => [],
findMessagesWithEmptyTextParts: () => [],
injectTextPart: () => false,
replaceEmptyTextParts: () => false,
}))
mock.module("../session-recovery/storage/empty-text", () => ({
replaceEmptyTextPartsAsync,
findMessagesWithEmptyTextPartsFromSDK,
}))
mock.module("../session-recovery/storage/text-part-injector", () => ({
injectTextPartAsync,
}))
async function importFreshMessageBuilder(): Promise<typeof import("./message-builder")> {
return import(`./message-builder?test=${Date.now()}-${Math.random()}`)
}
afterAll(() => {
mock.restore()
})
describe("sanitizeEmptyMessagesBeforeSummarize", () => {
beforeEach(() => {
replaceEmptyTextPartsAsync.mockReset()
replaceEmptyTextPartsAsync.mockResolvedValue(false)
injectTextPartAsync.mockReset()
injectTextPartAsync.mockResolvedValue(false)
findMessagesWithEmptyTextPartsFromSDK.mockReset()
findMessagesWithEmptyTextPartsFromSDK.mockResolvedValue([])
})
test("#given sqlite message with tool content and empty text part #when sanitizing #then it fixes the mixed-content message", async () => {
const { sanitizeEmptyMessagesBeforeSummarize, PLACEHOLDER_TEXT } = await importFreshMessageBuilder()
const client = {
session: {
messages: mock(() => Promise.resolve({
data: [
{
info: { id: "msg-1" },
parts: [
{ type: "tool_result", text: "done" },
{ type: "text", text: "" },
],
},
],
})),
},
} as never
findMessagesWithEmptyTextPartsFromSDK.mockResolvedValue(["msg-1"])
replaceEmptyTextPartsAsync.mockResolvedValue(true)
const fixedCount = await sanitizeEmptyMessagesBeforeSummarize("ses-1", client)
expect(fixedCount).toBe(1)
expect(replaceEmptyTextPartsAsync).toHaveBeenCalledWith(client, "ses-1", "msg-1", PLACEHOLDER_TEXT)
expect(injectTextPartAsync).not.toHaveBeenCalled()
})
test("#given sqlite message with mixed content and failed replacement #when sanitizing #then it injects the placeholder text part", async () => {
const { sanitizeEmptyMessagesBeforeSummarize, PLACEHOLDER_TEXT } = await importFreshMessageBuilder()
const client = {
session: {
messages: mock(() => Promise.resolve({
data: [
{
info: { id: "msg-2" },
parts: [
{ type: "tool_use", text: "call" },
{ type: "text", text: "" },
],
},
],
})),
},
} as never
findMessagesWithEmptyTextPartsFromSDK.mockResolvedValue(["msg-2"])
injectTextPartAsync.mockResolvedValue(true)
const fixedCount = await sanitizeEmptyMessagesBeforeSummarize("ses-2", client)
expect(fixedCount).toBe(1)
expect(injectTextPartAsync).toHaveBeenCalledWith(client, "ses-2", "msg-2", PLACEHOLDER_TEXT)
})
})

View File

@@ -8,7 +8,7 @@ import {
injectTextPart, injectTextPart,
replaceEmptyTextParts, replaceEmptyTextParts,
} from "../session-recovery/storage" } from "../session-recovery/storage"
import { replaceEmptyTextPartsAsync } from "../session-recovery/storage/empty-text" import { findMessagesWithEmptyTextPartsFromSDK, replaceEmptyTextPartsAsync } from "../session-recovery/storage/empty-text"
import { injectTextPartAsync } from "../session-recovery/storage/text-part-injector" import { injectTextPartAsync } from "../session-recovery/storage/text-part-injector"
import type { Client } from "./client" import type { Client } from "./client"
@@ -86,12 +86,14 @@ export async function sanitizeEmptyMessagesBeforeSummarize(
): Promise<number> { ): Promise<number> {
if (client && isSqliteBackend()) { if (client && isSqliteBackend()) {
const emptyMessageIds = await findEmptyMessageIdsFromSDK(client, sessionID) const emptyMessageIds = await findEmptyMessageIdsFromSDK(client, sessionID)
if (emptyMessageIds.length === 0) { const emptyTextPartIds = await findMessagesWithEmptyTextPartsFromSDK(client, sessionID)
const allIds = [...new Set([...emptyMessageIds, ...emptyTextPartIds])]
if (allIds.length === 0) {
return 0 return 0
} }
let fixedCount = 0 let fixedCount = 0
for (const messageID of emptyMessageIds) { for (const messageID of allIds) {
const replaced = await replaceEmptyTextPartsAsync(client, sessionID, messageID, PLACEHOLDER_TEXT) const replaced = await replaceEmptyTextPartsAsync(client, sessionID, messageID, PLACEHOLDER_TEXT)
if (replaced) { if (replaced) {
fixedCount++ fixedCount++
@@ -107,7 +109,7 @@ export async function sanitizeEmptyMessagesBeforeSummarize(
log("[auto-compact] pre-summarize sanitization fixed empty messages", { log("[auto-compact] pre-summarize sanitization fixed empty messages", {
sessionID, sessionID,
fixedCount, fixedCount,
totalEmpty: emptyMessageIds.length, totalEmpty: allIds.length,
}) })
} }

View File

@@ -1,8 +1,9 @@
import type { PluginInput } from "@opencode-ai/plugin" import type { PluginInput } from "@opencode-ai/plugin"
import type { BackgroundManager } from "../../features/background-agent" import type { BackgroundManager } from "../../features/background-agent"
import { isAgentRegistered } from "../../features/claude-code-session-state"
import { log } from "../../shared/logger" import { log } from "../../shared/logger"
import { createInternalAgentTextPart, resolveInheritedPromptTools } from "../../shared" import { createInternalAgentTextPart, resolveInheritedPromptTools } from "../../shared"
import { getAgentDisplayName } from "../../shared/agent-display-names" import { getAgentConfigKey } from "../../shared/agent-display-names"
import { HOOK_NAME } from "./hook-name" import { HOOK_NAME } from "./hook-name"
import { BOULDER_CONTINUATION_PROMPT } from "./system-reminder-templates" import { BOULDER_CONTINUATION_PROMPT } from "./system-reminder-templates"
import { resolveRecentPromptContextForSession } from "./recent-model-resolver" import { resolveRecentPromptContextForSession } from "./recent-model-resolver"
@@ -48,24 +49,33 @@ export async function injectBoulderContinuation(input: {
const preferredSessionContext = preferredTaskSessionId const preferredSessionContext = preferredTaskSessionId
? `\n\n[Preferred reuse session for current top-level plan task${preferredTaskTitle ? `: ${preferredTaskTitle}` : ""}: ${preferredTaskSessionId}]` ? `\n\n[Preferred reuse session for current top-level plan task${preferredTaskTitle ? `: ${preferredTaskTitle}` : ""}: ${preferredTaskSessionId}]`
: "" : ""
const prompt = const prompt =
BOULDER_CONTINUATION_PROMPT.replace(/{PLAN_NAME}/g, planName) + BOULDER_CONTINUATION_PROMPT.replace(/{PLAN_NAME}/g, planName) +
`\n\n[Status: ${total - remaining}/${total} completed, ${remaining} remaining]` + `\n\n[Status: ${total - remaining}/${total} completed, ${remaining} remaining]` +
preferredSessionContext + preferredSessionContext +
worktreeContext worktreeContext
const continuationAgent = agent ?? (isAgentRegistered("atlas") ? "atlas" : undefined)
try { if (!continuationAgent || !isAgentRegistered(continuationAgent)) {
log(`[${HOOK_NAME}] Injecting boulder continuation`, { sessionID, planName, remaining }) log(`[${HOOK_NAME}] Skipped injection: continuation agent unavailable`, {
sessionID,
agent: continuationAgent ?? agent ?? "unknown",
})
return
}
try {
log(`[${HOOK_NAME}] Injecting boulder continuation`, { sessionID, planName, remaining })
const promptContext = await resolveRecentPromptContextForSession(ctx, sessionID) const promptContext = await resolveRecentPromptContextForSession(ctx, sessionID)
const inheritedTools = resolveInheritedPromptTools(sessionID, promptContext.tools) const inheritedTools = resolveInheritedPromptTools(sessionID, promptContext.tools)
await ctx.client.session.promptAsync({ await ctx.client.session.promptAsync({
path: { id: sessionID }, path: { id: sessionID },
body: { body: {
agent: getAgentDisplayName(agent ?? "atlas"), agent: getAgentConfigKey(continuationAgent),
...(promptContext.model !== undefined ? { model: promptContext.model } : {}), ...(promptContext.model !== undefined ? { model: promptContext.model } : {}),
...(inheritedTools ? { tools: inheritedTools } : {}), ...(inheritedTools ? { tools: inheritedTools } : {}),
parts: [createInternalAgentTextPart(prompt)], parts: [createInternalAgentTextPart(prompt)],
}, },
query: { directory: ctx.directory }, query: { directory: ctx.directory },

View File

@@ -6,7 +6,7 @@ import { join } from "node:path"
import { randomUUID } from "node:crypto" import { randomUUID } from "node:crypto"
import { clearBoulderState, writeBoulderState } from "../../features/boulder-state" import { clearBoulderState, writeBoulderState } from "../../features/boulder-state"
import { _resetForTesting } from "../../features/claude-code-session-state" import { _resetForTesting, registerAgentName } from "../../features/claude-code-session-state"
import type { BoulderState } from "../../features/boulder-state" import type { BoulderState } from "../../features/boulder-state"
const TEST_STORAGE_ROOT = join(tmpdir(), `atlas-compaction-storage-${randomUUID()}`) const TEST_STORAGE_ROOT = join(tmpdir(), `atlas-compaction-storage-${randomUUID()}`)
@@ -66,6 +66,8 @@ describe("atlas hook compaction agent filtering", () => {
mkdirSync(testDirectory, { recursive: true }) mkdirSync(testDirectory, { recursive: true })
clearBoulderState(testDirectory) clearBoulderState(testDirectory)
_resetForTesting() _resetForTesting()
registerAgentName("atlas")
registerAgentName("sisyphus")
}) })
afterEach(() => { afterEach(() => {

View File

@@ -6,7 +6,7 @@ import { tmpdir } from "node:os"
import { join } from "node:path" import { join } from "node:path"
import { clearBoulderState, readBoulderState, writeBoulderState } from "../../features/boulder-state" import { clearBoulderState, readBoulderState, writeBoulderState } from "../../features/boulder-state"
import type { BoulderState } from "../../features/boulder-state" import type { BoulderState } from "../../features/boulder-state"
import { _resetForTesting, setSessionAgent, subagentSessions } from "../../features/claude-code-session-state" import { _resetForTesting, registerAgentName, setSessionAgent, subagentSessions } from "../../features/claude-code-session-state"
const { createAtlasHook } = await import("./index") const { createAtlasHook } = await import("./index")
@@ -64,6 +64,8 @@ describe("atlas hook idle-event session lineage", () => {
promptCalls = [] promptCalls = []
clearBoulderState(testDirectory) clearBoulderState(testDirectory)
_resetForTesting() _resetForTesting()
registerAgentName("atlas")
registerAgentName("sisyphus")
subagentSessions.clear() subagentSessions.clear()
}) })

View File

@@ -5,7 +5,7 @@ import {
readBoulderState, readBoulderState,
readCurrentTopLevelTask, readCurrentTopLevelTask,
} from "../../features/boulder-state" } from "../../features/boulder-state"
import { getSessionAgent, subagentSessions } from "../../features/claude-code-session-state" import { getSessionAgent, isAgentRegistered, subagentSessions } from "../../features/claude-code-session-state"
import { getAgentConfigKey } from "../../shared/agent-display-names" import { getAgentConfigKey } from "../../shared/agent-display-names"
import { log } from "../../shared/logger" import { log } from "../../shared/logger"
import { injectBoulderContinuation } from "./boulder-continuation-injector" import { injectBoulderContinuation } from "./boulder-continuation-injector"
@@ -141,7 +141,15 @@ export async function handleAtlasSessionIdle(input: {
if (subagentSessions.has(sessionID)) { if (subagentSessions.has(sessionID)) {
const sessionAgent = getSessionAgent(sessionID) const sessionAgent = getSessionAgent(sessionID)
const agentKey = getAgentConfigKey(sessionAgent ?? "") const agentKey = getAgentConfigKey(sessionAgent ?? "")
const requiredAgentKey = getAgentConfigKey(boulderState.agent ?? "atlas") const requiredAgentName = boulderState.agent ?? (isAgentRegistered("atlas") ? "atlas" : undefined)
if (!requiredAgentName || !isAgentRegistered(requiredAgentName)) {
log(`[${HOOK_NAME}] Skipped: boulder agent is unavailable for continuation`, {
sessionID,
requiredAgent: boulderState.agent ?? "unknown",
})
return
}
const requiredAgentKey = getAgentConfigKey(requiredAgentName)
const agentMatches = const agentMatches =
agentKey === requiredAgentKey || agentKey === requiredAgentKey ||
(requiredAgentKey === getAgentConfigKey("atlas") && agentKey === getAgentConfigKey("sisyphus")) (requiredAgentKey === getAgentConfigKey("atlas") && agentKey === getAgentConfigKey("sisyphus"))
@@ -149,10 +157,10 @@ export async function handleAtlasSessionIdle(input: {
log(`[${HOOK_NAME}] Skipped: subagent agent does not match boulder agent`, { log(`[${HOOK_NAME}] Skipped: subagent agent does not match boulder agent`, {
sessionID, sessionID,
agent: sessionAgent ?? "unknown", agent: sessionAgent ?? "unknown",
requiredAgent: boulderState.agent ?? "atlas", requiredAgent: requiredAgentName,
}) })
return return
} }
} }
const sessionState = getState(sessionID) const sessionState = getState(sessionID)

View File

@@ -9,7 +9,7 @@ import {
readBoulderState, readBoulderState,
} from "../../features/boulder-state" } from "../../features/boulder-state"
import type { BoulderState } from "../../features/boulder-state" import type { BoulderState } from "../../features/boulder-state"
import { _resetForTesting, subagentSessions, updateSessionAgent } from "../../features/claude-code-session-state" import { _resetForTesting, registerAgentName, subagentSessions, updateSessionAgent } from "../../features/claude-code-session-state"
import type { PendingTaskRef } from "./types" import type { PendingTaskRef } from "./types"
const TEST_STORAGE_ROOT = join(tmpdir(), `atlas-message-storage-${randomUUID()}`) const TEST_STORAGE_ROOT = join(tmpdir(), `atlas-message-storage-${randomUUID()}`)
@@ -90,6 +90,9 @@ describe("atlas hook", () => {
} }
beforeEach(() => { beforeEach(() => {
_resetForTesting()
registerAgentName("atlas")
registerAgentName("sisyphus")
TEST_DIR = join(tmpdir(), `atlas-test-${randomUUID()}`) TEST_DIR = join(tmpdir(), `atlas-test-${randomUUID()}`)
SISYPHUS_DIR = join(TEST_DIR, ".sisyphus") SISYPHUS_DIR = join(TEST_DIR, ".sisyphus")
if (!existsSync(TEST_DIR)) { if (!existsSync(TEST_DIR)) {
@@ -102,6 +105,7 @@ describe("atlas hook", () => {
}) })
afterEach(() => { afterEach(() => {
_resetForTesting()
clearBoulderState(TEST_DIR) clearBoulderState(TEST_DIR)
if (existsSync(TEST_DIR)) { if (existsSync(TEST_DIR)) {
rmSync(TEST_DIR, { recursive: true, force: true }) rmSync(TEST_DIR, { recursive: true, force: true })
@@ -1182,9 +1186,11 @@ session_id: ses_untrusted_999
beforeEach(() => { beforeEach(() => {
_resetForTesting() _resetForTesting()
subagentSessions.clear() registerAgentName("atlas")
setupMessageStorage(MAIN_SESSION_ID, "atlas") registerAgentName("sisyphus")
}) subagentSessions.clear()
setupMessageStorage(MAIN_SESSION_ID, "atlas")
})
afterEach(() => { afterEach(() => {
cleanupMessageStorage(MAIN_SESSION_ID) cleanupMessageStorage(MAIN_SESSION_ID)

View File

@@ -2,7 +2,9 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs" import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os" import { tmpdir } from "node:os"
import { join } from "node:path" import { join } from "node:path"
import { autoMigrateLegacyPluginEntry } from "./auto-migrate" async function importFreshAutoMigrateModule(): Promise<typeof import("./auto-migrate")> {
return import(`./auto-migrate?test=${Date.now()}-${Math.random()}`)
}
describe("autoMigrateLegacyPluginEntry", () => { describe("autoMigrateLegacyPluginEntry", () => {
let testConfigDir = "" let testConfigDir = ""
@@ -17,13 +19,15 @@ describe("autoMigrateLegacyPluginEntry", () => {
}) })
describe("#given opencode.json has a bare legacy plugin entry", () => { describe("#given opencode.json has a bare legacy plugin entry", () => {
it("#then replaces oh-my-opencode with oh-my-openagent", () => { it("#then replaces oh-my-opencode with oh-my-openagent", async () => {
// given // given
writeFileSync( writeFileSync(
join(testConfigDir, "opencode.json"), join(testConfigDir, "opencode.json"),
JSON.stringify({ plugin: ["oh-my-opencode"] }, null, 2) + "\n", JSON.stringify({ plugin: ["oh-my-opencode"] }, null, 2) + "\n",
) )
const { autoMigrateLegacyPluginEntry } = await importFreshAutoMigrateModule()
// when // when
const result = autoMigrateLegacyPluginEntry(testConfigDir) const result = autoMigrateLegacyPluginEntry(testConfigDir)
@@ -37,13 +41,15 @@ describe("autoMigrateLegacyPluginEntry", () => {
}) })
describe("#given opencode.json has a version-pinned legacy entry", () => { describe("#given opencode.json has a version-pinned legacy entry", () => {
it("#then preserves the version suffix", () => { it("#then preserves the version suffix", async () => {
// given // given
writeFileSync( writeFileSync(
join(testConfigDir, "opencode.json"), join(testConfigDir, "opencode.json"),
JSON.stringify({ plugin: ["oh-my-opencode@3.10.0"] }, null, 2) + "\n", JSON.stringify({ plugin: ["oh-my-opencode@3.10.0"] }, null, 2) + "\n",
) )
const { autoMigrateLegacyPluginEntry } = await importFreshAutoMigrateModule()
// when // when
const result = autoMigrateLegacyPluginEntry(testConfigDir) const result = autoMigrateLegacyPluginEntry(testConfigDir)
@@ -57,13 +63,15 @@ describe("autoMigrateLegacyPluginEntry", () => {
}) })
describe("#given both canonical and legacy entries exist", () => { describe("#given both canonical and legacy entries exist", () => {
it("#then removes legacy entry and keeps canonical", () => { it("#then removes legacy entry and keeps canonical", async () => {
// given // given
writeFileSync( writeFileSync(
join(testConfigDir, "opencode.json"), join(testConfigDir, "opencode.json"),
JSON.stringify({ plugin: ["oh-my-openagent", "oh-my-opencode"] }, null, 2) + "\n", JSON.stringify({ plugin: ["oh-my-openagent", "oh-my-opencode"] }, null, 2) + "\n",
) )
const { autoMigrateLegacyPluginEntry } = await importFreshAutoMigrateModule()
// when // when
const result = autoMigrateLegacyPluginEntry(testConfigDir) const result = autoMigrateLegacyPluginEntry(testConfigDir)
@@ -75,8 +83,9 @@ describe("autoMigrateLegacyPluginEntry", () => {
}) })
describe("#given no config file exists", () => { describe("#given no config file exists", () => {
it("#then returns migrated false", () => { it("#then returns migrated false", async () => {
// given - empty dir // given - empty dir
const { autoMigrateLegacyPluginEntry } = await importFreshAutoMigrateModule()
// when // when
const result = autoMigrateLegacyPluginEntry(testConfigDir) const result = autoMigrateLegacyPluginEntry(testConfigDir)
@@ -88,13 +97,15 @@ describe("autoMigrateLegacyPluginEntry", () => {
}) })
describe("#given opencode.jsonc has comments and a legacy entry", () => { describe("#given opencode.jsonc has comments and a legacy entry", () => {
it("#then preserves comments and replaces entry", () => { it("#then preserves comments and replaces entry", async () => {
// given // given
writeFileSync( writeFileSync(
join(testConfigDir, "opencode.jsonc"), join(testConfigDir, "opencode.jsonc"),
'{\n // my config\n "plugin": ["oh-my-opencode"]\n}\n', '{\n // my config\n "plugin": ["oh-my-opencode"]\n}\n',
) )
const { autoMigrateLegacyPluginEntry } = await importFreshAutoMigrateModule()
// when // when
const result = autoMigrateLegacyPluginEntry(testConfigDir) const result = autoMigrateLegacyPluginEntry(testConfigDir)
@@ -108,11 +119,13 @@ describe("autoMigrateLegacyPluginEntry", () => {
}) })
describe("#given only canonical entry exists", () => { describe("#given only canonical entry exists", () => {
it("#then returns migrated false and leaves file untouched", () => { it("#then returns migrated false and leaves file untouched", async () => {
// given // given
const original = JSON.stringify({ plugin: ["oh-my-openagent"] }, null, 2) + "\n" const original = JSON.stringify({ plugin: ["oh-my-openagent"] }, null, 2) + "\n"
writeFileSync(join(testConfigDir, "opencode.json"), original) writeFileSync(join(testConfigDir, "opencode.json"), original)
const { autoMigrateLegacyPluginEntry } = await importFreshAutoMigrateModule()
// when // when
const result = autoMigrateLegacyPluginEntry(testConfigDir) const result = autoMigrateLegacyPluginEntry(testConfigDir)

View File

@@ -669,4 +669,43 @@ describe("preemptive-compaction", () => {
expect(ctx.client.session.summarize).toHaveBeenCalled() expect(ctx.client.session.summarize).toHaveBeenCalled()
}) })
it("should ignore stale cached Anthropic limits for older models", async () => {
const modelContextLimitsCache = new Map<string, number>()
modelContextLimitsCache.set("anthropic/claude-sonnet-4-5", 500000)
const hook = createPreemptiveCompactionHook(ctx as never, {} as never, {
anthropicContext1MEnabled: false,
modelContextLimitsCache,
})
const sessionID = "ses_old_anthropic_limit"
await hook.event({
event: {
type: "message.updated",
properties: {
info: {
role: "assistant",
sessionID,
providerID: "anthropic",
modelID: "claude-sonnet-4-5",
finish: true,
tokens: {
input: 170000,
output: 0,
reasoning: 0,
cache: { read: 10000, write: 0 },
},
},
},
},
})
await hook["tool.execute.after"](
{ tool: "bash", sessionID, callID: "call_1" },
{ title: "", output: "test", metadata: null }
)
expect(ctx.client.session.summarize).toHaveBeenCalled()
})
}) })

View File

@@ -186,7 +186,7 @@ describe("detectCompletionInSessionMessages", () => {
}) })
describe("#given semantic completion patterns", () => { describe("#given semantic completion patterns", () => {
test("#when agent says 'task is complete' #then should detect semantic completion", async () => { test("#when agent says 'task is complete' without explicit promise #then should NOT detect completion", async () => {
// #given // #given
const messages: SessionMessage[] = [ const messages: SessionMessage[] = [
{ {
@@ -205,10 +205,10 @@ describe("detectCompletionInSessionMessages", () => {
}) })
// #then // #then
expect(detected).toBe(true) expect(detected).toBe(false)
}) })
test("#when agent says 'all items are done' #then should detect semantic completion", async () => { test("#when agent says 'all items are done' without explicit promise #then should NOT detect completion", async () => {
// #given // #given
const messages: SessionMessage[] = [ const messages: SessionMessage[] = [
{ {
@@ -227,10 +227,10 @@ describe("detectCompletionInSessionMessages", () => {
}) })
// #then // #then
expect(detected).toBe(true) expect(detected).toBe(false)
}) })
test("#when agent says 'nothing left to do' #then should detect semantic completion", async () => { test("#when agent says 'nothing left to do' without explicit promise #then should NOT detect completion", async () => {
// #given // #given
const messages: SessionMessage[] = [ const messages: SessionMessage[] = [
{ {
@@ -249,10 +249,10 @@ describe("detectCompletionInSessionMessages", () => {
}) })
// #then // #then
expect(detected).toBe(true) expect(detected).toBe(false)
}) })
test("#when agent says 'successfully completed all' #then should detect semantic completion", async () => { test("#when agent says 'successfully completed all' without explicit promise #then should NOT detect completion", async () => {
// #given // #given
const messages: SessionMessage[] = [ const messages: SessionMessage[] = [
{ {
@@ -271,7 +271,7 @@ describe("detectCompletionInSessionMessages", () => {
}) })
// #then // #then
expect(detected).toBe(true) expect(detected).toBe(false)
}) })
test("#when promise is VERIFIED #then semantic completion should NOT trigger", async () => { test("#when promise is VERIFIED #then semantic completion should NOT trigger", async () => {
@@ -295,6 +295,75 @@ describe("detectCompletionInSessionMessages", () => {
// #then // #then
expect(detected).toBe(false) expect(detected).toBe(false)
}) })
test("#when completion text appears inside a quote #then should NOT detect completion", async () => {
// #given
const messages: SessionMessage[] = [
{
info: { role: "assistant" },
parts: [{ type: "text", text: 'The user wrote: "the task is complete". I am still working.' }],
},
]
const ctx = createPluginInput(messages)
// #when
const detected = await detectCompletionInSessionMessages(ctx, {
sessionID: "session-quoted",
promise: "DONE",
apiTimeoutMs: 1000,
directory: "/tmp",
})
// #then
expect(detected).toBe(false)
})
test("#when tool_result says all items are complete #then should NOT detect completion", async () => {
// #given
const messages: SessionMessage[] = [
{
info: { role: "assistant" },
parts: [
{ type: "tool_result", text: "Background agent report: all items are complete." },
{ type: "text", text: "Still validating the final behavior." },
],
},
]
const ctx = createPluginInput(messages)
// #when
const detected = await detectCompletionInSessionMessages(ctx, {
sessionID: "session-tool-result-semantic",
promise: "DONE",
apiTimeoutMs: 1000,
directory: "/tmp",
})
// #then
expect(detected).toBe(false)
})
test("#when assistant says complete but not actually done #then should NOT detect completion", async () => {
// #given
const messages: SessionMessage[] = [
{
info: { role: "assistant" },
parts: [{ type: "text", text: "The implementation looks complete, but I still need to run the tests." }],
},
]
const ctx = createPluginInput(messages)
// #when
const detected = await detectCompletionInSessionMessages(ctx, {
sessionID: "session-not-actually-done",
promise: "DONE",
apiTimeoutMs: 1000,
directory: "/tmp",
})
// #then
expect(detected).toBe(false)
})
}) })
}) })

View File

@@ -39,6 +39,8 @@ const SEMANTIC_COMPLETION_PATTERNS = [
/\bnothing\s+(?:left|more|remaining)\s+to\s+(?:do|implement|fix)\b/i, /\bnothing\s+(?:left|more|remaining)\s+to\s+(?:do|implement|fix)\b/i,
] ]
const SEMANTIC_DONE_FALLBACK_ENABLED = false
export function detectSemanticCompletion(text: string): boolean { export function detectSemanticCompletion(text: string): boolean {
return SEMANTIC_COMPLETION_PATTERNS.some((pattern) => pattern.test(text)) return SEMANTIC_COMPLETION_PATTERNS.some((pattern) => pattern.test(text))
} }
@@ -65,9 +67,8 @@ export function detectCompletionInTranscript(
const entryText = extractTranscriptEntryText(entry) const entryText = extractTranscriptEntryText(entry)
if (!entryText) continue if (!entryText) continue
if (pattern.test(entryText)) return true if (pattern.test(entryText)) return true
// Fallback: semantic completion only for DONE promise and assistant entries
const isAssistantEntry = entry.type === "assistant" || entry.type === "text" const isAssistantEntry = entry.type === "assistant" || entry.type === "text"
if (promise === "DONE" && isAssistantEntry && detectSemanticCompletion(entryText)) { if (SEMANTIC_DONE_FALLBACK_ENABLED && promise === "DONE" && isAssistantEntry && detectSemanticCompletion(entryText)) {
log("[ralph-loop] WARNING: Semantic completion detected in transcript (agent used natural language instead of <promise>DONE</promise>)") log("[ralph-loop] WARNING: Semantic completion detected in transcript (agent used natural language instead of <promise>DONE</promise>)")
return true return true
} }
@@ -135,8 +136,7 @@ export async function detectCompletionInSessionMessages(
return true return true
} }
// Fallback: semantic completion only for DONE promise if (SEMANTIC_DONE_FALLBACK_ENABLED && options.promise === "DONE" && detectSemanticCompletion(responseText)) {
if (options.promise === "DONE" && detectSemanticCompletion(responseText)) {
log("[ralph-loop] WARNING: Semantic completion detected (agent used natural language instead of <promise>DONE</promise>)", { log("[ralph-loop] WARNING: Semantic completion detected (agent used natural language instead of <promise>DONE</promise>)", {
sessionID: options.sessionID, sessionID: options.sessionID,
}) })

View File

@@ -1,7 +1,13 @@
import { describe, expect, it } from "bun:test" import { describe, expect, it } from "bun:test"
import { readMessagesFromSDK, readPartsFromSDK } from "../storage" async function importFreshReaders() {
import { readMessages } from "./messages-reader" const token = `${Date.now()}-${Math.random()}`
import { readParts } from "./parts-reader" const [{ readMessagesFromSDK, readMessages }, { readPartsFromSDK, readParts }] = await Promise.all([
import(`./messages-reader?test=${token}`),
import(`./parts-reader?test=${token}`),
])
return { readMessagesFromSDK, readPartsFromSDK, readMessages, readParts }
}
function createMockClient(handlers: { function createMockClient(handlers: {
messages?: (sessionID: string) => unknown[] messages?: (sessionID: string) => unknown[]
@@ -28,6 +34,7 @@ function createMockClient(handlers: {
describe("session-recovery storage SDK readers", () => { describe("session-recovery storage SDK readers", () => {
it("readPartsFromSDK returns empty array when fetch fails", async () => { it("readPartsFromSDK returns empty array when fetch fails", async () => {
//#given a client that throws on request //#given a client that throws on request
const { readPartsFromSDK } = await importFreshReaders()
const client = createMockClient({}) as Parameters<typeof readPartsFromSDK>[0] const client = createMockClient({}) as Parameters<typeof readPartsFromSDK>[0]
//#when readPartsFromSDK is called //#when readPartsFromSDK is called
@@ -39,6 +46,7 @@ describe("session-recovery storage SDK readers", () => {
it("readPartsFromSDK returns stored parts from SDK response", async () => { it("readPartsFromSDK returns stored parts from SDK response", async () => {
//#given a client that returns a message with parts //#given a client that returns a message with parts
const { readPartsFromSDK } = await importFreshReaders()
const sessionID = "ses_test" const sessionID = "ses_test"
const messageID = "msg_test" const messageID = "msg_test"
const storedParts = [ const storedParts = [
@@ -58,6 +66,7 @@ describe("session-recovery storage SDK readers", () => {
it("readMessagesFromSDK normalizes and sorts messages", async () => { it("readMessagesFromSDK normalizes and sorts messages", async () => {
//#given a client that returns messages list //#given a client that returns messages list
const { readMessagesFromSDK } = await importFreshReaders()
const sessionID = "ses_test" const sessionID = "ses_test"
const client = createMockClient({ const client = createMockClient({
messages: () => [ messages: () => [
@@ -78,8 +87,9 @@ describe("session-recovery storage SDK readers", () => {
]) ])
}) })
it("readParts returns empty array for nonexistent message", () => { it("readParts returns empty array for nonexistent message", async () => {
//#given a message ID that has no stored parts //#given a message ID that has no stored parts
const { readParts } = await importFreshReaders()
//#when readParts is called //#when readParts is called
const parts = readParts("msg_nonexistent") const parts = readParts("msg_nonexistent")
@@ -87,8 +97,9 @@ describe("session-recovery storage SDK readers", () => {
expect(parts).toEqual([]) expect(parts).toEqual([])
}) })
it("readMessages returns empty array for nonexistent session", () => { it("readMessages returns empty array for nonexistent session", async () => {
//#given a session ID that has no stored messages //#given a session ID that has no stored messages
const { readMessages } = await importFreshReaders()
//#when readMessages is called //#when readMessages is called
const messages = readMessages("ses_nonexistent") const messages = readMessages("ses_nonexistent")

View File

@@ -12,7 +12,6 @@ import {
import type { BoulderState } from "../../features/boulder-state" import type { BoulderState } from "../../features/boulder-state"
import * as sessionState from "../../features/claude-code-session-state" import * as sessionState from "../../features/claude-code-session-state"
import * as worktreeDetector from "./worktree-detector" import * as worktreeDetector from "./worktree-detector"
import * as worktreeDetector from "./worktree-detector"
describe("start-work hook", () => { describe("start-work hook", () => {
let testDir: string let testDir: string
@@ -26,6 +25,9 @@ describe("start-work hook", () => {
} }
beforeEach(() => { beforeEach(() => {
sessionState._resetForTesting()
sessionState.registerAgentName("atlas")
sessionState.registerAgentName("sisyphus")
testDir = join(tmpdir(), `start-work-test-${randomUUID()}`) testDir = join(tmpdir(), `start-work-test-${randomUUID()}`)
sisyphusDir = join(testDir, ".sisyphus") sisyphusDir = join(testDir, ".sisyphus")
if (!existsSync(testDir)) { if (!existsSync(testDir)) {
@@ -38,6 +40,7 @@ describe("start-work hook", () => {
}) })
afterEach(() => { afterEach(() => {
sessionState._resetForTesting()
clearBoulderState(testDir) clearBoulderState(testDir)
if (existsSync(testDir)) { if (existsSync(testDir)) {
rmSync(testDir, { recursive: true, force: true }) rmSync(testDir, { recursive: true, force: true })
@@ -409,7 +412,7 @@ describe("start-work hook", () => {
// given // given
const hook = createStartWorkHook(createMockPluginInput()) const hook = createStartWorkHook(createMockPluginInput())
const output = { const output = {
message: {}, message: {} as Record<string, unknown>,
parts: [{ type: "text", text: "<session-context></session-context>" }], parts: [{ type: "text", text: "<session-context></session-context>" }],
} }
@@ -422,6 +425,29 @@ describe("start-work hook", () => {
// then // then
expect(output.message.agent).toBe("Atlas (Plan Executor)") expect(output.message.agent).toBe("Atlas (Plan Executor)")
}) })
test("should keep the current agent when Atlas is unavailable", async () => {
// given
sessionState._resetForTesting()
sessionState.registerAgentName("sisyphus")
sessionState.updateSessionAgent("ses-prometheus-to-sisyphus", "sisyphus")
const hook = createStartWorkHook(createMockPluginInput())
const output = {
message: {} as Record<string, unknown>,
parts: [{ type: "text", text: "<session-context></session-context>" }],
}
// when
await hook["chat.message"](
{ sessionID: "ses-prometheus-to-sisyphus" },
output
)
// then
expect(output.message.agent).toBe("Sisyphus (Ultraworker)")
expect(sessionState.getSessionAgent("ses-prometheus-to-sisyphus")).toBe("sisyphus")
})
}) })
describe("worktree support", () => { describe("worktree support", () => {

View File

@@ -12,7 +12,7 @@ import {
} from "../../features/boulder-state" } from "../../features/boulder-state"
import { log } from "../../shared/logger" import { log } from "../../shared/logger"
import { getAgentDisplayName } from "../../shared/agent-display-names" import { getAgentDisplayName } from "../../shared/agent-display-names"
import { updateSessionAgent, isAgentRegistered } from "../../features/claude-code-session-state" import { getSessionAgent, isAgentRegistered, updateSessionAgent } from "../../features/claude-code-session-state"
import { detectWorktreePath } from "./worktree-detector" import { detectWorktreePath } from "./worktree-detector"
import { parseUserRequest } from "./parse-user-request" import { parseUserRequest } from "./parse-user-request"
@@ -80,14 +80,13 @@ export function createStartWorkHook(ctx: PluginInput) {
if (!promptText.includes("<session-context>")) return if (!promptText.includes("<session-context>")) return
log(`[${HOOK_NAME}] Processing start-work command`, { sessionID: input.sessionID }) log(`[${HOOK_NAME}] Processing start-work command`, { sessionID: input.sessionID })
const atlasDisplayName = getAgentDisplayName("atlas") const activeAgent = isAgentRegistered("atlas")
if (isAgentRegistered("atlas") || isAgentRegistered(atlasDisplayName)) { ? "atlas"
updateSessionAgent(input.sessionID, "atlas") : getSessionAgent(input.sessionID) ?? "sisyphus"
if (output.message) { const activeAgentDisplayName = getAgentDisplayName(activeAgent)
output.message["agent"] = atlasDisplayName updateSessionAgent(input.sessionID, activeAgent)
} if (output.message) {
} else { output.message["agent"] = activeAgentDisplayName
log(`[${HOOK_NAME}] Atlas agent not available, continuing with current agent`, { sessionID: input.sessionID })
} }
const existingState = readBoulderState(ctx.directory) const existingState = readBoulderState(ctx.directory)
@@ -116,7 +115,7 @@ The requested plan "${getPlanName(matchedPlan)}" has been completed.
All ${progress.total} tasks are done. Create a new plan with: /plan "your task"` All ${progress.total} tasks are done. Create a new plan with: /plan "your task"`
} else { } else {
if (existingState) clearBoulderState(ctx.directory) if (existingState) clearBoulderState(ctx.directory)
const newState = createBoulderState(matchedPlan, sessionId, "atlas", worktreePath) const newState = createBoulderState(matchedPlan, sessionId, activeAgent, worktreePath)
writeBoulderState(ctx.directory, newState) writeBoulderState(ctx.directory, newState)
contextInfo = ` contextInfo = `
@@ -223,7 +222,7 @@ All ${plans.length} plan(s) are complete. Create a new plan with: /plan "your ta
} else if (incompletePlans.length === 1) { } else if (incompletePlans.length === 1) {
const planPath = incompletePlans[0] const planPath = incompletePlans[0]
const progress = getPlanProgress(planPath) const progress = getPlanProgress(planPath)
const newState = createBoulderState(planPath, sessionId, "atlas", worktreePath) const newState = createBoulderState(planPath, sessionId, activeAgent, worktreePath)
writeBoulderState(ctx.directory, newState) writeBoulderState(ctx.directory, newState)
contextInfo += ` contextInfo += `

View File

@@ -9,7 +9,7 @@ export interface TasksTodowriteDisablerConfig {
export function createTasksTodowriteDisablerHook( export function createTasksTodowriteDisablerHook(
config: TasksTodowriteDisablerConfig, config: TasksTodowriteDisablerConfig,
) { ) {
const isTaskSystemEnabled = config.experimental?.task_system ?? false; const isTaskSystemEnabled = config.experimental?.task_system ?? true;
return { return {
"tool.execute.before": async ( "tool.execute.before": async (

View File

@@ -59,7 +59,7 @@ describe("tasks-todowrite-disabler", () => {
}) })
}) })
describe("when experimental.task_system is disabled or undefined", () => { describe("when experimental.task_system is disabled", () => {
test("should not block TodoWrite when flag is false", async () => { test("should not block TodoWrite when flag is false", async () => {
// given // given
const hook = createTasksTodowriteDisablerHook({ experimental: { task_system: false } }) const hook = createTasksTodowriteDisablerHook({ experimental: { task_system: false } })
@@ -78,7 +78,7 @@ describe("tasks-todowrite-disabler", () => {
).resolves.toBeUndefined() ).resolves.toBeUndefined()
}) })
test("should not block TodoWrite when experimental is undefined", async () => { test("should block TodoWrite when experimental is undefined because task_system defaults to enabled", async () => {
// given // given
const hook = createTasksTodowriteDisablerHook({}) const hook = createTasksTodowriteDisablerHook({})
const input = { const input = {
@@ -93,7 +93,7 @@ describe("tasks-todowrite-disabler", () => {
// when / then // when / then
await expect( await expect(
hook["tool.execute.before"](input, output) hook["tool.execute.before"](input, output)
).resolves.toBeUndefined() ).rejects.toThrow("TodoRead/TodoWrite are DISABLED")
}) })
test("should not block TodoRead when flag is false", async () => { test("should not block TodoRead when flag is false", async () => {

View File

@@ -246,7 +246,13 @@ describe("parseConfigPartially", () => {
const result = parseConfigPartially({}); const result = parseConfigPartially({});
expect(result).not.toBeNull(); expect(result).not.toBeNull();
expect(Object.keys(result!).length).toBe(0); expect(result).toEqual({
git_master: {
commit_footer: true,
include_co_authored_by: true,
git_env_prefix: "GIT_MASTER=1",
},
});
}); });
}); });

View File

@@ -290,7 +290,7 @@ describe("applyAgentConfig builtin override protection", () => {
}) })
// then // then
expect(createSisyphusJuniorAgentSpy).toHaveBeenCalledWith(undefined, "openai/gpt-5.4", false) expect(createSisyphusJuniorAgentSpy).toHaveBeenCalledWith(undefined, "openai/gpt-5.4", true)
}) })
test("includes project and global .agents skills in builtin agent awareness", async () => { test("includes project and global .agents skills in builtin agent awareness", async () => {

View File

@@ -90,7 +90,7 @@ export async function applyAgentConfig(params: {
params.pluginConfig.browser_automation_engine?.provider ?? "playwright"; params.pluginConfig.browser_automation_engine?.provider ?? "playwright";
const currentModel = params.config.model as string | undefined; const currentModel = params.config.model as string | undefined;
const disabledSkills = new Set<string>(params.pluginConfig.disabled_skills ?? []); const disabledSkills = new Set<string>(params.pluginConfig.disabled_skills ?? []);
const useTaskSystem = params.pluginConfig.experimental?.task_system ?? false; const useTaskSystem = params.pluginConfig.experimental?.task_system ?? true;
const disableOmoEnv = params.pluginConfig.experimental?.disable_omo_env ?? false; const disableOmoEnv = params.pluginConfig.experimental?.disable_omo_env ?? false;
const includeClaudeAgents = params.pluginConfig.claude_code?.agents ?? true; const includeClaudeAgents = params.pluginConfig.claude_code?.agents ?? true;

View File

@@ -1243,7 +1243,7 @@ describe("per-agent todowrite/todoread deny when task_system enabled", () => {
expect(agentResult[getAgentDisplayName("hephaestus")]?.permission?.todoread).toBeUndefined() expect(agentResult[getAgentDisplayName("hephaestus")]?.permission?.todoread).toBeUndefined()
}) })
test("does not deny todowrite/todoread when task_system is undefined", async () => { test("denies todowrite/todoread when task_system is undefined", async () => {
//#given //#given
const createBuiltinAgentsMock = agents.createBuiltinAgents as unknown as { const createBuiltinAgentsMock = agents.createBuiltinAgents as unknown as {
mockResolvedValue: (value: Record<string, unknown>) => void mockResolvedValue: (value: Record<string, unknown>) => void
@@ -1271,8 +1271,8 @@ describe("per-agent todowrite/todoread deny when task_system enabled", () => {
//#then //#then
const agentResult = config.agent as Record<string, { permission?: Record<string, unknown> }> const agentResult = config.agent as Record<string, { permission?: Record<string, unknown> }>
expect(agentResult[getAgentDisplayName("sisyphus")]?.permission?.todowrite).toBeUndefined() expect(agentResult[getAgentDisplayName("sisyphus")]?.permission?.todowrite).toBe("deny")
expect(agentResult[getAgentDisplayName("sisyphus")]?.permission?.todoread).toBeUndefined() expect(agentResult[getAgentDisplayName("sisyphus")]?.permission?.todoread).toBe("deny")
}) })
}) })

View File

@@ -15,7 +15,7 @@ function createParams(overrides: {
return { return {
config: { tools: {}, permission: {} } as Record<string, unknown>, config: { tools: {}, permission: {} } as Record<string, unknown>,
pluginConfig: { pluginConfig: {
experimental: { task_system: overrides.taskSystem ?? false }, experimental: overrides.taskSystem === undefined ? undefined : { task_system: overrides.taskSystem },
disabled_tools: overrides.disabledTools, disabled_tools: overrides.disabledTools,
} as OhMyOpenCodeConfig, } as OhMyOpenCodeConfig,
agentResult: agentResult as Record<string, unknown>, agentResult: agentResult as Record<string, unknown>,
@@ -216,6 +216,30 @@ describe("applyToolConfig", () => {
}) })
}) })
describe("#given task_system is undefined", () => {
describe("#when applying tool config", () => {
it.each([
"atlas",
"sisyphus",
"hephaestus",
"prometheus",
"sisyphus-junior",
])("#then should deny todo tools for %s agent by default", (agentName) => {
const params = createParams({
agents: [agentName],
})
applyToolConfig(params)
const agent = params.agentResult[agentName] as {
permission: Record<string, unknown>
}
expect(agent.permission.todowrite).toBe("deny")
expect(agent.permission.todoread).toBe("deny")
})
})
})
describe("#given disabled_tools includes 'question'", () => { describe("#given disabled_tools includes 'question'", () => {
let originalConfigContent: string | undefined let originalConfigContent: string | undefined
let originalCliRunMode: string | undefined let originalCliRunMode: string | undefined

View File

@@ -15,7 +15,7 @@ function getConfigQuestionPermission(): string | null {
} }
function agentByKey(agentResult: Record<string, unknown>, key: string): AgentWithPermission | undefined { function agentByKey(agentResult: Record<string, unknown>, key: string): AgentWithPermission | undefined {
return (agentResult[key] ?? agentResult[getAgentDisplayName(key)]) as return (agentResult[getAgentDisplayName(key)] ?? agentResult[key]) as
| AgentWithPermission | AgentWithPermission
| undefined; | undefined;
} }
@@ -25,7 +25,8 @@ export function applyToolConfig(params: {
pluginConfig: OhMyOpenCodeConfig; pluginConfig: OhMyOpenCodeConfig;
agentResult: Record<string, unknown>; agentResult: Record<string, unknown>;
}): void { }): void {
const denyTodoTools = params.pluginConfig.experimental?.task_system const taskSystemEnabled = params.pluginConfig.experimental?.task_system ?? true
const denyTodoTools = taskSystemEnabled
? { todowrite: "deny", todoread: "deny" } ? { todowrite: "deny", todoread: "deny" }
: {} : {}
@@ -40,7 +41,7 @@ export function applyToolConfig(params: {
LspCodeActionResolve: false, LspCodeActionResolve: false,
"task_*": false, "task_*": false,
teammate: false, teammate: false,
...(params.pluginConfig.experimental?.task_system ...(taskSystemEnabled
? { todowrite: false, todoread: false } ? { todowrite: false, todoread: false }
: {}), : {}),
...(skillDeniedByHost ...(skillDeniedByHost

View File

@@ -1,6 +1,7 @@
const { describe, expect, test } = require("bun:test") const { describe, expect, test } = require("bun:test")
const { createToolExecuteBeforeHandler } = require("./tool-execute-before") const { createToolExecuteBeforeHandler } = require("./tool-execute-before")
const { createToolRegistry } = require("./tool-registry") const { createToolRegistry } = require("./tool-registry")
const { builtinTools } = require("../tools")
describe("createToolExecuteBeforeHandler", () => { describe("createToolExecuteBeforeHandler", () => {
test("does not execute subagent question blocker hook for question tool", async () => { test("does not execute subagent question blocker hook for question tool", async () => {
@@ -268,6 +269,44 @@ describe("createToolRegistry", () => {
}) })
}) })
}) })
describe("#given max_tools is lower than or equal to builtin tool count", () => {
describe("#when creating the tool registry", () => {
test("#then it trims to the exact configured cap", () => {
const result = createToolRegistry(
createRegistryInput({
experimental: { max_tools: Object.keys(builtinTools).length },
}),
)
expect(Object.keys(result.filteredTools)).toHaveLength(Object.keys(builtinTools).length)
})
})
})
describe("#given max_tools is set below the full plugin tool count", () => {
describe("#when creating the tool registry", () => {
test("#then it enforces the exact cap deterministically", () => {
const result = createToolRegistry(
createRegistryInput({
experimental: { max_tools: 10 },
}),
)
expect(Object.keys(result.filteredTools)).toHaveLength(10)
})
test("#then it keeps the task tool when lower-priority tools can satisfy the cap", () => {
const result = createToolRegistry(
createRegistryInput({
experimental: { max_tools: 10 },
}),
)
expect(result.filteredTools.task).toBeDefined()
})
})
})
}) })
export {} export {}

View File

@@ -40,6 +40,63 @@ export type ToolRegistryResult = {
taskSystemEnabled: boolean taskSystemEnabled: boolean
} }
const LOW_PRIORITY_TOOL_ORDER = [
"session_list",
"session_read",
"session_search",
"session_info",
"interactive_bash",
"look_at",
"call_omo_agent",
"task_create",
"task_get",
"task_list",
"task_update",
"background_output",
"background_cancel",
"hashline_edit",
"ast_grep_replace",
"ast_grep_search",
"glob",
"grep",
"skill_mcp",
"skill",
"task",
"lsp_rename",
"lsp_prepare_rename",
"lsp_find_references",
"lsp_goto_definition",
"lsp_symbols",
"lsp_diagnostics",
] as const
function trimToolsToCap(filteredTools: ToolsRecord, maxTools: number): void {
const toolNames = Object.keys(filteredTools)
if (toolNames.length <= maxTools) return
const removableToolNames = [
...LOW_PRIORITY_TOOL_ORDER.filter((toolName) => toolNames.includes(toolName)),
...toolNames
.filter((toolName) => !LOW_PRIORITY_TOOL_ORDER.includes(toolName as (typeof LOW_PRIORITY_TOOL_ORDER)[number]))
.sort(),
]
let currentCount = toolNames.length
let removed = 0
for (const toolName of removableToolNames) {
if (currentCount <= maxTools) break
if (!filteredTools[toolName]) continue
delete filteredTools[toolName]
currentCount -= 1
removed += 1
}
log(
`[tool-registry] Trimmed ${removed} tools to satisfy max_tools=${maxTools}. Final plugin tool count=${currentCount}.`,
)
}
export function createToolRegistry(args: { export function createToolRegistry(args: {
ctx: PluginContext ctx: PluginContext
pluginConfig: OhMyOpenCodeConfig pluginConfig: OhMyOpenCodeConfig
@@ -158,29 +215,7 @@ export function createToolRegistry(args: {
const maxTools = pluginConfig.experimental?.max_tools const maxTools = pluginConfig.experimental?.max_tools
if (maxTools) { if (maxTools) {
const estimatedBuiltinTools = 20 trimToolsToCap(filteredTools, maxTools)
const pluginToolBudget = maxTools - estimatedBuiltinTools
const toolEntries = Object.entries(filteredTools)
if (pluginToolBudget > 0 && toolEntries.length > pluginToolBudget) {
const excess = toolEntries.length - pluginToolBudget
log(`[tool-registry] Tool count (${toolEntries.length} plugin + ~${estimatedBuiltinTools} builtin = ~${toolEntries.length + estimatedBuiltinTools}) exceeds max_tools=${maxTools}. Trimming ${excess} lower-priority tools.`)
const lowPriorityTools = [
"session_list", "session_read", "session_search", "session_info",
"call_omo_agent", "interactive_bash", "look_at",
"task_create", "task_get", "task_list", "task_update",
]
let removed = 0
for (const toolName of lowPriorityTools) {
if (removed >= excess) break
if (filteredTools[toolName]) {
delete filteredTools[toolName]
removed += 1
}
}
if (removed < excess) {
log(`[tool-registry] WARNING: Could not trim enough tools. ${toolEntries.length - removed} plugin tools remain.`)
}
}
} }
return { return {

View File

@@ -45,7 +45,7 @@ describe("resolveActualContextLimit", () => {
expect(actualLimit).toBe(1_000_000) expect(actualLimit).toBe(1_000_000)
}) })
it("returns cached limit for Anthropic models when modelContextLimitsCache has entry", () => { it("returns default 200K for older Anthropic models when 1M mode is disabled", () => {
// given // given
delete process.env[ANTHROPIC_CONTEXT_ENV_KEY] delete process.env[ANTHROPIC_CONTEXT_ENV_KEY]
delete process.env[VERTEX_CONTEXT_ENV_KEY] delete process.env[VERTEX_CONTEXT_ENV_KEY]
@@ -59,7 +59,7 @@ describe("resolveActualContextLimit", () => {
}) })
// then // then
expect(actualLimit).toBe(500_000) expect(actualLimit).toBe(200_000)
}) })
it("returns default 200K for Anthropic models without cached limit and 1M mode disabled", () => { it("returns default 200K for Anthropic models without cached limit and 1M mode disabled", () => {
@@ -126,6 +126,40 @@ describe("resolveActualContextLimit", () => {
expect(actualLimit).toBe(1_000_000) expect(actualLimit).toBe(1_000_000)
}) })
it("supports Anthropic 4.6 high-variant model IDs without widening older models", () => {
// given
delete process.env[ANTHROPIC_CONTEXT_ENV_KEY]
delete process.env[VERTEX_CONTEXT_ENV_KEY]
const modelContextLimitsCache = new Map<string, number>()
modelContextLimitsCache.set("anthropic/claude-sonnet-4-6-high", 500_000)
// when
const actualLimit = resolveActualContextLimit("anthropic", "claude-sonnet-4-6-high", {
anthropicContext1MEnabled: false,
modelContextLimitsCache,
})
// then
expect(actualLimit).toBe(500_000)
})
it("ignores stale cached limits for older Anthropic models with suffixed IDs", () => {
// given
delete process.env[ANTHROPIC_CONTEXT_ENV_KEY]
delete process.env[VERTEX_CONTEXT_ENV_KEY]
const modelContextLimitsCache = new Map<string, number>()
modelContextLimitsCache.set("anthropic/claude-sonnet-4-5-high", 500_000)
// when
const actualLimit = resolveActualContextLimit("anthropic", "claude-sonnet-4-5-high", {
anthropicContext1MEnabled: false,
modelContextLimitsCache,
})
// then
expect(actualLimit).toBe(200_000)
})
it("returns null for non-Anthropic providers without a cached limit", () => { it("returns null for non-Anthropic providers without a cached limit", () => {
// given // given
delete process.env[ANTHROPIC_CONTEXT_ENV_KEY] delete process.env[ANTHROPIC_CONTEXT_ENV_KEY]

View File

@@ -19,6 +19,10 @@ function getAnthropicActualLimit(modelCacheState?: ContextLimitModelCacheState):
: DEFAULT_ANTHROPIC_ACTUAL_LIMIT : DEFAULT_ANTHROPIC_ACTUAL_LIMIT
} }
function supportsCachedAnthropicLimit(modelID: string): boolean {
return /^claude-(opus|sonnet)-4(?:-|\.)6(?:-high)?$/.test(modelID)
}
export function resolveActualContextLimit( export function resolveActualContextLimit(
providerID: string, providerID: string,
modelID: string, modelID: string,
@@ -29,7 +33,7 @@ export function resolveActualContextLimit(
if (explicit1M === 1_000_000) return explicit1M if (explicit1M === 1_000_000) return explicit1M
const cachedLimit = modelCacheState?.modelContextLimitsCache?.get(`${providerID}/${modelID}`) const cachedLimit = modelCacheState?.modelContextLimitsCache?.get(`${providerID}/${modelID}`)
if (cachedLimit) return cachedLimit if (cachedLimit && supportsCachedAnthropicLimit(modelID)) return cachedLimit
return DEFAULT_ANTHROPIC_ACTUAL_LIMIT return DEFAULT_ANTHROPIC_ACTUAL_LIMIT
} }

View File

@@ -2,7 +2,10 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs" import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os" import { tmpdir } from "node:os"
import { join } from "node:path" import { join } from "node:path"
import { migrateLegacyPluginEntry } from "./migrate-legacy-plugin-entry"
async function importFreshMigrationModule(): Promise<typeof import("./migrate-legacy-plugin-entry")> {
return import(`./migrate-legacy-plugin-entry?test=${Date.now()}-${Math.random()}`)
}
describe("migrateLegacyPluginEntry", () => { describe("migrateLegacyPluginEntry", () => {
let testDir = "" let testDir = ""
@@ -18,9 +21,10 @@ describe("migrateLegacyPluginEntry", () => {
describe("#given opencode.json contains oh-my-opencode plugin entry", () => { describe("#given opencode.json contains oh-my-opencode plugin entry", () => {
describe("#when migrating the config", () => { describe("#when migrating the config", () => {
it("#then replaces oh-my-opencode with oh-my-openagent", () => { it("#then replaces oh-my-opencode with oh-my-openagent", async () => {
const configPath = join(testDir, "opencode.json") const configPath = join(testDir, "opencode.json")
writeFileSync(configPath, JSON.stringify({ plugin: ["oh-my-opencode@latest"] }, null, 2)) writeFileSync(configPath, JSON.stringify({ plugin: ["oh-my-opencode@latest"] }, null, 2))
const { migrateLegacyPluginEntry } = await importFreshMigrationModule()
const result = migrateLegacyPluginEntry(configPath) const result = migrateLegacyPluginEntry(configPath)
@@ -34,9 +38,10 @@ describe("migrateLegacyPluginEntry", () => {
describe("#given opencode.json contains bare oh-my-opencode entry", () => { describe("#given opencode.json contains bare oh-my-opencode entry", () => {
describe("#when migrating the config", () => { describe("#when migrating the config", () => {
it("#then replaces with oh-my-openagent", () => { it("#then replaces with oh-my-openagent", async () => {
const configPath = join(testDir, "opencode.json") const configPath = join(testDir, "opencode.json")
writeFileSync(configPath, JSON.stringify({ plugin: ["oh-my-opencode"] }, null, 2)) writeFileSync(configPath, JSON.stringify({ plugin: ["oh-my-opencode"] }, null, 2))
const { migrateLegacyPluginEntry } = await importFreshMigrationModule()
const result = migrateLegacyPluginEntry(configPath) const result = migrateLegacyPluginEntry(configPath)
@@ -50,9 +55,10 @@ describe("migrateLegacyPluginEntry", () => {
describe("#given opencode.json contains pinned oh-my-opencode version", () => { describe("#given opencode.json contains pinned oh-my-opencode version", () => {
describe("#when migrating the config", () => { describe("#when migrating the config", () => {
it("#then preserves the version pin", () => { it("#then preserves the version pin", async () => {
const configPath = join(testDir, "opencode.json") const configPath = join(testDir, "opencode.json")
writeFileSync(configPath, JSON.stringify({ plugin: ["oh-my-opencode@3.11.0"] }, null, 2)) writeFileSync(configPath, JSON.stringify({ plugin: ["oh-my-opencode@3.11.0"] }, null, 2))
const { migrateLegacyPluginEntry } = await importFreshMigrationModule()
const result = migrateLegacyPluginEntry(configPath) const result = migrateLegacyPluginEntry(configPath)
@@ -65,10 +71,11 @@ describe("migrateLegacyPluginEntry", () => {
describe("#given opencode.json already uses oh-my-openagent", () => { describe("#given opencode.json already uses oh-my-openagent", () => {
describe("#when checking for migration", () => { describe("#when checking for migration", () => {
it("#then returns false and does not modify the file", () => { it("#then returns false and does not modify the file", async () => {
const configPath = join(testDir, "opencode.json") const configPath = join(testDir, "opencode.json")
const original = JSON.stringify({ plugin: ["oh-my-openagent@latest"] }, null, 2) const original = JSON.stringify({ plugin: ["oh-my-openagent@latest"] }, null, 2)
writeFileSync(configPath, original) writeFileSync(configPath, original)
const { migrateLegacyPluginEntry } = await importFreshMigrationModule()
const result = migrateLegacyPluginEntry(configPath) const result = migrateLegacyPluginEntry(configPath)
@@ -78,9 +85,89 @@ describe("migrateLegacyPluginEntry", () => {
}) })
}) })
describe("#given plugin entries contain both canonical and legacy values", () => {
describe("#when migrating the config", () => {
it("#then removes the legacy entry instead of duplicating the canonical one", async () => {
const configPath = join(testDir, "opencode.json")
writeFileSync(configPath, JSON.stringify({ plugin: ["oh-my-openagent", "oh-my-opencode"] }, null, 2))
const { migrateLegacyPluginEntry } = await importFreshMigrationModule()
const result = migrateLegacyPluginEntry(configPath)
expect(result).toBe(true)
const saved = JSON.parse(readFileSync(configPath, "utf-8")) as { plugin: string[] }
expect(saved.plugin).toEqual(["oh-my-openagent"])
})
})
})
describe("#given unrelated strings contain the legacy package name", () => {
describe("#when migrating the config", () => {
it("#then rewrites only plugin entries and preserves unrelated fields", async () => {
const configPath = join(testDir, "opencode.json")
writeFileSync(
configPath,
JSON.stringify(
{
plugin: ["oh-my-opencode"],
notes: "keep oh-my-opencode in this text field",
paths: ["/tmp/oh-my-opencode/cache"],
},
null,
2,
),
)
const { migrateLegacyPluginEntry } = await importFreshMigrationModule()
const result = migrateLegacyPluginEntry(configPath)
expect(result).toBe(true)
const saved = JSON.parse(readFileSync(configPath, "utf-8")) as {
plugin: string[]
notes: string
paths: string[]
}
expect(saved.plugin).toEqual(["oh-my-openagent"])
expect(saved.notes).toBe("keep oh-my-opencode in this text field")
expect(saved.paths).toEqual(["/tmp/oh-my-opencode/cache"])
})
})
})
describe("#given opencode.jsonc contains a nested plugin key before the top-level plugin array", () => {
describe("#when migrating the config", () => {
it("#then rewrites only the top-level plugin array", async () => {
const configPath = join(testDir, "opencode.jsonc")
writeFileSync(
configPath,
`{
"nested": {
"plugin": ["oh-my-opencode"]
},
"plugin": ["oh-my-opencode@latest"]
}
`,
)
const { migrateLegacyPluginEntry } = await importFreshMigrationModule()
const result = migrateLegacyPluginEntry(configPath)
expect(result).toBe(true)
const content = readFileSync(configPath, "utf-8")
expect(content).toContain(`"nested": {
"plugin": ["oh-my-opencode"]
}`)
expect(content).toContain(`"plugin": [
"oh-my-openagent@latest"
]`)
})
})
})
describe("#given config file does not exist", () => { describe("#given config file does not exist", () => {
describe("#when attempting migration", () => { describe("#when attempting migration", () => {
it("#then returns false", () => { it("#then returns false", async () => {
const { migrateLegacyPluginEntry } = await importFreshMigrationModule()
const result = migrateLegacyPluginEntry(join(testDir, "nonexistent.json")) const result = migrateLegacyPluginEntry(join(testDir, "nonexistent.json"))
expect(result).toBe(false) expect(result).toBe(false)

View File

@@ -1,8 +1,54 @@
import { existsSync, readFileSync, writeFileSync } from "node:fs" import { existsSync, readFileSync, writeFileSync } from "node:fs"
import { applyEdits, modify } from "jsonc-parser"
import { parseJsoncSafe } from "./jsonc-parser"
import { log } from "./logger" import { log } from "./logger"
import { LEGACY_PLUGIN_NAME, PLUGIN_NAME } from "./plugin-identity" import { LEGACY_PLUGIN_NAME, PLUGIN_NAME } from "./plugin-identity"
interface OpenCodeConfig {
plugin?: string[]
}
function isLegacyEntry(entry: string): boolean {
return entry === LEGACY_PLUGIN_NAME || entry.startsWith(`${LEGACY_PLUGIN_NAME}@`)
}
function isCanonicalEntry(entry: string): boolean {
return entry === PLUGIN_NAME || entry.startsWith(`${PLUGIN_NAME}@`)
}
function toCanonicalEntry(entry: string): string {
if (entry === LEGACY_PLUGIN_NAME) return PLUGIN_NAME
if (entry.startsWith(`${LEGACY_PLUGIN_NAME}@`)) {
return `${PLUGIN_NAME}${entry.slice(LEGACY_PLUGIN_NAME.length)}`
}
return entry
}
function normalizePluginEntries(entries: string[]): string[] {
const hasCanonical = entries.some(isCanonicalEntry)
if (hasCanonical) {
return entries.filter((entry) => !isLegacyEntry(entry))
}
return entries.map((entry) => (isLegacyEntry(entry) ? toCanonicalEntry(entry) : entry))
}
function updateJsoncPluginArray(content: string, pluginEntries: string[]): string | null {
const edits = modify(content, ["plugin"], pluginEntries, {
formattingOptions: {
insertSpaces: true,
tabSize: 2,
eol: "\n",
},
getInsertionIndex: () => 0,
})
if (edits.length === 0) return null
return applyEdits(content, edits)
}
export function migrateLegacyPluginEntry(configPath: string): boolean { export function migrateLegacyPluginEntry(configPath: string): boolean {
if (!existsSync(configPath)) return false if (!existsSync(configPath)) return false
@@ -10,8 +56,15 @@ export function migrateLegacyPluginEntry(configPath: string): boolean {
const content = readFileSync(configPath, "utf-8") const content = readFileSync(configPath, "utf-8")
if (!content.includes(LEGACY_PLUGIN_NAME)) return false if (!content.includes(LEGACY_PLUGIN_NAME)) return false
const updated = content.replaceAll(LEGACY_PLUGIN_NAME, PLUGIN_NAME) const parseResult = parseJsoncSafe<OpenCodeConfig>(content)
if (updated === content) return false const pluginEntries = parseResult.data?.plugin
if (!pluginEntries || !pluginEntries.some(isLegacyEntry)) return false
const updatedPluginEntries = normalizePluginEntries(pluginEntries)
const updated = configPath.endsWith(".jsonc")
? updateJsoncPluginArray(content, updatedPluginEntries)
: JSON.stringify({ ...(parseResult.data as OpenCodeConfig), plugin: updatedPluginEntries }, null, 2) + "\n"
if (!updated || updated === content) return false
writeFileSync(configPath, updated, "utf-8") writeFileSync(configPath, updated, "utf-8")
log("[migrateLegacyPluginEntry] Auto-migrated opencode.json plugin entry", { log("[migrateLegacyPluginEntry] Auto-migrated opencode.json plugin entry", {

View File

@@ -125,4 +125,28 @@ describe("resolveSkillPathReferences", () => {
//#then //#then
expect(result).toBe("/skills/frontend/scripts/search.py") expect(result).toBe("/skills/frontend/scripts/search.py")
}) })
it("does not resolve traversal paths that escape the base directory", () => {
//#given
const content = "Read @data/../../../../etc/passwd before running"
const basePath = "/skills/frontend"
//#when
const result = resolveSkillPathReferences(content, basePath)
//#then
expect(result).toBe("Read @data/../../../../etc/passwd before running")
})
it("does not resolve directory traversal with trailing slash", () => {
//#given
const content = "Inspect @data/../../../secret/"
const basePath = "/skills/frontend"
//#when
const result = resolveSkillPathReferences(content, basePath)
//#then
expect(result).toBe("Inspect @data/../../../secret/")
})
}) })

View File

@@ -1,4 +1,4 @@
import { join } from "path" import { isAbsolute, relative, resolve, sep } from "node:path"
function looksLikeFilePath(path: string): boolean { function looksLikeFilePath(path: string): boolean {
if (path.endsWith("/")) return true if (path.endsWith("/")) return true
@@ -6,22 +6,21 @@ function looksLikeFilePath(path: string): boolean {
return /\.[a-zA-Z0-9]+$/.test(lastSegment) return /\.[a-zA-Z0-9]+$/.test(lastSegment)
} }
/**
* Resolves @path references in skill content to absolute paths.
*
* Matches @references that contain at least one slash (e.g., @scripts/search.py, @data/)
* to avoid false positives with decorators (@param), JSDoc tags (@ts-ignore), etc.
* Also skips npm scoped packages (@scope/package) by requiring a file extension or trailing slash.
*
* Email addresses are excluded since they have alphanumeric characters before @.
*/
export function resolveSkillPathReferences(content: string, basePath: string): string { export function resolveSkillPathReferences(content: string, basePath: string): string {
const normalizedBase = basePath.endsWith("/") ? basePath.slice(0, -1) : basePath const normalizedBase = basePath.endsWith("/") ? basePath.slice(0, -1) : basePath
return content.replace( return content.replace(
/(?<![a-zA-Z0-9])@([a-zA-Z0-9_-]+\/[a-zA-Z0-9_.\-\/]*)/g, /(?<![a-zA-Z0-9])@([a-zA-Z0-9_-]+\/[a-zA-Z0-9_.\-\/]*)/g,
(match, relativePath: string) => { (match, relativePath: string) => {
if (!looksLikeFilePath(relativePath)) return match if (!looksLikeFilePath(relativePath)) return match
return join(normalizedBase, relativePath) const resolvedPath = resolve(normalizedBase, relativePath)
const relativePathFromBase = relative(normalizedBase, resolvedPath)
if (relativePathFromBase.startsWith("..") || isAbsolute(relativePathFromBase)) {
return match
}
if (relativePath.endsWith("/") && !resolvedPath.endsWith(sep)) {
return `${resolvedPath}/`
}
return resolvedPath
} }
) )
} }

View File

@@ -76,7 +76,9 @@ describe("resolveModelForDelegateTask", () => {
}) })
describe("#when availableModels is empty (cache exists but empty)", () => { describe("#when availableModels is empty (cache exists but empty)", () => {
test("#then falls through to category default model (existing behavior)", () => { test("#then keeps the category default when its provider is connected", () => {
const readConnectedProvidersSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["anthropic"])
const result = resolveModelForDelegateTask({ const result = resolveModelForDelegateTask({
categoryDefaultModel: "anthropic/claude-sonnet-4-6", categoryDefaultModel: "anthropic/claude-sonnet-4-6",
fallbackChain: [ fallbackChain: [
@@ -87,6 +89,40 @@ describe("resolveModelForDelegateTask", () => {
}) })
expect(result).toEqual({ model: "anthropic/claude-sonnet-4-6" }) expect(result).toEqual({ model: "anthropic/claude-sonnet-4-6" })
readConnectedProvidersSpy.mockRestore()
})
test("#then skips a disconnected category default and resolves via a connected fallback", () => {
const readConnectedProvidersSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai"])
const result = resolveModelForDelegateTask({
categoryDefaultModel: "anthropic/claude-sonnet-4-6",
fallbackChain: [
{ providers: ["openai"], model: "gpt-5.4", variant: "high" },
],
availableModels: new Set(),
systemDefaultModel: "anthropic/claude-sonnet-4-6",
})
expect(result).toEqual({
model: "openai/gpt-5.4",
variant: "high",
fallbackEntry: { providers: ["openai"], model: "gpt-5.4", variant: "high" },
matchedFallback: true,
})
readConnectedProvidersSpy.mockRestore()
})
test("#then skips disconnected user fallback models and keeps the first connected fallback", () => {
const readConnectedProvidersSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai"])
const result = resolveModelForDelegateTask({
userFallbackModels: ["anthropic/claude-sonnet-4-6", "openai/gpt-5.4"],
availableModels: new Set(),
})
expect(result).toEqual({ model: "openai/gpt-5.4", matchedFallback: true })
readConnectedProvidersSpy.mockRestore()
}) })
}) })
@@ -225,16 +261,23 @@ describe("resolveModelForDelegateTask", () => {
}) })
describe("#when availableModels is empty", () => { describe("#when availableModels is empty", () => {
test("#then falls through to existing resolution (cache partially ready)", () => { test("#then uses connected providers to avoid disconnected category defaults", () => {
const readConnectedProvidersSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai"])
const result = resolveModelForDelegateTask({ const result = resolveModelForDelegateTask({
categoryDefaultModel: "anthropic/claude-sonnet-4-6", categoryDefaultModel: "anthropic/claude-sonnet-4-6",
fallbackChain: [ fallbackChain: [
{ providers: ["anthropic"], model: "claude-sonnet-4-6" }, { providers: ["openai"], model: "gpt-5.4" },
], ],
availableModels: new Set(), availableModels: new Set(),
}) })
expect(result).toBeDefined() expect(result).toEqual({
model: "openai/gpt-5.4",
fallbackEntry: { providers: ["openai"], model: "gpt-5.4" },
matchedFallback: true,
})
readConnectedProvidersSpy.mockRestore()
}) })
}) })
}) })

View File

@@ -59,6 +59,8 @@ export function resolveModelForDelegateTask(input: {
return { model: userModel } return { model: userModel }
} }
const connectedProviders = input.availableModels.size === 0 ? readConnectedProvidersCache() : null
// Before provider cache is created (first run), skip model resolution entirely. // Before provider cache is created (first run), skip model resolution entirely.
// OpenCode will use its system default model when no model is specified in the prompt. // OpenCode will use its system default model when no model is specified in the prompt.
if (input.availableModels.size === 0 && !hasProviderModelsCache() && !hasConnectedProvidersCache()) { if (input.availableModels.size === 0 && !hasProviderModelsCache() && !hasConnectedProvidersCache()) {
@@ -77,7 +79,15 @@ export function resolveModelForDelegateTask(input: {
} }
if (input.availableModels.size === 0) { if (input.availableModels.size === 0) {
return { model: categoryDefault } const categoryProvider = categoryDefault.includes("/") ? categoryDefault.split("/")[0] : undefined
if (!connectedProviders || !categoryProvider || connectedProviders.includes(categoryProvider)) {
return { model: categoryDefault }
}
log("[resolveModelForDelegateTask] skipping disconnected category default on cold cache", {
categoryDefault,
connectedProviders,
})
} }
const parts = categoryDefault.split("/") const parts = categoryDefault.split("/")
@@ -95,9 +105,19 @@ export function resolveModelForDelegateTask(input: {
const userFallbackModels = input.userFallbackModels const userFallbackModels = input.userFallbackModels
if (userFallbackModels && userFallbackModels.length > 0) { if (userFallbackModels && userFallbackModels.length > 0) {
if (input.availableModels.size === 0) { if (input.availableModels.size === 0) {
const first = userFallbackModels[0] ? parseUserFallbackModel(userFallbackModels[0]) : undefined for (const fallbackModel of userFallbackModels) {
if (first) { const parsedFallback = parseUserFallbackModel(fallbackModel)
return { model: first.baseModel, variant: first.variant, matchedFallback: true } if (!parsedFallback) continue
if (
connectedProviders &&
parsedFallback.providerHint &&
!parsedFallback.providerHint.some((provider) => connectedProviders.includes(provider))
) {
continue
}
return { model: parsedFallback.baseModel, variant: parsedFallback.variant, matchedFallback: true }
} }
} else { } else {
for (const fallbackModel of userFallbackModels) { for (const fallbackModel of userFallbackModels) {
@@ -115,7 +135,6 @@ export function resolveModelForDelegateTask(input: {
const fallbackChain = input.fallbackChain const fallbackChain = input.fallbackChain
if (fallbackChain && fallbackChain.length > 0) { if (fallbackChain && fallbackChain.length > 0) {
if (input.availableModels.size === 0) { if (input.availableModels.size === 0) {
const connectedProviders = readConnectedProvidersCache()
if (connectedProviders) { if (connectedProviders) {
const connectedSet = new Set(connectedProviders) const connectedSet = new Set(connectedProviders)
for (const entry of fallbackChain) { for (const entry of fallbackChain) {

View File

@@ -76,13 +76,13 @@ export function createDelegateTask(options: DelegateTaskToolOptions): ToolDefini
- category: For task delegation (uses Sisyphus-Junior with category-optimized model) - category: For task delegation (uses Sisyphus-Junior with category-optimized model)
- subagent_type: For direct agent invocation (explore, librarian, oracle, etc.) - subagent_type: For direct agent invocation (explore, librarian, oracle, etc.)
**DO NOT provide both.** If category is provided, subagent_type is ignored. **DO NOT provide both.** category and subagent_type are mutually exclusive.
- load_skills: ALWAYS REQUIRED. Pass [] if no skills needed, or ["skill-1", "skill-2"] for category tasks. - load_skills: ALWAYS REQUIRED. Pass [] if no skills needed, or ["skill-1", "skill-2"] for category tasks.
- category: Use predefined category → Spawns Sisyphus-Junior with category config - category: Use predefined category → Spawns Sisyphus-Junior with category config
Available categories: Available categories:
${categoryList} ${categoryList}
- subagent_type: Use specific agent directly (explore, librarian, oracle, metis, momus) - subagent_type: Use a specific callable non-primary agent directly (for example: explore, librarian, oracle, metis, momus)
- run_in_background: REQUIRED. true=async (returns task_id), false=sync (waits). Use background=true ONLY for parallel exploration with 5+ independent queries. - run_in_background: REQUIRED. true=async (returns task_id), false=sync (waits). Use background=true ONLY for parallel exploration with 5+ independent queries.
- session_id: Existing Task session to continue (from previous task output). Continues agent with FULL CONTEXT PRESERVED - saves tokens, maintains continuity. - session_id: Existing Task session to continue (from previous task output). Continues agent with FULL CONTEXT PRESERVED - saves tokens, maintains continuity.
- command: The command that triggered this task (optional, for slash command tracking). - command: The command that triggered this task (optional, for slash command tracking).
@@ -102,7 +102,7 @@ export function createDelegateTask(options: DelegateTaskToolOptions): ToolDefini
prompt: tool.schema.string().describe("Full detailed prompt for the agent"), prompt: tool.schema.string().describe("Full detailed prompt for the agent"),
run_in_background: tool.schema.boolean().describe("REQUIRED. true=async (returns task_id), false=sync (waits). Use false for task delegation, true ONLY for parallel exploration."), run_in_background: tool.schema.boolean().describe("REQUIRED. true=async (returns task_id), false=sync (waits). Use false for task delegation, true ONLY for parallel exploration."),
category: tool.schema.string().optional().describe(`REQUIRED if subagent_type not provided. Do NOT provide both category and subagent_type.`), category: tool.schema.string().optional().describe(`REQUIRED if subagent_type not provided. Do NOT provide both category and subagent_type.`),
subagent_type: tool.schema.string().optional().describe("REQUIRED if category not provided. Do NOT provide both category and subagent_type. Valid values: explore, librarian, oracle, metis, momus"), subagent_type: tool.schema.string().optional().describe("REQUIRED if category not provided. Do NOT provide both category and subagent_type. Must be a callable non-primary agent name returned by app.agents()."),
session_id: tool.schema.string().optional().describe("Existing Task session to continue"), session_id: tool.schema.string().optional().describe("Existing Task session to continue"),
command: tool.schema.string().optional().describe("The command that triggered this task"), command: tool.schema.string().optional().describe("The command that triggered this task"),
}, },
@@ -115,7 +115,7 @@ export function createDelegateTask(options: DelegateTaskToolOptions): ToolDefini
` - You provided: category="${args.category}", subagent_type="${args.subagent_type}"\n` + ` - You provided: category="${args.category}", subagent_type="${args.subagent_type}"\n` +
` - Use category for task delegation (e.g., category="${categoryExamples.split(", ")[0]}")\n` + ` - Use category for task delegation (e.g., category="${categoryExamples.split(", ")[0]}")\n` +
` - Use subagent_type for direct agent invocation (e.g., subagent_type="explore")\n` + ` - Use subagent_type for direct agent invocation (e.g., subagent_type="explore")\n` +
` - Valid subagent_type values: explore, librarian, oracle, metis, momus` ` - subagent_type must be a callable non-primary agent name returned by app.agents()`
) )
} }
if (args.category) { if (args.category) {

View File

@@ -616,6 +616,33 @@ describe("skill tool - browserProvider forwarding", () => {
}) })
describe("skill tool - nativeSkills integration", () => { describe("skill tool - nativeSkills integration", () => {
it("includes native skills in the description even when skills are pre-seeded", async () => {
//#given
const tool = createSkillTool({
skills: [createMockSkill("seeded-skill")],
nativeSkills: {
async all() {
return [{
name: "native-visible-skill",
description: "Native skill exposed from config",
location: "/external/skills/native-visible-skill/SKILL.md",
content: "Native visible skill body",
}]
},
async get() { return undefined },
async dirs() { return [] },
},
})
//#when
expect(tool.description).toContain("seeded-skill")
await tool.execute({ name: "native-visible-skill" }, mockContext)
//#then
expect(tool.description).toContain("seeded-skill")
expect(tool.description).toContain("native-visible-skill")
})
it("merges native skills exposed by PluginInput.skills.all()", async () => { it("merges native skills exposed by PluginInput.skills.all()", async () => {
//#given //#given
const tool = createSkillTool({ const tool = createSkillTool({
@@ -639,6 +666,6 @@ describe("skill tool - nativeSkills integration", () => {
//#then //#then
expect(result).toContain("external-plugin-skill") expect(result).toContain("external-plugin-skill")
expect(result).toContain("Test skill body content") expect(result).toContain("External plugin skill body")
}) })
}) })

View File

@@ -105,6 +105,11 @@ async function extractSkillBody(skill: LoadedSkill): Promise<string> {
return templateMatch ? templateMatch[1].trim() : fullTemplate return templateMatch ? templateMatch[1].trim() : fullTemplate
} }
if (skill.scope === "config" && skill.definition.template) {
const templateMatch = skill.definition.template.match(/<skill-instruction>([\s\S]*?)<\/skill-instruction>/)
return templateMatch ? templateMatch[1].trim() : skill.definition.template
}
if (skill.path) { if (skill.path) {
return extractSkillTemplate(skill) return extractSkillTemplate(skill)
} }
@@ -235,11 +240,13 @@ export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition
return cachedDescription return cachedDescription
} }
// Eagerly build description when callers pre-provide skills/commands.
if (options.skills !== undefined) { if (options.skills !== undefined) {
const skillInfos = options.skills.map(loadedSkillToInfo) const skillInfos = options.skills.map(loadedSkillToInfo)
const commandsForDescription = options.commands ?? [] const commandsForDescription = options.commands ?? []
cachedDescription = formatCombinedDescription(skillInfos, commandsForDescription) cachedDescription = formatCombinedDescription(skillInfos, commandsForDescription)
if (options.nativeSkills) {
void buildDescription()
}
} else if (options.commands !== undefined) { } else if (options.commands !== undefined) {
cachedDescription = formatCombinedDescription([], options.commands) cachedDescription = formatCombinedDescription([], options.commands)
} else { } else {
@@ -248,6 +255,9 @@ export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition
return tool({ return tool({
get description() { get description() {
if (cachedDescription === null) {
void buildDescription()
}
return cachedDescription ?? TOOL_DESCRIPTION_PREFIX return cachedDescription ?? TOOL_DESCRIPTION_PREFIX
}, },
args: { args: {
@@ -259,8 +269,8 @@ export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition
}, },
async execute(args: SkillArgs, ctx?: { agent?: string }) { async execute(args: SkillArgs, ctx?: { agent?: string }) {
const skills = await getSkills() const skills = await getSkills()
cachedDescription = null
const commands = getCommands() const commands = getCommands()
cachedDescription = formatCombinedDescription(skills.map(loadedSkillToInfo), commands)
const requestedName = args.name.replace(/^\//, "") const requestedName = args.name.replace(/^\//, "")