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:
90
.github/workflows/publish-platform.yml
vendored
90
.github/workflows/publish-platform.yml
vendored
@@ -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
|
||||||
|
|||||||
147
.github/workflows/publish.yml
vendored
147
.github/workflows/publish.yml
vendored
@@ -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 }}
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 (1–10) |
|
| `turn_protection.turns` | `3` | Recent turns protected from pruning (1–10) |
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
24
package.json
24
package.json
@@ -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"
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 || ""
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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 += `
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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", {
|
||||||
|
|||||||
@@ -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/")
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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")
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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(/^\//, "")
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user