Compare commits

..

45 Commits

Author SHA1 Message Date
github-actions[bot]
dab3e1e13f release: v3.0.0-beta.13 2026-01-22 18:04:51 +00:00
justsisyphus
71474bb4a2 fix(delegate-task): reset model cache between tests 2026-01-23 02:59:03 +09:00
justsisyphus
aa6355cc46 refactor(atlas): improve delegation guidance in validation step 2026-01-23 02:59:03 +09:00
Kenny
e8b1e56e5c Merge pull request #989 from boojongmin/dev
docs: add Korean (한국어) README
2026-01-22 12:49:36 -05:00
justsisyphus
8df56794ca fix(test): update model-fallback snapshots for github-copilot model naming 2026-01-23 02:27:52 +09:00
justsisyphus
6e84a14f20 fix(model-resolver): return variant from fallback chain, handle model name normalization
- Add variant to ModelResolutionResult return type
- Return variant from matched fallback entry
- Add normalizeModelName() for Claude model hyphen/period differences
- Add transformModelForProvider() for github-copilot model names
- Update delegate-task to use resolved variant (user config takes priority)
- Fix test expectations for new fallback behavior
2026-01-23 02:20:32 +09:00
justsisyphus
7de376e24f docs: regenerate all AGENTS.md files with updated structure 2026-01-23 02:14:08 +09:00
justsisyphus
0e18efc7e4 refactor(keyword-detector): change keyword injection from synthetic to direct message transform
- Replace collector.register() with direct output.parts[textIndex].text modification
- All keyword types (ultrawork, search, analyze) now prepend to user message text
- Message format: keyword message + '---' separator + original text
- Update tests to verify text transformation instead of collector registration
- All 18 tests pass
2026-01-23 01:53:59 +09:00
justsisyphus
e15677efd5 fix(migration): add hook rename and removal mappings for v3.0.0 upgrade
- Add sisyphus-orchestrator → atlas hook rename mapping
- Add null mappings for removed hooks (preemptive-compaction, empty-message-sanitizer)
- Update migrateHookNames() to filter out removed hooks and return removed list
- Log warning when obsolete hooks are removed from disabled_hooks
- Add tests for new migration scenarios
2026-01-23 01:24:07 +09:00
justsisyphus
45b2782d55 refactor: use case-insensitive matching for agent names
Apply case-insensitive utilities across agents, delegation, and tool
restrictions. Removes duplicate implementations from agents/utils.ts.
Agent validation now normalizes input to canonical names.
2026-01-23 00:55:01 +09:00
justsisyphus
febc32d7f4 feat(shared): add case-insensitive utilities for agent name matching
Add findCaseInsensitive, includesCaseInsensitive, findByNameCaseInsensitive,
and equalsIgnoreCase functions for consistent case-insensitive lookups across
the codebase. Enables 'Oracle', 'oracle', 'ORACLE' to work interchangeably.
2026-01-23 00:54:50 +09:00
justsisyphus
76a01d4942 fix: allow 0 in providerConcurrency/modelConcurrency to disable providers
Previously min(1) validation caused entire config to fail when 0 was used,
silently ignoring all agent overrides.
2026-01-23 00:44:51 +09:00
justsisyphus
83bcf70bdd docs: sync category names with code and add missing schema fields
- Update category tables to match DEFAULT_CATEGORIES in constants.ts
- Remove non-existent 'most-capable' and 'general' categories
- Add 'unspecified-low' and 'unspecified-high' categories
- Update model names to full paths (e.g., openai/gpt-5.2-codex)
- Add new CategoryConfig schema fields: description, variant, top_p, etc.
- Fix atlas.ts prompt examples with correct category names
2026-01-23 00:13:42 +09:00
boojongmin
91d85d3df7 docs: add Korean (한국어) README 2026-01-23 00:04:45 +09:00
justsisyphus
638a314f6d docs: update AGENTS.md files and configuration docs
Refresh documentation across all AGENTS.md files and update configuration documentation with latest features.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-01-22 22:48:50 +09:00
justsisyphus
ff92a4caa2 refactor: update plugin handlers and shared utils
Minor updates to config handler, plugin detector, index entry, and builtin command templates.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-01-22 22:48:36 +09:00
justsisyphus
dda502a697 fix(tools): update background-task and session-manager
Minor improvements to background task handling and session manager utilities.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-01-22 22:48:21 +09:00
justsisyphus
2690301833 fix(tools): minor ast-grep improvements
Small fixes to AST-grep constants, tools, and utility functions.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-01-22 22:48:06 +09:00
justsisyphus
3f002ff50c fix(cli): minor fixes to formatter and events
Small improvements to version formatter and run events handling.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-01-22 22:47:55 +09:00
justsisyphus
bb14537b14 test(cli): add install command tests with snapshots
Add comprehensive tests for the install command with snapshot testing for generated configurations.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-01-22 22:47:43 +09:00
justsisyphus
bdbc8d73cb fix(hooks): minor fixes across multiple hooks
Apply consistent naming and small improvements to atlas, background-compaction, claude-code-hooks, and non-interactive-env hooks.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-01-22 22:47:31 +09:00
justsisyphus
4b1ea1244f feat(hooks): extend keyword detector patterns
Add more keyword patterns for improved detection of work modes and triggers.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-01-22 22:47:16 +09:00
justsisyphus
cc7160b3b5 refactor(hooks): update delegate-task-retry naming consistency
Rename agent parameter to subagent_type for consistency with delegate-task tool changes.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-01-22 22:47:01 +09:00
justsisyphus
440e53ad9d feat(hooks): enhance prometheus-md-only with better patterns
Add more comprehensive markdown-only file patterns for Prometheus planning agent. Extend test coverage for new patterns.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-01-22 22:46:50 +09:00
justsisyphus
72098213ee refactor(task-toast): improve task toast manager types and logic
Add new toast types and improve state management for background task notifications. Update tests to cover new scenarios.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-01-22 22:46:36 +09:00
justsisyphus
aa2b052d28 refactor(delegate-task): enhance delegation with dynamic descriptions
Generate tool description dynamically from available categories and skills. Remove hardcoded DELEGATE_TASK_DESCRIPTION constant. Improve parameter handling with unified 'subagent_type' field replacing 'agent'.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-01-22 22:46:23 +09:00
justsisyphus
0edfc7f36a feat(config): add description field to category schema
Allow users to override category descriptions via config. The description is shown in delegate_task prompt to help LLMs understand category purpose.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-01-22 22:45:57 +09:00
justsisyphus
4ffb9b1c93 refactor(agents): simplify dynamic agent prompt builder
Reduce complexity in prompt builder by removing redundant helper functions and streamlining category/skill/agent description generation.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-01-22 22:45:41 +09:00
justsisyphus
0610ef8c77 refactor(agents): update atlas, prometheus, sisyphus-junior prompts
Align agent prompts with new architecture. Simplify atlas prompt structure, update prometheus for cleaner flow, and minor sisyphus-junior adjustments.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-01-22 22:45:25 +09:00
justsisyphus
5e27ceeb81 refactor(agents): streamline sisyphus prompt and remove generator script
Simplify sisyphus prompt by removing redundant sections and inline generation. Delete generate-sisyphus-prompt.ts as prompts are now managed directly in the agent definition.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-01-22 22:45:05 +09:00
justsisyphus
de3a6aae11 refactor(agents): make agent creation async with model resolution
Convert createBuiltinAgents to async function that fetches available models at plugin init time. Add case-insensitive matching helpers and integrate discovered skills from both builtin and user sources.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-01-22 22:44:43 +09:00
justsisyphus
76211a3185 feat(cli): add doctor check for model resolution
Add new 'model-resolution' check to diagnose model fallback chain health. Validates that configured agents and categories can resolve to available models, surfacing misconfiguration early.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-01-22 22:44:11 +09:00
justsisyphus
04b026dd15 refactor(cli): simplify model fallback using new resolution system
Replace complex fallback logic with new shared model resolution utilities. The model-fallback module now delegates to resolveModelWithFallback() for cleaner, consistent model selection across the codebase.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-01-22 22:43:48 +09:00
justsisyphus
54b4844d3f refactor(shared): improve model resolver with 3-step resolution
Implement resolveModelWithFallback() that tries: 1) user override, 2) fuzzy match from requirements chain against available models, 3) system default. Export new model utilities from shared index.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-01-22 22:43:27 +09:00
justsisyphus
bc62c23a85 feat(shared): add model availability with fuzzy matching and fetch
Implement fuzzyMatchModel() for case-insensitive substring matching with provider filtering. Add fetchAvailableModels() to get available models from OpenCode client with caching.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-01-22 22:43:10 +09:00
justsisyphus
f4a0d5ec40 feat(shared): add model requirements with agent/category fallback chains
Define ModelRequirement type with FallbackEntry chains for multi-provider model resolution. Each agent and category specifies ordered fallback preferences with provider arrays and optional variants.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-01-22 22:42:53 +09:00
github-actions[bot]
d863daceef @boojongmin has signed the CLA in code-yeongyu/oh-my-opencode#989 2026-01-22 12:39:37 +00:00
Kenny
d220654e84 Merge pull request #982 from KNN-07/fix/opencode-config-dir-env-support
fix: respect OPENCODE_CONFIG_DIR environment variable across all config paths
2026-01-22 06:44:35 -05:00
Nguyen Khac Trung Kien
e65d57285f fix: respect OPENCODE_CONFIG_DIR environment variable across all config paths
Multiple files were hardcoding ~/.config/opencode paths instead of using
getOpenCodeConfigDir() which respects the OPENCODE_CONFIG_DIR env var.

This broke profile isolation features like OCX ghost mode, where users
set OPENCODE_CONFIG_DIR to a custom path but oh-my-opencode.json and
other configs weren't being read from that location.

Changes:
- plugin-config.ts: Use getOpenCodeConfigDir() directly
- cli/doctor/checks: Use getOpenCodeConfigDir() for auth and config checks
- tools/lsp/config.ts: Use getOpenCodeConfigDir() for LSP config paths
- command loaders: Use getOpenCodeConfigDir() for global command dirs
- hooks: Use getOpenCodeConfigDir() for hook config paths
- config-path.ts: Mark getUserConfigDir() as deprecated
- tests: Ensure OPENCODE_CONFIG_DIR is properly isolated in tests
2026-01-22 12:15:09 +07:00
Kenny
80b4067b8e Merge pull request #937 from luojiyin1987/fix/typos
fix: correct spelling errors (IDEALY→IDEALLY, EXPLICITELY→EXPLICITLY)
2026-01-21 21:58:23 -05:00
luojiyin
e3cc4c8cef Merge branch 'dev' into fix/typos 2026-01-22 10:51:45 +08:00
justsisyphus
c8175c2678 Revert "fix(tools): override OpenCode HTTP-based session tools with local implementation"
This reverts commit 89bde5ce64.
2026-01-22 11:28:27 +09:00
justsisyphus
7f2eb0a568 refactor(ci): separate platform publish into dedicated workflow
- publish.yml: main package only, triggers platform workflow on success
- publish-platform.yml: dedicated workflow with fresh OIDC token per run
- Fixes OIDC token expiration during large binary uploads (~40MB+)
- Platform workflow can also be triggered manually via workflow_dispatch
2026-01-22 11:22:26 +09:00
justsisyphus
89bde5ce64 fix(tools): override OpenCode HTTP-based session tools with local implementation
OpenCode v1.1.30+ added HTTP-based SessionList/Read/Search/Info tools that
require localhost:4096 server. Renamed our tools to PascalCase to override
them with local file-based implementation, fixing 'Unable to connect' errors.
2026-01-22 11:22:26 +09:00
luojiyin
d61817bc76 fix: correct spelling errors (IDEALY→IDEALLY, EXPLICITELY→EXPLICITLY) 2026-01-20 13:31:37 +08:00
101 changed files with 6012 additions and 2019 deletions

108
.github/workflows/publish-platform.yml vendored Normal file
View File

@@ -0,0 +1,108 @@
name: publish-platform
run-name: "platform packages ${{ inputs.version }}"
on:
workflow_call:
inputs:
version:
required: true
type: string
dist_tag:
required: false
type: string
default: ""
workflow_dispatch:
inputs:
version:
description: "Version to publish (e.g., 3.0.0-beta.12)"
required: true
type: string
dist_tag:
description: "npm dist tag (e.g., beta, latest)"
required: false
type: string
default: ""
permissions:
contents: read
id-token: write
jobs:
publish-platform:
runs-on: ubuntu-latest
strategy:
fail-fast: false
max-parallel: 2
matrix:
platform: [darwin-arm64, darwin-x64, linux-x64, linux-arm64, linux-x64-musl, linux-arm64-musl, windows-x64]
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- uses: actions/setup-node@v4
with:
node-version: "24"
registry-url: "https://registry.npmjs.org"
- name: Install dependencies
run: bun install
env:
BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/napi"
- name: Check if already published
id: check
run: |
PKG_NAME="oh-my-opencode-${{ matrix.platform }}"
VERSION="${{ inputs.version }}"
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://registry.npmjs.org/${PKG_NAME}/${VERSION}")
if [ "$STATUS" = "200" ]; then
echo "skip=true" >> $GITHUB_OUTPUT
echo "✓ ${PKG_NAME}@${VERSION} already published"
else
echo "skip=false" >> $GITHUB_OUTPUT
echo "→ ${PKG_NAME}@${VERSION} needs publishing"
fi
- name: Update version
if: steps.check.outputs.skip != 'true'
run: |
VERSION="${{ inputs.version }}"
cd packages/${{ matrix.platform }}
jq --arg v "$VERSION" '.version = $v' package.json > tmp.json && mv tmp.json package.json
- name: Build binary
if: steps.check.outputs.skip != 'true'
run: |
PLATFORM="${{ matrix.platform }}"
case "$PLATFORM" in
darwin-arm64) TARGET="bun-darwin-arm64" ;;
darwin-x64) TARGET="bun-darwin-x64" ;;
linux-x64) TARGET="bun-linux-x64" ;;
linux-arm64) TARGET="bun-linux-arm64" ;;
linux-x64-musl) TARGET="bun-linux-x64-musl" ;;
linux-arm64-musl) TARGET="bun-linux-arm64-musl" ;;
windows-x64) TARGET="bun-windows-x64" ;;
esac
if [ "$PLATFORM" = "windows-x64" ]; then
OUTPUT="packages/${PLATFORM}/bin/oh-my-opencode.exe"
else
OUTPUT="packages/${PLATFORM}/bin/oh-my-opencode"
fi
bun build src/cli/index.ts --compile --minify --target=$TARGET --outfile=$OUTPUT
- name: Publish ${{ matrix.platform }}
if: steps.check.outputs.skip != 'true'
run: |
cd packages/${{ matrix.platform }}
TAG_ARG=""
if [ -n "${{ inputs.dist_tag }}" ]; then
TAG_ARG="--tag ${{ inputs.dist_tag }}"
fi
npm publish --access public $TAG_ARG
env:
NPM_CONFIG_PROVENANCE: false

View File

@@ -1,5 +1,5 @@
name: publish
run-name: "${{ format('release {0}', inputs.bump) }}"
run-name: "${{ format('release {0}', inputs.version || inputs.bump) }}"
on:
workflow_dispatch:
@@ -14,16 +14,11 @@ on:
- minor
- major
version:
description: "Override version (e.g., 3.0.0-beta.6 for beta release). Takes precedence over bump."
description: "Override version (e.g., 3.0.0-beta.6). Takes precedence over bump."
required: false
type: string
skip_platform:
description: "Skip platform binary packages (use when already published)"
required: false
type: boolean
default: false
republish:
description: "Re-publish mode: skip version check, only publish missing packages"
description: "Skip platform binary packages"
required: false
type: boolean
default: false
@@ -33,6 +28,7 @@ concurrency: ${{ github.workflow }}-${{ github.ref }}
permissions:
contents: write
id-token: write
actions: write
jobs:
test:
@@ -69,8 +65,7 @@ jobs:
- name: Type check
run: bun run typecheck
# Build everything and upload artifacts
build:
publish-main:
runs-on: ubuntu-latest
needs: [test, typecheck]
if: github.repository == 'code-yeongyu/oh-my-opencode'
@@ -88,6 +83,11 @@ jobs:
with:
bun-version: latest
- uses: actions/setup-node@v4
with:
node-version: "24"
registry-url: "https://registry.npmjs.org"
- name: Install dependencies
run: bun install
env:
@@ -109,7 +109,6 @@ jobs:
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
# Calculate dist tag
if [[ "$VERSION" == *"-"* ]]; then
DIST_TAG=$(echo "$VERSION" | cut -d'-' -f2 | cut -d'.' -f1)
echo "dist_tag=${DIST_TAG:-next}" >> $GITHUB_OUTPUT
@@ -119,43 +118,52 @@ jobs:
echo "Version: $VERSION"
- name: Update versions in package.json files
run: bun run script/publish.ts --prepare-only
env:
VERSION: ${{ steps.version.outputs.version }}
- name: Check if already published
id: check
run: |
VERSION="${{ steps.version.outputs.version }}"
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://registry.npmjs.org/oh-my-opencode/${VERSION}")
if [ "$STATUS" = "200" ]; then
echo "skip=true" >> $GITHUB_OUTPUT
echo "✓ oh-my-opencode@${VERSION} already published"
else
echo "skip=false" >> $GITHUB_OUTPUT
fi
- name: Update version
if: steps.check.outputs.skip != 'true'
run: |
VERSION="${{ steps.version.outputs.version }}"
jq --arg v "$VERSION" '.version = $v' package.json > tmp.json && mv tmp.json package.json
for platform in darwin-arm64 darwin-x64 linux-x64 linux-arm64 linux-x64-musl linux-arm64-musl windows-x64; 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: Build main package
if: steps.check.outputs.skip != 'true'
run: |
bun build src/index.ts --outdir dist --target bun --format esm --external @ast-grep/napi
bun build src/cli/index.ts --outdir dist/cli --target bun --format esm --external @ast-grep/napi
bunx tsc --emitDeclarationOnly
bun run build:schema
- name: Build platform binaries
if: inputs.skip_platform != true
run: bun run build:binaries
- name: Upload main package artifact
uses: actions/upload-artifact@v4
with:
name: main-package
path: |
dist/
package.json
assets/
README.md
LICENSE.md
retention-days: 1
- name: Upload platform artifacts
if: inputs.skip_platform != true
uses: actions/upload-artifact@v4
with:
name: platform-packages
path: packages/
retention-days: 1
- name: Publish main package
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:
NPM_CONFIG_PROVENANCE: true
- name: Git commit and tag
if: steps.check.outputs.skip != 'true'
run: |
git config user.email "github-actions[bot]@users.noreply.github.com"
git config user.name "github-actions[bot]"
@@ -167,98 +175,24 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Publish platform packages in parallel (each job gets fresh OIDC token)
publish-platform:
trigger-platform:
runs-on: ubuntu-latest
needs: build
needs: publish-main
if: inputs.skip_platform != true
strategy:
fail-fast: false
matrix:
platform: [darwin-arm64, darwin-x64, linux-x64, linux-arm64, linux-x64-musl, linux-arm64-musl, windows-x64]
steps:
- uses: actions/setup-node@v4
with:
node-version: "24"
registry-url: "https://registry.npmjs.org"
- name: Download platform artifacts
uses: actions/download-artifact@v4
with:
name: platform-packages
path: packages/
- name: Check if already published
id: check
- name: Trigger platform publish workflow
run: |
PKG_NAME="oh-my-opencode-${{ matrix.platform }}"
VERSION="${{ needs.build.outputs.version }}"
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://registry.npmjs.org/${PKG_NAME}/${VERSION}")
if [ "$STATUS" = "200" ]; then
echo "skip=true" >> $GITHUB_OUTPUT
echo "✓ ${PKG_NAME}@${VERSION} already published"
else
echo "skip=false" >> $GITHUB_OUTPUT
echo "→ ${PKG_NAME}@${VERSION} needs publishing"
fi
- name: Publish ${{ matrix.platform }}
if: steps.check.outputs.skip != 'true'
run: |
cd packages/${{ matrix.platform }}
TAG_ARG=""
if [ -n "${{ needs.build.outputs.dist_tag }}" ]; then
TAG_ARG="--tag ${{ needs.build.outputs.dist_tag }}"
fi
npm publish --access public $TAG_ARG
gh workflow run publish-platform.yml \
--repo ${{ github.repository }} \
--ref ${{ github.ref }} \
-f version=${{ needs.publish-main.outputs.version }} \
-f dist_tag=${{ needs.publish-main.outputs.dist_tag }}
env:
NPM_CONFIG_PROVENANCE: false
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Publish main package after all platform packages
publish-main:
runs-on: ubuntu-latest
needs: [build, publish-platform]
if: always() && needs.build.result == 'success' && (inputs.skip_platform == true || needs.publish-platform.result == 'success' || needs.publish-platform.result == 'skipped')
steps:
- uses: actions/setup-node@v4
with:
node-version: "24"
registry-url: "https://registry.npmjs.org"
- name: Download main package artifact
uses: actions/download-artifact@v4
with:
name: main-package
path: .
- name: Check if already published
id: check
run: |
VERSION="${{ needs.build.outputs.version }}"
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://registry.npmjs.org/oh-my-opencode/${VERSION}")
if [ "$STATUS" = "200" ]; then
echo "skip=true" >> $GITHUB_OUTPUT
echo "✓ oh-my-opencode@${VERSION} already published"
else
echo "skip=false" >> $GITHUB_OUTPUT
fi
- name: Publish main package
if: steps.check.outputs.skip != 'true'
run: |
TAG_ARG=""
if [ -n "${{ needs.build.outputs.dist_tag }}" ]; then
TAG_ARG="--tag ${{ needs.build.outputs.dist_tag }}"
fi
npm publish --access public --provenance $TAG_ARG
env:
NPM_CONFIG_PROVENANCE: true
# Create release and cleanup
release:
runs-on: ubuntu-latest
needs: [build, publish-main]
if: always() && needs.build.result == 'success'
needs: publish-main
steps:
- uses: actions/checkout@v4
with:
@@ -267,9 +201,8 @@ jobs:
- name: Generate changelog
id: changelog
run: |
VERSION="${{ needs.build.outputs.version }}"
VERSION="${{ needs.publish-main.outputs.version }}"
# Find previous tag
PREV_TAG=""
if [[ "$VERSION" == *"-beta."* ]]; then
BASE="${VERSION%-beta.*}"
@@ -289,13 +222,11 @@ jobs:
NOTES=$(git log "v${PREV_TAG}..v${VERSION}" --oneline --format="- %h %s" 2>/dev/null | grep -vE "^- \w+ (ignore:|test:|chore:|ci:|release:)" || echo "No notable changes")
# Write to file for multiline support
echo "$NOTES" > /tmp/changelog.md
echo "notes_file=/tmp/changelog.md" >> $GITHUB_OUTPUT
- name: Create GitHub release
run: |
VERSION="${{ needs.build.outputs.version }}"
VERSION="${{ needs.publish-main.outputs.version }}"
gh release view "v${VERSION}" >/dev/null 2>&1 || \
gh release create "v${VERSION}" --title "v${VERSION}" --notes-file /tmp/changelog.md
env:
@@ -311,7 +242,7 @@ jobs:
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
VERSION="${{ needs.build.outputs.version }}"
VERSION="${{ needs.publish-main.outputs.version }}"
git stash --include-untracked || true
git checkout master
git reset --hard "v${VERSION}"

173
AGENTS.md
View File

@@ -1,28 +1,28 @@
# PROJECT KNOWLEDGE BASE
**Generated:** 2026-01-20T17:18:00+09:00
**Commit:** 3d3d3e49
**Generated:** 2026-01-23T02:09:00+09:00
**Commit:** 0e18efc7
**Branch:** dev
## OVERVIEW
ClaudeCode plugin implementing multi-model agent orchestration (Claude Opus 4.5, GPT-5.2, Gemini 3, Grok, GLM-4.7). 31 lifecycle hooks, 20+ tools (LSP, AST-Grep, delegation), 10 specialized agents, Claude Code compatibility layer. "oh-my-zsh" for ClaudeCode.
OpenCode plugin: multi-model agent orchestration (Claude Opus 4.5, GPT-5.2, Gemini 3, Grok, GLM-4.7). 31 lifecycle hooks, 20+ tools (LSP, AST-Grep, delegation), 10 specialized agents, full Claude Code compatibility. "oh-my-zsh" for OpenCode.
## STRUCTURE
```
oh-my-opencode/
├── src/
│ ├── agents/ # 10 AI agents (Sisyphus, oracle, librarian, explore, frontend, etc.) - see src/agents/AGENTS.md
│ ├── hooks/ # 31 lifecycle hooks (PreToolUse, PostToolUse, Stop, etc.) - see src/hooks/AGENTS.md
│ ├── tools/ # 20+ tools (LSP, AST-Grep, delegation, session) - see src/tools/AGENTS.md
│ ├── features/ # Background agents, Claude Code compat layer - see src/features/AGENTS.md
│ ├── shared/ # 43 cross-cutting utilities - see src/shared/AGENTS.md
│ ├── cli/ # CLI installer, doctor, run - see src/cli/AGENTS.md
│ ├── mcp/ # Built-in MCPs: websearch, context7, grep_app
│ ├── agents/ # 10 AI agents - see src/agents/AGENTS.md
│ ├── hooks/ # 31 lifecycle hooks - see src/hooks/AGENTS.md
│ ├── tools/ # 20+ tools - see src/tools/AGENTS.md
│ ├── features/ # Background agents, Claude Code compat - see src/features/AGENTS.md
│ ├── shared/ # 50 cross-cutting utilities - see src/shared/AGENTS.md
│ ├── cli/ # CLI installer, doctor - see src/cli/AGENTS.md
│ ├── mcp/ # Built-in MCPs - see src/mcp/AGENTS.md
│ ├── config/ # Zod schema, TypeScript types
│ └── index.ts # Main plugin entry (589 lines)
├── script/ # build-schema.ts, publish.ts, build-binaries.ts
│ └── index.ts # Main plugin entry (590 lines)
├── script/ # build-schema.ts, build-binaries.ts
├── packages/ # 7 platform-specific binaries
└── dist/ # Build output (ESM + .d.ts)
```
@@ -31,88 +31,67 @@ oh-my-opencode/
| Task | Location | Notes |
|------|----------|-------|
| Add agent | `src/agents/` | Create .ts with factory, add to `builtinAgents` in index.ts |
| Add agent | `src/agents/` | Create .ts with factory, add to `agentSources` |
| Add hook | `src/hooks/` | Create dir with `createXXXHook()`, register in index.ts |
| Add tool | `src/tools/` | Dir with index/types/constants/tools.ts, add to `builtinTools` |
| Add tool | `src/tools/` | Dir with index/types/constants/tools.ts |
| Add MCP | `src/mcp/` | Create config, add to index.ts |
| Add skill | `src/features/builtin-skills/` | Create dir with SKILL.md |
| LSP behavior | `src/tools/lsp/` | client.ts (connection), tools.ts (handlers) |
| AST-Grep | `src/tools/ast-grep/` | napi.ts for @ast-grep/napi binding |
| Config schema | `src/config/schema.ts` | Zod schema, run `bun run build:schema` after changes |
| Claude Code compat | `src/features/claude-code-*-loader/` | Command, skill, agent, mcp loaders |
| Background agents | `src/features/background-agent/` | manager.ts (1165 lines) for task lifecycle |
| Skill MCP | `src/features/skill-mcp-manager/` | MCP servers embedded in skills |
| CLI installer | `src/cli/install.ts` | Interactive TUI (462 lines) |
| Doctor checks | `src/cli/doctor/checks/` | 14 health checks across 6 categories |
| Config schema | `src/config/schema.ts` | Zod schema, run `bun run build:schema` |
| Background agents | `src/features/background-agent/` | manager.ts (1335 lines) |
| Orchestrator | `src/hooks/atlas/` | Main orchestration hook (771 lines) |
## TDD (Test-Driven Development)
**MANDATORY for new features and bug fixes.** Follow RED-GREEN-REFACTOR:
| Phase | Action | Verification |
|-------|--------|--------------|
| **RED** | Write test describing expected behavior | `bun test` → FAIL (expected) |
| **GREEN** | Implement minimum code to pass | `bun test` → PASS |
| **REFACTOR** | Improve code quality, remove duplication | `bun test` → PASS (must stay green) |
**MANDATORY.** RED-GREEN-REFACTOR:
1. **RED**: Write test → `bun test` → FAIL
2. **GREEN**: Implement minimum → PASS
3. **REFACTOR**: Clean up → stay GREEN
**Rules:**
- NEVER write implementation before test
- NEVER delete failing tests to "pass" - fix the code
- Test file naming: `*.test.ts` alongside source
- BDD comments: `#given`, `#when`, `#then` (same as AAA)
- NEVER delete failing tests - fix the code
- Test file: `*.test.ts` alongside source
- BDD comments: `#given`, `#when`, `#then`
## CONVENTIONS
- **Package manager**: Bun only (`bun run`, `bun build`, `bunx`)
- **Types**: bun-types (not @types/node)
- **Types**: bun-types (NEVER @types/node)
- **Build**: `bun build` (ESM) + `tsc --emitDeclarationOnly`
- **Exports**: Barrel pattern in index.ts; explicit named exports
- **Naming**: kebab-case directories, `createXXXHook`/`createXXXTool` factories
- **Testing**: BDD comments `#given/#when/#then`, 83 test files
- **Exports**: Barrel pattern via index.ts
- **Naming**: kebab-case dirs, `createXXXHook`/`createXXXTool` factories
- **Testing**: BDD comments, 90 test files
- **Temperature**: 0.1 for code agents, max 0.3
## ANTI-PATTERNS (THIS PROJECT)
## ANTI-PATTERNS
| Category | Forbidden |
|----------|-----------|
| **Package Manager** | npm, yarn - use Bun exclusively |
| **Types** | @types/node - use bun-types |
| **File Ops** | mkdir/touch/rm/cp/mv in code - agents use bash tool |
| **Publishing** | Direct `bun publish` - use GitHub Actions workflow_dispatch |
| **Versioning** | Local version bump - managed by CI |
| **Date References** | Year 2024 - use current year |
| **Type Safety** | `as any`, `@ts-ignore`, `@ts-expect-error` |
| **Error Handling** | Empty catch blocks `catch(e) {}` |
| **Testing** | Deleting failing tests to "pass" |
| **Agent Calls** | Sequential agent calls - use `delegate_task` for parallel |
| **Tool Access** | Broad tool access - prefer explicit `include` |
| **Hook Logic** | Heavy PreToolUse computation - slows every tool call |
| **Commits** | Giant commits (3+ files = 2+ commits), separate test from impl |
| **Temperature** | >0.3 for code agents |
| **Trust** | Trust agent self-reports - ALWAYS verify independently |
## UNIQUE STYLES
- **Platform**: Union type `"darwin" | "linux" | "win32" | "unsupported"`
- **Optional props**: Extensive `?` for optional interface properties
- **Flexible objects**: `Record<string, unknown>` for dynamic configs
- **Agent tools**: `tools: { include: [...] }` or `tools: { exclude: [...] }`
- **Hook naming**: `createXXXHook` function convention
- **Factory pattern**: Components created via `createXXX()` functions
| Package Manager | npm, yarn - Bun exclusively |
| Types | @types/node - use bun-types |
| File Ops | mkdir/touch/rm/cp/mv in code - use bash tool |
| Publishing | Direct `bun publish` - GitHub Actions only |
| Versioning | Local version bump - CI manages |
| Type Safety | `as any`, `@ts-ignore`, `@ts-expect-error` |
| Error Handling | Empty catch blocks |
| Testing | Deleting failing tests |
| Agent Calls | Sequential - use `delegate_task` parallel |
| Hook Logic | Heavy PreToolUse - slows every call |
| Commits | Giant (3+ files), separate test from impl |
| Temperature | >0.3 for code agents |
| Trust | Agent self-reports - ALWAYS verify |
## AGENT MODELS
| Agent | Default Model | Purpose |
|-------|---------------|---------|
| Sisyphus | anthropic/claude-opus-4-5 | Primary orchestrator with extended thinking |
| oracle | openai/gpt-5.2 | Read-only consultation, high-IQ debugging |
| librarian | opencode/glm-4.7-free | Multi-repo analysis, docs, GitHub search |
| explore | opencode/grok-code | Fast codebase exploration (contextual grep) |
| Agent | Model | Purpose |
|-------|-------|---------|
| Sisyphus | anthropic/claude-opus-4-5 | Primary orchestrator |
| Atlas | anthropic/claude-opus-4-5 | Master orchestrator |
| oracle | openai/gpt-5.2 | Consultation, debugging |
| librarian | opencode/glm-4.7-free | Docs, GitHub search |
| explore | opencode/grok-code | Fast codebase grep |
| multimodal-looker | google/gemini-3-flash | PDF/image analysis |
| Prometheus (Planner) | anthropic/claude-opus-4-5 | Strategic planning, interview mode |
| Metis (Plan Consultant) | anthropic/claude-sonnet-4-5 | Pre-planning analysis |
| Momus (Plan Reviewer) | anthropic/claude-sonnet-4-5 | Plan validation |
| Prometheus | anthropic/claude-opus-4-5 | Strategic planning |
## COMMANDS
@@ -120,60 +99,42 @@ oh-my-opencode/
bun run typecheck # Type check
bun run build # ESM + declarations + schema
bun run rebuild # Clean + Build
bun run build:schema # Schema only
bun test # Run tests (83 test files)
bun test # 90 test files
```
## DEPLOYMENT
**GitHub Actions workflow_dispatch only**
1. Never modify package.json version locally
2. Commit & push changes
3. Trigger `publish` workflow: `gh workflow run publish -f bump=patch`
**Critical**: Never `bun publish` directly. Never bump version locally.
## CI PIPELINE
- **ci.yml**: Parallel test/typecheck → build → auto-commit schema on master → rolling `next` draft release
- **publish.yml**: Manual workflow_dispatch → version bump → changelog → 8-package OIDC npm publish → force-push master
**GitHub Actions workflow_dispatch ONLY**
1. Commit & push changes
2. Trigger: `gh workflow run publish -f bump=patch`
3. Never `bun publish` directly, never bump version locally
## COMPLEXITY HOTSPOTS
| File | Lines | Description |
|------|-------|-------------|
| `src/agents/atlas.ts` | 1383 | Orchestrator agent, 7-section delegation, wisdom accumulation |
| `src/features/builtin-skills/skills.ts` | 1203 | Skill definitions (playwright, git-master, frontend-ui-ux) |
| `src/agents/prometheus-prompt.ts` | 1196 | Planning agent, interview mode, Momus loop |
| `src/features/background-agent/manager.ts` | 1165 | Task lifecycle, concurrency, notification batching |
| `src/hooks/atlas/index.ts` | 771 | Orchestrator hook implementation |
| `src/tools/delegate-task/tools.ts` | 770 | Category-based task delegation |
| `src/cli/config-manager.ts` | 616 | JSONC parsing, multi-level config |
| `src/agents/sisyphus.ts` | 615 | Main Sisyphus prompt |
| `src/features/builtin-commands/templates/refactor.ts` | 619 | Refactoring command template |
| `src/tools/lsp/client.ts` | 596 | LSP protocol, JSON-RPC |
| `src/agents/atlas.ts` | 1383 | Orchestrator, 7-section delegation |
| `src/features/background-agent/manager.ts` | 1335 | Task lifecycle, concurrency |
| `src/features/builtin-skills/skills.ts` | 1203 | Skill definitions |
| `src/agents/prometheus-prompt.ts` | 1196 | Planning agent |
| `src/tools/delegate-task/tools.ts` | 1038 | Category-based delegation |
| `src/hooks/atlas/index.ts` | 771 | Orchestrator hook |
## MCP ARCHITECTURE
Three-tier MCP system:
1. **Built-in**: `websearch` (Exa), `context7` (docs), `grep_app` (GitHub search)
2. **Claude Code compatible**: `.mcp.json` files with `${VAR}` expansion
3. **Skill-embedded**: YAML frontmatter in skills (e.g., playwright)
Three-tier system:
1. **Built-in**: websearch (Exa), context7 (docs), grep_app (GitHub)
2. **Claude Code compat**: .mcp.json with `${VAR}` expansion
3. **Skill-embedded**: YAML frontmatter in skills
## CONFIG SYSTEM
- **Zod validation**: `src/config/schema.ts`
- **JSONC support**: Comments and trailing commas
- **JSONC support**: Comments, trailing commas
- **Multi-level**: Project (`.opencode/`) → User (`~/.config/opencode/`)
- **CLI doctor**: Validates config and reports errors
## NOTES
- **Testing**: Bun native test (`bun test`), BDD-style, 83 test files
- **ClaudeCode**: Requires >= 1.0.150
- **Multi-lang docs**: README.md (EN), README.ko.md (KO), README.ja.md (JA), README.zh-cn.md (ZH-CN)
- **Config**: `~/.config/opencode/oh-my-opencode.json` (user) or `.opencode/oh-my-opencode.json` (project)
- **OpenCode**: Requires >= 1.0.150
- **Flaky tests**: ralph-loop (CI timeout), session-state (parallel pollution)
- **Trusted deps**: @ast-grep/cli, @ast-grep/napi, @code-yeongyu/comment-checker
- **Claude Code Compat**: Full compatibility layer for settings.json hooks, commands, skills, agents, MCPs
- **Flaky tests**: 2 known flaky tests (ralph-loop CI timeout, session-state parallel pollution)

376
README.ko.md Normal file
View File

@@ -0,0 +1,376 @@
> [!WARNING]
> **보안 경고: 사칭 사이트**
>
> **ohmyopencode.com은 이 프로젝트와 제휴 관계가 아닙니다.** 우리는 해당 사이트를 운영하거나 지지하지 않습니다.
>
> OhMyOpenCode는 **무료 오픈 소스**입니다. "공식"을 표방하는 제3자 사이트에서 설치 프로그램을 다운로드하거나 결제 정보를 입력하지 마십시오.
>
> 사칭 사이트는 유료 벽 뒤에 있어 **배포하는 내용을 확인할 수 없습니다.** 해당 사이트의 다운로드는 **잠재적으로 위험한 것으로 간주**하세요.
>
> ✅ 공식 다운로드: https://github.com/code-yeongyu/oh-my-opencode/releases
> [!NOTE]
>
> [![Sisyphus Labs — Sisyphus is the agent that codes like your team.](./.github/assets/sisyphuslabs.png?v=2)](https://sisyphuslabs.ai)
> > **Sisyphus의 완전한 제품화 버전을 구축하여 프론티어 에이전트의 미래를 정의하고 있습니다. <br />[여기서](https://sisyphuslabs.ai) 대기 명단에 등록하세요.**
>
> [!TIP]
>
> [![The Orchestrator is now available in beta.](./.github/assets/orchestrator-atlas.png?v=3)](https://github.com/code-yeongyu/oh-my-opencode/releases/tag/v3.0.0-beta.10)
> > **오케스트레이터가 베타 버전으로 사용 가능합니다. 설치하려면 `oh-my-opencode@3.0.0-beta.10`을 사용하세요.**
>
> 함께해요!
>
> | [<img alt="Discord link" src="https://img.shields.io/discord/1452487457085063218?color=5865F2&label=discord&labelColor=black&logo=discord&logoColor=white&style=flat-square" width="156px" />](https://discord.gg/PUwSMR9XNk) | 기여자와 동료 `oh-my-opencode` 사용자와 연결하려면 [Discord 커뮤니티](https://discord.gg/PUwSMR9XNk)에 가입하세요. |
> | :-----| :----- |
> | [<img alt="X link" src="https://img.shields.io/badge/Follow-%40justsisyphus-00CED1?style=flat-square&logo=x&labelColor=black" width="156px" />](https://x.com/justsisyphus) | `oh-my-opencode`에 대한 뉴스와 업데이트가 제 X 계정에 게시되었습니다. <br /> 실수로 정지된 이후, [@justsisyphus](https://x.com/justsisyphus)가 제 대신 업데이트를 게시합니다. |
> | [<img alt="GitHub Follow" src="https://img.shields.io/github/followers/code-yeongyu?style=flat-square&logo=github&labelColor=black&color=24292f" width="156px" />](https://github.com/code-yeongyu) | 더 많은 프로젝트를 위해 GitHub에서 [@code-yeongyu](https://github.com/code-yeongyu)를 팔로우하세요. |
<!-- <CENTERED SECTION FOR GITHUB DISPLAY> -->
<div align="center">
[![Oh My OpenCode](./.github/assets/hero.jpg)](https://github.com/code-yeongyu/oh-my-opencode#oh-my-opencode)
[![Preview](./.github/assets/omo.png)](https://github.com/code-yeongyu/oh-my-opencode#oh-my-opencode)
</div>
> 이것은 코딩을 스테로이드로 만드는 것 — 실제로 작동하는 `oh-my-opencode`입니다. 백그라운드 에이전트 실행, 오라클, 라이브러리언, 프론트엔드 엔지니어와 같은 전문 에이전트 호출. 정교하게 제작된 LSP/AST 도구, 큐레이팅된 MCP, 완전한 Claude Code 호환 계층 사용.
# Claude OAuth 액세스 공지
## TL;DR
> Q. oh-my-opencode를 사용할 수 있나요?
네.
> Q. Claude Code 구독과 함께 사용할 수 있나요?
기술적으로는 가능합니다. 하지만 사용을 추천할 수는 없습니다.
## FULL
> 2026년 1월 현재, Anthropic은 ToS 위반을 이유로 제3자 OAuth 액세스를 제한했습니다.
>
> [**Anthropic은 이 프로젝트 oh-my-opencode를 opencode 차단의 정당화로 인용했습니다.**](https://x.com/thdxr/status/2010149530486911014)
>
> 실제로 커뮤니티에는 Claude Code의 oauth 요청 서명을 위조하는 일부 플러그인이 존재합니다.
>
> 기술적 감지 여부와 관계없이 이러한 도구는 작동할 수 있지만, 사용자는 ToS 영향을 인식해야 하며 개인적으로는 사용을 추천하지 않습니다.
>
> 이 프로젝트는 공식이 아닌 도구 사용으로 발생하는 모든 문제에 대해 책임지지 않으며, **우리는 해당 oauth 시스템에 대한 사용자 정의 구현이 없습니다.**
<div align="center">
[![GitHub Release](https://img.shields.io/github/v/release/code-yeongyu/oh-my-opencode?color=369eff&labelColor=black&logo=github&style=flat-square)](https://github.com/code-yeongyu/oh-my-opencode/releases)
[![npm downloads](https://img.shields.io/npm/dt/oh-my-opencode?color=ff6b35&labelColor=black&style=flat-square)](https://www.npmjs.com/package/oh-my-opencode)
[![GitHub Contributors](https://img.shields.io/github/contributors/code-yeongyu/oh-my-opencode?color=c4f042&labelColor=black&style=flat-square)](https://github.com/code-yeongyu/oh-my-opencode/graphs/contributors)
[![GitHub Forks](https://img.shields.io/github/forks/code-yeongyu/oh-my-opencode?color=8ae8ff&labelColor=black&style=flat-square)](https://github.com/code-yeongyu/oh-my-opencode/network/members)
[![GitHub Stars](https://img.shields.io/github/stars/code-yeongyu/oh-my-opencode?color=ffcb47&labelColor=black&style=flat-square)](https://github.com/code-yeongyu/oh-my-opencode/stargazers)
[![GitHub Issues](https://img.shields.io/github/issues/code-yeongyu/oh-my-opencode?color=ff80eb&labelColor=black&style=flat-square)](https://github.com/code-yeongyu/oh-my-opencode/issues)
[![License](https://img.shields.io/badge/license-SUL--1.0-white?labelColor=black&style=flat-square)](https://github.com/code-yeongyu/oh-my-opencode/blob/master/LICENSE.md)
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/code-yeongyu/oh-my-opencode)
[English](README.md) | [한국어](README.ko.md) | [日本語](README.ja.md) | [简体中文](README.zh-cn.md)
</div>
<!-- </CENTERED SECTION FOR GITHUB DISPLAY> -->
## 리뷰
> "이것 덕분에 Cursor 구독을 취소했습니다. 오픈 소스 커뮤니티에서 믿을 수 없는 일들이 일어나고 있습니다." - [Arthur Guiot](https://x.com/arthur_guiot/status/2008736347092382053?s=20)
> "Claude Code가 7일 동안 하는 일을 인간은 3개월 동안 한다면, Sisyphus는 1시간 만에 합니다. 작업이 완료될 때까지 작동합니다. 규율 있는 에이전트입니다." — B, 양적 연구원
> "Oh My Opencode로 하루 만에 8000개의 eslint 경고를 해결했습니다" — [Jacob Ferrari](https://x.com/jacobferrari_/status/2003258761952289061)
> "Ohmyopencode와 ralph 루프를 사용하여 하룻밤 사이에 45,000줄의 tauri 앱을 SaaS 웹 앱으로 변환했습니다. 인터뷰 프롬프트로 시작하여 질문에 대한 등급과 추천을 물어봤습니다. 그것이 작동하는 모습을 보는 것은 놀라웠고, 이 아침에 기본적으로 작동하는 웹사이트로 깨어나는 것이었습니다!" - [James Hargis](https://x.com/hargabyte/status/2007299688261882202)
> "oh-my-opencode를 사용하세요, 다시는 돌아갈 수 없을 것입니다" — [d0t3ch](https://x.com/d0t3ch/status/2001685618200580503)
> "아직 왜 그렇게 훌륭한지 정확히 설명할 수 없지만, 개발 경험이 완전히 다른 차원에 도달했습니다." - [
苔硯:こけすずり](https://x.com/kokesuzuri/status/2008532913961529372?s=20)
> "이번 주말에 open code, oh my opencode, supermemory으로 마인크래프트/소울스 같은 기괴한 것을 만들고 있습니다."
> "점심 후 산책을 가는 동안 웅크림 애니메이션을 추가하도록 요청 중입니다. [동영상]" - [MagiMetal](https://x.com/MagiMetal/status/2005374704178373023)
> "여러분이 이것을 핵심에 통합하고 그를 채용해야 합니다. 진지합니다. 정말, 정말, 정말 훌륭합니다." — Henning Kilset
> "그를 설득할 수 있다면 @yeon_gyu_kim을 고용하세요, 이 사람은 opencode를 혁신했습니다." — [mysticaltech](https://x.com/mysticaltech/status/2001858758608376079)
> "Oh My OpenCode는 실제로 미칩니다" - [YouTube - Darren Builds AI](https://www.youtube.com/watch?v=G_Snfh2M41M)
---
## 목차
- [Oh My OpenCode](#oh-my-opencode)
- [이 README를 읽지 않고 건너뛰세요](#이-readme를-읽지-않고-건너뛰세요)
- [에이전트의 시대입니다](#에이전트의-시대입니다)
- [🪄 마법의 단어: `ultrawork`](#-마법의-단어-ultrawork)
- [읽고 싶은 분들을 위해: Sisyphus를 소개합니다](#읽고-싶은-분들을-위해-sisyphus를-소개합니다)
- [그냥 설치하세요](#그냥-설치하세요)
- [설치](#설치)
- [인간을 위한](#인간을-위한)
- [LLM 에이전트를 위한](#llm-에이전트를-위한)
- [제거](#제거)
- [기능](#기능)
- [구성](#구성)
- [JSONC 지원](#jsonc-지원)
- [Google 인증](#google-인증)
- [에이전트](#에이전트)
- [권한 옵션](#권한-옵션)
- [내장 스킬](#내장-스킬)
- [Git Master](#git-master)
- [Sisyphus 에이전트](#sisyphus-에이전트)
- [백그라운드 작업](#백그라운드-작업)
- [카테고리](#카테고리)
- [](#훅)
- [MCP](#mcp)
- [LSP](#lsp)
- [실험적 기능](#실험적-기능)
- [환경 변수](#환경-변수)
- [작성자의 메모](#작성자의-메모)
- [경고](#경고)
- [다음 기업 전문가들이 사랑합니다](#다음-기업-전문가들이-사랑합니다)
# Oh My OpenCode
[Claude Code](https://www.claude.com/product/claude-code)는 훌륭합니다.
하지만 해커라면 [OpenCode](https://github.com/sst/opencode)에 반하게 될 것입니다.
**ChatGPT, Claude, Gemini 구독으로 시작하세요. OPENCODE는 모든 것을 포함합니다.**
- 끝없이 확장 가능. 끝없이 사용자 정의 가능.
- 화면 깜빡임 없음.
- [LSP](https://opencode.ai/docs/lsp/), [린터, 포맷터](https://opencode.ai/docs/formatters/)가 파일별로 자동으로 활성화되며 모든 것을 조정할 수 있습니다.
- 모델 혼합 및 매칭. **목적별로 오케스트레이션합니다.**
- 기능이 풍부합니다. 아름답습니다. 터미널이 막히지 않습니다. 고성능입니다.
Windows에서 Linux로 처음 전환했던 때를 기억하나요? 원하는 대로 모든 것을 구성했을 때의 그 짜릿함?
그 마법이 사라진 것 같은 시대에 OpenCode가 그것을 되돌려줍니다.
코딩과 컴퓨터를 사랑한다면 OpenCode는 그 Windows 이후의 Linux와 같은 순간입니다.
**하지만 문제는 배워야 할 것이 많고, 복잡하며, 당신의 시간은 비싸다는 것입니다.**
내가 그것을 해결했습니다.
해커가 아니더라도 몇 분만 투자하세요. 기술과 생산성을 배가하세요.
이 문서를 에이전트에 넘겨서 설정하도록 하세요.
## 이 README를 읽지 않고 건너뛰세요
### 에이전트의 시대입니다
- **Claude Code / AmpCode / Factory Droid / Cursor에 이 링크를 붙여넣고 설명하도록 요청하세요.**
- 왜 좋은지, 왜 그저 또 다른 상용구 설정이 아닌지, 실제로 무엇이 좋아지는지 물어보세요.
- LLM 에이전트라면 이 프로젝트로 지금 가능한 것을 사용자에게 설명하세요.
다음과 같이:
```
Hey please read this readme and tell me why it is different from other agent harness? why is this actually good? https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/refs/heads/dev/README.md
```
### 🪄 마법의 단어: `ultrawork`
**이 모든 것을 읽고 싶지 않나요? 프롬프트에 `ultrawork`(또는 `ulw`)를 포함하세요.**
그게 전부입니다. 우리가 제공하는 모든 기능이 마법처럼 작동합니다 — 병렬 에이전트, 백그라운드 작업, 깊은 탐색, 완료될 때까지 끈질긴 실행. 에이전트가 나머지를 자동으로 파악합니다.
### 읽고 싶은 분들을 위해: Sisyphus를 소개합니다
![Meet Sisyphus](.github/assets/sisyphus.png)
그리스 신화에서 시시포스는 신들을 속인 형벌로 영원히 바위를 언덕 위로 굴려야 했습니다. LLM 에이전트는 정말 잘못한 것이 없지만, 그들도 매일 자신의 "돌" — 생각을 굴립니다.
내 삶도 다르지 않습니다. 돌이켜보면 우리는 이 에이전트들과 그리 다르지 않습니다.
**맞습니다! LLM 에이전트는 우리와 다르지 않습니다. 훌륭한 도구와 확고한 팀원을 제공하면 우리만큼 훌륭한 코드를 작성하고 똑같이 훌륭하게 작업할 수 있습니다.**
우리의 주요 에이전트를 만나보세요: Sisyphus (Opus 4.5 High). 아래는 Sisyphus가 그 바위를 굴리는 데 사용하는 도구입니다.
*아래의 모든 것은 사용자 정의 가능합니다. 원하는 것을 가져가세요. 모든 기능은 기본적으로 활성화됩니다. 아무것도 할 필요가 없습니다. 포함되어 있으며, 즉시 작동합니다.*
- Sisyphus의 팀원 (큐레이팅된 에이전트)
- Oracle: 디자인, 디버깅 (GPT 5.2 Medium)
- Frontend UI/UX Engineer: 프론트엔드 개발 (Gemini 3 Pro)
- Librarian: 공식 문서, 오픈 소스 구현, 코드베이스 탐색 (Claude Sonnet 4.5)
- Explore: 엄청나게 빠른 코드베이스 탐색 (Contextual Grep) (Grok Code)
- 완전한 LSP / AstGrep 지원: 결정적으로 리팩토링합니다.
- TODO 연속 강제: 에이전트가 중간에 멈추면 계속하도록 강제합니다. **이것이 Sisyphus가 그 바위를 굴리게 하는 것입니다.**
- 주석 검사기: AI가 과도한 주석을 추가하는 것을 방지합니다. Sisyphus가 생성한 코드는 인간이 작성한 것과 구별할 수 없어야 합니다.
- Claude Code 호환성: 명령, 에이전트, 스킬, MCP, 훅(PreToolUse, PostToolUse, UserPromptSubmit, Stop)
- 큐레이팅된 MCP:
- Exa (웹 검색)
- Context7 (공식 문서)
- Grep.app (GitHub 코드 검색)
- 대화형 터미널 지원 - Tmux 통합
- 비동기 에이전트
- ...
#### 그냥 설치하세요
[개요 페이지](docs/guide/overview.md)에서 많은 것을 배울 수 있지만, 다음은 예제 워크플로와 같습니다.
이것을 설치하는 것만으로 에이전트가 다음과 같이 작동합니다:
1. Sisyphus는 파일을 직접 찾는 데 시간을 낭비하지 않습니다. 메인 에이전트의 컨텍스트를 깔끔하게 유지합니다. 대신 병렬로 더 빠르고 저렴한 모델에 백그라운드 작업을 실행하여 지도를 매핑합니다.
1. Sisyphus는 리팩토링을 위해 LSP를 활용합니다. 더 결정적이고 안전하며 정교합니다.
1. 무거운 작업에 UI 터치가 필요할 때, Sisyphus는 프론트엔드 작업을 Gemini 3 Pro에 직접 위임합니다.
1. Sisyphus가 루프에 갇히거나 벽에 부딪히면 머리를 계속 부딪히지 않습니다. GPT 5.2에 고지능 전략 백업을 요청합니다.
1. 복잡한 오픈 소스 프레임워크를 작업하고 있나요? Sisyphus는 하위 에이전트를 생성하여 실시간으로 원시 소스 코드와 문서를 소화합니다. 완전한 컨텍스트 인식으로 작동합니다.
1. Sisyphus가 주석을 다루면 존재를 정당화하거나 제거합니다. 코드베이스를 깔끔하게 유지합니다.
1. Sisyphus는 TODO 목록에 묶여 있습니다. 시작한 것을 완료하지 않으면 시스템이 "바위 굴리기" 모드로 다시 강제합니다. 작업이 완료됩니다.
1. 솔직히, 문서를 읽을 필요조차 없습니다. 프롬프트를 작성하세요. 'ultrawork' 키워드를 포함하세요. Sisyphus는 구조를 분석하고, 컨텍스트를 수집하고, 외부 소스 코드를 파헤치고, 작업이 100% 완료될 때까지 계속 바위를 굴립니다.
1. 사실, 'ultrawork'를 입력하는 것도 너무 많은 노력입니다. 'ulw'를 입력하세요. 그냥 ulw. 커피를 마시세요. 작업이 완료되었습니다.
무언가를 찾아야 하나요? 공식 문서, 전체 코드베이스 기록, 공개 GitHub 구현을 검색합니다 — grep뿐만 아니라 내장 LSP 도구와 AST-Grep을 사용합니다.
3. LLM에 위임할 때 컨텍스트 관리에 대해 걱정하지 마세요. 내가 다 처리했습니다.
- OhMyOpenCode는 컨텍스트 부하를 줄이기 위해 공격적으로 여러 에이전트를 활용합니다.
- **이제 귀하의 에이전트는 개발 팀 리드입니다. 당신은 AI 매니저입니다.**
4. 작업이 완료될 때까지 멈추지 않습니다.
5. 이 프로젝트에 깊이 파고들고 싶지 않나요? 문제 없습니다. 'ultrathink'를 입력하세요.
이 모든 것이 필요하지 않다면, 앞서 언급했듯이 특정 기능을 선택할 수 있습니다.
## 설치
### 인간을 위한
이 프롬프트를 LLM 에이전트(Claude Code, AmpCode, Cursor 등)에 복사하여 붙여넣으세요:
```
Install and configure oh-my-opencode by following the instructions here:
https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/refs/heads/master/docs/guide/installation.md
```
또는 [설치 가이드](docs/guide/installation.md)를 직접 읽으세요 — 하지만 **에이전트가 처리하도록 하는 것을 강력히 권장합니다. 인간은 실수를 합니다.**
### LLM 에이전트를 위한
설치 가이드를 가져와서 따르세요:
```bash
curl -s https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/refs/heads/master/docs/guide/installation.md
```
## 제거
oh-my-opencode를 제거하려면:
1. **OpenCode 구성에서 플러그인 제거**
`~/.config/opencode/opencode.json`(또는 `opencode.jsonc`)을 편집하고 `plugin` 배열에서 `"oh-my-opencode"`를 제거하세요:
```bash
# Using jq
jq '.plugin = [.plugin[] | select(. != "oh-my-opencode")]' \
~/.config/opencode/opencode.json > /tmp/oc.json && \
mv /tmp/oc.json ~/.config/opencode/opencode.json
```
2. **구성 파일 제거 (선택 사항)**
```bash
# Remove user config
rm -f ~/.config/opencode/oh-my-opencode.json
# Remove project config (if exists)
rm -f .opencode/oh-my-opencode.json
```
3. **제거 확인**
```bash
opencode --version
# Plugin should no longer be loaded
```
## 기능
당연히 존재해야 한다고 생각할 많은 기능이 있으며, 한 번 경험하면 이전 방식으로 돌아갈 수 없을 것입니다.
자세한 내용은 전체 [기능 문서](docs/features.md)를 참조하세요.
**빠른 개요:**
- **에이전트**: Sisyphus(주요 에이전트), Prometheus(플래너), Oracle(아키텍처/디버깅), Librarian(문서/코드 검색), Explore(빠른 코드베이스 grep), Multimodal Looker
- **백그라운드 에이전트**: 실제 개발 팀처럼 여러 에이전트를 병렬로 실행
- **LSP 및 AST 도구**: 리팩토링, 이름 변경, 진단, AST 인식 코드 검색
- **컨텍스트 주입**: AGENTS.md, README.md, 조건부 규칙 자동 주입
- **Claude Code 호환성**: 완전한 훅 시스템, 명령, 스킬, 에이전트, MCP
- **내장 MCP**: websearch(Exa), context7(문서), grep_app(GitHub 검색)
- **세션 도구**: 세션 기록 나열, 읽기, 검색 및 분석
- **생산성 기능**: Ralph 루프, Todo 강제, 주석 검사기, 생각 모드 등
## 구성
매우 의견이 강하지만 취향에 맞게 조정 가능합니다.
자세한 내용은 전체 [구성 문서](docs/configurations.md)를 참조하세요.
**빠른 개요:**
- **구성 위치**: `.opencode/oh-my-opencode.json`(프로젝트) 또는 `~/.config/opencode/oh-my-opencode.json`(사용자)
- **JSONC 지원**: 주석 및 후행 쉼표 지원
- **에이전트**: 모든 에이전트의 모델, 온도, 프롬프트 및 권한 재정의
- **내장 스킬**: `playwright`(브라우저 자동화), `git-master`(원자적 커밋)
- **Sisyphus 에이전트**: Prometheus(플래너) 및 Metis(계획 컨설턴트)가 있는 주요 오케스트레이터
- **백그라운드 작업**: 공급자/모델별 동시성 제한 구성
- **카테고리**: 도메인별 작업 위임(`visual`, `business-logic`, 사용자 정의)
- **훅**: 25개 이상의 내장 훅, `disabled_hooks`를 통해 모두 구성 가능
- **MCP**: 내장 websearch(Exa), context7(문서), grep_app(GitHub 검색)
- **LSP**: 리팩토링 도구가 있는 완전한 LSP 지원
- **실험적 기능**: 공격적 자르기, 자동 재개 등
## 작성자의 메모
**이 프로젝트의 철학에 궁금한가요?** [Ultrawork 선언문](docs/ultrawork-manifesto.md)을 읽어보세요.
Oh My OpenCode를 설치하세요.
순수하게 개인용으로 $24,000 토큰 가치의 LLM을 사용했습니다.
모든 도구를 시도하고 구성했습니다. OpenCode가 승리했습니다.
내가 겪은 모든 문제에 대한 답변이 이 플러그인에 구워져 있습니다. 설치하고 바로 가세요.
OpenCode가 Debian/Arch라면 Oh My OpenCode는 Ubuntu/[Omarchy](https://omarchy.org/)입니다.
[AmpCode](https://ampcode.com)와 [Claude Code](https://code.claude.com/docs/overview)에 큰 영향을 받았습니다 — 여기에 그들의 기능을 포팅했고, 종종 개선했습니다. 그리고 여전히 구축 중입니다.
그것은 **Open**Code이니까요.
다른 하니스가 약속하지만 전달할 수 없는 다중 모델 오케스트레이션, 안정성, 풍부한 기능을 즐기세요.
계속 테스트하고 업데이트하겠습니다. 저는 이 프로젝트의 가장 집요한 사용자입니다.
- 어떤 모델이 가장 날카로운 논리를 가지고 있나요?
- 누가 디버깅의 신인가요?
- 누가 가장 훌륭한 글을 쓰나요?
- 누가 프론트엔드를 지배하나요?
- 누가 백엔드를 소유하나요?
- 일일 주행에 어떤 모델이 가장 빠른가요?
- 다른 하니스가 어떤 새로운 기능을 출시하고 있나요?
이 플러그인은 그 경험의 증류입니다. 최고를 취하세요. 더 나은 아이디어가 있나요? PR을 환영합니다.
**에이전트 하니스 선택에 대해 고민하지 마세요.**
**연구를 하고, 최고에서 차용하고, 여기에 업데이트를 배포하겠습니다.**
이것이 오만하게 들리고 더 나은 답이 있다면 기여하세요. 환영합니다.
여기에 언급된 모든 프로젝트나 모델과 제휴 관계가 없습니다. 이것은 순수한 개인적인 실험과 선호입니다.
이 프로젝트의 99%는 OpenCode를 사용하여 구축되었습니다. 기능을 테스트했습니다 — 제대로 된 TypeScript를 작성하는 방법을 정말 모릅니다. **하지만 개인적으로 검토하고 이 문서의 대부분을 다시 작성했으므로 자신감을 가지고 읽으세요.**
## 경고
- 생산성이 너무 급증할 수 있습니다. 동료에게 눈치채이지 마세요.
- 실제로, 소문을 퍼뜨리겠습니다. 누가 이기는지 봅시다.
- [1.0.132](https://github.com/sst/opencode/releases/tag/v1.0.132) 이전 버전을 사용 중인 경우 OpenCode 버그로 인해 구성이 손상될 수 있습니다.
- [수정 사항](https://github.com/sst/opencode/pull/5040)은 1.0.132 이후에 병합되었습니다 — 더 새로운 버전을 사용하세요.
- 재미있는 사실: 해당 PR은 OhMyOpenCode의 Librarian, Explore 및 Oracle 설정 덕분에 발견되고 수정되었습니다.
## 다음 기업 전문가들이 사랑합니다
- [Indent](https://indentcorp.com)
- Spray(인플루언서 마케팅 솔루션), vovushop(국가 간 상거래 플랫폼), vreview(AI 상거래 리뷰 마케팅 솔루션) 제작
- [Google](https://google.com)
- [Microsoft](https://microsoft.com)
*이 놀라운 히어로 이미지에 대해 [@junhoyeo](https://github.com/junhoyeo)에게 특별히 감사드립니다.*

View File

@@ -75,7 +75,7 @@ Yes, technically possible. But I cannot recommend using it.
[![License](https://img.shields.io/badge/license-SUL--1.0-white?labelColor=black&style=flat-square)](https://github.com/code-yeongyu/oh-my-opencode/blob/master/LICENSE.md)
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/code-yeongyu/oh-my-opencode)
[English](README.md) | [日本語](README.ja.md) | [简体中文](README.zh-cn.md)
[English](README.md) | [한국어](README.ko.md) | [日本語](README.ja.md) | [简体中文](README.zh-cn.md)
</div>
@@ -120,7 +120,7 @@ Yes, technically possible. But I cannot recommend using it.
- [For LLM Agents](#for-llm-agents)
- [Uninstallation](#uninstallation)
- [Features](#features)
- [Configuration](#configuration)
- [Configuration](#configuration)
- [JSONC Support](#jsonc-support)
- [Google Auth](#google-auth)
- [Agents](#agents)

View File

@@ -1741,6 +1741,9 @@
"additionalProperties": {
"type": "object",
"properties": {
"description": {
"type": "string"
},
"model": {
"type": "string"
},
@@ -2127,7 +2130,7 @@
},
"additionalProperties": {
"type": "number",
"minimum": 1
"minimum": 0
}
},
"modelConcurrency": {
@@ -2137,7 +2140,7 @@
},
"additionalProperties": {
"type": "number",
"minimum": 1
"minimum": 0
}
},
"staleTimeoutMs": {

View File

@@ -19,14 +19,15 @@ A Category is an agent configuration preset optimized for specific domains.
### Available Built-in Categories
| Category | Optimal Model | Characteristics | Use Cases |
|----------|---------------|-----------------|-----------|
| `visual-engineering` | `gemini-3-pro` | High creativity (Temp 0.7) | Frontend, UI/UX, animations, styling |
| `ultrabrain` | `gpt-5.2` | Maximum logical reasoning (Temp 0.1) | Architecture design, complex business logic, debugging |
| `artistry` | `gemini-3-pro` | Artistic (Temp 0.9) | Creative ideation, design concepts, storytelling |
| `quick` | `claude-haiku` | Fast (Temp 0.3) | Simple tasks, refactoring, script writing |
| `writing` | `gemini-3-flash` | Natural flow (Temp 0.5) | Documentation, technical blogs, README writing |
| `most-capable` | `claude-opus` | High performance (Temp 0.1) | Extremely difficult complex tasks |
| Category | Default Model | Use Cases |
|----------|---------------|-----------|
| `visual-engineering` | `google/gemini-3-pro-preview` | Frontend, UI/UX, design, styling, animation |
| `ultrabrain` | `openai/gpt-5.2-codex` (xhigh) | Deep logical reasoning, complex architecture decisions requiring extensive analysis |
| `artistry` | `google/gemini-3-pro-preview` (max) | Highly creative/artistic tasks, novel ideas |
| `quick` | `anthropic/claude-haiku-4-5` | Trivial tasks - single file changes, typo fixes, simple modifications |
| `unspecified-low` | `anthropic/claude-sonnet-4-5` | Tasks that don't fit other categories, low effort required |
| `unspecified-high` | `anthropic/claude-opus-4-5` (max) | Tasks that don't fit other categories, high effort required |
| `writing` | `google/gemini-3-flash-preview` | Documentation, prose, technical writing |
### Usage
@@ -156,12 +157,18 @@ You can fine-tune categories in `oh-my-opencode.json`.
| Field | Type | Description |
|-------|------|-------------|
| `description` | string | Human-readable description of the category's purpose. Shown in delegate_task prompt. |
| `model` | string | AI model ID to use (e.g., `anthropic/claude-opus-4-5`) |
| `variant` | string | Model variant (e.g., `max`, `xhigh`) |
| `temperature` | number | Creativity level (0.0 ~ 2.0). Lower is more deterministic. |
| `top_p` | number | Nucleus sampling parameter (0.0 ~ 1.0) |
| `prompt_append` | string | Content to append to system prompt when this category is selected |
| `thinking` | object | Thinking model configuration (`{ type: "enabled", budgetTokens: 16000 }`) |
| `reasoningEffort` | string | Reasoning effort level (`low`, `medium`, `high`) |
| `textVerbosity` | string | Text verbosity level (`low`, `medium`, `high`) |
| `tools` | object | Tool usage control (disable with `{ "tool_name": false }`) |
| `maxTokens` | number | Maximum response token count |
| `is_unstable_agent` | boolean | Mark agent as unstable - forces background mode for monitoring |
### Example Configuration

View File

@@ -308,268 +308,128 @@ Add custom categories in `oh-my-opencode.json`:
Each category supports: `model`, `temperature`, `top_p`, `maxTokens`, `thinking`, `reasoningEffort`, `textVerbosity`, `tools`, `prompt_append`.
## Model Selection System
## Model Resolution System
The installer automatically configures optimal models based on your subscriptions. This section explains how models are selected for each agent and category.
At runtime, Oh My OpenCode uses a 3-step resolution process to determine which model to use for each agent and category. This happens dynamically based on your configuration and available models.
### Overview
**Problem**: Users have different subscription combinations (Claude, OpenAI, Gemini, etc.). The system needs to automatically select the best available model for each task.
**Problem**: Users have different provider configurations. The system needs to select the best available model for each task at runtime.
**Solution**: A tiered fallback system that:
1. Prioritizes native provider subscriptions (Claude, OpenAI, Gemini)
2. Falls back through alternative providers in priority order
3. Applies capability-specific logic (e.g., Oracle prefers GPT, visual tasks prefer Gemini)
**Solution**: A simple 3-step resolution flow:
1. **Step 1: User Override** — If you specify a model in `oh-my-opencode.json`, use exactly that
2. **Step 2: Provider Fallback** — Try each provider in the requirement's priority order until one is available
3. **Step 3: System Default** — Fall back to OpenCode's configured default model
### Provider Priority
### Resolution Flow
```
┌─────────────────────────────────────────────────────────────────┐
│ MODEL SELECTION FLOW │
│ MODEL RESOLUTION FLOW │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Step 1: USER OVERRIDE │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ TIER 1: NATIVE PROVIDERS │ │
│ │ (Your direct subscriptions) │ │
│ │ │ │
│ │ Claude (anthropic/) ──► OpenAI (openai/) ──► Gemini │ │
│ │ │ │ (google/) │ │
│ │ ▼ ▼ │ │ │
│ │ Opus/Sonnet/Haiku GPT-5.2/Codex Gemini 3 Pro │ │
│ │ User specified model in oh-my-opencode.json? │ │
│ │ YES → Use exactly as specified │ │
│ │ NO → Continue to Step 2 │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ (if no native available)
│ ▼
│ Step 2: PROVIDER PRIORITY FALLBACK │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ TIER 2: OPENCODE ZEN │ │
│ │ (opencode/ prefix models) │ │
│ │ For each provider in requirement.providers order: │ │
│ │ │ │
│ │ opencode/claude-opus-4-5, opencode/gpt-5.2, etc. │ │
│ │ Example for Sisyphus: │ │
│ │ anthropic → github-copilot → opencode → antigravity │ │
│ │ │ │ │ │ │ │
│ │ ▼ ▼ ▼ ▼ │ │
│ │ Try: anthropic/claude-opus-4-5 │ │
│ │ Try: github-copilot/claude-opus-4-5 │ │
│ │ Try: opencode/claude-opus-4-5 │ │
│ │ ... │ │
│ │ │ │
│ │ Found in available models? → Return matched model │ │
│ │ Not found? → Try next provider │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ (if no OpenCode Zen)
│ ▼ (all providers exhausted)
│ Step 3: SYSTEM DEFAULT │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ TIER 3: GITHUB COPILOT │ │
│ │ (github-copilot/ prefix models) │ │
│ │ │ │
│ │ github-copilot/claude-opus-4.5, etc. │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ (if no Copilot) │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ TIER 4: Z.AI CODING PLAN │ │
│ │ (zai-coding-plan/ prefix models) │ │
│ │ │ │
│ │ zai-coding-plan/glm-4.7 (GLM models only) │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ (ultimate fallback) │
│ ┌─────────────────────────────────────────────────────────┐ │
│ │ FALLBACK: FREE TIER │ │
│ │ │ │
│ │ opencode/glm-4.7-free │ │
│ │ Return systemDefaultModel (from opencode.json) │ │
│ └─────────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
```
### Native Tier Cross-Fallback
### Agent Provider Chains
Within the Native tier, models fall back based on capability requirements:
Each agent has a defined provider priority chain. The system tries providers in order until it finds an available model:
| Capability | 1st Choice | 2nd Choice | 3rd Choice |
|------------|------------|------------|------------|
| **High-tier tasks** (Sisyphus, Atlas) | Claude Opus | OpenAI GPT-5.2 | Gemini 3 Pro |
| **Standard tasks** | Claude Sonnet | OpenAI GPT-5.2 | Gemini 3 Flash |
| **Quick tasks** | Claude Haiku | OpenAI GPT-5.1-mini | Gemini 3 Flash |
| **Deep reasoning** (Oracle) | OpenAI GPT-5.2-Codex | Claude Opus | Gemini 3 Pro |
| **Visual/UI tasks** | Gemini 3 Pro | OpenAI GPT-5.2 | Claude Sonnet |
| **Writing tasks** | Gemini 3 Flash | OpenAI GPT-5.2 | Claude Sonnet |
| Agent | Model (no prefix) | Provider Priority Chain |
|-------|-------------------|-------------------------|
| **Sisyphus** | `claude-opus-4-5` | anthropic → github-copilot → opencode → antigravity → google |
| **oracle** | `gpt-5.2` | openai → anthropic → google → github-copilot → opencode |
| **librarian** | `glm-4.7-free` | opencode → github-copilot → anthropic |
| **explore** | `grok-code` | opencode → anthropic → github-copilot |
| **multimodal-looker** | `gemini-3-pro-preview` | google → openai → anthropic → github-copilot → opencode |
| **Prometheus (Planner)** | `claude-opus-4-5` | anthropic → github-copilot → opencode → antigravity → google |
| **Metis (Plan Consultant)** | `claude-sonnet-4-5` | anthropic → github-copilot → opencode → antigravity → google |
| **Momus (Plan Reviewer)** | `claude-opus-4-5` | anthropic → github-copilot → opencode → antigravity → google |
| **Atlas** | `claude-sonnet-4-5` | anthropic → github-copilot → opencode → antigravity → google |
### Agent-Specific Rules
### Category Provider Chains
#### Standard Agents
Categories follow the same resolution logic:
| Agent | Capability | Example (Claude + OpenAI + Gemini) |
|-------|------------|-------------------------------------|
| **Sisyphus** | High-tier (isMax20) or Standard | `anthropic/claude-opus-4-5` or `anthropic/claude-sonnet-4-5` |
| **Oracle** | Deep reasoning | `openai/gpt-5.2-codex` |
| **Prometheus** | High-tier/Standard | Same as Sisyphus |
| **Metis** | High-tier/Standard | Same as Sisyphus |
| **Momus** | Deep reasoning | `openai/gpt-5.2-codex` |
| **Atlas** | High-tier/Standard | Same as Sisyphus |
| **multimodal-looker** | Visual | `google/gemini-3-pro-preview` |
| Category | Model (no prefix) | Provider Priority Chain |
|----------|-------------------|-------------------------|
| **visual-engineering** | `gemini-3-pro-preview` | google → openai → anthropic → github-copilot → opencode |
| **ultrabrain** | `gpt-5.2-codex` | openai → anthropic → google → github-copilot → opencode |
| **artistry** | `gemini-3-pro-preview` | google → openai → anthropic → github-copilot → opencode |
| **quick** | `claude-haiku-4-5` | anthropic → github-copilot → opencode → antigravity → google |
| **unspecified-low** | `claude-sonnet-4-5` | anthropic → github-copilot → opencode → antigravity → google |
| **unspecified-high** | `claude-opus-4-5` | anthropic → github-copilot → opencode → antigravity → google |
| **writing** | `gemini-3-flash-preview` | google → openai → anthropic → github-copilot → opencode |
#### Special Case: explore Agent
### Checking Your Configuration
The `explore` agent has unique logic for cost optimization:
Use the `doctor` command to see how models resolve with your current configuration:
```
┌────────────────────────────────────────┐
│ EXPLORE AGENT LOGIC │
├────────────────────────────────────────┤
│ │
│ Has Claude + isMax20? │
│ │ │
│ YES │ NO │
│ ▼ │ ▼ │
│ ┌──────┐│┌────────────────────┐ │
│ │Haiku ││ │ opencode/grok-code │ │
│ │4.5 │││ (free & fast) │ │
│ └──────┘│└────────────────────┘ │
│ │
│ Rationale: │
│ • max20 users want to use Claude quota │
│ • Others save quota with free grok │
└────────────────────────────────────────┘
```bash
bunx oh-my-opencode doctor --verbose
```
#### Special Case: librarian Agent
The `librarian` agent prioritizes Z.ai when available:
```
┌────────────────────────────────────────┐
│ LIBRARIAN AGENT LOGIC │
├────────────────────────────────────────┤
│ │
│ Has Z.ai Coding Plan? │
│ │ │
│ YES │ NO │
│ ▼ │ ▼ │
│ ┌──────────────┐ ┌──────────────────┐ │
│ │zai-coding- │ │ Normal fallback │ │
│ │plan/glm-4.7 │ │ chain applies │ │
│ └──────────────┘ └──────────────────┘ │
│ │
│ Rationale: │
│ • GLM excels at documentation tasks │
│ • Z.ai provides dedicated GLM access │
└────────────────────────────────────────┘
```
### Category-Specific Rules
Categories follow the same fallback logic as agents:
| Category | Primary Capability | Fallback Chain |
|----------|-------------------|----------------|
| `visual-engineering` | Visual | Gemini → OpenAI → Claude |
| `ultrabrain` | Deep reasoning | OpenAI → Claude → Gemini |
| `artistry` | Visual/Creative | Gemini → OpenAI → Claude |
| `quick` | Quick tasks | Claude Haiku → OpenAI mini → Gemini Flash |
| `unspecified-low` | Standard | Claude Sonnet → OpenAI → Gemini Flash |
| `unspecified-high` | High-tier | Claude Opus → OpenAI → Gemini Pro |
| `writing` | Writing | Gemini Flash → OpenAI → Claude |
### Subscription Scenarios
#### Scenario 1: Claude Only (Standard Plan)
```json
// User has: Claude Pro (not max20)
{
"agents": {
"Sisyphus": { "model": "anthropic/claude-sonnet-4-5" },
"oracle": { "model": "anthropic/claude-opus-4-5" },
"explore": { "model": "opencode/grok-code" },
"librarian": { "model": "opencode/glm-4.7-free" }
}
}
```
#### Scenario 2: Claude Only (Max20 Plan)
```json
// User has: Claude Max (max20 mode)
{
"agents": {
"Sisyphus": { "model": "anthropic/claude-opus-4-5" },
"oracle": { "model": "anthropic/claude-opus-4-5" },
"explore": { "model": "anthropic/claude-haiku-4-5" },
"librarian": { "model": "opencode/glm-4.7-free" }
}
}
```
#### Scenario 3: ChatGPT Only
```json
// User has: OpenAI/ChatGPT Plus only
{
"agents": {
"Sisyphus": { "model": "openai/gpt-5.2" },
"oracle": { "model": "openai/gpt-5.2-codex" },
"explore": { "model": "opencode/grok-code" },
"multimodal-looker": { "model": "openai/gpt-5.2" },
"librarian": { "model": "opencode/glm-4.7-free" }
}
}
```
#### Scenario 4: Full Stack (Claude + OpenAI + Gemini)
```json
// User has: All native providers
{
"agents": {
"Sisyphus": { "model": "anthropic/claude-opus-4-5" },
"oracle": { "model": "openai/gpt-5.2-codex" },
"explore": { "model": "anthropic/claude-haiku-4-5" },
"multimodal-looker": { "model": "google/gemini-3-pro-preview" },
"librarian": { "model": "opencode/glm-4.7-free" }
}
}
```
#### Scenario 5: GitHub Copilot Only
```json
// User has: GitHub Copilot only (no native providers)
{
"agents": {
"Sisyphus": { "model": "github-copilot/claude-sonnet-4.5" },
"oracle": { "model": "github-copilot/gpt-5.2-codex" },
"explore": { "model": "opencode/grok-code" },
"librarian": { "model": "github-copilot/gpt-5.2" }
}
}
```
### isMax20 Flag Impact
The `isMax20` flag (Claude Max 20x mode) affects high-tier task model selection:
| isMax20 | High-tier Capability | Result |
|---------|---------------------|--------|
| `true` | Uses `unspecified-high` | Opus-class models |
| `false` | Uses `unspecified-low` | Sonnet-class models |
**Affected agents**: Sisyphus, Prometheus, Metis, Atlas
**Why?**: Max20 users have 20x more Claude usage, so they can afford Opus for orchestration. Standard users should conserve quota with Sonnet.
The "Model Resolution" check shows:
- Each agent/category's model requirement
- Provider fallback chain
- User overrides (if configured)
- Effective resolution path
### Manual Override
You can always override automatic selection in `oh-my-opencode.json`:
Override any agent or category model in `oh-my-opencode.json`:
```json
{
"agents": {
"Sisyphus": {
"model": "anthropic/claude-sonnet-4-5" // Force specific model
"model": "anthropic/claude-sonnet-4-5"
},
"oracle": {
"model": "openai/o3" // Use different model
"model": "openai/o3"
}
},
"categories": {
"visual-engineering": {
"model": "anthropic/claude-opus-4-5" // Override category default
"model": "anthropic/claude-opus-4-5"
}
}
}
```
When you specify a model override, it takes precedence (Step 1) and the provider fallback chain is skipped entirely.
## Hooks
Disable specific built-in hooks via `disabled_hooks` in `~/.config/opencode/oh-my-opencode.json` or `.opencode/oh-my-opencode.json`:

View File

@@ -63,7 +63,7 @@ Ask the user these questions to determine CLI options:
**Provider Priority**: Native (anthropic/, openai/, google/) > GitHub Copilot > OpenCode Zen > Z.ai Coding Plan
MUST STRONGLY WARNING, WHEN USER SAID THEY DON'T HAVE CLAUDE SUBSCRIPTION, SISYPHUS AGENT MIGHT NOT WORK IDEALY.
MUST STRONGLY WARNING, WHEN USER SAID THEY DON'T HAVE CLAUDE SUBSCRIPTION, SISYPHUS AGENT MIGHT NOT WORK IDEALLY.
### Step 1: Install OpenCode (if not installed)

View File

@@ -291,15 +291,15 @@ delegate_task(category="quick", prompt="...") // "Just get it done fast
### Built-in Categories
| Category | Model | Temp | When to Use |
|----------|-------|------|-------------|
| `visual-engineering` | Gemini 3 Pro | 0.7 | Frontend, UI/UX, design, animations |
| `ultrabrain` | GPT-5.2 | 0.1 | Complex architecture, business logic |
| `artistry` | Gemini 3 Pro | 0.9 | Creative tasks, novel ideas |
| `quick` | Claude Haiku 4.5 | 0.3 | Small tasks, budget-friendly |
| `most-capable` | Claude Opus 4.5 | 0.1 | Maximum reasoning power |
| `writing` | Gemini 3 Flash | 0.5 | Documentation, prose |
| `general` | Claude Sonnet 4.5 | 0.3 | Default, general purpose |
| Category | Model | When to Use |
|----------|-------|-------------|
| `visual-engineering` | Gemini 3 Pro | Frontend, UI/UX, design, styling, animation |
| `ultrabrain` | GPT-5.2 Codex (xhigh) | Deep logical reasoning, complex architecture decisions |
| `artistry` | Gemini 3 Pro (max) | Highly creative/artistic tasks, novel ideas |
| `quick` | Claude Haiku 4.5 | Trivial tasks - single file changes, typo fixes |
| `unspecified-low` | Claude Sonnet 4.5 | Tasks that don't fit other categories, low effort |
| `unspecified-high` | Claude Opus 4.5 (max) | Tasks that don't fit other categories, high effort |
| `writing` | Gemini 3 Flash | Documentation, prose, technical writing |
### Custom Categories

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode",
"version": "3.0.0-beta.12",
"version": "3.0.0-beta.13",
"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",
"types": "dist/index.d.ts",
@@ -73,13 +73,13 @@
"typescript": "^5.7.3"
},
"optionalDependencies": {
"oh-my-opencode-darwin-arm64": "3.0.0-beta.12",
"oh-my-opencode-darwin-x64": "3.0.0-beta.12",
"oh-my-opencode-linux-arm64": "3.0.0-beta.12",
"oh-my-opencode-linux-arm64-musl": "3.0.0-beta.12",
"oh-my-opencode-linux-x64": "3.0.0-beta.12",
"oh-my-opencode-linux-x64-musl": "3.0.0-beta.12",
"oh-my-opencode-windows-x64": "3.0.0-beta.12"
"oh-my-opencode-darwin-arm64": "3.0.0-beta.13",
"oh-my-opencode-darwin-x64": "3.0.0-beta.13",
"oh-my-opencode-linux-arm64": "3.0.0-beta.13",
"oh-my-opencode-linux-arm64-musl": "3.0.0-beta.13",
"oh-my-opencode-linux-x64": "3.0.0-beta.13",
"oh-my-opencode-linux-x64-musl": "3.0.0-beta.13",
"oh-my-opencode-windows-x64": "3.0.0-beta.13"
},
"trustedDependencies": [
"@ast-grep/cli",

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-darwin-arm64",
"version": "3.0.0-beta.12",
"version": "3.0.0-beta.13",
"description": "Platform-specific binary for oh-my-opencode (darwin-arm64)",
"license": "MIT",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-darwin-x64",
"version": "3.0.0-beta.12",
"version": "3.0.0-beta.13",
"description": "Platform-specific binary for oh-my-opencode (darwin-x64)",
"license": "MIT",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-linux-arm64-musl",
"version": "3.0.0-beta.12",
"version": "3.0.0-beta.13",
"description": "Platform-specific binary for oh-my-opencode (linux-arm64-musl)",
"license": "MIT",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-linux-arm64",
"version": "3.0.0-beta.12",
"version": "3.0.0-beta.13",
"description": "Platform-specific binary for oh-my-opencode (linux-arm64)",
"license": "MIT",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-linux-x64-musl",
"version": "3.0.0-beta.12",
"version": "3.0.0-beta.13",
"description": "Platform-specific binary for oh-my-opencode (linux-x64-musl)",
"license": "MIT",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-linux-x64",
"version": "3.0.0-beta.12",
"version": "3.0.0-beta.13",
"description": "Platform-specific binary for oh-my-opencode (linux-x64)",
"license": "MIT",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-windows-x64",
"version": "3.0.0-beta.12",
"version": "3.0.0-beta.13",
"description": "Platform-specific binary for oh-my-opencode (windows-x64)",
"license": "MIT",
"repository": {

View File

@@ -1,105 +0,0 @@
#!/usr/bin/env bun
/**
* Generate the full Sisyphus system prompt and output to sisyphus-prompt.md
*
* Usage:
* bun run script/generate-sisyphus-prompt.ts
*/
import { createSisyphusAgent } from "../src/agents/sisyphus"
import { ORACLE_PROMPT_METADATA } from "../src/agents/oracle"
import { LIBRARIAN_PROMPT_METADATA } from "../src/agents/librarian"
import { EXPLORE_PROMPT_METADATA } from "../src/agents/explore"
import { MULTIMODAL_LOOKER_PROMPT_METADATA } from "../src/agents/multimodal-looker"
import { createBuiltinSkills } from "../src/features/builtin-skills"
import { DEFAULT_CATEGORIES, CATEGORY_DESCRIPTIONS } from "../src/tools/delegate-task/constants"
import type { AvailableAgent, AvailableCategory, AvailableSkill } from "../src/agents/dynamic-agent-prompt-builder"
import type { BuiltinAgentName, AgentPromptMetadata } from "../src/agents/types"
import { writeFileSync } from "node:fs"
import { join } from "node:path"
// Build available agents (same logic as utils.ts)
const agentMetadata: Record<string, AgentPromptMetadata> = {
oracle: ORACLE_PROMPT_METADATA,
librarian: LIBRARIAN_PROMPT_METADATA,
explore: EXPLORE_PROMPT_METADATA,
"multimodal-looker": MULTIMODAL_LOOKER_PROMPT_METADATA,
}
const agentDescriptions: Record<string, string> = {
oracle: "Read-only consultation agent. High-IQ reasoning specialist for debugging hard problems and high-difficulty architecture design.",
librarian: "Specialized codebase understanding agent for multi-repository analysis, searching remote codebases, retrieving official documentation, and finding implementation examples using GitHub CLI, Context7, and Web Search. MUST BE USED when users ask to look up code in remote repositories, explain library internals, or find usage examples in open source.",
explore: 'Contextual grep for codebases. Answers "Where is X?", "Which file has Y?", "Find the code that does Z". Fire multiple in parallel for broad searches. Specify thoroughness: "quick" for basic, "medium" for moderate, "very thorough" for comprehensive analysis.',
"multimodal-looker": "Analyze media files (PDFs, images, diagrams) that require interpretation beyond raw text. Extracts specific information or summaries from documents, describes visual content. Use when you need analyzed/extracted data rather than literal file contents.",
}
const availableAgents: AvailableAgent[] = Object.entries(agentMetadata).map(([name, metadata]) => ({
name: name as BuiltinAgentName,
description: agentDescriptions[name] ?? "",
metadata,
}))
// Build available categories
const availableCategories: AvailableCategory[] = Object.entries(DEFAULT_CATEGORIES).map(([name]) => ({
name,
description: CATEGORY_DESCRIPTIONS[name] ?? "General tasks",
}))
// Build available skills
const builtinSkills = createBuiltinSkills()
const availableSkills: AvailableSkill[] = builtinSkills.map((skill) => ({
name: skill.name,
description: skill.description,
location: "plugin" as const,
}))
// Generate the agent config
const model = "anthropic/claude-opus-4-5"
const sisyphusConfig = createSisyphusAgent(
model,
availableAgents,
undefined, // no tool names
availableSkills,
availableCategories
)
// Output to file
const outputPath = join(import.meta.dirname, "..", "sisyphus-prompt.md")
const content = `# Sisyphus System Prompt
> Auto-generated by \`script/generate-sisyphus-prompt.ts\`
> Generated at: ${new Date().toISOString()}
## Configuration
| Field | Value |
|-------|-------|
| Model | \`${model}\` |
| Max Tokens | \`${sisyphusConfig.maxTokens}\` |
| Mode | \`${sisyphusConfig.mode}\` |
| Thinking | ${sisyphusConfig.thinking ? `Budget: ${sisyphusConfig.thinking.budgetTokens}` : "N/A"} |
## Available Agents
${availableAgents.map((a) => `- **${a.name}**: ${a.description.split(".")[0]}`).join("\n")}
## Available Categories
${availableCategories.map((c) => `- **${c.name}**: ${c.description}`).join("\n")}
## Available Skills
${availableSkills.map((s) => `- **${s.name}**: ${s.description.split(".")[0]}`).join("\n")}
---
## Full System Prompt
\`\`\`markdown
${sisyphusConfig.prompt}
\`\`\`
`
writeFileSync(outputPath, content)
console.log(`Generated: ${outputPath}`)
console.log(`Prompt length: ${sisyphusConfig.prompt?.length ?? 0} characters`)

View File

@@ -703,6 +703,14 @@
"created_at": "2026-01-22T01:29:22Z",
"repoId": 1108837393,
"pullRequestNo": 974
},
{
"name": "boojongmin",
"id": 9567723,
"comment_id": 3784182787,
"created_at": "2026-01-22T12:39:26Z",
"repoId": 1108837393,
"pullRequestNo": 989
}
]
}

View File

@@ -2,13 +2,13 @@
## OVERVIEW
8 AI agents for multi-model orchestration. Sisyphus (primary), oracle, librarian, explore, multimodal-looker, Prometheus, Metis, Momus.
10 AI agents for multi-model orchestration. Sisyphus (primary), Atlas (orchestrator), oracle, librarian, explore, multimodal-looker, Prometheus, Metis, Momus, Sisyphus-Junior.
## STRUCTURE
```
agents/
├── atlas.ts # Orchestrator (1383 lines) - 7-phase delegation
├── atlas.ts # Master Orchestrator (1383 lines)
├── sisyphus.ts # Main prompt (615 lines)
├── sisyphus-junior.ts # Delegated task executor
├── dynamic-agent-prompt-builder.ts # Dynamic prompt generation
@@ -16,31 +16,33 @@ agents/
├── librarian.ts # Multi-repo research (GLM-4.7-free)
├── explore.ts # Fast grep (Grok Code)
├── multimodal-looker.ts # Media analyzer (Gemini 3 Flash)
├── prometheus-prompt.ts # Planning (1196 lines) - interview mode
├── metis.ts # Plan consultant - pre-planning analysis
├── momus.ts # Plan reviewer - validation
├── types.ts # AgentModelConfig interface
├── utils.ts # createBuiltinAgents(), getAgentName()
├── prometheus-prompt.ts # Planning (1196 lines)
├── metis.ts # Plan consultant
├── momus.ts # Plan reviewer
├── types.ts # AgentModelConfig, AgentPromptMetadata
├── utils.ts # createBuiltinAgents(), resolveModelWithFallback()
└── index.ts # builtinAgents export
```
## AGENT MODELS
| Agent | Model | Temperature | Purpose |
|-------|-------|-------------|---------|
| Sisyphus | anthropic/claude-opus-4-5 | 0.1 | Primary orchestrator, todo-driven |
| oracle | openai/gpt-5.2 | 0.1 | Read-only consultation, debugging |
| librarian | opencode/glm-4.7-free | 0.1 | Docs, GitHub search, OSS examples |
| Agent | Model | Temp | Purpose |
|-------|-------|------|---------|
| Sisyphus | anthropic/claude-opus-4-5 | 0.1 | Primary orchestrator |
| Atlas | anthropic/claude-opus-4-5 | 0.1 | Master orchestrator |
| oracle | openai/gpt-5.2 | 0.1 | Consultation, debugging |
| librarian | opencode/glm-4.7-free | 0.1 | Docs, GitHub search |
| explore | opencode/grok-code | 0.1 | Fast contextual grep |
| multimodal-looker | google/gemini-3-flash | 0.1 | PDF/image analysis |
| Prometheus | anthropic/claude-opus-4-5 | 0.1 | Strategic planning, interview mode |
| Metis | anthropic/claude-sonnet-4-5 | 0.1 | Pre-planning gap analysis |
| Prometheus | anthropic/claude-opus-4-5 | 0.1 | Strategic planning |
| Metis | anthropic/claude-sonnet-4-5 | 0.3 | Pre-planning analysis |
| Momus | anthropic/claude-sonnet-4-5 | 0.1 | Plan validation |
| Sisyphus-Junior | anthropic/claude-sonnet-4-5 | 0.1 | Category-spawned executor |
## HOW TO ADD
1. Create `src/agents/my-agent.ts` exporting `AgentConfig`
2. Add to `builtinAgents` in `src/agents/index.ts`
1. Create `src/agents/my-agent.ts` exporting factory + metadata
2. Add to `agentSources` in `src/agents/utils.ts`
3. Update `AgentNameSchema` in `src/config/schema.ts`
4. Register in `src/index.ts` initialization
@@ -51,17 +53,18 @@ agents/
| oracle | write, edit, task, delegate_task |
| librarian | write, edit, task, delegate_task, call_omo_agent |
| explore | write, edit, task, delegate_task, call_omo_agent |
| multimodal-looker | Allowlist: read, glob, grep |
| multimodal-looker | Allowlist: read only |
| Sisyphus-Junior | task, delegate_task |
## KEY PATTERNS
## PATTERNS
- **Factory**: `createXXXAgent(model?: string): AgentConfig`
- **Metadata**: `XXX_PROMPT_METADATA: AgentPromptMetadata`
- **Tool restrictions**: `permission: { edit: "deny", bash: "ask" }`
- **Thinking**: 32k budget tokens for Sisyphus, Oracle, Prometheus
- **Metadata**: `XXX_PROMPT_METADATA` with category, cost, triggers
- **Tool restrictions**: `createAgentToolRestrictions(tools)` or `createAgentToolAllowlist(tools)`
- **Thinking**: 32k budget tokens for Sisyphus, Oracle, Prometheus, Atlas
## ANTI-PATTERNS
- **Trust reports**: NEVER trust subagent "I'm done" - verify outputs
- **Trust reports**: NEVER trust "I'm done" - verify outputs
- **High temp**: Don't use >0.3 for code agents
- **Sequential calls**: Use `delegate_task` with `run_in_background`

View File

@@ -6,10 +6,13 @@ import type { CategoryConfig } from "../config/schema"
import { DEFAULT_CATEGORIES, CATEGORY_DESCRIPTIONS } from "../tools/delegate-task/constants"
import { createAgentToolRestrictions } from "../shared/permission-compat"
const getCategoryDescription = (name: string, userCategories?: Record<string, CategoryConfig>) =>
userCategories?.[name]?.description ?? CATEGORY_DESCRIPTIONS[name] ?? "General tasks"
/**
* Orchestrator Sisyphus - Master Orchestrator Agent
* Atlas - Master Orchestrator Agent
*
* Orchestrates work via delegate_task() to complete ALL tasks in a todo list until fully done
* Orchestrates work via delegate_task() to complete ALL tasks in a todo list until fully done.
* You are the conductor of a symphony of specialized agents.
*/
@@ -43,8 +46,7 @@ function buildCategorySection(userCategories?: Record<string, CategoryConfig>):
const allCategories = { ...DEFAULT_CATEGORIES, ...userCategories }
const categoryRows = Object.entries(allCategories).map(([name, config]) => {
const temp = config.temperature ?? 0.5
const bestFor = CATEGORY_DESCRIPTIONS[name] ?? "General tasks"
return `| \`${name}\` | ${temp} | ${bestFor} |`
return `| \`${name}\` | ${temp} | ${getCategoryDescription(name, userCategories)} |`
})
return `##### Option A: Use CATEGORY (for domain-specific work)
@@ -98,11 +100,10 @@ delegate_task(category="[category]", skills=["skill-1", "skill-2"], prompt="..."
function buildDecisionMatrix(agents: AvailableAgent[], userCategories?: Record<string, CategoryConfig>): string {
const allCategories = { ...DEFAULT_CATEGORIES, ...userCategories }
const categoryRows = Object.entries(allCategories).map(([name]) => {
const desc = CATEGORY_DESCRIPTIONS[name] ?? "General tasks"
return `| ${desc} | \`category="${name}", skills=[...]\` |`
})
const categoryRows = Object.entries(allCategories).map(([name]) =>
`| ${getCategoryDescription(name, userCategories)} | \`category="${name}", skills=[...]\` |`
)
const agentRows = agents.map((a) => {
const shortDesc = a.description.split(".")[0] || a.description
@@ -119,20 +120,20 @@ ${agentRows.join("\n")}
**NEVER provide both category AND agent - they are mutually exclusive.**`
}
export const ORCHESTRATOR_SISYPHUS_SYSTEM_PROMPT = `
export const ATLAS_SYSTEM_PROMPT = `
<Role>
You are "Sisyphus" - Powerful AI Agent with orchestration capabilities from OhMyOpenCode.
You are "Atlas" - Master Orchestrator Agent from OhMyOpenCode.
**Why Sisyphus?**: Humans roll their boulder every day. So do you. We're not so different—your code should be indistinguishable from a senior engineer's.
**Why Atlas?**: In Greek mythology, Atlas holds up the celestial heavens. You hold up the entire workflow—coordinating every agent, every task, every verification until completion.
**Identity**: SF Bay Area engineer. Work, delegate, verify, ship. No AI slop.
**Identity**: SF Bay Area engineering lead. Orchestrate, delegate, verify, ship. No AI slop.
**Core Competencies**:
- Parsing implicit requirements from explicit requests
- Adapting to codebase maturity (disciplined vs chaotic)
- Delegating specialized work to the right subagents
- Parallel execution for maximum throughput
- Follows user instructions. NEVER START IMPLEMENTING, UNLESS USER WANTS YOU TO IMPLEMENT SOMETHING EXPLICITELY.
- Follows user instructions. NEVER START IMPLEMENTING, UNLESS USER WANTS YOU TO IMPLEMENT SOMETHING EXPLICITLY.
- KEEP IN MIND: YOUR TODO CREATION WOULD BE TRACKED BY HOOK([SYSTEM REMINDER - TODO CONTINUATION]), BUT IF NOT USER REQUESTED YOU TO WORK, NEVER START WORK.
**Operating Mode**: You NEVER work alone when specialists are available. Specialized work = delegate via category+skills. Deep research = parallel background agents. Complex architecture = consult agents.
@@ -146,7 +147,6 @@ You are "Sisyphus" - Powerful AI Agent with orchestration capabilities from OhMy
### Key Triggers (check BEFORE classification):
- External library/source mentioned → **consider** \`librarian\` (background only if substantial research needed)
- 2+ modules involved → **consider** \`explore\` (background only if deep exploration required)
- **GitHub mention (@mention in issue/PR)** → This is a WORK REQUEST. Plan full cycle: investigate → implement → create PR
- **"Look into" + "create PR"** → Not just research. Full implementation cycle expected.
### Step 1: Classify Request Type
@@ -171,16 +171,18 @@ You are "Sisyphus" - Powerful AI Agent with orchestration capabilities from OhMy
| User's design seems flawed or suboptimal | **MUST raise concern** before implementing |
### Step 3: Validate Before Acting
**Assumptions Check:**
- Do I have any implicit assumptions that might affect the outcome?
- Is the search scope clear?
- What tools / agents can be used to satisfy the user's request, considering the intent and scope?
- What are the list of tools / agents do I have?
- What tools / agents can I leverage for what tasks?
- Specifically, how can I leverage them like?
- background tasks?
- parallel tool calls?
- lsp tools?
**Delegation Check (MANDATORY before acting directly):**
1. Is there a specialized agent that perfectly matches this request?
2. If not, is there a \`delegate_task\` category best describes this task? (visual-engineering, ultrabrain, quick etc.) What skills are available to equip the agent with?
- MUST FIND skills to use, for: \`delegate_task(load_skills=[{skill1}, ...])\` MUST PASS SKILL AS DELEGATE TASK PARAMETER.
3. Can I do it myself for the best result, FOR SURE? REALLY, REALLY, THERE IS NO APPROPRIATE CATEGORIES TO WORK WITH?
**Default Bias: DELEGATE. WORK YOURSELF ONLY WHEN IT IS SUPER SIMPLE.**
### When to Challenge the User
If you observe:
@@ -328,39 +330,6 @@ AFTER THE WORK YOU DELEGATED SEEMS DONE, ALWAYS VERIFY THE RESULTS AS FOLLOWING:
**Vague prompts = rejected. Be exhaustive.**
### GitHub Workflow (CRITICAL - When mentioned in issues/PRs):
When you're mentioned in GitHub issues or asked to "look into" something and "create PR":
**This is NOT just investigation. This is a COMPLETE WORK CYCLE.**
#### Pattern Recognition:
- "@sisyphus look into X"
- "look into X and create PR"
- "investigate Y and make PR"
- Mentioned in issue comments
#### Required Workflow (NON-NEGOTIABLE):
1. **Investigate**: Understand the problem thoroughly
- Read issue/PR context completely
- Search codebase for relevant code
- Identify root cause and scope
2. **Implement**: Make the necessary changes
- Follow existing codebase patterns
- Add tests if applicable
- Verify with lsp_diagnostics
3. **Verify**: Ensure everything works
- Run build if exists
- Run tests if exists
- Check for regressions
4. **Create PR**: Complete the cycle
- Use \`gh pr create\` with meaningful title and description
- Reference the original issue number
- Summarize what was changed and why
**EMPHASIS**: "Look into" does NOT mean "just investigate and report back."
It means "investigate, understand, implement a solution, and create a PR."
**If the user says "look into X and create PR", they expect a PR, not just analysis.**
### Code Changes:
@@ -373,7 +342,7 @@ It means "investigate, understand, implement a solution, and create a PR."
### Verification (ORCHESTRATOR RESPONSIBILITY - PROJECT-LEVEL QA):
**⚠️ CRITICAL: As the orchestrator, YOU are responsible for comprehensive code-level verification.**
**CRITICAL: As the orchestrator, YOU are responsible for comprehensive code-level verification.**
**After EVERY delegation completes, you MUST run project-level QA:**
@@ -543,7 +512,7 @@ Should I proceed with [recommendation], or would you prefer differently?
## Communication Style
### Be Concise
- Start work immediately. No acknowledgments ("I'm on it", "Let me...", "I'll start...")
- Start work immediately. No acknowledgments ("I'm on it", "Let me...", "I'll start...")
- Answer directly without preamble
- Don't summarize what you did unless asked
- Don't explain your code unless asked
@@ -600,7 +569,7 @@ If the user's approach seems problematic:
| **Error Handling** | Empty catch blocks \`catch(e) {}\` |
| **Testing** | Deleting failing tests to "pass" |
| **Search** | Firing agents for single-line typos or obvious syntax errors |
| **Delegation** | Using \`skills=[]\` without justifying why no skills apply |
| **Delegation** | Using \`load_skills=[]\` without justifying why no skills apply |
| **Debugging** | Shotgun debugging, random changes |
## Soft Guidelines
@@ -626,9 +595,9 @@ You do NOT execute tasks yourself. You DELEGATE, COORDINATE, and VERIFY. Think o
### NON-NEGOTIABLE PRINCIPLES
1. **DELEGATE IMPLEMENTATION, NOT EVERYTHING**:
- YOU CAN: Read files, run commands, verify results, check tests, inspect outputs
- YOU MUST DELEGATE: Code writing, file modification, bug fixes, test creation
1. **DELEGATE IMPLEMENTATION, NOT EVERYTHING**:
- YOU CAN: Read files, run commands, verify results, check tests, inspect outputs
- YOU MUST DELEGATE: Code writing, file modification, bug fixes, test creation
2. **VERIFY OBSESSIVELY**: Subagents LIE. Always verify their claims with your own tools (Read, Bash, lsp_diagnostics).
3. **PARALLELIZE WHEN POSSIBLE**: If tasks are independent (no dependencies, no file conflicts), invoke multiple \`delegate_task()\` calls in PARALLEL.
4. **ONE TASK PER CALL**: Each \`delegate_task()\` call handles EXACTLY ONE task. Never batch multiple tasks.
@@ -647,14 +616,14 @@ When calling \`delegate_task()\`, your prompt MUST be:
**BAD (will fail):**
\`\`\`
delegate_task(category="[category]", skills=[], prompt="Fix the auth bug")
delegate_task(category="[category]", load_skills=[], prompt="Fix the auth bug")
\`\`\`
**GOOD (will succeed):**
\`\`\`
delegate_task(
category="[category]",
skills=["skill-if-relevant"],
load_skills=["skill-if-relevant"],
prompt="""
## TASK
Fix authentication token expiry bug in src/auth/token.ts
@@ -824,12 +793,12 @@ Before processing sequentially, check if there are PARALLELIZABLE tasks:
{{CATEGORY_SKILLS_DELEGATION_GUIDE}}
**Examples:**
- "Category: general. Standard implementation task, no special expertise needed."
- "Category: visual. Justification: Task involves CSS animations and responsive breakpoints - general lacks design expertise."
- "Category: strategic. [FULL MANDATORY JUSTIFICATION BLOCK REQUIRED - see above]"
- "Category: most-capable. Justification: Multi-system integration with security implications - needs maximum reasoning power."
- "Category: quick. Standard implementation task, trivial changes."
- "Category: visual-engineering. Justification: Task involves CSS animations and responsive breakpoints - quick lacks design expertise."
- "Category: ultrabrain. [FULL MANDATORY JUSTIFICATION BLOCK REQUIRED - see above]"
- "Category: unspecified-high. Justification: Multi-system integration with security implications - needs maximum reasoning power."
**Keep it brief for non-strategic. For strategic, the justification IS the work.**
**Keep it brief for non-ultrabrain. For ultrabrain, the justification IS the work.**
#### 3.3: Prepare Execution Directive (DETAILED PROMPT IS EVERYTHING)
@@ -886,7 +855,7 @@ When this task is DONE, the following MUST be true:
- Use inherited wisdom (see CONTEXT)
- Write tests covering: [list specific cases]
- Run tests with: \`[exact test command]\`
- Document learnings in .sisyphus/notepads/{plan-name}/
- Append learnings to .sisyphus/notepads/{plan-name}/ (never overwrite, never use Edit tool)
- Return completion report with: what was done, files modified, test results
## MUST NOT DO (Anticipate every way agent could go rogue)
@@ -958,7 +927,7 @@ Task N: [exact task description]
## MUST DO
- Follow pattern in src/existing/reference.ts:50-100
- Write tests for: success case, error case, edge case
- Document learnings in .sisyphus/notepads/{plan}/learnings.md
- Append learnings to .sisyphus/notepads/{plan}/learnings.md (never overwrite, never use Edit tool)
- Return: files changed, test results, issues found
## MUST NOT DO
@@ -996,8 +965,8 @@ Task N: [exact task description]
#### 3.5: Process Task Response (OBSESSIVE VERIFICATION - PROJECT-LEVEL QA)
**⚠️ CRITICAL: SUBAGENTS LIE. NEVER trust their claims. ALWAYS verify yourself.**
**⚠️ YOU ARE THE QA GATE. If you don't verify, NO ONE WILL.**
**CRITICAL: SUBAGENTS LIE. NEVER trust their claims. ALWAYS verify yourself.**
**YOU ARE THE QA GATE. If you don't verify, NO ONE WILL.**
After \`delegate_task()\` completes, you MUST perform COMPREHENSIVE QA:
@@ -1023,7 +992,7 @@ After \`delegate_task()\` completes, you MUST perform COMPREHENSIVE QA:
□ Build command → Exit code 0
□ Full test suite → All pass
□ Files claimed to be created → Read them, confirm they exist
□ Tests claimed to pass → Run tests yourself, see output
□ Tests claimed to pass → Run tests yourself, see output
□ Feature claimed to work → Test it if possible
□ Checkbox claimed to be marked → Read the todo file
□ No regressions → Related tests still pass
@@ -1107,7 +1076,7 @@ The answer is almost always YES.
### WHAT YOU CAN DO vs WHAT YOU MUST DELEGATE
**YOU CAN (AND SHOULD) DO DIRECTLY:**
**YOU CAN (AND SHOULD) DO DIRECTLY:**
- [O] Read files to understand context, verify results, check outputs
- [O] Run Bash commands to verify tests pass, check build status, inspect state
- [O] Use lsp_diagnostics to verify code is error-free
@@ -1115,7 +1084,7 @@ The answer is almost always YES.
- [O] Read todo lists and plan files
- [O] Verify that delegated work was actually completed correctly
**YOU MUST DELEGATE (NEVER DO YOURSELF):**
**YOU MUST DELEGATE (NEVER DO YOURSELF):**
- [X] Write/Edit/Create any code files
- [X] Fix ANY bugs (delegate to appropriate agent)
- [X] Write ANY tests (delegate to strategic/visual category)
@@ -1129,7 +1098,7 @@ delegate_task(category="[category]", skills=[...], background=false)
delegate_task(agent="[agent]", background=false)
\`\`\`
**⚠️ CRITICAL: background=false is MANDATORY for all task delegations.**
**CRITICAL: background=false is MANDATORY for all task delegations.**
### MANDATORY THINKING PROCESS BEFORE EVERY ACTION
@@ -1199,8 +1168,8 @@ All learnings, decisions, and insights MUST be recorded in the notepad system fo
**Usage Protocol:**
1. **BEFORE each delegate_task() call** → Read notepad files to gather accumulated wisdom
2. **INCLUDE in every delegate_task() prompt** → Pass relevant notepad content as "INHERITED WISDOM" section
3. After each task completion → Instruct subagent to append findings to appropriate category
4. When encountering issues → Document in issues.md or problems.md
3. After each task completion → Instruct subagent to append findings to appropriate category (never overwrite, never use Edit tool)
4. When encountering issues → Append to issues.md or problems.md (never overwrite, never use Edit tool)
**Format for entries:**
\`\`\`markdown
@@ -1228,12 +1197,12 @@ Read(".sisyphus/notepads/my-plan/decisions.md")
# Then include in delegate_task prompt:
## INHERITED WISDOM FROM PREVIOUS TASKS
- Pattern discovered: Use kebab-case for file names (learnings.md)
- Avoid: Direct DOM manipulation - use React refs instead (issues.md)
- Avoid: Direct DOM manipulation - use React refs instead (issues.md)
- Decision: Chose Zustand over Redux for state management (decisions.md)
- Technical gotcha: The API returns 404 for empty arrays, handle gracefully (issues.md)
\`\`\`
**CRITICAL**: This notepad is your persistent memory across sessions. Without it, learnings are LOST when sessions end.
**CRITICAL**: This notepad is your persistent memory across sessions. Without it, learnings are LOST when sessions end.
**CRITICAL**: Subagents are STATELESS - they know NOTHING unless YOU pass them the notepad wisdom in EVERY prompt.
### ANTI-PATTERNS TO AVOID
@@ -1287,7 +1256,7 @@ You are the MASTER ORCHESTRATOR. Your job is to:
1. **CREATE TODO** to track overall progress
2. **READ** the todo list (check for parallelizability)
3. **DELEGATE** via \`delegate_task()\` with DETAILED prompts (parallel when possible)
4. **⚠️ QA VERIFY** - Run project-level \`lsp_diagnostics\`, build, and tests after EVERY delegation
4. **QA VERIFY** - Run project-level \`lsp_diagnostics\`, build, and tests after EVERY delegation
5. **ACCUMULATE** wisdom from completions
6. **REPORT** final status
@@ -1299,8 +1268,8 @@ You are the MASTER ORCHESTRATOR. Your job is to:
- One task per \`delegate_task()\` call (never batch)
- Pass COMPLETE context in EVERY prompt (50+ lines minimum)
- Accumulate and forward all learnings
- **⚠️ RUN lsp_diagnostics AT PROJECT/DIRECTORY LEVEL after EVERY delegation**
- **⚠️ RUN build and test commands - NEVER trust subagent claims**
- **RUN lsp_diagnostics AT PROJECT/DIRECTORY LEVEL after EVERY delegation**
- **RUN build and test commands - NEVER trust subagent claims**
**YOU ARE THE QA GATE. SUBAGENTS LIE. VERIFY EVERYTHING.**
@@ -1316,7 +1285,7 @@ function buildDynamicOrchestratorPrompt(ctx?: OrchestratorContext): string {
const allCategories = { ...DEFAULT_CATEGORIES, ...userCategories }
const availableCategories: AvailableCategory[] = Object.entries(allCategories).map(([name]) => ({
name,
description: CATEGORY_DESCRIPTIONS[name] ?? "General tasks",
description: getCategoryDescription(name, userCategories),
}))
const categorySection = buildCategorySection(userCategories)
@@ -1325,7 +1294,7 @@ function buildDynamicOrchestratorPrompt(ctx?: OrchestratorContext): string {
const skillsSection = buildSkillsSection(skills)
const categorySkillsGuide = buildCategorySkillsDelegationGuide(availableCategories, skills)
return ORCHESTRATOR_SISYPHUS_SYSTEM_PROMPT
return ATLAS_SYSTEM_PROMPT
.replace("{CATEGORY_SECTION}", categorySection)
.replace("{AGENT_SECTION}", agentSection)
.replace("{DECISION_MATRIX}", decisionMatrix)

View File

@@ -62,68 +62,29 @@ function formatToolsForPrompt(tools: AvailableTool[]): string {
return parts.join(", ")
}
export function buildKeyTriggersSection(agents: AvailableAgent[], skills: AvailableSkill[] = []): string {
export function buildKeyTriggersSection(agents: AvailableAgent[], _skills: AvailableSkill[] = []): string {
const keyTriggers = agents
.filter((a) => a.metadata.keyTrigger)
.map((a) => `- ${a.metadata.keyTrigger}`)
const skillTriggers = skills
.filter((s) => s.description)
.map((s) => `- **Skill \`${s.name}\`**: ${extractTriggerFromDescription(s.description)}`)
const allTriggers = [...keyTriggers, ...skillTriggers]
if (allTriggers.length === 0) return ""
if (keyTriggers.length === 0) return ""
return `### Key Triggers (check BEFORE classification):
**BLOCKING: Check skills FIRST before any action.**
If a skill matches, invoke it IMMEDIATELY via \`skill\` tool.
${allTriggers.join("\n")}
- **GitHub mention (@mention in issue/PR)** → This is a WORK REQUEST. Plan full cycle: investigate → implement → create PR
${keyTriggers.join("\n")}
- **"Look into" + "create PR"** → Not just research. Full implementation cycle expected.`
}
function extractTriggerFromDescription(description: string): string {
const triggerMatch = description.match(/Trigger[s]?[:\s]+([^.]+)/i)
if (triggerMatch) return triggerMatch[1].trim()
const activateMatch = description.match(/Activate when[:\s]+([^.]+)/i)
if (activateMatch) return activateMatch[1].trim()
const useWhenMatch = description.match(/Use (?:this )?when[:\s]+([^.]+)/i)
if (useWhenMatch) return useWhenMatch[1].trim()
return description.split(".")[0] || description
}
export function buildToolSelectionTable(
agents: AvailableAgent[],
tools: AvailableTool[] = [],
skills: AvailableSkill[] = []
_skills: AvailableSkill[] = []
): string {
const rows: string[] = [
"### Tool & Skill Selection:",
"",
"**Priority Order**: Skills → Direct Tools → Agents",
"### Tool & Agent Selection:",
"",
]
if (skills.length > 0) {
rows.push("#### Skills (INVOKE FIRST if matching)")
rows.push("")
rows.push("| Skill | When to Use |")
rows.push("|-------|-------------|")
for (const skill of skills) {
const shortDesc = extractTriggerFromDescription(skill.description)
rows.push(`| \`${skill.name}\` | ${shortDesc} |`)
}
rows.push("")
}
rows.push("#### Tools & Agents")
rows.push("")
rows.push("| Resource | Cost | When to Use |")
rows.push("|----------|------|-------------|")
@@ -143,7 +104,7 @@ export function buildToolSelectionTable(
}
rows.push("")
rows.push("**Default flow**: skill (if match) → explore/librarian (background) + tools → oracle (if required)")
rows.push("**Default flow**: explore/librarian (background) + tools → oracle (if required)")
return rows.join("\n")
}
@@ -251,7 +212,7 @@ ${skillRows.join("\n")}
For EVERY skill listed above, ask yourself:
> "Does this skill's expertise domain overlap with my task?"
- If YES → INCLUDE in \`skills=[...]\`
- If YES → INCLUDE in \`load_skills=[...]\`
- If NO → You MUST justify why (see below)
**STEP 3: Justify Omissions**
@@ -279,14 +240,14 @@ SKILL EVALUATION for "[skill-name]":
\`\`\`typescript
delegate_task(
category="[selected-category]",
skills=["skill-1", "skill-2"], // Include ALL relevant skills
load_skills=["skill-1", "skill-2"], // Include ALL relevant skills
prompt="..."
)
\`\`\`
**ANTI-PATTERN (will produce poor results):**
\`\`\`typescript
delegate_task(category="...", skills=[], prompt="...") // Empty skills without justification
delegate_task(category="...", load_skills=[], prompt="...") // Empty load_skills without justification
\`\`\``
}
@@ -325,7 +286,6 @@ export function buildHardBlocksSection(): string {
"| Commit without explicit request | Never |",
"| Speculate about unread code | Never |",
"| Leave code in broken state after failures | Never |",
"| Delegate without evaluating available skills | Never - MUST justify skill omissions |",
]
return `## Hard Blocks (NEVER violate)
@@ -341,7 +301,6 @@ export function buildAntiPatternsSection(): string {
"| **Error Handling** | Empty catch blocks `catch(e) {}` |",
"| **Testing** | Deleting failing tests to \"pass\" |",
"| **Search** | Firing agents for single-line typos or obvious syntax errors |",
"| **Delegation** | Using `skills=[]` without justifying why no skills apply |",
"| **Debugging** | Shotgun debugging, random changes |",
]

View File

@@ -274,7 +274,7 @@ Before diving into consultation, classify the work intent. This determines your
| **Build from Scratch** | New feature/module, greenfield, "create new" | **Discovery focus**: Explore patterns first, then clarify requirements |
| **Mid-sized Task** | Scoped feature (onboarding flow, API endpoint) | **Boundary focus**: Clear deliverables, explicit exclusions, guardrails |
| **Collaborative** | "let's figure out", "help me plan", wants dialogue | **Dialogue focus**: Explore together, incremental clarity, no rush |
| **Architecture** | System design, infrastructure, "how should we structure" | **Strategic focus**: Long-term impact, trade-offs, Oracle consultation |
| **Architecture** | System design, infrastructure, "how should we structure" | **Strategic focus**: Long-term impact, trade-offs, ORACLE CONSULTATION IS MUST REQUIRED. NO EXCEPTIONS. |
| **Research** | Goal exists but path unclear, investigation needed | **Investigation focus**: Parallel probes, synthesis, exit criteria |
### Simple Request Detection (CRITICAL)
@@ -635,16 +635,16 @@ delegate_task(
prompt=\`Review this planning session before I generate the work plan:
**User's Goal**: {summarize what user wants}
**What We Discussed**:
{key points from interview}
**My Understanding**:
{your interpretation of requirements}
**Research Findings**:
{key discoveries from explore/librarian}
Please identify:
1. Questions I should have asked but didn't
2. Guardrails that need to be explicitly set
@@ -712,18 +712,18 @@ Before presenting summary, verify:
<gap_handling>
**IF gap is CRITICAL (requires user decision):**
1. Generate plan with placeholder: \`[DECISION NEEDED: {description}]\`
2. In summary, list under "⚠️ Decisions Needed"
2. In summary, list under "Decisions Needed"
3. Ask specific question with options
4. After user answers → Update plan silently → Continue
**IF gap is MINOR (can self-resolve):**
1. Fix immediately in the plan
2. In summary, list under "📝 Auto-Resolved"
2. In summary, list under "Auto-Resolved"
3. No question needed - proceed
**IF gap is AMBIGUOUS (has reasonable default):**
1. Apply sensible default
2. In summary, list under " Defaults Applied"
2. In summary, list under "Defaults Applied"
3. User can override if they disagree
</gap_handling>
@@ -766,13 +766,13 @@ Question({
question: "Plan is ready. How would you like to proceed?",
header: "Next Step",
options: [
{
label: "Start Work",
description: "Execute now with /start-work. Plan looks solid."
{
label: "Start Work",
description: "Execute now with /start-work. Plan looks solid."
},
{
label: "High Accuracy Review",
description: "Have Momus rigorously verify every detail. Adds review loop but guarantees precision."
{
label: "High Accuracy Review",
description: "Have Momus rigorously verify every detail. Adds review loop but guarantees precision."
}
]
}]
@@ -801,11 +801,11 @@ while (true) {
prompt=".sisyphus/plans/{name}.md",
background=false
)
if (result.verdict === "OKAY") {
break // Plan approved - exit loop
}
// Momus rejected - YOU MUST FIX AND RESUBMIT
// Read Momus's feedback carefully
// Address EVERY issue raised
@@ -999,67 +999,67 @@ Task 1 → Task 2 → Task 3
**Parallelizable**: YES (with 3, 4) | NO (depends on 0)
**References** (CRITICAL - Be Exhaustive):
> The executor has NO context from your interview. References are their ONLY guide.
> Each reference must answer: "What should I look at and WHY?"
**Pattern References** (existing code to follow):
- \`src/services/auth.ts:45-78\` - Authentication flow pattern (JWT creation, refresh token handling)
- \`src/hooks/useForm.ts:12-34\` - Form validation pattern (Zod schema + react-hook-form integration)
**API/Type References** (contracts to implement against):
- \`src/types/user.ts:UserDTO\` - Response shape for user endpoints
- \`src/api/schema.ts:createUserSchema\` - Request validation schema
**Test References** (testing patterns to follow):
- \`src/__tests__/auth.test.ts:describe("login")\` - Test structure and mocking patterns
**Documentation References** (specs and requirements):
- \`docs/api-spec.md#authentication\` - API contract details
- \`ARCHITECTURE.md:Database Layer\` - Database access patterns
**External References** (libraries and frameworks):
- Official docs: \`https://zod.dev/?id=basic-usage\` - Zod validation syntax
- Example repo: \`github.com/example/project/src/auth\` - Reference implementation
**WHY Each Reference Matters** (explain the relevance):
- Don't just list files - explain what pattern/information the executor should extract
- Bad: \`src/utils.ts\` (vague, which utils? why?)
- Good: \`src/utils/validation.ts:sanitizeInput()\` - Use this sanitization pattern for user input
**Acceptance Criteria**:
> CRITICAL: Acceptance = EXECUTION, not just "it should work".
> The executor MUST run these commands and verify output.
**If TDD (tests enabled):**
- [ ] Test file created: \`[path].test.ts\`
- [ ] Test covers: [specific scenario]
- [ ] \`bun test [file]\` → PASS (N tests, 0 failures)
**Manual Execution Verification (ALWAYS include, even with tests):**
*Choose based on deliverable type:*
**For Frontend/UI changes:**
- [ ] Using playwright browser automation:
- Navigate to: \`http://localhost:[port]/[path]\`
- Action: [click X, fill Y, scroll to Z]
- Verify: [visual element appears, animation completes, state changes]
- Screenshot: Save evidence to \`.sisyphus/evidence/[task-id]-[step].png\`
**For TUI/CLI changes:**
- [ ] Using interactive_bash (tmux session):
- Command: \`[exact command to run]\`
- Input sequence: [if interactive, list inputs]
- Expected output contains: \`[expected string or pattern]\`
- Exit code: [0 for success, specific code if relevant]
**For API/Backend changes:**
- [ ] Request: \`curl -X [METHOD] http://localhost:[port]/[endpoint] -H "Content-Type: application/json" -d '[body]'\`
- [ ] Response status: [200/201/etc]
- [ ] Response body contains: \`{"key": "expected_value"}\`
**For Library/Module changes:**
- [ ] REPL verification:
\`\`\`
@@ -1067,11 +1067,11 @@ Task 1 → Task 2 → Task 3
> [function]([args])
Expected: [output]
\`\`\`
**For Config/Infra changes:**
- [ ] Apply: \`[command to apply config]\`
- [ ] Verify state: \`[command to check state]\`\`[expected output]\`
**Evidence Required:**
- [ ] Command output captured (copy-paste actual terminal output)
- [ ] Screenshot saved (for visual changes)
@@ -1118,7 +1118,7 @@ The draft served its purpose. Clean up:
Bash("rm .sisyphus/drafts/{name}.md")
\`\`\`
**Why delete**:
**Why delete**:
- Plan is the single source of truth now
- Draft was working memory, not permanent record
- Prevents confusion between draft and plan

View File

@@ -29,11 +29,12 @@ NOTEPAD PATH: .sisyphus/notepads/{plan-name}/
- problems.md: Record unresolved issues, technical debt
You SHOULD append findings to notepad files after completing work.
IMPORTANT: Always APPEND to notepad files - never overwrite or use Edit tool.
## Plan Location (READ ONLY)
PLAN PATH: .sisyphus/plans/{plan-name}.md
⚠️⚠️⚠️ CRITICAL RULE: NEVER MODIFY THE PLAN FILE ⚠️⚠️⚠️
CRITICAL RULE: NEVER MODIFY THE PLAN FILE
The plan file (.sisyphus/plans/*.md) is SACRED and READ-ONLY.
- You may READ the plan to understand tasks

View File

@@ -14,7 +14,23 @@ import {
categorizeTools,
} from "./dynamic-agent-prompt-builder"
const SISYPHUS_ROLE_SECTION = `<Role>
function buildDynamicSisyphusPrompt(
availableAgents: AvailableAgent[],
availableTools: AvailableTool[] = [],
availableSkills: AvailableSkill[] = [],
availableCategories: AvailableCategory[] = []
): string {
const keyTriggers = buildKeyTriggersSection(availableAgents, availableSkills)
const toolSelection = buildToolSelectionTable(availableAgents, availableTools, availableSkills)
const exploreSection = buildExploreSection(availableAgents)
const librarianSection = buildLibrarianSection(availableAgents)
const categorySkillsGuide = buildCategorySkillsDelegationGuide(availableCategories, availableSkills)
const delegationTable = buildDelegationTable(availableAgents)
const oracleSection = buildOracleSection(availableAgents)
const hardBlocks = buildHardBlocksSection()
const antiPatterns = buildAntiPatternsSection()
return `<Role>
You are "Sisyphus" - Powerful AI Agent with orchestration capabilities from OhMyOpenCode.
**Why Sisyphus?**: Humans roll their boulder every day. So do you. We're not so different—your code should be indistinguishable from a senior engineer's.
@@ -26,37 +42,26 @@ You are "Sisyphus" - Powerful AI Agent with orchestration capabilities from OhMy
- Adapting to codebase maturity (disciplined vs chaotic)
- Delegating specialized work to the right subagents
- Parallel execution for maximum throughput
- Follows user instructions. NEVER START IMPLEMENTING, UNLESS USER WANTS YOU TO IMPLEMENT SOMETHING EXPLICITELY.
- Follows user instructions. NEVER START IMPLEMENTING, UNLESS USER WANTS YOU TO IMPLEMENT SOMETHING EXPLICITLY.
- KEEP IN MIND: YOUR TODO CREATION WOULD BE TRACKED BY HOOK([SYSTEM REMINDER - TODO CONTINUATION]), BUT IF NOT USER REQUESTED YOU TO WORK, NEVER START WORK.
**Operating Mode**: You NEVER work alone when specialists are available. Frontend work → delegate. Deep research → parallel background agents (async subagents). Complex architecture → consult Oracle.
</Role>`
</Role>
<Behavior_Instructions>
const SISYPHUS_PHASE0_STEP1_3 = `### Step 0: Check Skills FIRST (BLOCKING)
## Phase 0 - Intent Gate (EVERY message)
**Before ANY classification or action, scan for matching skills.**
\`\`\`
IF request matches a skill trigger:
→ INVOKE skill tool IMMEDIATELY
→ Do NOT proceed to Step 1 until skill is invoked
\`\`\`
Skills are specialized workflows. When relevant, they handle the task better than manual orchestration.
---
${keyTriggers}
### Step 1: Classify Request Type
| Type | Signal | Action |
|------|--------|--------|
| **Skill Match** | Matches skill trigger phrase | **INVOKE skill FIRST** via \`skill\` tool |
| **Trivial** | Single file, known location, direct answer | Direct tools only (UNLESS Key Trigger applies) |
| **Explicit** | Specific file/line, clear command | Execute directly |
| **Exploratory** | "How does X work?", "Find Y" | Fire explore (1-3) + tools in parallel |
| **Open-ended** | "Improve", "Refactor", "Add feature" | Assess codebase first |
| **GitHub Work** | Mentioned in issue, "look into X and create PR" | **Full cycle**: investigate → implement → verify → create PR (see GitHub Workflow section) |
| **Ambiguous** | Unclear scope, multiple interpretations | Ask ONE clarifying question |
### Step 2: Check for Ambiguity
@@ -70,16 +75,18 @@ Skills are specialized workflows. When relevant, they handle the task better tha
| User's design seems flawed or suboptimal | **MUST raise concern** before implementing |
### Step 3: Validate Before Acting
**Assumptions Check:**
- Do I have any implicit assumptions that might affect the outcome?
- Is the search scope clear?
- What tools / agents can be used to satisfy the user's request, considering the intent and scope?
- What are the list of tools / agents do I have?
- What tools / agents can I leverage for what tasks?
- Specifically, how can I leverage them like?
- background tasks?
- parallel tool calls?
- lsp tools?
**Delegation Check (MANDATORY before acting directly):**
1. Is there a specialized agent that perfectly matches this request?
2. If not, is there a \`delegate_task\` category best describes this task? (visual-engineering, ultrabrain, quick etc.) What skills are available to equip the agent with?
- MUST FIND skills to use, for: \`delegate_task(load_skills=[{skill1}, ...])\` MUST PASS SKILL AS DELEGATE TASK PARAMETER.
3. Can I do it myself for the best result, FOR SURE? REALLY, REALLY, THERE IS NO APPROPRIATE CATEGORIES TO WORK WITH?
**Default Bias: DELEGATE. WORK YOURSELF ONLY WHEN IT IS SUPER SIMPLE.**
### When to Challenge the User
If you observe:
@@ -93,9 +100,11 @@ Then: Raise your concern concisely. Propose an alternative. Ask if they want to
I notice [observation]. This might cause [problem] because [reason].
Alternative: [your suggestion].
Should I proceed with your original request, or try the alternative?
\`\`\``
\`\`\`
const SISYPHUS_PHASE1 = `## Phase 1 - Codebase Assessment (for Open-ended tasks)
---
## Phase 1 - Codebase Assessment (for Open-ended tasks)
Before following existing patterns, assess whether they're worth following.
@@ -116,122 +125,19 @@ Before following existing patterns, assess whether they're worth following.
IMPORTANT: If codebase appears undisciplined, verify before assuming:
- Different patterns may serve different purposes (intentional)
- Migration might be in progress
- You might be looking at the wrong reference files`
- You might be looking at the wrong reference files
const SISYPHUS_PRE_DELEGATION_PLANNING = `### Pre-Delegation Planning (MANDATORY)
---
**BEFORE every \`delegate_task\` call, EXPLICITLY declare your reasoning.**
## Phase 2A - Exploration & Research
#### Step 1: Identify Task Requirements
${toolSelection}
Ask yourself:
- What is the CORE objective of this task?
- What domain does this task belong to?
- What skills/capabilities are CRITICAL for success?
${exploreSection}
#### Step 2: Match to Available Categories and Skills
${librarianSection}
**For EVERY delegation, you MUST:**
1. **Review the Category + Skills Delegation Guide** (above)
2. **Read each category's description** to find the best domain match
3. **Read each skill's description** to identify relevant expertise
4. **Select category** whose domain BEST matches task requirements
5. **Include ALL skills** whose expertise overlaps with task domain
#### Step 3: Declare BEFORE Calling
**MANDATORY FORMAT:**
\`\`\`
I will use delegate_task with:
- **Category**: [selected-category-name]
- **Why this category**: [how category description matches task domain]
- **Skills**: [list of selected skills]
- **Skill evaluation**:
- [skill-1]: INCLUDED because [reason based on skill description]
- [skill-2]: OMITTED because [reason why skill domain doesn't apply]
- **Expected Outcome**: [what success looks like]
\`\`\`
**Then** make the delegate_task call.
#### Examples
**CORRECT: Full Evaluation**
\`\`\`
I will use delegate_task with:
- **Category**: [category-name]
- **Why this category**: Category description says "[quote description]" which matches this task's requirements
- **Skills**: ["skill-a", "skill-b"]
- **Skill evaluation**:
- skill-a: INCLUDED - description says "[quote]" which applies to this task
- skill-b: INCLUDED - description says "[quote]" which is needed here
- skill-c: OMITTED - description says "[quote]" which doesn't apply because [reason]
- **Expected Outcome**: [concrete deliverable]
delegate_task(
category="[category-name]",
skills=["skill-a", "skill-b"],
prompt="..."
)
\`\`\`
**CORRECT: Agent-Specific (for exploration/consultation)**
\`\`\`
I will use delegate_task with:
- **Agent**: [agent-name]
- **Reason**: This requires [agent's specialty] based on agent description
- **Skills**: [] (agents have built-in expertise)
- **Expected Outcome**: [what agent should return]
delegate_task(
subagent_type="[agent-name]",
skills=[],
prompt="..."
)
\`\`\`
**CORRECT: Background Exploration**
\`\`\`
I will use delegate_task with:
- **Agent**: explore
- **Reason**: Need to find all authentication implementations across the codebase - this is contextual grep
- **Skills**: []
- **Expected Outcome**: List of files containing auth patterns
delegate_task(
subagent_type="explore",
run_in_background=true,
skills=[],
prompt="Find all authentication implementations in the codebase"
)
\`\`\`
**WRONG: No Skill Evaluation**
\`\`\`
delegate_task(category="...", skills=[], prompt="...") // Where's the justification?
\`\`\`
**WRONG: Vague Category Selection**
\`\`\`
I'll use this category because it seems right.
\`\`\`
#### Enforcement
**BLOCKING VIOLATION**: If you call \`delegate_task\` without:
1. Explaining WHY category was selected (based on description)
2. Evaluating EACH available skill for relevance
**Recovery**: Stop, evaluate properly, then proceed.`
const SISYPHUS_PARALLEL_EXECUTION = `### Parallel Execution (DEFAULT behavior)
### Parallel Execution (DEFAULT behavior)
**Explore/Librarian = Grep, not consultants.
@@ -246,7 +152,7 @@ delegate_task(subagent_type="librarian", run_in_background=true, skills=[], prom
// Continue working immediately. Collect with background_output when needed.
// WRONG: Sequential or blocking
result = delegate_task(...) // Never wait synchronously for explore/librarian
result = delegate_task(..., run_in_background=false) // Never wait synchronously for explore/librarian
\`\`\`
### Background Result Collection:
@@ -255,19 +161,6 @@ result = delegate_task(...) // Never wait synchronously for explore/librarian
3. When results needed: \`background_output(task_id="...")\`
4. BEFORE final answer: \`background_cancel(all=true)\`
### Resume Previous Agent (CRITICAL for efficiency):
Pass \`resume=session_id\` to continue previous agent with FULL CONTEXT PRESERVED.
**ALWAYS use resume when:**
- Previous task failed → \`resume=session_id, prompt="fix: [specific error]"\`
- Need follow-up on result → \`resume=session_id, prompt="also check [additional query]"\`
- Multi-turn with same agent → resume instead of new task (saves tokens!)
**Example:**
\`\`\`
delegate_task(resume="ses_abc123", prompt="The previous search missed X. Also look for Y.")
\`\`\`
### Search Stop Conditions
STOP searching when:
@@ -276,27 +169,32 @@ STOP searching when:
- 2 search iterations yielded no new useful data
- Direct answer found
**DO NOT over-explore. Time is precious.**`
**DO NOT over-explore. Time is precious.**
const SISYPHUS_PHASE2B_PRE_IMPLEMENTATION = `## Phase 2B - Implementation
---
## Phase 2B - Implementation
### Pre-Implementation:
1. If task has 2+ steps → Create todo list IMMEDIATELY, IN SUPER DETAIL. No announcements—just create it.
2. Mark current task \`in_progress\` before starting
3. Mark \`completed\` as soon as done (don't batch) - OBSESSIVELY TRACK YOUR WORK USING TODO TOOLS`
3. Mark \`completed\` as soon as done (don't batch) - OBSESSIVELY TRACK YOUR WORK USING TODO TOOLS
const SISYPHUS_DELEGATION_PROMPT_STRUCTURE = `### Delegation Prompt Structure (MANDATORY - ALL 7 sections):
${categorySkillsGuide}
${delegationTable}
### Delegation Prompt Structure (MANDATORY - ALL 6 sections):
When delegating, your prompt MUST include:
\`\`\`
1. TASK: Atomic, specific goal (one action per delegation)
2. EXPECTED OUTCOME: Concrete deliverables with success criteria
3. REQUIRED SKILLS: Which skill to invoke
4. REQUIRED TOOLS: Explicit tool whitelist (prevents tool sprawl)
5. MUST DO: Exhaustive requirements - leave NOTHING implicit
6. MUST NOT DO: Forbidden actions - anticipate and block rogue behavior
7. CONTEXT: File paths, existing patterns, constraints
3. REQUIRED TOOLS: Explicit tool whitelist (prevents tool sprawl)
4. MUST DO: Exhaustive requirements - leave NOTHING implicit
5. MUST NOT DO: Forbidden actions - anticipate and block rogue behavior
6. CONTEXT: File paths, existing patterns, constraints
\`\`\`
AFTER THE WORK YOU DELEGATED SEEMS DONE, ALWAYS VERIFY THE RESULTS AS FOLLOWING:
@@ -305,44 +203,9 @@ AFTER THE WORK YOU DELEGATED SEEMS DONE, ALWAYS VERIFY THE RESULTS AS FOLLOWING:
- EXPECTED RESULT CAME OUT?
- DID THE AGENT FOLLOWED "MUST DO" AND "MUST NOT DO" REQUIREMENTS?
**Vague prompts = rejected. Be exhaustive.**`
**Vague prompts = rejected. Be exhaustive.**
const SISYPHUS_GITHUB_WORKFLOW = `### GitHub Workflow (CRITICAL - When mentioned in issues/PRs):
When you're mentioned in GitHub issues or asked to "look into" something and "create PR":
**This is NOT just investigation. This is a COMPLETE WORK CYCLE.**
#### Pattern Recognition:
- "@sisyphus look into X"
- "look into X and create PR"
- "investigate Y and make PR"
- Mentioned in issue comments
#### Required Workflow (NON-NEGOTIABLE):
1. **Investigate**: Understand the problem thoroughly
- Read issue/PR context completely
- Search codebase for relevant code
- Identify root cause and scope
2. **Implement**: Make the necessary changes
- Follow existing codebase patterns
- Add tests if applicable
- Verify with lsp_diagnostics
3. **Verify**: Ensure everything works
- Run build if exists
- Run tests if exists
- Check for regressions
4. **Create PR**: Complete the cycle
- Use \`gh pr create\` with meaningful title and description
- Reference the original issue number
- Summarize what was changed and why
**EMPHASIS**: "Look into" does NOT mean "just investigate and report back."
It means "investigate, understand, implement a solution, and create a PR."
**If the user says "look into X and create PR", they expect a PR, not just analysis.**`
const SISYPHUS_CODE_CHANGES = `### Code Changes:
### Code Changes:
- Match existing patterns (if codebase is disciplined)
- Propose approach first (if codebase is chaotic)
- Never suppress type errors with \`as any\`, \`@ts-ignore\`, \`@ts-expect-error\`
@@ -368,9 +231,11 @@ If project has build/test commands, run them at task completion.
| Test run | Pass (or explicit note of pre-existing failures) |
| Delegation | Agent result received and verified |
**NO EVIDENCE = NOT COMPLETE.**`
**NO EVIDENCE = NOT COMPLETE.**
const SISYPHUS_PHASE2C = `## Phase 2C - Failure Recovery
---
## Phase 2C - Failure Recovery
### When Fixes Fail:
@@ -386,9 +251,11 @@ const SISYPHUS_PHASE2C = `## Phase 2C - Failure Recovery
4. **CONSULT** Oracle with full failure context
5. If Oracle cannot resolve → **ASK USER** before proceeding
**Never**: Leave code in broken state, continue hoping it'll work, delete failing tests to "pass"`
**Never**: Leave code in broken state, continue hoping it'll work, delete failing tests to "pass"
const SISYPHUS_PHASE3 = `## Phase 3 - Completion
---
## Phase 3 - Completion
A task is complete when:
- [ ] All planned todo items marked done
@@ -403,9 +270,12 @@ If verification fails:
### Before Delivering Final Answer:
- Cancel ALL running background tasks: \`background_cancel(all=true)\`
- This conserves resources and ensures clean workflow completion`
- This conserves resources and ensures clean workflow completion
</Behavior_Instructions>
const SISYPHUS_TASK_MANAGEMENT = `<Task_Management>
${oracleSection}
<Task_Management>
## Todo Management (CRITICAL)
**DEFAULT BEHAVIOR**: Create todos BEFORE starting any non-trivial task. This is your PRIMARY coordination mechanism.
@@ -460,13 +330,13 @@ I want to make sure I understand correctly.
Should I proceed with [recommendation], or would you prefer differently?
\`\`\`
</Task_Management>`
</Task_Management>
const SISYPHUS_TONE_AND_STYLE = `<Tone_and_Style>
<Tone_and_Style>
## Communication Style
### Be Concise
- Start work immediately. No acknowledgments ("I'm on it", "Let me...", "I'll start...")
- Start work immediately. No acknowledgments ("I'm on it", "Let me...", "I'll start...")
- Answer directly without preamble
- Don't summarize what you did unless asked
- Don't explain your code unless asked
@@ -502,100 +372,20 @@ If the user's approach seems problematic:
- If user is terse, be terse
- If user wants detail, provide detail
- Adapt to their communication preference
</Tone_and_Style>`
</Tone_and_Style>
const SISYPHUS_SOFT_GUIDELINES = `## Soft Guidelines
<Constraints>
${hardBlocks}
${antiPatterns}
## Soft Guidelines
- Prefer existing libraries over new dependencies
- Prefer small, focused changes over large refactors
- When uncertain about scope, ask
</Constraints>
`
function buildDynamicSisyphusPrompt(
availableAgents: AvailableAgent[],
availableTools: AvailableTool[] = [],
availableSkills: AvailableSkill[] = [],
availableCategories: AvailableCategory[] = []
): string {
const keyTriggers = buildKeyTriggersSection(availableAgents, availableSkills)
const toolSelection = buildToolSelectionTable(availableAgents, availableTools, availableSkills)
const exploreSection = buildExploreSection(availableAgents)
const librarianSection = buildLibrarianSection(availableAgents)
const categorySkillsGuide = buildCategorySkillsDelegationGuide(availableCategories, availableSkills)
const delegationTable = buildDelegationTable(availableAgents)
const oracleSection = buildOracleSection(availableAgents)
const hardBlocks = buildHardBlocksSection()
const antiPatterns = buildAntiPatternsSection()
const sections = [
SISYPHUS_ROLE_SECTION,
"<Behavior_Instructions>",
"",
"## Phase 0 - Intent Gate (EVERY message)",
"",
keyTriggers,
"",
SISYPHUS_PHASE0_STEP1_3,
"",
"---",
"",
SISYPHUS_PHASE1,
"",
"---",
"",
"## Phase 2A - Exploration & Research",
"",
toolSelection,
"",
exploreSection,
"",
librarianSection,
"",
SISYPHUS_PRE_DELEGATION_PLANNING,
"",
SISYPHUS_PARALLEL_EXECUTION,
"",
"---",
"",
SISYPHUS_PHASE2B_PRE_IMPLEMENTATION,
"",
categorySkillsGuide,
"",
delegationTable,
"",
SISYPHUS_DELEGATION_PROMPT_STRUCTURE,
"",
SISYPHUS_GITHUB_WORKFLOW,
"",
SISYPHUS_CODE_CHANGES,
"",
"---",
"",
SISYPHUS_PHASE2C,
"",
"---",
"",
SISYPHUS_PHASE3,
"",
"</Behavior_Instructions>",
"",
oracleSection,
"",
SISYPHUS_TASK_MANAGEMENT,
"",
SISYPHUS_TONE_AND_STYLE,
"",
"<Constraints>",
hardBlocks,
"",
antiPatterns,
"",
SISYPHUS_SOFT_GUIDELINES,
]
return sections.filter((s) => s !== "").join("\n")
}
export function createSisyphusAgent(
@@ -630,4 +420,3 @@ export function createSisyphusAgent(
return { ...base, thinking: { type: "enabled", budgetTokens: 32000 } }
}

View File

@@ -5,11 +5,11 @@ import type { AgentConfig } from "@opencode-ai/sdk"
const TEST_DEFAULT_MODEL = "anthropic/claude-opus-4-5"
describe("createBuiltinAgents with model overrides", () => {
test("Sisyphus with default model has thinking config", () => {
test("Sisyphus with default model has thinking config", async () => {
// #given - no overrides, using systemDefaultModel
// #when
const agents = createBuiltinAgents([], {}, undefined, TEST_DEFAULT_MODEL)
const agents = await createBuiltinAgents([], {}, undefined, TEST_DEFAULT_MODEL)
// #then
expect(agents.Sisyphus.model).toBe("anthropic/claude-opus-4-5")
@@ -17,14 +17,14 @@ describe("createBuiltinAgents with model overrides", () => {
expect(agents.Sisyphus.reasoningEffort).toBeUndefined()
})
test("Sisyphus with GPT model override has reasoningEffort, no thinking", () => {
test("Sisyphus with GPT model override has reasoningEffort, no thinking", async () => {
// #given
const overrides = {
Sisyphus: { model: "github-copilot/gpt-5.2" },
}
// #when
const agents = createBuiltinAgents([], overrides, undefined, TEST_DEFAULT_MODEL)
const agents = await createBuiltinAgents([], overrides, undefined, TEST_DEFAULT_MODEL)
// #then
expect(agents.Sisyphus.model).toBe("github-copilot/gpt-5.2")
@@ -32,40 +32,40 @@ describe("createBuiltinAgents with model overrides", () => {
expect(agents.Sisyphus.thinking).toBeUndefined()
})
test("Sisyphus with systemDefaultModel GPT has reasoningEffort, no thinking", () => {
test("Sisyphus uses first fallbackChain entry when no availableModels provided", async () => {
// #given
const systemDefaultModel = "openai/gpt-5.2"
// #when
const agents = createBuiltinAgents([], {}, undefined, systemDefaultModel)
const agents = await createBuiltinAgents([], {}, undefined, systemDefaultModel)
// #then
expect(agents.Sisyphus.model).toBe("openai/gpt-5.2")
expect(agents.Sisyphus.reasoningEffort).toBe("medium")
expect(agents.Sisyphus.thinking).toBeUndefined()
// #then - Sisyphus first fallbackChain entry is anthropic/claude-opus-4-5
expect(agents.Sisyphus.model).toBe("anthropic/claude-opus-4-5")
expect(agents.Sisyphus.thinking).toEqual({ type: "enabled", budgetTokens: 32000 })
expect(agents.Sisyphus.reasoningEffort).toBeUndefined()
})
test("Oracle with default model has reasoningEffort", () => {
// #given - no overrides, using systemDefaultModel for other agents
// Oracle uses its own default model (openai/gpt-5.2) from the factory singleton
test("Oracle uses first fallbackChain entry when no availableModels provided", async () => {
// #given - Oracle's first fallbackChain entry is openai/gpt-5.2
// #when
const agents = createBuiltinAgents([], {}, undefined, TEST_DEFAULT_MODEL)
const agents = await createBuiltinAgents([], {}, undefined, TEST_DEFAULT_MODEL)
// #then - Oracle uses systemDefaultModel since model is now required
expect(agents.oracle.model).toBe("anthropic/claude-opus-4-5")
expect(agents.oracle.thinking).toEqual({ type: "enabled", budgetTokens: 32000 })
expect(agents.oracle.reasoningEffort).toBeUndefined()
// #then - Oracle first fallbackChain entry is openai/gpt-5.2
expect(agents.oracle.model).toBe("openai/gpt-5.2")
expect(agents.oracle.reasoningEffort).toBe("medium")
expect(agents.oracle.textVerbosity).toBe("high")
expect(agents.oracle.thinking).toBeUndefined()
})
test("Oracle with GPT model override has reasoningEffort, no thinking", () => {
test("Oracle with GPT model override has reasoningEffort, no thinking", async () => {
// #given
const overrides = {
oracle: { model: "openai/gpt-5.2" },
}
// #when
const agents = createBuiltinAgents([], overrides, undefined, TEST_DEFAULT_MODEL)
const agents = await createBuiltinAgents([], overrides, undefined, TEST_DEFAULT_MODEL)
// #then
expect(agents.oracle.model).toBe("openai/gpt-5.2")
@@ -74,14 +74,14 @@ describe("createBuiltinAgents with model overrides", () => {
expect(agents.oracle.thinking).toBeUndefined()
})
test("Oracle with Claude model override has thinking, no reasoningEffort", () => {
test("Oracle with Claude model override has thinking, no reasoningEffort", async () => {
// #given
const overrides = {
oracle: { model: "anthropic/claude-sonnet-4" },
}
// #when
const agents = createBuiltinAgents([], overrides, undefined, TEST_DEFAULT_MODEL)
const agents = await createBuiltinAgents([], overrides, undefined, TEST_DEFAULT_MODEL)
// #then
expect(agents.oracle.model).toBe("anthropic/claude-sonnet-4")
@@ -90,14 +90,14 @@ describe("createBuiltinAgents with model overrides", () => {
expect(agents.oracle.textVerbosity).toBeUndefined()
})
test("non-model overrides are still applied after factory rebuild", () => {
test("non-model overrides are still applied after factory rebuild", async () => {
// #given
const overrides = {
Sisyphus: { model: "github-copilot/gpt-5.2", temperature: 0.5 },
}
// #when
const agents = createBuiltinAgents([], overrides, undefined, TEST_DEFAULT_MODEL)
const agents = await createBuiltinAgents([], overrides, undefined, TEST_DEFAULT_MODEL)
// #then
expect(agents.Sisyphus.model).toBe("github-copilot/gpt-5.2")

View File

@@ -10,10 +10,11 @@ import { createMetisAgent } from "./metis"
import { createAtlasAgent } from "./atlas"
import { createMomusAgent } from "./momus"
import type { AvailableAgent, AvailableCategory, AvailableSkill } from "./dynamic-agent-prompt-builder"
import { deepMerge } from "../shared"
import { deepMerge, fetchAvailableModels, resolveModelWithFallback, AGENT_MODEL_REQUIREMENTS, findCaseInsensitive, includesCaseInsensitive } from "../shared"
import { DEFAULT_CATEGORIES, CATEGORY_DESCRIPTIONS } from "../tools/delegate-task/constants"
import { resolveMultipleSkills } from "../features/opencode-skill-loader/skill-content"
import { createBuiltinSkills } from "../features/builtin-skills"
import type { LoadedSkill, SkillScope } from "../features/opencode-skill-loader/types"
type AgentSource = AgentFactory | AgentConfig
@@ -131,18 +132,29 @@ function mergeAgentConfig(
return merged
}
export function createBuiltinAgents(
function mapScopeToLocation(scope: SkillScope): AvailableSkill["location"] {
if (scope === "user" || scope === "opencode") return "user"
if (scope === "project" || scope === "opencode-project") return "project"
return "plugin"
}
export async function createBuiltinAgents(
disabledAgents: BuiltinAgentName[] = [],
agentOverrides: AgentOverrides = {},
directory?: string,
systemDefaultModel?: string,
categories?: CategoriesConfig,
gitMasterConfig?: GitMasterConfig
): Record<string, AgentConfig> {
gitMasterConfig?: GitMasterConfig,
discoveredSkills: LoadedSkill[] = [],
client?: any
): Promise<Record<string, AgentConfig>> {
if (!systemDefaultModel) {
throw new Error("createBuiltinAgents requires systemDefaultModel")
}
// Fetch available models at plugin init
const availableModels = client ? await fetchAvailableModels(client) : new Set<string>()
const result: Record<string, AgentConfig> = {}
const availableAgents: AvailableAgent[] = []
@@ -152,27 +164,54 @@ export function createBuiltinAgents(
const availableCategories: AvailableCategory[] = Object.entries(mergedCategories).map(([name]) => ({
name,
description: CATEGORY_DESCRIPTIONS[name] ?? "General tasks",
description: categories?.[name]?.description ?? CATEGORY_DESCRIPTIONS[name] ?? "General tasks",
}))
const builtinSkills = createBuiltinSkills()
const availableSkills: AvailableSkill[] = builtinSkills.map((skill) => ({
const builtinSkillNames = new Set(builtinSkills.map(s => s.name))
const builtinAvailable: AvailableSkill[] = builtinSkills.map((skill) => ({
name: skill.name,
description: skill.description,
location: "plugin" as const,
}))
const discoveredAvailable: AvailableSkill[] = discoveredSkills
.filter(s => !builtinSkillNames.has(s.name))
.map((skill) => ({
name: skill.name,
description: skill.definition.description ?? "",
location: mapScopeToLocation(skill.scope),
}))
const availableSkills: AvailableSkill[] = [...builtinAvailable, ...discoveredAvailable]
for (const [name, source] of Object.entries(agentSources)) {
const agentName = name as BuiltinAgentName
if (agentName === "Sisyphus") continue
if (agentName === "Atlas") continue
if (disabledAgents.includes(agentName)) continue
if (includesCaseInsensitive(disabledAgents, agentName)) continue
const override = agentOverrides[agentName]
const model = override?.model ?? systemDefaultModel
const override = findCaseInsensitive(agentOverrides, agentName)
const requirement = AGENT_MODEL_REQUIREMENTS[agentName]
// Use resolver to determine model
const { model } = resolveModelWithFallback({
userModel: override?.model,
fallbackChain: requirement?.fallbackChain,
availableModels,
systemDefaultModel,
})
let config = buildAgent(source, model, mergedCategories, gitMasterConfig)
// Apply variant from override or requirement
if (override?.variant) {
config = { ...config, variant: override.variant }
} else if (requirement?.variant) {
config = { ...config, variant: requirement.variant }
}
if (agentName === "librarian" && directory && config.prompt) {
const envContext = createEnvContext()
@@ -197,7 +236,15 @@ export function createBuiltinAgents(
if (!disabledAgents.includes("Sisyphus")) {
const sisyphusOverride = agentOverrides["Sisyphus"]
const sisyphusModel = sisyphusOverride?.model ?? systemDefaultModel
const sisyphusRequirement = AGENT_MODEL_REQUIREMENTS["Sisyphus"]
// Use resolver to determine model
const { model: sisyphusModel } = resolveModelWithFallback({
userModel: sisyphusOverride?.model,
fallbackChain: sisyphusRequirement?.fallbackChain,
availableModels,
systemDefaultModel,
})
let sisyphusConfig = createSisyphusAgent(
sisyphusModel,
@@ -206,6 +253,13 @@ export function createBuiltinAgents(
availableSkills,
availableCategories
)
// Apply variant from override or requirement
if (sisyphusOverride?.variant) {
sisyphusConfig = { ...sisyphusConfig, variant: sisyphusOverride.variant }
} else if (sisyphusRequirement?.variant) {
sisyphusConfig = { ...sisyphusConfig, variant: sisyphusRequirement.variant }
}
if (directory && sisyphusConfig.prompt) {
const envContext = createEnvContext()
@@ -221,13 +275,29 @@ export function createBuiltinAgents(
if (!disabledAgents.includes("Atlas")) {
const orchestratorOverride = agentOverrides["Atlas"]
const orchestratorModel = orchestratorOverride?.model ?? systemDefaultModel
let orchestratorConfig = createAtlasAgent({
model: orchestratorModel,
availableAgents,
availableSkills,
userCategories: categories,
})
const atlasRequirement = AGENT_MODEL_REQUIREMENTS["Atlas"]
// Use resolver to determine model
const { model: atlasModel } = resolveModelWithFallback({
userModel: orchestratorOverride?.model,
fallbackChain: atlasRequirement?.fallbackChain,
availableModels,
systemDefaultModel,
})
let orchestratorConfig = createAtlasAgent({
model: atlasModel,
availableAgents,
availableSkills,
userCategories: categories,
})
// Apply variant from override or requirement
if (orchestratorOverride?.variant) {
orchestratorConfig = { ...orchestratorConfig, variant: orchestratorOverride.variant }
} else if (atlasRequirement?.variant) {
orchestratorConfig = { ...orchestratorConfig, variant: atlasRequirement.variant }
}
if (orchestratorOverride) {
orchestratorConfig = mergeAgentConfig(orchestratorConfig, orchestratorOverride)

View File

@@ -2,90 +2,70 @@
## OVERVIEW
CLI entry point: `bunx oh-my-opencode`. Interactive installer, doctor diagnostics, session runner. Uses Commander.js + @clack/prompts TUI.
CLI entry: `bunx oh-my-opencode`. Interactive installer, doctor diagnostics. Commander.js + @clack/prompts.
## STRUCTURE
```
cli/
├── index.ts # Commander.js entry, 5 subcommands
├── install.ts # Interactive TUI installer (462 lines)
├── config-manager.ts # JSONC parsing, multi-level merge (730 lines)
├── types.ts # InstallArgs, InstallConfig, DetectedConfig
├── index.ts # Commander.js entry
├── install.ts # Interactive TUI (520 lines)
├── config-manager.ts # JSONC parsing (641 lines)
├── types.ts # InstallArgs, InstallConfig
├── doctor/
│ ├── index.ts # Doctor command entry
│ ├── index.ts # Doctor entry
│ ├── runner.ts # Check orchestration
│ ├── formatter.ts # Colored output, symbols
│ ├── constants.ts # Check IDs, categories, symbols
│ ├── formatter.ts # Colored output
│ ├── constants.ts # Check IDs, symbols
│ ├── types.ts # CheckResult, CheckDefinition
│ └── checks/ # 14 checks across 6 categories
│ └── checks/ # 14 checks, 21 files
│ ├── version.ts # OpenCode + plugin version
│ ├── config.ts # JSONC validity, Zod validation
│ ├── config.ts # JSONC validity, Zod
│ ├── auth.ts # Anthropic, OpenAI, Google
│ ├── dependencies.ts # AST-Grep, Comment Checker
│ ├── lsp.ts # LSP server connectivity
│ ├── mcp.ts # MCP server validation
│ └── gh.ts # GitHub CLI availability
│ ├── lsp.ts # LSP connectivity
│ ├── mcp.ts # MCP validation
│ └── gh.ts # GitHub CLI
├── run/
── index.ts # Run command entry
│ └── runner.ts # Session launcher
── index.ts # Session launcher
└── get-local-version/
── index.ts # Version detection
└── formatter.ts # Version output
── index.ts # Version detection
```
## CLI COMMANDS
## COMMANDS
| Command | Purpose |
|---------|---------|
| `install` | Interactive setup, subscription detection |
| `doctor` | 14 health checks, `--verbose`, `--json`, `--category` |
| `run` | Launch OpenCode session with completion enforcement |
| `get-local-version` | Version detection, update checking |
| `install` | Interactive setup |
| `doctor` | 14 health checks |
| `run` | Launch session |
| `get-local-version` | Version check |
## DOCTOR CHECK CATEGORIES
## DOCTOR CATEGORIES
| Category | Checks |
|----------|--------|
| installation | opencode, plugin registration |
| configuration | config validity, Zod validation |
| installation | opencode, plugin |
| configuration | config validity, Zod |
| authentication | anthropic, openai, google |
| dependencies | ast-grep CLI/NAPI, comment-checker |
| tools | LSP, MCP connectivity |
| dependencies | ast-grep, comment-checker |
| tools | LSP, MCP |
| updates | version comparison |
## HOW TO ADD CHECK
1. Create `src/cli/doctor/checks/my-check.ts`:
```typescript
export function getMyCheckDefinition(): CheckDefinition {
return {
id: "my-check",
name: "My Check",
category: "configuration",
check: async () => ({ status: "pass", message: "OK" })
}
}
```
1. Create `src/cli/doctor/checks/my-check.ts`
2. Export from `checks/index.ts`
3. Add to `getAllCheckDefinitions()`
## TUI FRAMEWORK
- **@clack/prompts**: `select()`, `spinner()`, `intro()`, `outro()`, `note()`
- **picocolors**: Colored terminal output
- **Symbols**: ✓ (pass), ✗ (fail), ⚠ (warn), ○ (skip)
## CONFIG-MANAGER
- **JSONC**: Comments (`// ...`), block comments, trailing commas
- **Multi-source**: User (`~/.config/opencode/`) + Project (`.opencode/`)
- **Env override**: `OPENCODE_CONFIG_DIR` for profile isolation
- **Validation**: Zod schema with error aggregation
- **@clack/prompts**: `select()`, `spinner()`, `intro()`
- **picocolors**: Terminal colors
- **Symbols**: ✓ (pass), ✗ (fail), ⚠ (warn)
## ANTI-PATTERNS
- **Blocking in non-TTY**: Check `process.stdout.isTTY`
- **Direct JSON.parse**: Use `parseJsonc()` for config
- **Silent failures**: Always return warn/fail in doctor
- **Hardcoded paths**: Use `ConfigManager`
- **Direct JSON.parse**: Use `parseJsonc()`
- **Silent failures**: Return warn/fail in doctor

File diff suppressed because it is too large Load Diff

View File

@@ -256,7 +256,7 @@ describe("generateOmoConfig - model fallback system", () => {
// #when generating config
const result = generateOmoConfig(config)
// #then should use github-copilot sonnet models
// #then should use github-copilot sonnet models (copilot fallback)
expect((result.agents as Record<string, { model: string }>).Sisyphus.model).toBe("github-copilot/claude-sonnet-4.5")
})
@@ -318,8 +318,8 @@ describe("generateOmoConfig - model fallback system", () => {
// #then Sisyphus should use native OpenAI (fallback within native tier)
expect((result.agents as Record<string, { model: string }>).Sisyphus.model).toBe("openai/gpt-5.2")
// #then Oracle should use native OpenAI (primary for ultrabrain)
expect((result.agents as Record<string, { model: string }>).oracle.model).toBe("openai/gpt-5.2-codex")
// #then Oracle should use native OpenAI (first fallback entry)
expect((result.agents as Record<string, { model: string }>).oracle.model).toBe("openai/gpt-5.2")
// #then multimodal-looker should use native OpenAI (fallback within native tier)
expect((result.agents as Record<string, { model: string }>)["multimodal-looker"].model).toBe("openai/gpt-5.2")
})

View File

@@ -1,11 +1,10 @@
import { existsSync, readFileSync } from "node:fs"
import { homedir } from "node:os"
import { join } from "node:path"
import type { CheckResult, CheckDefinition, AuthProviderInfo, AuthProviderId } from "../types"
import { CHECK_IDS, CHECK_NAMES } from "../constants"
import { parseJsonc } from "../../../shared"
import { parseJsonc, getOpenCodeConfigDir } from "../../../shared"
const OPENCODE_CONFIG_DIR = join(homedir(), ".config", "opencode")
const OPENCODE_CONFIG_DIR = getOpenCodeConfigDir({ binary: "opencode" })
const OPENCODE_JSON = join(OPENCODE_CONFIG_DIR, "opencode.json")
const OPENCODE_JSONC = join(OPENCODE_CONFIG_DIR, "opencode.jsonc")

View File

@@ -1,12 +1,11 @@
import { existsSync, readFileSync } from "node:fs"
import { homedir } from "node:os"
import { join } from "node:path"
import type { CheckResult, CheckDefinition, ConfigInfo } from "../types"
import { CHECK_IDS, CHECK_NAMES, PACKAGE_NAME } from "../constants"
import { parseJsonc, detectConfigFile } from "../../../shared"
import { parseJsonc, detectConfigFile, getOpenCodeConfigDir } from "../../../shared"
import { OhMyOpenCodeConfigSchema } from "../../../config"
const USER_CONFIG_DIR = join(homedir(), ".config", "opencode")
const USER_CONFIG_DIR = getOpenCodeConfigDir({ binary: "opencode" })
const USER_CONFIG_BASE = join(USER_CONFIG_DIR, `${PACKAGE_NAME}`)
const PROJECT_CONFIG_BASE = join(process.cwd(), ".opencode", PACKAGE_NAME)

View File

@@ -2,6 +2,7 @@ import type { CheckDefinition } from "../types"
import { getOpenCodeCheckDefinition } from "./opencode"
import { getPluginCheckDefinition } from "./plugin"
import { getConfigCheckDefinition } from "./config"
import { getModelResolutionCheckDefinition } from "./model-resolution"
import { getAuthCheckDefinitions } from "./auth"
import { getDependencyCheckDefinitions } from "./dependencies"
import { getGhCliCheckDefinition } from "./gh"
@@ -12,6 +13,7 @@ import { getVersionCheckDefinition } from "./version"
export * from "./opencode"
export * from "./plugin"
export * from "./config"
export * from "./model-resolution"
export * from "./auth"
export * from "./dependencies"
export * from "./gh"
@@ -24,6 +26,7 @@ export function getAllCheckDefinitions(): CheckDefinition[] {
getOpenCodeCheckDefinition(),
getPluginCheckDefinition(),
getConfigCheckDefinition(),
getModelResolutionCheckDefinition(),
...getAuthCheckDefinitions(),
...getDependencyCheckDefinitions(),
getGhCliCheckDefinition(),

View File

@@ -0,0 +1,139 @@
import { describe, it, expect, beforeEach, afterEach, spyOn, mock } from "bun:test"
describe("model-resolution check", () => {
describe("getModelResolutionInfo", () => {
// #given: Model requirements are defined in model-requirements.ts
// #when: Getting model resolution info
// #then: Returns info for all agents and categories with their provider chains
it("returns agent requirements with provider chains", async () => {
const { getModelResolutionInfo } = await import("./model-resolution")
const info = getModelResolutionInfo()
// #then: Should have agent entries
const sisyphus = info.agents.find((a) => a.name === "Sisyphus")
expect(sisyphus).toBeDefined()
expect(sisyphus!.requirement.fallbackChain[0]?.model).toBe("claude-opus-4-5")
expect(sisyphus!.requirement.fallbackChain[0]?.providers).toContain("anthropic")
expect(sisyphus!.requirement.fallbackChain[0]?.providers).toContain("github-copilot")
})
it("returns category requirements with provider chains", async () => {
const { getModelResolutionInfo } = await import("./model-resolution")
const info = getModelResolutionInfo()
// #then: Should have category entries
const visual = info.categories.find((c) => c.name === "visual-engineering")
expect(visual).toBeDefined()
expect(visual!.requirement.fallbackChain[0]?.model).toBe("gemini-3-pro-preview")
expect(visual!.requirement.fallbackChain[0]?.providers).toContain("google")
})
})
describe("getModelResolutionInfoWithOverrides", () => {
// #given: User has overrides in oh-my-opencode.json
// #when: Getting resolution info with config
// #then: Shows user override in Step 1 position
it("shows user override for agent when configured", async () => {
const { getModelResolutionInfoWithOverrides } = await import("./model-resolution")
// #given: User has override for oracle agent
const mockConfig = {
agents: {
oracle: { model: "anthropic/claude-opus-4-5" },
},
}
const info = getModelResolutionInfoWithOverrides(mockConfig)
// #then: Oracle should show the override
const oracle = info.agents.find((a) => a.name === "oracle")
expect(oracle).toBeDefined()
expect(oracle!.userOverride).toBe("anthropic/claude-opus-4-5")
expect(oracle!.effectiveResolution).toBe("User override: anthropic/claude-opus-4-5")
})
it("shows user override for category when configured", async () => {
const { getModelResolutionInfoWithOverrides } = await import("./model-resolution")
// #given: User has override for visual-engineering category
const mockConfig = {
categories: {
"visual-engineering": { model: "openai/gpt-5.2" },
},
}
const info = getModelResolutionInfoWithOverrides(mockConfig)
// #then: visual-engineering should show the override
const visual = info.categories.find((c) => c.name === "visual-engineering")
expect(visual).toBeDefined()
expect(visual!.userOverride).toBe("openai/gpt-5.2")
expect(visual!.effectiveResolution).toBe("User override: openai/gpt-5.2")
})
it("shows provider fallback when no override exists", async () => {
const { getModelResolutionInfoWithOverrides } = await import("./model-resolution")
// #given: No overrides configured
const mockConfig = {}
const info = getModelResolutionInfoWithOverrides(mockConfig)
// #then: Should show provider fallback chain
const sisyphus = info.agents.find((a) => a.name === "Sisyphus")
expect(sisyphus).toBeDefined()
expect(sisyphus!.userOverride).toBeUndefined()
expect(sisyphus!.effectiveResolution).toContain("Provider fallback:")
expect(sisyphus!.effectiveResolution).toContain("anthropic")
})
})
describe("checkModelResolution", () => {
// #given: Doctor check is executed
// #when: Running the model resolution check
// #then: Returns pass with details showing resolution flow
it("returns pass status with agent and category counts", async () => {
const { checkModelResolution } = await import("./model-resolution")
const result = await checkModelResolution()
// #then: Should pass and show counts
expect(result.status).toBe("pass")
expect(result.message).toMatch(/\d+ agents?, \d+ categories?/)
})
it("includes resolution details in verbose mode details array", async () => {
const { checkModelResolution } = await import("./model-resolution")
const result = await checkModelResolution()
// #then: Details should contain agent/category resolution info
expect(result.details).toBeDefined()
expect(result.details!.length).toBeGreaterThan(0)
// Should have Current Models header and sections
expect(result.details!.some((d) => d.includes("Current Models"))).toBe(true)
expect(result.details!.some((d) => d.includes("Agents:"))).toBe(true)
expect(result.details!.some((d) => d.includes("Categories:"))).toBe(true)
// Should have legend
expect(result.details!.some((d) => d.includes("user override"))).toBe(true)
})
})
describe("getModelResolutionCheckDefinition", () => {
it("returns valid check definition", async () => {
const { getModelResolutionCheckDefinition } = await import("./model-resolution")
const def = getModelResolutionCheckDefinition()
expect(def.id).toBe("model-resolution")
expect(def.name).toBe("Model Resolution")
expect(def.category).toBe("configuration")
expect(typeof def.check).toBe("function")
})
})
})

View File

@@ -0,0 +1,210 @@
import { readFileSync } from "node:fs"
import type { CheckResult, CheckDefinition } from "../types"
import { CHECK_IDS, CHECK_NAMES } from "../constants"
import { parseJsonc, detectConfigFile } from "../../../shared"
import {
AGENT_MODEL_REQUIREMENTS,
CATEGORY_MODEL_REQUIREMENTS,
type ModelRequirement,
} from "../../../shared/model-requirements"
import { homedir } from "node:os"
import { join } from "node:path"
const PACKAGE_NAME = "oh-my-opencode"
const USER_CONFIG_DIR = join(homedir(), ".config", "opencode")
const USER_CONFIG_BASE = join(USER_CONFIG_DIR, PACKAGE_NAME)
const PROJECT_CONFIG_BASE = join(process.cwd(), ".opencode", PACKAGE_NAME)
export interface AgentResolutionInfo {
name: string
requirement: ModelRequirement
userOverride?: string
effectiveModel: string
effectiveResolution: string
}
export interface CategoryResolutionInfo {
name: string
requirement: ModelRequirement
userOverride?: string
effectiveModel: string
effectiveResolution: string
}
export interface ModelResolutionInfo {
agents: AgentResolutionInfo[]
categories: CategoryResolutionInfo[]
}
interface OmoConfig {
agents?: Record<string, { model?: string }>
categories?: Record<string, { model?: string }>
}
function loadConfig(): OmoConfig | null {
const projectDetected = detectConfigFile(PROJECT_CONFIG_BASE)
if (projectDetected.format !== "none") {
try {
const content = readFileSync(projectDetected.path, "utf-8")
return parseJsonc<OmoConfig>(content)
} catch {
return null
}
}
const userDetected = detectConfigFile(USER_CONFIG_BASE)
if (userDetected.format !== "none") {
try {
const content = readFileSync(userDetected.path, "utf-8")
return parseJsonc<OmoConfig>(content)
} catch {
return null
}
}
return null
}
function formatProviderChain(providers: string[]): string {
return providers.join(" → ")
}
function getEffectiveModel(requirement: ModelRequirement, userOverride?: string): string {
if (userOverride) {
return userOverride
}
const firstEntry = requirement.fallbackChain[0]
if (!firstEntry) {
return "unknown"
}
return `${firstEntry.providers[0]}/${firstEntry.model}`
}
function buildEffectiveResolution(
requirement: ModelRequirement,
userOverride?: string,
): string {
if (userOverride) {
return `User override: ${userOverride}`
}
const firstEntry = requirement.fallbackChain[0]
if (!firstEntry) {
return "No fallback chain defined"
}
return `Provider fallback: ${formatProviderChain(firstEntry.providers)}${firstEntry.model}`
}
export function getModelResolutionInfo(): ModelResolutionInfo {
const agents: AgentResolutionInfo[] = Object.entries(AGENT_MODEL_REQUIREMENTS).map(
([name, requirement]) => ({
name,
requirement,
effectiveModel: getEffectiveModel(requirement),
effectiveResolution: buildEffectiveResolution(requirement),
}),
)
const categories: CategoryResolutionInfo[] = Object.entries(CATEGORY_MODEL_REQUIREMENTS).map(
([name, requirement]) => ({
name,
requirement,
effectiveModel: getEffectiveModel(requirement),
effectiveResolution: buildEffectiveResolution(requirement),
}),
)
return { agents, categories }
}
export function getModelResolutionInfoWithOverrides(config: OmoConfig): ModelResolutionInfo {
const agents: AgentResolutionInfo[] = Object.entries(AGENT_MODEL_REQUIREMENTS).map(
([name, requirement]) => {
const userOverride = config.agents?.[name]?.model
return {
name,
requirement,
userOverride,
effectiveModel: getEffectiveModel(requirement, userOverride),
effectiveResolution: buildEffectiveResolution(requirement, userOverride),
}
},
)
const categories: CategoryResolutionInfo[] = Object.entries(CATEGORY_MODEL_REQUIREMENTS).map(
([name, requirement]) => {
const userOverride = config.categories?.[name]?.model
return {
name,
requirement,
userOverride,
effectiveModel: getEffectiveModel(requirement, userOverride),
effectiveResolution: buildEffectiveResolution(requirement, userOverride),
}
},
)
return { agents, categories }
}
function formatModelWithVariant(model: string, variant?: string): string {
return variant ? `${model} (${variant})` : model
}
function getEffectiveVariant(requirement: ModelRequirement): string | undefined {
const firstEntry = requirement.fallbackChain[0]
return firstEntry?.variant ?? requirement.variant
}
function buildDetailsArray(info: ModelResolutionInfo): string[] {
const details: string[] = []
details.push("═══ Current Models ═══")
details.push("")
details.push("Agents:")
for (const agent of info.agents) {
const marker = agent.userOverride ? "●" : "○"
const display = formatModelWithVariant(agent.effectiveModel, getEffectiveVariant(agent.requirement))
details.push(` ${marker} ${agent.name}: ${display}`)
}
details.push("")
details.push("Categories:")
for (const category of info.categories) {
const marker = category.userOverride ? "●" : "○"
const display = formatModelWithVariant(category.effectiveModel, getEffectiveVariant(category.requirement))
details.push(` ${marker} ${category.name}: ${display}`)
}
details.push("")
details.push("● = user override, ○ = provider fallback")
return details
}
export async function checkModelResolution(): Promise<CheckResult> {
const config = loadConfig() ?? {}
const info = getModelResolutionInfoWithOverrides(config)
const agentCount = info.agents.length
const categoryCount = info.categories.length
const agentOverrides = info.agents.filter((a) => a.userOverride).length
const categoryOverrides = info.categories.filter((c) => c.userOverride).length
const totalOverrides = agentOverrides + categoryOverrides
const overrideNote = totalOverrides > 0 ? ` (${totalOverrides} override${totalOverrides > 1 ? "s" : ""})` : ""
return {
name: CHECK_NAMES[CHECK_IDS.MODEL_RESOLUTION],
status: "pass",
message: `${agentCount} agents, ${categoryCount} categories${overrideNote}`,
details: buildDetailsArray(info),
}
}
export function getModelResolutionCheckDefinition(): CheckDefinition {
return {
id: CHECK_IDS.MODEL_RESOLUTION,
name: CHECK_NAMES[CHECK_IDS.MODEL_RESOLUTION],
category: "configuration",
check: checkModelResolution,
critical: false,
}
}

View File

@@ -21,6 +21,7 @@ export const CHECK_IDS = {
OPENCODE_INSTALLATION: "opencode-installation",
PLUGIN_REGISTRATION: "plugin-registration",
CONFIG_VALIDATION: "config-validation",
MODEL_RESOLUTION: "model-resolution",
AUTH_ANTHROPIC: "auth-anthropic",
AUTH_OPENAI: "auth-openai",
AUTH_GOOGLE: "auth-google",
@@ -38,6 +39,7 @@ export const CHECK_NAMES: Record<string, string> = {
[CHECK_IDS.OPENCODE_INSTALLATION]: "OpenCode Installation",
[CHECK_IDS.PLUGIN_REGISTRATION]: "Plugin Registration",
[CHECK_IDS.CONFIG_VALIDATION]: "Configuration Validity",
[CHECK_IDS.MODEL_RESOLUTION]: "Model Resolution",
[CHECK_IDS.AUTH_ANTHROPIC]: "Anthropic (Claude) Auth",
[CHECK_IDS.AUTH_OPENAI]: "OpenAI (ChatGPT) Auth",
[CHECK_IDS.AUTH_GOOGLE]: "Google (Gemini) Auth",

View File

@@ -2,13 +2,13 @@ import color from "picocolors"
import type { VersionInfo } from "./types"
const SYMBOLS = {
check: color.green(""),
cross: color.red(""),
arrow: color.cyan(""),
info: color.blue(""),
warn: color.yellow(""),
pin: color.magenta("📌"),
dev: color.cyan("🔧"),
check: color.green("[OK]"),
cross: color.red("[X]"),
arrow: color.cyan("->"),
info: color.blue("[i]"),
warn: color.yellow("[!]"),
pin: color.magenta("[PINNED]"),
dev: color.cyan("[DEV]"),
}
export function formatVersionOutput(info: VersionInfo): string {

151
src/cli/install.test.ts Normal file
View File

@@ -0,0 +1,151 @@
import { describe, expect, test, mock, beforeEach, afterEach, spyOn } from "bun:test"
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { install } from "./install"
import * as configManager from "./config-manager"
import type { InstallArgs } from "./types"
// Mock console methods to capture output
const mockConsoleLog = mock(() => {})
const mockConsoleError = mock(() => {})
describe("install CLI - binary check behavior", () => {
let tempDir: string
let originalEnv: string | undefined
let isOpenCodeInstalledSpy: ReturnType<typeof spyOn>
let getOpenCodeVersionSpy: ReturnType<typeof spyOn>
beforeEach(() => {
// #given temporary config directory
tempDir = join(tmpdir(), `omo-test-${Date.now()}-${Math.random().toString(36).slice(2)}`)
mkdirSync(tempDir, { recursive: true })
originalEnv = process.env.OPENCODE_CONFIG_DIR
process.env.OPENCODE_CONFIG_DIR = tempDir
// Reset config context
configManager.resetConfigContext()
configManager.initConfigContext("opencode", null)
// Capture console output
console.log = mockConsoleLog
mockConsoleLog.mockClear()
})
afterEach(() => {
if (originalEnv !== undefined) {
process.env.OPENCODE_CONFIG_DIR = originalEnv
} else {
delete process.env.OPENCODE_CONFIG_DIR
}
if (existsSync(tempDir)) {
rmSync(tempDir, { recursive: true, force: true })
}
isOpenCodeInstalledSpy?.mockRestore()
getOpenCodeVersionSpy?.mockRestore()
})
test("non-TUI mode: should show warning but continue when OpenCode binary not found", async () => {
// #given OpenCode binary is NOT installed
isOpenCodeInstalledSpy = spyOn(configManager, "isOpenCodeInstalled").mockResolvedValue(false)
getOpenCodeVersionSpy = spyOn(configManager, "getOpenCodeVersion").mockResolvedValue(null)
const args: InstallArgs = {
tui: false,
claude: "yes",
openai: "no",
gemini: "no",
copilot: "no",
opencodeZen: "no",
zaiCodingPlan: "no",
}
// #when running install
const exitCode = await install(args)
// #then should return success (0), not failure (1)
expect(exitCode).toBe(0)
// #then should have printed a warning (not error)
const allCalls = mockConsoleLog.mock.calls.flat().join("\n")
expect(allCalls).toContain("[!]") // warning symbol
expect(allCalls).toContain("OpenCode")
})
test("non-TUI mode: should create opencode.json with plugin even when binary not found", async () => {
// #given OpenCode binary is NOT installed
isOpenCodeInstalledSpy = spyOn(configManager, "isOpenCodeInstalled").mockResolvedValue(false)
getOpenCodeVersionSpy = spyOn(configManager, "getOpenCodeVersion").mockResolvedValue(null)
// #given mock npm fetch
globalThis.fetch = mock(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ latest: "3.0.0" }),
} as Response)
) as unknown as typeof fetch
const args: InstallArgs = {
tui: false,
claude: "yes",
openai: "no",
gemini: "no",
copilot: "no",
opencodeZen: "no",
zaiCodingPlan: "no",
}
// #when running install
const exitCode = await install(args)
// #then should create opencode.json
const configPath = join(tempDir, "opencode.json")
expect(existsSync(configPath)).toBe(true)
// #then opencode.json should have plugin entry
const config = JSON.parse(readFileSync(configPath, "utf-8"))
expect(config.plugin).toBeDefined()
expect(config.plugin.some((p: string) => p.includes("oh-my-opencode"))).toBe(true)
// #then exit code should be 0 (success)
expect(exitCode).toBe(0)
})
test("non-TUI mode: should still succeed and complete all steps when binary exists", async () => {
// #given OpenCode binary IS installed
isOpenCodeInstalledSpy = spyOn(configManager, "isOpenCodeInstalled").mockResolvedValue(true)
getOpenCodeVersionSpy = spyOn(configManager, "getOpenCodeVersion").mockResolvedValue("1.0.200")
// #given mock npm fetch
globalThis.fetch = mock(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ latest: "3.0.0" }),
} as Response)
) as unknown as typeof fetch
const args: InstallArgs = {
tui: false,
claude: "yes",
openai: "no",
gemini: "no",
copilot: "no",
opencodeZen: "no",
zaiCodingPlan: "no",
}
// #when running install
const exitCode = await install(args)
// #then should return success
expect(exitCode).toBe(0)
// #then should have printed success (OK symbol)
const allCalls = mockConsoleLog.mock.calls.flat().join("\n")
expect(allCalls).toContain("[OK]")
expect(allCalls).toContain("OpenCode 1.0.200")
})
})

View File

@@ -16,13 +16,13 @@ import packageJson from "../../package.json" with { type: "json" }
const VERSION = packageJson.version
const SYMBOLS = {
check: color.green(""),
cross: color.red(""),
arrow: color.cyan(""),
bullet: color.dim(""),
info: color.blue(""),
warn: color.yellow(""),
star: color.yellow(""),
check: color.green("[OK]"),
cross: color.red("[X]"),
arrow: color.cyan("->"),
bullet: color.dim("*"),
info: color.blue("[i]"),
warn: color.yellow("[!]"),
star: color.yellow("*"),
}
function formatProvider(name: string, enabled: boolean, detail?: string): string {
@@ -295,14 +295,13 @@ async function runNonTuiInstall(args: InstallArgs): Promise<number> {
printStep(step++, totalSteps, "Checking OpenCode installation...")
const installed = await isOpenCodeInstalled()
if (!installed) {
printError("OpenCode is not installed on this system.")
printInfo("Visit https://opencode.ai/docs for installation instructions")
return 1
}
const version = await getOpenCodeVersion()
printSuccess(`OpenCode ${version ?? ""} detected`)
if (!installed) {
printWarning("OpenCode binary not found. Plugin will be configured, but you'll need to install OpenCode to use it.")
printInfo("Visit https://opencode.ai/docs for installation instructions")
} else {
printSuccess(`OpenCode ${version ?? ""} detected`)
}
if (isUpdate) {
const initial = detectedToInitialValues(detected)
@@ -351,7 +350,7 @@ async function runNonTuiInstall(args: InstallArgs): Promise<number> {
if (!config.hasClaude) {
console.log()
console.log(color.bgRed(color.white(color.bold(" ⚠️ CRITICAL WARNING "))))
console.log(color.bgRed(color.white(color.bold(" CRITICAL WARNING "))))
console.log()
console.log(color.red(color.bold(" Sisyphus agent is STRONGLY optimized for Claude Opus 4.5.")))
console.log(color.red(" Without Claude, you may experience significantly degraded performance:"))
@@ -375,7 +374,7 @@ async function runNonTuiInstall(args: InstallArgs): Promise<number> {
`${color.bold("Pro Tip:")} Include ${color.cyan("ultrawork")} (or ${color.cyan("ulw")}) in your prompt.\n` +
`All features work like magic—parallel agents, background tasks,\n` +
`deep exploration, and relentless execution until completion.`,
"🪄 The Magic Word"
"The Magic Word"
)
console.log(`${SYMBOLS.star} ${color.yellow("If you found this helpful, consider starring the repo!")}`)
@@ -390,7 +389,7 @@ async function runNonTuiInstall(args: InstallArgs): Promise<number> {
(config.hasClaude ? ` ${SYMBOLS.bullet} Anthropic ${color.gray("→ Claude Pro/Max")}\n` : "") +
(config.hasGemini ? ` ${SYMBOLS.bullet} Google ${color.gray("→ OAuth with Antigravity")}\n` : "") +
(config.hasCopilot ? ` ${SYMBOLS.bullet} GitHub ${color.gray("→ Copilot")}` : ""),
"🔐 Authenticate Your Providers"
"Authenticate Your Providers"
)
}
@@ -416,16 +415,14 @@ export async function install(args: InstallArgs): Promise<number> {
s.start("Checking OpenCode installation")
const installed = await isOpenCodeInstalled()
if (!installed) {
s.stop("OpenCode is not installed")
p.log.error("OpenCode is not installed on this system.")
p.note("Visit https://opencode.ai/docs for installation instructions", "Installation Guide")
p.outro(color.red("Please install OpenCode first."))
return 1
}
const version = await getOpenCodeVersion()
s.stop(`OpenCode ${version ?? "installed"} ${color.green("✓")}`)
if (!installed) {
s.stop(`OpenCode binary not found ${color.yellow("[!]")}`)
p.log.warn("OpenCode binary not found. Plugin will be configured, but you'll need to install OpenCode to use it.")
p.note("Visit https://opencode.ai/docs for installation instructions", "Installation Guide")
} else {
s.stop(`OpenCode ${version ?? "installed"} ${color.green("[OK]")}`)
}
const config = await runTuiMode(detected)
if (!config) return 1
@@ -470,7 +467,7 @@ export async function install(args: InstallArgs): Promise<number> {
if (!config.hasClaude) {
console.log()
console.log(color.bgRed(color.white(color.bold(" ⚠️ CRITICAL WARNING "))))
console.log(color.bgRed(color.white(color.bold(" CRITICAL WARNING "))))
console.log()
console.log(color.red(color.bold(" Sisyphus agent is STRONGLY optimized for Claude Opus 4.5.")))
console.log(color.red(" Without Claude, you may experience significantly degraded performance:"))
@@ -495,7 +492,7 @@ export async function install(args: InstallArgs): Promise<number> {
`Include ${color.cyan("ultrawork")} (or ${color.cyan("ulw")}) in your prompt.\n` +
`All features work like magic—parallel agents, background tasks,\n` +
`deep exploration, and relentless execution until completion.`,
"🪄 The Magic Word"
"The Magic Word"
)
p.log.message(`${color.yellow("★")} If you found this helpful, consider starring the repo!`)
@@ -510,7 +507,7 @@ export async function install(args: InstallArgs): Promise<number> {
if (config.hasCopilot) providers.push(`GitHub ${color.gray("→ Copilot")}`)
console.log()
console.log(color.bold("🔐 Authenticate Your Providers"))
console.log(color.bold("Authenticate Your Providers"))
console.log()
console.log(` Run ${color.cyan("opencode auth login")} and select:`)
for (const provider of providers) {

View File

@@ -0,0 +1,423 @@
import { describe, expect, test } from "bun:test"
import { generateModelConfig } from "./model-fallback"
import type { InstallConfig } from "./types"
function createConfig(overrides: Partial<InstallConfig> = {}): InstallConfig {
return {
hasClaude: false,
isMax20: false,
hasOpenAI: false,
hasGemini: false,
hasCopilot: false,
hasOpencodeZen: false,
hasZaiCodingPlan: false,
...overrides,
}
}
describe("generateModelConfig", () => {
describe("no providers available", () => {
test("returns ULTIMATE_FALLBACK for all agents and categories when no providers", () => {
// #given no providers are available
const config = createConfig()
// #when generateModelConfig is called
const result = generateModelConfig(config)
// #then should use ULTIMATE_FALLBACK for everything
expect(result).toMatchSnapshot()
})
})
describe("single native provider", () => {
test("uses Claude models when only Claude is available", () => {
// #given only Claude is available
const config = createConfig({ hasClaude: true })
// #when generateModelConfig is called
const result = generateModelConfig(config)
// #then should use Claude models per NATIVE_FALLBACK_CHAINS
expect(result).toMatchSnapshot()
})
test("uses Claude models with isMax20 flag", () => {
// #given Claude is available with Max 20 plan
const config = createConfig({ hasClaude: true, isMax20: true })
// #when generateModelConfig is called
const result = generateModelConfig(config)
// #then should use higher capability models for Sisyphus
expect(result).toMatchSnapshot()
})
test("uses OpenAI models when only OpenAI is available", () => {
// #given only OpenAI is available
const config = createConfig({ hasOpenAI: true })
// #when generateModelConfig is called
const result = generateModelConfig(config)
// #then should use OpenAI models
expect(result).toMatchSnapshot()
})
test("uses OpenAI models with isMax20 flag", () => {
// #given OpenAI is available with Max 20 plan
const config = createConfig({ hasOpenAI: true, isMax20: true })
// #when generateModelConfig is called
const result = generateModelConfig(config)
// #then should use higher capability models
expect(result).toMatchSnapshot()
})
test("uses Gemini models when only Gemini is available", () => {
// #given only Gemini is available
const config = createConfig({ hasGemini: true })
// #when generateModelConfig is called
const result = generateModelConfig(config)
// #then should use Gemini models
expect(result).toMatchSnapshot()
})
test("uses Gemini models with isMax20 flag", () => {
// #given Gemini is available with Max 20 plan
const config = createConfig({ hasGemini: true, isMax20: true })
// #when generateModelConfig is called
const result = generateModelConfig(config)
// #then should use higher capability models
expect(result).toMatchSnapshot()
})
})
describe("all native providers", () => {
test("uses preferred models from fallback chains when all natives available", () => {
// #given all native providers are available
const config = createConfig({
hasClaude: true,
hasOpenAI: true,
hasGemini: true,
})
// #when generateModelConfig is called
const result = generateModelConfig(config)
// #then should use first provider in each fallback chain
expect(result).toMatchSnapshot()
})
test("uses preferred models with isMax20 flag when all natives available", () => {
// #given all native providers are available with Max 20 plan
const config = createConfig({
hasClaude: true,
hasOpenAI: true,
hasGemini: true,
isMax20: true,
})
// #when generateModelConfig is called
const result = generateModelConfig(config)
// #then should use higher capability models
expect(result).toMatchSnapshot()
})
})
describe("fallback providers", () => {
test("uses OpenCode Zen models when only OpenCode Zen is available", () => {
// #given only OpenCode Zen is available
const config = createConfig({ hasOpencodeZen: true })
// #when generateModelConfig is called
const result = generateModelConfig(config)
// #then should use OPENCODE_ZEN_MODELS
expect(result).toMatchSnapshot()
})
test("uses OpenCode Zen models with isMax20 flag", () => {
// #given OpenCode Zen is available with Max 20 plan
const config = createConfig({ hasOpencodeZen: true, isMax20: true })
// #when generateModelConfig is called
const result = generateModelConfig(config)
// #then should use higher capability models
expect(result).toMatchSnapshot()
})
test("uses GitHub Copilot models when only Copilot is available", () => {
// #given only GitHub Copilot is available
const config = createConfig({ hasCopilot: true })
// #when generateModelConfig is called
const result = generateModelConfig(config)
// #then should use GITHUB_COPILOT_MODELS
expect(result).toMatchSnapshot()
})
test("uses GitHub Copilot models with isMax20 flag", () => {
// #given GitHub Copilot is available with Max 20 plan
const config = createConfig({ hasCopilot: true, isMax20: true })
// #when generateModelConfig is called
const result = generateModelConfig(config)
// #then should use higher capability models
expect(result).toMatchSnapshot()
})
test("uses ZAI model for librarian when only ZAI is available", () => {
// #given only ZAI is available
const config = createConfig({ hasZaiCodingPlan: true })
// #when generateModelConfig is called
const result = generateModelConfig(config)
// #then should use ZAI_MODEL for librarian
expect(result).toMatchSnapshot()
})
test("uses ZAI model for librarian with isMax20 flag", () => {
// #given ZAI is available with Max 20 plan
const config = createConfig({ hasZaiCodingPlan: true, isMax20: true })
// #when generateModelConfig is called
const result = generateModelConfig(config)
// #then should use ZAI_MODEL for librarian
expect(result).toMatchSnapshot()
})
})
describe("mixed provider scenarios", () => {
test("uses Claude + OpenCode Zen combination", () => {
// #given Claude and OpenCode Zen are available
const config = createConfig({
hasClaude: true,
hasOpencodeZen: true,
})
// #when generateModelConfig is called
const result = generateModelConfig(config)
// #then should prefer Claude (native) over OpenCode Zen
expect(result).toMatchSnapshot()
})
test("uses OpenAI + Copilot combination", () => {
// #given OpenAI and Copilot are available
const config = createConfig({
hasOpenAI: true,
hasCopilot: true,
})
// #when generateModelConfig is called
const result = generateModelConfig(config)
// #then should prefer OpenAI (native) over Copilot
expect(result).toMatchSnapshot()
})
test("uses Claude + ZAI combination (librarian uses ZAI)", () => {
// #given Claude and ZAI are available
const config = createConfig({
hasClaude: true,
hasZaiCodingPlan: true,
})
// #when generateModelConfig is called
const result = generateModelConfig(config)
// #then librarian should use ZAI, others use Claude
expect(result).toMatchSnapshot()
})
test("uses Gemini + Claude combination (explore uses Gemini)", () => {
// #given Gemini and Claude are available
const config = createConfig({
hasGemini: true,
hasClaude: true,
})
// #when generateModelConfig is called
const result = generateModelConfig(config)
// #then explore should use Gemini flash
expect(result).toMatchSnapshot()
})
test("uses all fallback providers together", () => {
// #given all fallback providers are available
const config = createConfig({
hasOpencodeZen: true,
hasCopilot: true,
hasZaiCodingPlan: true,
})
// #when generateModelConfig is called
const result = generateModelConfig(config)
// #then should prefer OpenCode Zen, but librarian uses ZAI
expect(result).toMatchSnapshot()
})
test("uses all providers together", () => {
// #given all providers are available
const config = createConfig({
hasClaude: true,
hasOpenAI: true,
hasGemini: true,
hasOpencodeZen: true,
hasCopilot: true,
hasZaiCodingPlan: true,
})
// #when generateModelConfig is called
const result = generateModelConfig(config)
// #then should prefer native providers, librarian uses ZAI
expect(result).toMatchSnapshot()
})
test("uses all providers with isMax20 flag", () => {
// #given all providers are available with Max 20 plan
const config = createConfig({
hasClaude: true,
hasOpenAI: true,
hasGemini: true,
hasOpencodeZen: true,
hasCopilot: true,
hasZaiCodingPlan: true,
isMax20: true,
})
// #when generateModelConfig is called
const result = generateModelConfig(config)
// #then should use higher capability models
expect(result).toMatchSnapshot()
})
})
describe("explore agent special cases", () => {
test("explore uses Gemini flash when Gemini available", () => {
// #given Gemini is available
const config = createConfig({ hasGemini: true })
// #when generateModelConfig is called
const result = generateModelConfig(config)
// #then explore should use gemini-3-flash-preview
expect(result.agents?.explore?.model).toBe("google/gemini-3-flash-preview")
})
test("explore uses Claude haiku when Claude + isMax20 but no Gemini", () => {
// #given Claude is available with Max 20 plan but no Gemini
const config = createConfig({ hasClaude: true, isMax20: true })
// #when generateModelConfig is called
const result = generateModelConfig(config)
// #then explore should use claude-haiku-4-5
expect(result.agents?.explore?.model).toBe("anthropic/claude-haiku-4-5")
})
test("explore uses grok-code when Claude without isMax20 and no Gemini", () => {
// #given Claude is available without Max 20 plan and no Gemini
const config = createConfig({ hasClaude: true, isMax20: false })
// #when generateModelConfig is called
const result = generateModelConfig(config)
// #then explore should use grok-code
expect(result.agents?.explore?.model).toBe("opencode/grok-code")
})
test("explore uses grok-code when only OpenAI available", () => {
// #given only OpenAI is available
const config = createConfig({ hasOpenAI: true })
// #when generateModelConfig is called
const result = generateModelConfig(config)
// #then explore should use grok-code (fallback)
expect(result.agents?.explore?.model).toBe("opencode/grok-code")
})
})
describe("Sisyphus agent special cases", () => {
test("Sisyphus uses sisyphus-high capability when isMax20 is true", () => {
// #given Claude is available with Max 20 plan
const config = createConfig({ hasClaude: true, isMax20: true })
// #when generateModelConfig is called
const result = generateModelConfig(config)
// #then Sisyphus should use opus (sisyphus-high)
expect(result.agents?.Sisyphus?.model).toBe("anthropic/claude-opus-4-5")
})
test("Sisyphus uses sisyphus-low capability when isMax20 is false", () => {
// #given Claude is available without Max 20 plan
const config = createConfig({ hasClaude: true, isMax20: false })
// #when generateModelConfig is called
const result = generateModelConfig(config)
// #then Sisyphus should use sonnet (sisyphus-low)
expect(result.agents?.Sisyphus?.model).toBe("anthropic/claude-sonnet-4-5")
})
})
describe("librarian agent special cases", () => {
test("librarian uses ZAI when ZAI is available regardless of other providers", () => {
// #given ZAI and Claude are available
const config = createConfig({
hasClaude: true,
hasZaiCodingPlan: true,
})
// #when generateModelConfig is called
const result = generateModelConfig(config)
// #then librarian should use ZAI_MODEL
expect(result.agents?.librarian?.model).toBe("zai-coding-plan/glm-4.7")
})
test("librarian uses claude-sonnet when ZAI not available but Claude is", () => {
// #given only Claude is available (no ZAI)
const config = createConfig({ hasClaude: true })
// #when generateModelConfig is called
const result = generateModelConfig(config)
// #then librarian should use claude-sonnet-4-5 (third in fallback chain after ZAI and opencode/glm)
expect(result.agents?.librarian?.model).toBe("anthropic/claude-sonnet-4-5")
})
})
describe("schema URL", () => {
test("always includes correct schema URL", () => {
// #given any config
const config = createConfig()
// #when generateModelConfig is called
const result = generateModelConfig(config)
// #then should include correct schema URL
expect(result.$schema).toBe(
"https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json"
)
})
})
})

View File

@@ -1,17 +1,10 @@
import {
AGENT_MODEL_REQUIREMENTS,
CATEGORY_MODEL_REQUIREMENTS,
type FallbackEntry,
} from "../shared/model-requirements"
import type { InstallConfig } from "./types"
type NativeProvider = "claude" | "openai" | "gemini"
type ModelCapability =
| "unspecified-high"
| "unspecified-low"
| "quick"
| "ultrabrain"
| "visual-engineering"
| "artistry"
| "writing"
| "glm"
interface ProviderAvailability {
native: {
claude: boolean
@@ -41,106 +34,8 @@ export interface GeneratedOmoConfig {
[key: string]: unknown
}
interface NativeFallbackEntry {
provider: NativeProvider
model: string
}
const NATIVE_FALLBACK_CHAINS: Record<ModelCapability, NativeFallbackEntry[]> = {
"unspecified-high": [
{ provider: "claude", model: "anthropic/claude-opus-4-5" },
{ provider: "openai", model: "openai/gpt-5.2" },
{ provider: "gemini", model: "google/gemini-3-pro-preview" },
],
"unspecified-low": [
{ provider: "claude", model: "anthropic/claude-sonnet-4-5" },
{ provider: "openai", model: "openai/gpt-5.2" },
{ provider: "gemini", model: "google/gemini-3-flash-preview" },
],
quick: [
{ provider: "claude", model: "anthropic/claude-haiku-4-5" },
{ provider: "openai", model: "openai/gpt-5.1-codex-mini" },
{ provider: "gemini", model: "google/gemini-3-flash-preview" },
],
ultrabrain: [
{ provider: "openai", model: "openai/gpt-5.2-codex" },
{ provider: "claude", model: "anthropic/claude-opus-4-5" },
{ provider: "gemini", model: "google/gemini-3-pro-preview" },
],
"visual-engineering": [
{ provider: "gemini", model: "google/gemini-3-pro-preview" },
{ provider: "openai", model: "openai/gpt-5.2" },
{ provider: "claude", model: "anthropic/claude-sonnet-4-5" },
],
artistry: [
{ provider: "gemini", model: "google/gemini-3-pro-preview" },
{ provider: "openai", model: "openai/gpt-5.2" },
{ provider: "claude", model: "anthropic/claude-opus-4-5" },
],
writing: [
{ provider: "gemini", model: "google/gemini-3-flash-preview" },
{ provider: "openai", model: "openai/gpt-5.2" },
{ provider: "claude", model: "anthropic/claude-sonnet-4-5" },
],
glm: [],
}
const OPENCODE_ZEN_MODELS: Record<ModelCapability, string> = {
"unspecified-high": "opencode/claude-opus-4-5",
"unspecified-low": "opencode/claude-sonnet-4-5",
quick: "opencode/claude-haiku-4-5",
ultrabrain: "opencode/gpt-5.2-codex",
"visual-engineering": "opencode/gemini-3-pro",
artistry: "opencode/gemini-3-pro",
writing: "opencode/gemini-3-flash",
glm: "opencode/glm-4.7-free",
}
const GITHUB_COPILOT_MODELS: Record<ModelCapability, string> = {
"unspecified-high": "github-copilot/claude-opus-4.5",
"unspecified-low": "github-copilot/claude-sonnet-4.5",
quick: "github-copilot/claude-haiku-4.5",
ultrabrain: "github-copilot/gpt-5.2-codex",
"visual-engineering": "github-copilot/gemini-3-pro-preview",
artistry: "github-copilot/gemini-3-pro-preview",
writing: "github-copilot/gemini-3-flash-preview",
glm: "github-copilot/gpt-5.2",
}
const ZAI_MODEL = "zai-coding-plan/glm-4.7"
interface AgentRequirement {
capability: ModelCapability
variant?: string
}
const AGENT_REQUIREMENTS: Record<string, AgentRequirement> = {
Sisyphus: { capability: "unspecified-high" },
oracle: { capability: "ultrabrain", variant: "high" },
librarian: { capability: "glm" },
explore: { capability: "quick" },
"multimodal-looker": { capability: "visual-engineering" },
"Prometheus (Planner)": { capability: "unspecified-high" },
"Metis (Plan Consultant)": { capability: "unspecified-high" },
"Momus (Plan Reviewer)": { capability: "ultrabrain", variant: "medium" },
Atlas: { capability: "unspecified-high" },
}
interface CategoryRequirement {
capability: ModelCapability
variant?: string
}
const CATEGORY_REQUIREMENTS: Record<string, CategoryRequirement> = {
"visual-engineering": { capability: "visual-engineering" },
ultrabrain: { capability: "ultrabrain" },
artistry: { capability: "artistry", variant: "max" },
quick: { capability: "quick" },
"unspecified-low": { capability: "unspecified-low" },
"unspecified-high": { capability: "unspecified-high" },
writing: { capability: "writing" },
}
const ULTIMATE_FALLBACK = "opencode/glm-4.7-free"
const SCHEMA_URL = "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json"
@@ -158,31 +53,58 @@ function toProviderAvailability(config: InstallConfig): ProviderAvailability {
}
}
function resolveModel(capability: ModelCapability, avail: ProviderAvailability): string {
const nativeChain = NATIVE_FALLBACK_CHAINS[capability]
for (const entry of nativeChain) {
if (avail.native[entry.provider]) {
return entry.model
}
function isProviderAvailable(provider: string, avail: ProviderAvailability): boolean {
const mapping: Record<string, boolean> = {
anthropic: avail.native.claude,
openai: avail.native.openai,
google: avail.native.gemini,
"github-copilot": avail.copilot,
opencode: avail.opencodeZen,
"zai-coding-plan": avail.zai,
}
if (avail.opencodeZen) {
return OPENCODE_ZEN_MODELS[capability]
}
if (avail.copilot) {
return GITHUB_COPILOT_MODELS[capability]
}
if (avail.zai) {
return ZAI_MODEL
}
return ULTIMATE_FALLBACK
return mapping[provider] ?? false
}
function resolveClaudeCapability(avail: ProviderAvailability): ModelCapability {
return avail.isMaxPlan ? "unspecified-high" : "unspecified-low"
function transformModelForProvider(provider: string, model: string): string {
if (provider === "github-copilot") {
return model
.replace("claude-opus-4-5", "claude-opus-4.5")
.replace("claude-sonnet-4-5", "claude-sonnet-4.5")
.replace("claude-haiku-4-5", "claude-haiku-4.5")
.replace("claude-sonnet-4", "claude-sonnet-4")
}
return model
}
function resolveModelFromChain(
fallbackChain: FallbackEntry[],
avail: ProviderAvailability
): { model: string; variant?: string } | null {
for (const entry of fallbackChain) {
for (const provider of entry.providers) {
if (isProviderAvailable(provider, avail)) {
const transformedModel = transformModelForProvider(provider, entry.model)
return {
model: `${provider}/${transformedModel}`,
variant: entry.variant,
}
}
}
}
return null
}
function getSisyphusFallbackChain(isMaxPlan: boolean): FallbackEntry[] {
// Sisyphus uses opus when isMaxPlan, sonnet otherwise
if (isMaxPlan) {
return AGENT_MODEL_REQUIREMENTS.Sisyphus.fallbackChain
}
// For non-max plan, use sonnet instead of opus
return [
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-sonnet-4-5" },
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2", variant: "high" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro-preview" },
]
}
export function generateModelConfig(config: InstallConfig): GeneratedOmoConfig {
@@ -199,10 +121,10 @@ export function generateModelConfig(config: InstallConfig): GeneratedOmoConfig {
return {
$schema: SCHEMA_URL,
agents: Object.fromEntries(
Object.keys(AGENT_REQUIREMENTS).map((role) => [role, { model: ULTIMATE_FALLBACK }])
Object.keys(AGENT_MODEL_REQUIREMENTS).map((role) => [role, { model: ULTIMATE_FALLBACK }])
),
categories: Object.fromEntries(
Object.keys(CATEGORY_REQUIREMENTS).map((cat) => [cat, { model: ULTIMATE_FALLBACK }])
Object.keys(CATEGORY_MODEL_REQUIREMENTS).map((cat) => [cat, { model: ULTIMATE_FALLBACK }])
),
}
}
@@ -210,28 +132,52 @@ export function generateModelConfig(config: InstallConfig): GeneratedOmoConfig {
const agents: Record<string, AgentConfig> = {}
const categories: Record<string, CategoryConfig> = {}
const claudeCapability = resolveClaudeCapability(avail)
for (const [role, req] of Object.entries(AGENT_REQUIREMENTS)) {
for (const [role, req] of Object.entries(AGENT_MODEL_REQUIREMENTS)) {
// Special case: librarian always uses ZAI first if available
if (role === "librarian" && avail.zai) {
agents[role] = { model: ZAI_MODEL }
} else if (role === "explore") {
if (avail.native.claude && avail.isMaxPlan) {
continue
}
// Special case: explore has custom Gemini → Claude → Grok logic
if (role === "explore") {
if (avail.native.gemini) {
agents[role] = { model: "google/gemini-3-flash-preview" }
} else if (avail.native.claude && avail.isMaxPlan) {
agents[role] = { model: "anthropic/claude-haiku-4-5" }
} else {
agents[role] = { model: "opencode/grok-code" }
}
continue
}
// Special case: Sisyphus uses different fallbackChain based on isMaxPlan
const fallbackChain =
role === "Sisyphus" ? getSisyphusFallbackChain(avail.isMaxPlan) : req.fallbackChain
const resolved = resolveModelFromChain(fallbackChain, avail)
if (resolved) {
const variant = resolved.variant ?? req.variant
agents[role] = variant ? { model: resolved.model, variant } : { model: resolved.model }
} else {
const capability = req.capability === "unspecified-high" ? claudeCapability : req.capability
const model = resolveModel(capability, avail)
agents[role] = req.variant ? { model, variant: req.variant } : { model }
agents[role] = { model: ULTIMATE_FALLBACK }
}
}
for (const [cat, req] of Object.entries(CATEGORY_REQUIREMENTS)) {
const capability = req.capability === "unspecified-high" ? claudeCapability : req.capability
const model = resolveModel(capability, avail)
categories[cat] = req.variant ? { model, variant: req.variant } : { model }
for (const [cat, req] of Object.entries(CATEGORY_MODEL_REQUIREMENTS)) {
// Special case: unspecified-high downgrades to unspecified-low when not isMaxPlan
const fallbackChain =
cat === "unspecified-high" && !avail.isMaxPlan
? CATEGORY_MODEL_REQUIREMENTS["unspecified-low"].fallbackChain
: req.fallbackChain
const resolved = resolveModelFromChain(fallbackChain, avail)
if (resolved) {
const variant = resolved.variant ?? req.variant
categories[cat] = variant ? { model: resolved.model, variant } : { model: resolved.model }
} else {
categories[cat] = { model: ULTIMATE_FALLBACK }
}
}
return {

View File

@@ -154,7 +154,7 @@ function logEventVerbose(ctx: RunContext, payload: EventPayload): void {
const input = toolProps?.input ?? {}
const inputStr = JSON.stringify(input).slice(0, 150)
console.error(
pc.cyan(`${sessionTag} TOOL.EXECUTE: ${pc.bold(toolName)}`)
pc.cyan(`${sessionTag} TOOL.EXECUTE: ${pc.bold(toolName)}`)
)
console.error(pc.dim(` input: ${inputStr}${inputStr.length >= 150 ? "..." : ""}`))
break
@@ -165,7 +165,7 @@ function logEventVerbose(ctx: RunContext, payload: EventPayload): void {
const output = resultProps?.output ?? ""
const preview = output.slice(0, 200).replace(/\n/g, "\\n")
console.error(
pc.green(`${sessionTag} TOOL.RESULT: "${preview}${output.length > 200 ? "..." : ""}"`)
pc.green(`${sessionTag} TOOL.RESULT: "${preview}${output.length > 200 ? "..." : ""}"`)
)
break
}
@@ -173,7 +173,7 @@ function logEventVerbose(ctx: RunContext, payload: EventPayload): void {
case "session.error": {
const errorProps = props as SessionErrorProps | undefined
const errorMsg = serializeError(errorProps?.error)
console.error(pc.red(`${sessionTag} SESSION.ERROR: ${errorMsg}`))
console.error(pc.red(`${sessionTag} SESSION.ERROR: ${errorMsg}`))
break
}
@@ -296,7 +296,7 @@ function handleToolExecute(
}
}
process.stdout.write(`\n${pc.cyan("")} ${pc.bold(toolName)}${inputPreview}\n`)
process.stdout.write(`\n${pc.cyan(">")} ${pc.bold(toolName)}${inputPreview}\n`)
}
function handleToolResult(

View File

@@ -148,6 +148,8 @@ export const SisyphusAgentConfigSchema = z.object({
})
export const CategoryConfigSchema = z.object({
/** Human-readable description of the category's purpose. Shown in delegate_task prompt. */
description: z.string().optional(),
model: z.string().optional(),
variant: z.string().optional(),
temperature: z.number().min(0).max(2).optional(),
@@ -276,8 +278,8 @@ export const RalphLoopConfigSchema = z.object({
export const BackgroundTaskConfigSchema = z.object({
defaultConcurrency: z.number().min(1).optional(),
providerConcurrency: z.record(z.string(), z.number().min(1)).optional(),
modelConcurrency: z.record(z.string(), z.number().min(1)).optional(),
providerConcurrency: z.record(z.string(), z.number().min(0)).optional(),
modelConcurrency: z.record(z.string(), z.number().min(0)).optional(),
/** Stale timeout in milliseconds - interrupt tasks with no activity for this duration (default: 180000 = 3 minutes, minimum: 60000 = 1 minute) */
staleTimeoutMs: z.number().min(60000).optional(),
})

View File

@@ -2,32 +2,30 @@
## OVERVIEW
Core feature modules + Claude Code compatibility layer. Background agents, skill MCP, builtin skills/commands, and 5 loaders for Claude Code compat.
Core feature modules + Claude Code compatibility layer. Background agents, skill MCP, builtin skills/commands, 5 loaders.
## STRUCTURE
```
features/
├── background-agent/ # Task lifecycle (1165 lines manager.ts)
│ ├── manager.ts # Launch → poll → complete orchestration
│ ├── concurrency.ts # Per-provider/model limits
├── background-agent/ # Task lifecycle (1335 lines)
│ ├── manager.ts # Launch → poll → complete
│ ├── concurrency.ts # Per-provider limits
│ └── types.ts # BackgroundTask, LaunchInput
├── skill-mcp-manager/ # MCP client lifecycle
│ ├── manager.ts # Lazy loading, idle cleanup
│ └── types.ts # SkillMcpConfig, transports
│ ├── manager.ts # Lazy loading, cleanup
│ └── types.ts # SkillMcpConfig
├── builtin-skills/ # Playwright, git-master, frontend-ui-ux
│ └── skills.ts # 1203 lines of skill definitions
│ └── skills.ts # 1203 lines
├── builtin-commands/ # ralph-loop, refactor, init-deep
│ └── templates/ # Command implementations
├── claude-code-agent-loader/ # ~/.claude/agents/*.md
├── claude-code-command-loader/ # ~/.claude/commands/*.md
├── claude-code-mcp-loader/ # .mcp.json with ${VAR} expansion
├── claude-code-mcp-loader/ # .mcp.json
├── claude-code-plugin-loader/ # installed_plugins.json
├── claude-code-session-state/ # Session state persistence
├── claude-code-session-state/ # Session persistence
├── opencode-skill-loader/ # Skills from 6 directories
├── context-injector/ # AGENTS.md/README.md injection
├── boulder-state/ # Todo state persistence
├── task-toast-manager/ # Toast notifications
└── hook-message-injector/ # Message injection
```
@@ -35,43 +33,25 @@ features/
| Type | Priority (highest first) |
|------|--------------------------|
| Commands | `.opencode/command/` > `~/.config/opencode/command/` > `.claude/commands/` > `~/.claude/commands/` |
| Skills | `.opencode/skills/` > `~/.config/opencode/skills/` > `.claude/skills/` > `~/.claude/skills/` |
| Agents | `.claude/agents/` > `~/.claude/agents/` |
| Commands | `.opencode/command/` > `~/.config/opencode/command/` > `.claude/commands/` |
| Skills | `.opencode/skills/` > `~/.config/opencode/skills/` > `.claude/skills/` |
| MCPs | `.claude/.mcp.json` > `.mcp.json` > `~/.claude/.mcp.json` |
## BACKGROUND AGENT
- **Lifecycle**: `launch``poll` (2s interval) → `complete`
- **Stability**: 3 consecutive polls with same message count = idle
- **Concurrency**: Per-provider/model limits (e.g., max 3 Opus, max 10 Gemini)
- **Notification**: Batched system reminders to parent session
- **Cleanup**: 30m TTL, 3m stale timeout, signal handlers
- **Lifecycle**: `launch``poll` (2s) → `complete`
- **Stability**: 3 consecutive polls = idle
- **Concurrency**: Per-provider/model limits
- **Cleanup**: 30m TTL, 3m stale timeout
## SKILL MCP
- **Lazy**: Clients created on first tool call
- **Transports**: stdio (local process), http (SSE/Streamable)
- **Environment**: `${VAR}` expansion in config
- **Lifecycle**: 5m idle cleanup, session-scoped
## CONFIG TOGGLES
```jsonc
{
"claude_code": {
"mcp": false, // Skip .mcp.json
"commands": false, // Skip commands/*.md
"skills": false, // Skip skills/*/SKILL.md
"agents": false, // Skip agents/*.md
"hooks": false // Skip settings.json hooks
}
}
```
- **Lazy**: Clients created on first call
- **Transports**: stdio, http (SSE/Streamable)
- **Lifecycle**: 5m idle cleanup
## ANTI-PATTERNS
- **Sequential delegation**: Use `delegate_task` for parallel
- **Trust self-reports**: ALWAYS verify agent outputs
- **Sequential delegation**: Use `delegate_task` parallel
- **Trust self-reports**: ALWAYS verify
- **Main thread blocks**: No heavy I/O in loader init
- **Manual versioning**: CI manages package.json version

View File

@@ -275,8 +275,8 @@ For each generated file:
Mode: {update | create-new}
Files:
./AGENTS.md (root, {N} lines)
./src/hooks/AGENTS.md ({N} lines)
[OK] ./AGENTS.md (root, {N} lines)
[OK] ./src/hooks/AGENTS.md ({N} lines)
Dirs Analyzed: {N}
AGENTS.md Created: {N}

View File

@@ -31,7 +31,7 @@ export const START_WORK_TEMPLATE = `You are starting a Sisyphus work session.
When listing plans for selection:
\`\`\`
📋 Available Work Plans
Available Work Plans
Current Time: {ISO timestamp}
Session ID: {current session id}
@@ -44,7 +44,7 @@ Which plan would you like to work on? (Enter number or plan name)
When resuming existing work:
\`\`\`
🔄 Resuming Work Session
Resuming Work Session
Active Plan: {plan-name}
Progress: {completed}/{total} tasks
@@ -55,7 +55,7 @@ Reading plan and continuing from last incomplete task...
When auto-selecting single plan:
\`\`\`
🚀 Starting Work Session
Starting Work Session
Plan: {plan-name}
Session ID: {session_id}

View File

@@ -1,10 +1,9 @@
import { promises as fs, type Dirent } from "fs"
import { join, basename } from "path"
import { homedir } from "os"
import { parseFrontmatter } from "../../shared/frontmatter"
import { sanitizeModelField } from "../../shared/model-sanitizer"
import { isMarkdownFile } from "../../shared/file-utils"
import { getClaudeConfigDir } from "../../shared"
import { getClaudeConfigDir, getOpenCodeConfigDir } from "../../shared"
import { log } from "../../shared/logger"
import type { CommandScope, CommandDefinition, CommandFrontmatter, LoadedCommand } from "./types"
@@ -122,7 +121,8 @@ export async function loadProjectCommands(): Promise<Record<string, CommandDefin
}
export async function loadOpencodeGlobalCommands(): Promise<Record<string, CommandDefinition>> {
const opencodeCommandsDir = join(homedir(), ".config", "opencode", "command")
const configDir = getOpenCodeConfigDir({ binary: "opencode" })
const opencodeCommandsDir = join(configDir, "command")
const commands = await loadCommandsFromDir(opencodeCommandsDir, "opencode")
return commandsToRecord(commands)
}

View File

@@ -160,7 +160,7 @@ describe("TaskToastManager", () => {
// #then - toast should NOT show warning - category default is expected
expect(mockClient.tui.showToast).toHaveBeenCalled()
const call = mockClient.tui.showToast.mock.calls[0][0]
expect(call.body.message).not.toContain("⚠️")
expect(call.body.message).not.toContain("[FALLBACK]")
expect(call.body.message).not.toContain("(category default)")
})
@@ -180,7 +180,7 @@ describe("TaskToastManager", () => {
// #then - toast should show fallback warning
expect(mockClient.tui.showToast).toHaveBeenCalled()
const call = mockClient.tui.showToast.mock.calls[0][0]
expect(call.body.message).toContain("⚠️")
expect(call.body.message).toContain("[FALLBACK]")
expect(call.body.message).toContain("anthropic/claude-sonnet-4-5")
expect(call.body.message).toContain("(system default fallback)")
})
@@ -201,7 +201,7 @@ describe("TaskToastManager", () => {
// #then - toast should show fallback warning
expect(mockClient.tui.showToast).toHaveBeenCalled()
const call = mockClient.tui.showToast.mock.calls[0][0]
expect(call.body.message).toContain("⚠️")
expect(call.body.message).toContain("[FALLBACK]")
expect(call.body.message).toContain("cliproxy/claude-opus-4-5")
expect(call.body.message).toContain("(inherited from parent)")
})
@@ -222,7 +222,7 @@ describe("TaskToastManager", () => {
// #then - toast should NOT show model warning
expect(mockClient.tui.showToast).toHaveBeenCalled()
const call = mockClient.tui.showToast.mock.calls[0][0]
expect(call.body.message).not.toContain("⚠️ Model:")
expect(call.body.message).not.toContain("[FALLBACK] Model:")
expect(call.body.message).not.toContain("(inherited)")
expect(call.body.message).not.toContain("(category default)")
expect(call.body.message).not.toContain("(system default)")
@@ -243,7 +243,7 @@ describe("TaskToastManager", () => {
// #then - toast should NOT show model warning
expect(mockClient.tui.showToast).toHaveBeenCalled()
const call = mockClient.tui.showToast.mock.calls[0][0]
expect(call.body.message).not.toContain("⚠️ Model:")
expect(call.body.message).not.toContain("[FALLBACK] Model:")
})
})
})

View File

@@ -24,6 +24,7 @@ export class TaskToastManager {
agent: string
isBackground: boolean
status?: TaskStatus
category?: string
skills?: string[]
modelInfo?: ModelFallbackInfo
}): void {
@@ -34,6 +35,7 @@ export class TaskToastManager {
status: task.status ?? "running",
startedAt: new Date(),
isBackground: task.isBackground,
category: task.category,
skills: task.skills,
modelInfo: task.modelInfo,
}
@@ -116,7 +118,7 @@ export class TaskToastManager {
"system-default": " (system default fallback)",
}
const suffix = suffixMap[newTask.modelInfo!.type as "inherited" | "system-default"]
lines.push(`⚠️ Model fallback: ${newTask.modelInfo!.model}${suffix}`)
lines.push(`[FALLBACK] Model: ${newTask.modelInfo!.model}${suffix}`)
lines.push("")
}
@@ -124,10 +126,11 @@ export class TaskToastManager {
lines.push(`Running (${running.length}):${concurrencyInfo}`)
for (const task of running) {
const duration = this.formatDuration(task.startedAt)
const bgIcon = task.isBackground ? "" : "🔄"
const bgIcon = task.isBackground ? "[BG]" : "[RUN]"
const isNew = task.id === newTask.id ? " ← NEW" : ""
const categoryInfo = task.category ? `/${task.category}` : ""
const skillsInfo = task.skills?.length ? ` [${task.skills.join(", ")}]` : ""
lines.push(`${bgIcon} ${task.description} (${task.agent})${skillsInfo} - ${duration}${isNew}`)
lines.push(`${bgIcon} ${task.description} (${task.agent}${categoryInfo})${skillsInfo} - ${duration}${isNew}`)
}
}
@@ -135,10 +138,11 @@ export class TaskToastManager {
if (lines.length > 0) lines.push("")
lines.push(`Queued (${queued.length}):`)
for (const task of queued) {
const bgIcon = task.isBackground ? "" : "⏸️"
const bgIcon = task.isBackground ? "[Q]" : "[W]"
const categoryInfo = task.category ? `/${task.category}` : ""
const skillsInfo = task.skills?.length ? ` [${task.skills.join(", ")}]` : ""
const isNew = task.id === newTask.id ? " ← NEW" : ""
lines.push(`${bgIcon} ${task.description} (${task.agent})${skillsInfo} - Queued${isNew}`)
lines.push(`${bgIcon} ${task.description} (${task.agent}${categoryInfo})${skillsInfo} - Queued${isNew}`)
}
}
@@ -158,8 +162,8 @@ export class TaskToastManager {
const queued = this.getQueuedTasks()
const title = newTask.isBackground
? `New Background Task`
: `🔄 New Task Executed`
? `New Background Task`
: `New Task Executed`
tuiClient.tui.showToast({
body: {
@@ -184,7 +188,7 @@ export class TaskToastManager {
const remaining = this.getRunningTasks()
const queued = this.getQueuedTasks()
let message = `"${task.description}" finished in ${task.duration}`
let message = `"${task.description}" finished in ${task.duration}`
if (remaining.length > 0 || queued.length > 0) {
message += `\n\nStill running: ${remaining.length} | Queued: ${queued.length}`
}

View File

@@ -1,8 +1,11 @@
import type { ModelSource } from "../../shared/model-resolver"
export type TaskStatus = "running" | "queued" | "completed" | "error"
export interface ModelFallbackInfo {
model: string
type: "user-defined" | "inherited" | "category-default" | "system-default"
source?: ModelSource
}
export interface TrackedTask {
@@ -12,6 +15,7 @@ export interface TrackedTask {
status: TaskStatus
startedAt: Date
isBackground: boolean
category?: string
skills?: string[]
modelInfo?: ModelFallbackInfo
}

View File

@@ -8,24 +8,26 @@
```
hooks/
├── atlas/ # Main orchestration & delegation (771 lines)
├── anthropic-context-window-limit-recovery/ # Auto-summarize at token limit
├── atlas/ # Main orchestration (771 lines)
├── anthropic-context-window-limit-recovery/ # Auto-summarize
├── todo-continuation-enforcer.ts # Force TODO completion
├── ralph-loop/ # Self-referential dev loop until done
├── claude-code-hooks/ # settings.json hook compat layer (13 files)
├── comment-checker/ # Prevents AI slop/excessive comments
├── ralph-loop/ # Self-referential dev loop
├── claude-code-hooks/ # settings.json compat layer - see AGENTS.md
├── comment-checker/ # Prevents AI slop
├── auto-slash-command/ # Detects /command patterns
├── rules-injector/ # Conditional rules from .claude/rules/
├── directory-agents-injector/ # Auto-injects AGENTS.md files
├── directory-readme-injector/ # Auto-injects README.md files
├── preemptive-compaction/ # Triggers summary at 85% context
├── edit-error-recovery/ # Recovers from tool failures
├── thinking-block-validator/ # Ensures valid <thinking> format
├── context-window-monitor.ts # Reminds agents of remaining headroom
├── rules-injector/ # Conditional rules
├── directory-agents-injector/ # Auto-injects AGENTS.md
├── directory-readme-injector/ # Auto-injects README.md
├── edit-error-recovery/ # Recovers from failures
├── thinking-block-validator/ # Ensures valid <thinking>
├── context-window-monitor.ts # Reminds of headroom
├── session-recovery/ # Auto-recovers from crashes
├── think-mode/ # Dynamic thinking budget
├── keyword-detector/ # ultrawork/search/analyze modes
├── background-notification/ # OS notification on task completion
├── background-notification/ # OS notification
├── prometheus-md-only/ # Planner read-only mode
├── agent-usage-reminder/ # Specialized agent hints
├── auto-update-checker/ # Plugin update check
└── tool-output-truncator.ts # Prevents context bloat
```
@@ -33,41 +35,37 @@ hooks/
| Event | Timing | Can Block | Use Case |
|-------|--------|-----------|----------|
| PreToolUse | Before tool | Yes | Validate/modify inputs, inject context |
| PostToolUse | After tool | No | Append warnings, truncate output |
| UserPromptSubmit | On prompt | Yes | Keyword detection, mode switching |
| Stop | Session idle | No | Auto-continue (todo-continuation, ralph-loop) |
| onSummarize | Compaction | No | Preserve critical state |
| PreToolUse | Before tool | Yes | Validate/modify inputs |
| PostToolUse | After tool | No | Append warnings, truncate |
| UserPromptSubmit | On prompt | Yes | Keyword detection |
| Stop | Session idle | No | Auto-continue |
| onSummarize | Compaction | No | Preserve state |
## EXECUTION ORDER
**chat.message**: keywordDetector → claudeCodeHooks → autoSlashCommand → startWork → ralphLoop
**tool.execute.before**: claudeCodeHooks → nonInteractiveEnv → commentChecker → directoryAgentsInjector → directoryReadmeInjector → rulesInjector
**tool.execute.before**: claudeCodeHooks → nonInteractiveEnv → commentChecker → directoryAgentsInjector → rulesInjector
**tool.execute.after**: editErrorRecovery → delegateTaskRetry → commentChecker → toolOutputTruncator → emptyTaskResponseDetector → claudeCodeHooks
**tool.execute.after**: editErrorRecovery → delegateTaskRetry → commentChecker → toolOutputTruncator → claudeCodeHooks
## HOW TO ADD
1. Create `src/hooks/name/` with `index.ts` exporting `createMyHook(ctx)`
2. Implement event handlers: `"tool.execute.before"`, `"tool.execute.after"`, etc.
3. Add hook name to `HookNameSchema` in `src/config/schema.ts`
4. Register in `src/index.ts`:
2. Add hook name to `HookNameSchema` in `src/config/schema.ts`
3. Register in `src/index.ts`:
```typescript
const myHook = isHookEnabled("my-hook") ? createMyHook(ctx) : null
// Add to event handlers
```
## PATTERNS
- **Session-scoped state**: `Map<sessionID, Set<string>>` for tracking per-session
- **Session-scoped state**: `Map<sessionID, Set<string>>`
- **Conditional execution**: Check `input.tool` before processing
- **Output modification**: `output.output += "\n${REMINDER}"` to append context
- **Async state**: Use promises for CLI path resolution, cache results
- **Output modification**: `output.output += "\n${REMINDER}"`
## ANTI-PATTERNS
- **Blocking non-critical**: Use PostToolUse warnings instead of PreToolUse blocks
- **Heavy computation**: Keep PreToolUse light - slows every tool call
- **Redundant injection**: Track injected files to prevent duplicates
- **Verbose output**: Keep hook messages technical, brief
- **Blocking non-critical**: Use PostToolUse warnings instead
- **Heavy computation**: Keep PreToolUse light
- **Redundant injection**: Track injected files

View File

@@ -68,7 +68,7 @@ const VERIFICATION_REMINDER = `**MANDATORY: WHAT YOU MUST DO RIGHT NOW**
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
⚠️ CRITICAL: Subagents FREQUENTLY LIE about completion.
CRITICAL: Subagents FREQUENTLY LIE about completion.
Tests FAILING, code has ERRORS, implementation INCOMPLETE - but they say "done".
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
@@ -107,7 +107,7 @@ const ORCHESTRATOR_DELEGATION_REQUIRED = `
---
⚠️⚠️⚠️ ${createSystemDirective(SystemDirectiveTypes.DELEGATION_REQUIRED)} ⚠️⚠️⚠️
${createSystemDirective(SystemDirectiveTypes.DELEGATION_REQUIRED)}
**STOP. YOU ARE VIOLATING ORCHESTRATOR PROTOCOL.**
@@ -117,7 +117,7 @@ You (Atlas) are attempting to directly modify a file outside \`.sisyphus/\`.
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
🚫 **THIS IS FORBIDDEN** (except for VERIFICATION purposes)
**THIS IS FORBIDDEN** (except for VERIFICATION purposes)
As an ORCHESTRATOR, you MUST:
1. **DELEGATE** all implementation work via \`delegate_task\`
@@ -148,7 +148,7 @@ delegate_task(
)
\`\`\`
⚠️⚠️⚠️ DELEGATE. DON'T IMPLEMENT. ⚠️⚠️⚠️
DELEGATE. DON'T IMPLEMENT.
---
`

View File

@@ -1,12 +1,12 @@
import { existsSync, readdirSync, readFileSync } from "fs"
import { join, basename, dirname } from "path"
import { homedir } from "os"
import {
parseFrontmatter,
resolveCommandsInText,
resolveFileReferencesInText,
sanitizeModelField,
getClaudeConfigDir,
getOpenCodeConfigDir,
} from "../../shared"
import type { CommandFrontmatter } from "../../features/claude-code-command-loader/types"
import { isMarkdownFile } from "../../shared/file-utils"
@@ -101,9 +101,10 @@ export interface ExecutorOptions {
}
async function discoverAllCommands(options?: ExecutorOptions): Promise<CommandInfo[]> {
const configDir = getOpenCodeConfigDir({ binary: "opencode" })
const userCommandsDir = join(getClaudeConfigDir(), "commands")
const projectCommandsDir = join(process.cwd(), ".claude", "commands")
const opencodeGlobalDir = join(homedir(), ".config", "opencode", "command")
const opencodeGlobalDir = join(configDir, "command")
const opencodeProjectDir = join(process.cwd(), ".opencode", "command")
const userCommands = discoverCommandsFromDir(userCommandsDir, "user")

View File

@@ -1,16 +1,12 @@
import * as path from "node:path"
import * as os from "node:os"
import * as fs from "node:fs"
import { getOpenCodeConfigDir } from "../../shared"
export const PACKAGE_NAME = "oh-my-opencode"
export const NPM_REGISTRY_URL = `https://registry.npmjs.org/-/package/${PACKAGE_NAME}/dist-tags`
export const NPM_FETCH_TIMEOUT = 5000
/**
* OpenCode plugin cache directory
* - Linux/macOS: ~/.cache/opencode/
* - Windows: %LOCALAPPDATA%/opencode/
*/
function getCacheDir(): string {
if (process.platform === "win32") {
return path.join(process.env.LOCALAPPDATA ?? os.homedir(), "opencode")
@@ -27,38 +23,11 @@ export const INSTALLED_PACKAGE_JSON = path.join(
"package.json"
)
/**
* OpenCode config file locations (priority order)
* On Windows, checks ~/.config first (cross-platform), then %APPDATA% (fallback)
* This matches shared/config-path.ts behavior for consistency
*/
function getUserConfigDir(): string {
if (process.platform === "win32") {
const crossPlatformDir = path.join(os.homedir(), ".config")
const appdataDir = process.env.APPDATA ?? path.join(os.homedir(), "AppData", "Roaming")
// Check cross-platform path first (~/.config)
const crossPlatformConfig = path.join(crossPlatformDir, "opencode", "opencode.json")
const crossPlatformConfigJsonc = path.join(crossPlatformDir, "opencode", "opencode.jsonc")
if (fs.existsSync(crossPlatformConfig) || fs.existsSync(crossPlatformConfigJsonc)) {
return crossPlatformDir
}
// Fall back to %APPDATA%
return appdataDir
}
return process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config")
}
/**
* Get the Windows-specific APPDATA directory (for fallback checks)
*/
export function getWindowsAppdataDir(): string | null {
if (process.platform !== "win32") return null
return process.env.APPDATA ?? path.join(os.homedir(), "AppData", "Roaming")
}
export const USER_CONFIG_DIR = getUserConfigDir()
export const USER_OPENCODE_CONFIG = path.join(USER_CONFIG_DIR, "opencode", "opencode.json")
export const USER_OPENCODE_CONFIG_JSONC = path.join(USER_CONFIG_DIR, "opencode", "opencode.jsonc")
export const USER_CONFIG_DIR = getOpenCodeConfigDir({ binary: "opencode" })
export const USER_OPENCODE_CONFIG = path.join(USER_CONFIG_DIR, "opencode.json")
export const USER_OPENCODE_CONFIG_JSONC = path.join(USER_CONFIG_DIR, "opencode.jsonc")

View File

@@ -71,8 +71,8 @@ export function createBackgroundCompactionHook(manager: BackgroundManager) {
sections.push("## Recently Completed Tasks")
sections.push("")
for (const t of completed) {
const statusEmoji = t.status === "completed" ? "" : t.status === "error" ? "" : "⏱️"
sections.push(`- ${statusEmoji} **\`${t.id}\`**: ${t.description}`)
const statusLabel = t.status === "completed" ? "[DONE]" : t.status === "error" ? "[ERROR]" : "[PENDING]"
sections.push(`- ${statusLabel} **\`${t.id}\`**: ${t.description}`)
}
sections.push("")
}

View File

@@ -1,37 +1,36 @@
# CLAUDE CODE HOOKS COMPATIBILITY LAYER
# CLAUDE CODE HOOKS COMPATIBILITY
## OVERVIEW
Full Claude Code settings.json hook compatibility. Executes user-defined hooks at 5 lifecycle events: PreToolUse, PostToolUse, UserPromptSubmit, Stop, PreCompact.
Full Claude Code settings.json hook compatibility. 5 lifecycle events: PreToolUse, PostToolUse, UserPromptSubmit, Stop, PreCompact.
## STRUCTURE
```
claude-code-hooks/
├── index.ts # Main factory (401 lines) - createClaudeCodeHooksHook()
├── index.ts # Main factory (401 lines)
├── config.ts # Loads ~/.claude/settings.json
├── config-loader.ts # Extended config from multiple sources
├── pre-tool-use.ts # PreToolUse hook executor (172 lines)
├── post-tool-use.ts # PostToolUse hook executor (199 lines)
├── user-prompt-submit.ts # UserPromptSubmit hook executor
├── stop.ts # Stop hook executor (session idle)
├── pre-compact.ts # PreCompact hook executor (context compaction)
├── transcript.ts # Tool use recording (252 lines)
├── tool-input-cache.ts # Caches tool inputs between pre/post
├── types.ts # Hook types, context interfaces
── todo.ts # Todo JSON parsing fix
└── plugin-config.ts # Plugin config access
├── config-loader.ts # Extended config
├── pre-tool-use.ts # PreToolUse executor
├── post-tool-use.ts # PostToolUse executor
├── user-prompt-submit.ts # UserPromptSubmit executor
├── stop.ts # Stop hook executor
├── pre-compact.ts # PreCompact executor
├── transcript.ts # Tool use recording
├── tool-input-cache.ts # Pre→post caching
├── types.ts # Hook types
── todo.ts # Todo JSON fix
```
## HOOK LIFECYCLE
| Event | When | Can Block | Context Fields |
|-------|------|-----------|----------------|
| **PreToolUse** | Before tool | Yes | sessionId, toolName, toolInput, cwd |
| **PostToolUse** | After tool | Warn only | + toolOutput, transcriptPath |
| **UserPromptSubmit** | On user message | Yes | sessionId, prompt, parts, cwd |
| **Stop** | Session idle | inject_prompt | sessionId, parentSessionId |
| **PreCompact** | Before summarize | No | sessionId, cwd |
| Event | When | Can Block | Context |
|-------|------|-----------|---------|
| PreToolUse | Before tool | Yes | sessionId, toolName, toolInput |
| PostToolUse | After tool | Warn | + toolOutput, transcriptPath |
| UserPromptSubmit | On message | Yes | sessionId, prompt, parts |
| Stop | Session idle | inject | sessionId, parentSessionId |
| PreCompact | Before summarize | No | sessionId |
## CONFIG SOURCES
@@ -39,32 +38,14 @@ Priority (highest first):
1. `.claude/settings.json` (project)
2. `~/.claude/settings.json` (user)
```json
{
"hooks": {
"PreToolUse": [{ "matcher": "Edit", "command": "./check.sh" }],
"PostToolUse": [{ "command": "post-hook.sh $TOOL_NAME" }]
}
}
```
## HOOK EXECUTION
1. User-defined hooks loaded from settings.json
2. Matchers filter by tool name (supports wildcards)
3. Commands executed via subprocess with environment:
- `$SESSION_ID`, `$TOOL_NAME`, `$TOOL_INPUT`, `$CWD`
1. Hooks loaded from settings.json
2. Matchers filter by tool name
3. Commands via subprocess with `$SESSION_ID`, `$TOOL_NAME`
4. Exit codes: 0=pass, 1=warn, 2=block
## KEY PATTERNS
- **Session tracking**: `Map<sessionID, state>` for first-message, error, interrupt
- **Input caching**: Tool inputs cached pre→post via `tool-input-cache.ts`
- **Transcript recording**: All tool uses logged for debugging
- **Todowrite fix**: Parses string todos to array (line 174-196)
## ANTI-PATTERNS
- **Heavy PreToolUse logic**: Runs before EVERY tool call
- **Blocking non-critical**: Use warnings in PostToolUse instead
- **Missing error handling**: Always wrap subprocess calls
- **Heavy PreToolUse**: Runs before EVERY tool call
- **Blocking non-critical**: Use PostToolUse warnings

View File

@@ -1,8 +1,8 @@
import { existsSync } from "fs"
import { homedir } from "os"
import { join } from "path"
import type { ClaudeHookEvent } from "./types"
import { log } from "../../shared/logger"
import { getOpenCodeConfigDir } from "../../shared"
export interface DisabledHooksConfig {
Stop?: string[]
@@ -16,7 +16,7 @@ export interface PluginExtendedConfig {
disabledHooks?: DisabledHooksConfig
}
const USER_CONFIG_PATH = join(homedir(), ".config", "opencode", "opencode-cc-plugin.json")
const USER_CONFIG_PATH = join(getOpenCodeConfigDir({ binary: "opencode" }), "opencode-cc-plugin.json")
function getProjectConfigPath(): string {
return join(process.cwd(), ".opencode", "opencode-cc-plugin.json")

View File

@@ -218,7 +218,7 @@ export function createClaudeCodeHooksHook(
.showToast({
body: {
title: "PreToolUse Hook Executed",
message: ` ${result.toolName ?? input.tool} ${result.hookName ?? "hook"}: BLOCKED ${result.elapsedMs ?? 0}ms\n${result.inputLines ?? ""}`,
message: `[BLOCKED] ${result.toolName ?? input.tool} ${result.hookName ?? "hook"}: ${result.elapsedMs ?? 0}ms\n${result.inputLines ?? ""}`,
variant: "error",
duration: 4000,
},

View File

@@ -14,7 +14,7 @@ describe("sisyphus-task-retry", () => {
const patternTexts = DELEGATE_TASK_ERROR_PATTERNS.map(p => p.pattern)
expect(patternTexts).toContain("run_in_background")
expect(patternTexts).toContain("skills")
expect(patternTexts).toContain("load_skills")
expect(patternTexts).toContain("category OR subagent_type")
expect(patternTexts).toContain("Unknown category")
expect(patternTexts).toContain("Unknown agent")
@@ -26,7 +26,7 @@ describe("sisyphus-task-retry", () => {
// #when detecting error
// #then should return matching error info
it("should detect run_in_background missing error", () => {
const output = " Invalid arguments: 'run_in_background' parameter is REQUIRED. Use run_in_background=false for task delegation."
const output = "[ERROR] Invalid arguments: 'run_in_background' parameter is REQUIRED. Use run_in_background=false for task delegation."
const result = detectDelegateTaskError(output)
@@ -34,17 +34,17 @@ describe("sisyphus-task-retry", () => {
expect(result?.errorType).toBe("missing_run_in_background")
})
it("should detect skills missing error", () => {
const output = " Invalid arguments: 'skills' parameter is REQUIRED. Use skills=[] if no skills needed."
it("should detect load_skills missing error", () => {
const output = "[ERROR] Invalid arguments: 'load_skills' parameter is REQUIRED. Use load_skills=[] if no skills are needed."
const result = detectDelegateTaskError(output)
expect(result).not.toBeNull()
expect(result?.errorType).toBe("missing_skills")
expect(result?.errorType).toBe("missing_load_skills")
})
it("should detect category/subagent mutual exclusion error", () => {
const output = " Invalid arguments: Provide EITHER category OR subagent_type, not both."
const output = "[ERROR] Invalid arguments: Provide EITHER category OR subagent_type, not both."
const result = detectDelegateTaskError(output)
@@ -53,7 +53,7 @@ describe("sisyphus-task-retry", () => {
})
it("should detect unknown category error", () => {
const output = ' Unknown category: "invalid-cat". Available: visual-engineering, ultrabrain, quick'
const output = '[ERROR] Unknown category: "invalid-cat". Available: visual-engineering, ultrabrain, quick'
const result = detectDelegateTaskError(output)
@@ -62,7 +62,7 @@ describe("sisyphus-task-retry", () => {
})
it("should detect unknown agent error", () => {
const output = ' Unknown agent: "fake-agent". Available agents: explore, librarian, oracle'
const output = '[ERROR] Unknown agent: "fake-agent". Available agents: explore, librarian, oracle'
const result = detectDelegateTaskError(output)
@@ -95,7 +95,7 @@ describe("sisyphus-task-retry", () => {
it("should provide fix for unknown category with available list", () => {
const errorInfo = {
errorType: "unknown_category",
originalOutput: ' Unknown category: "bad". Available: visual-engineering, ultrabrain'
originalOutput: '[ERROR] Unknown category: "bad". Available: visual-engineering, ultrabrain'
}
const guidance = buildRetryGuidance(errorInfo)
@@ -107,7 +107,7 @@ describe("sisyphus-task-retry", () => {
it("should provide fix for unknown agent with available list", () => {
const errorInfo = {
errorType: "unknown_agent",
originalOutput: ' Unknown agent: "fake". Available agents: explore, oracle'
originalOutput: '[ERROR] Unknown agent: "fake". Available agents: explore, oracle'
}
const guidance = buildRetryGuidance(errorInfo)

View File

@@ -13,9 +13,9 @@ export const DELEGATE_TASK_ERROR_PATTERNS: DelegateTaskErrorPattern[] = [
fixHint: "Add run_in_background=false (for delegation) or run_in_background=true (for parallel exploration)",
},
{
pattern: "skills",
errorType: "missing_skills",
fixHint: "Add skills=[] parameter (empty array if no skills needed)",
pattern: "load_skills",
errorType: "missing_load_skills",
fixHint: "Add load_skills=[] parameter (empty array if no skills needed). Note: Calling Skill tool does NOT populate this.",
},
{
pattern: "category OR subagent_type",
@@ -60,7 +60,7 @@ export interface DetectedError {
}
export function detectDelegateTaskError(output: string): DetectedError | null {
if (!output.includes("")) return null
if (!output.includes("[ERROR]") && !output.includes("Invalid arguments")) return null
for (const errorPattern of DELEGATE_TASK_ERROR_PATTERNS) {
if (output.includes(errorPattern.pattern)) {
@@ -110,7 +110,7 @@ delegate_task(
prompt="Detailed prompt...",
category="unspecified-low", // OR subagent_type="explore"
run_in_background=false,
skills=[]
load_skills=[]
)
\`\`\`
`

View File

@@ -163,15 +163,23 @@ delegate_task(agent="oracle", prompt="Review my approach: [describe plan]")
---
YOU MUST LEVERAGE ALL AVAILABLE AGENTS TO THEIR FULLEST POTENTIAL.
YOU MUST LEVERAGE ALL AVAILABLE AGENTS / **CATEGORY + SKILLS** TO THEIR FULLEST POTENTIAL.
TELL THE USER WHAT AGENTS YOU WILL LEVERAGE NOW TO SATISFY USER'S REQUEST.
## AGENT UTILIZATION PRINCIPLES (by capability, not by name)
## AGENTS / **CATEGORY + SKILLS** UTILIZATION PRINCIPLES (by capability, not by name)
- **Codebase Exploration**: Spawn exploration agents using BACKGROUND TASKS for file patterns, internal implementations, project structure
- **Documentation & References**: Use librarian-type agents via BACKGROUND TASKS for API references, examples, external library docs
- **Planning & Strategy**: NEVER plan yourself - ALWAYS spawn a dedicated planning agent for work breakdown
- MUST USE PLAN AGENT. MUST USE PLAN AGENT. MUST USE PLAN AGENT.
- ALWAYS ASK PLAN AGENT TO WHAT CATEGORY + SKILLS / AGENTS TO LEVERAGE.
- IF IMPLEMENT TASK, MUST ADD TODO NOW: "CONSULT WITH PLAN AGENT WITH CATEGORY + SKILLS"
- **High-IQ Reasoning**: Leverage specialized agents for architecture decisions, code review, strategic planning
- **Frontend/UI Tasks**: Delegate to UI-specialized agents for design and implementation
- **SPECIAL TASKS COVERED WITH CATEGORY + LOAD_SKILLS**: Delegate to specialized agents with category+skills for design and implementation, as following guide:
- CATEGORY + SKILL GUIDE
- MUST PASS \`load_skills\` FOR REQUIRED_SKILLS. MUST USE \`load_skills\` FOR REQUIRED_SKILLS.
- Simple project setup -> delegate_task(category="unspecified-low", load_skills=[{project-setup-skill}])
- Super Complex Server Workflow Implementation -> delegate_task(category="ultrabrain", load_skills=["terraform-master"], ...)
- Web Frontend Component Writing -> delegate_task(category="visual-engineering", load_skills=["frontend-ui-ux", "playwright"], ...)
## EXECUTION RULES
- **TODO**: Track EVERY step. Mark complete IMMEDIATELY after each.
@@ -179,6 +187,7 @@ TELL THE USER WHAT AGENTS YOU WILL LEVERAGE NOW TO SATISFY USER'S REQUEST.
- **BACKGROUND FIRST**: Use delegate_task for exploration/research agents (10+ concurrent if needed).
- **VERIFY**: Re-read request after completion. Check ALL requirements met before reporting done.
- **DELEGATE**: Don't do everything yourself - orchestrate specialized agents for their strengths.
- **CATEGORY + LOAD_SKILLS**
## WORKFLOW
1. Analyze the request and identify required capabilities
@@ -257,6 +266,12 @@ Write these criteria explicitly. Share with user if scope is non-trivial.
THE USER ASKED FOR X. DELIVER EXACTLY X. NOT A SUBSET. NOT A DEMO. NOT A STARTING POINT.
1. EXPLORES + LIBRARIANS
2. GATHER -> PLAN AGENT SPAWN
3. WORK BY DELEGATING TO ANOTHER AGENTS
NOW.
</ultrawork-mode>
---

View File

@@ -5,7 +5,7 @@ import { ContextCollector } from "../../features/context-injector"
import * as sharedModule from "../../shared"
import * as sessionState from "../../features/claude-code-session-state"
describe("keyword-detector registers to ContextCollector", () => {
describe("keyword-detector message transform", () => {
let logCalls: Array<{ msg: string; data?: unknown }>
let logSpy: ReturnType<typeof spyOn>
let getMainSessionSpy: ReturnType<typeof spyOn>
@@ -33,7 +33,7 @@ describe("keyword-detector registers to ContextCollector", () => {
} as any
}
test("should register ultrawork keyword to ContextCollector", async () => {
test("should prepend ultrawork message to text part", async () => {
// #given - a fresh ContextCollector and keyword-detector hook
const collector = new ContextCollector()
const hook = createKeywordDetectorHook(createMockPluginInput(), collector)
@@ -46,15 +46,15 @@ describe("keyword-detector registers to ContextCollector", () => {
// #when - keyword detection runs
await hook["chat.message"]({ sessionID }, output)
// #then - ultrawork context should be registered in collector
expect(collector.hasPending(sessionID)).toBe(true)
const pending = collector.getPending(sessionID)
expect(pending.entries.length).toBeGreaterThan(0)
expect(pending.entries[0].source).toBe("keyword-detector")
expect(pending.entries[0].id).toBe("keyword-ultrawork")
// #then - message should be prepended to text part with separator and original text
const textPart = output.parts.find(p => p.type === "text")
expect(textPart).toBeDefined()
expect(textPart!.text).toContain("---")
expect(textPart!.text).toContain("do something")
expect(textPart!.text).toContain("YOU MUST LEVERAGE ALL AVAILABLE AGENTS")
})
test("should register search keyword to ContextCollector", async () => {
test("should prepend search message to text part", async () => {
// #given - mock getMainSessionID to return our session (isolate from global state)
const collector = new ContextCollector()
const sessionID = "search-test-session"
@@ -68,13 +68,15 @@ describe("keyword-detector registers to ContextCollector", () => {
// #when - keyword detection runs
await hook["chat.message"]({ sessionID }, output)
// #then - search context should be registered in collector
expect(collector.hasPending(sessionID)).toBe(true)
const pending = collector.getPending(sessionID)
expect(pending.entries.some((e) => e.id === "keyword-search")).toBe(true)
// #then - search message should be prepended to text part
const textPart = output.parts.find(p => p.type === "text")
expect(textPart).toBeDefined()
expect(textPart!.text).toContain("---")
expect(textPart!.text).toContain("for the bug")
expect(textPart!.text).toContain("[search-mode]")
})
test("should NOT register to collector when no keywords detected", async () => {
test("should NOT transform when no keywords detected", async () => {
// #given - no keywords in message
const collector = new ContextCollector()
const hook = createKeywordDetectorHook(createMockPluginInput(), collector)
@@ -87,8 +89,10 @@ describe("keyword-detector registers to ContextCollector", () => {
// #when - keyword detection runs
await hook["chat.message"]({ sessionID }, output)
// #then - nothing should be registered
expect(collector.hasPending(sessionID)).toBe(false)
// #then - text should remain unchanged
const textPart = output.parts.find(p => p.type === "text")
expect(textPart).toBeDefined()
expect(textPart!.text).toBe("just a normal message")
})
})
@@ -375,11 +379,12 @@ describe("keyword-detector agent-specific ultrawork messages", () => {
await hook["chat.message"]({ sessionID, agent: "prometheus" }, output)
// #then - should use planner-specific message with "YOU ARE A PLANNER" content
const pending = collector.getPending(sessionID)
const ultraworkEntry = pending.entries.find((e) => e.id === "keyword-ultrawork")
expect(ultraworkEntry).toBeDefined()
expect(ultraworkEntry!.content).toContain("YOU ARE A PLANNER, NOT AN IMPLEMENTER")
expect(ultraworkEntry!.content).not.toContain("YOU MUST LEVERAGE ALL AVAILABLE AGENTS")
const textPart = output.parts.find(p => p.type === "text")
expect(textPart).toBeDefined()
expect(textPart!.text).toContain("YOU ARE A PLANNER, NOT AN IMPLEMENTER")
expect(textPart!.text).not.toContain("YOU MUST LEVERAGE ALL AVAILABLE AGENTS")
expect(textPart!.text).toContain("---")
expect(textPart!.text).toContain("plan this feature")
})
test("should use planner-specific ultrawork message when agent name contains 'planner'", async () => {
@@ -396,10 +401,11 @@ describe("keyword-detector agent-specific ultrawork messages", () => {
await hook["chat.message"]({ sessionID, agent: "Prometheus (Planner)" }, output)
// #then - should use planner-specific message
const pending = collector.getPending(sessionID)
const ultraworkEntry = pending.entries.find((e) => e.id === "keyword-ultrawork")
expect(ultraworkEntry).toBeDefined()
expect(ultraworkEntry!.content).toContain("YOU ARE A PLANNER, NOT AN IMPLEMENTER")
const textPart = output.parts.find(p => p.type === "text")
expect(textPart).toBeDefined()
expect(textPart!.text).toContain("YOU ARE A PLANNER, NOT AN IMPLEMENTER")
expect(textPart!.text).toContain("---")
expect(textPart!.text).toContain("create a work plan")
})
test("should use normal ultrawork message when agent is Sisyphus", async () => {
@@ -416,11 +422,12 @@ describe("keyword-detector agent-specific ultrawork messages", () => {
await hook["chat.message"]({ sessionID, agent: "Sisyphus" }, output)
// #then - should use normal ultrawork message with agent utilization instructions
const pending = collector.getPending(sessionID)
const ultraworkEntry = pending.entries.find((e) => e.id === "keyword-ultrawork")
expect(ultraworkEntry).toBeDefined()
expect(ultraworkEntry!.content).toContain("YOU MUST LEVERAGE ALL AVAILABLE AGENTS")
expect(ultraworkEntry!.content).not.toContain("YOU ARE A PLANNER, NOT AN IMPLEMENTER")
const textPart = output.parts.find(p => p.type === "text")
expect(textPart).toBeDefined()
expect(textPart!.text).toContain("YOU MUST LEVERAGE ALL AVAILABLE AGENTS")
expect(textPart!.text).not.toContain("YOU ARE A PLANNER, NOT AN IMPLEMENTER")
expect(textPart!.text).toContain("---")
expect(textPart!.text).toContain("implement this feature")
})
test("should use normal ultrawork message when agent is undefined", async () => {
@@ -437,11 +444,12 @@ describe("keyword-detector agent-specific ultrawork messages", () => {
await hook["chat.message"]({ sessionID }, output)
// #then - should use normal ultrawork message (default behavior)
const pending = collector.getPending(sessionID)
const ultraworkEntry = pending.entries.find((e) => e.id === "keyword-ultrawork")
expect(ultraworkEntry).toBeDefined()
expect(ultraworkEntry!.content).toContain("YOU MUST LEVERAGE ALL AVAILABLE AGENTS")
expect(ultraworkEntry!.content).not.toContain("YOU ARE A PLANNER, NOT AN IMPLEMENTER")
const textPart = output.parts.find(p => p.type === "text")
expect(textPart).toBeDefined()
expect(textPart!.text).toContain("YOU MUST LEVERAGE ALL AVAILABLE AGENTS")
expect(textPart!.text).not.toContain("YOU ARE A PLANNER, NOT AN IMPLEMENTER")
expect(textPart!.text).toContain("---")
expect(textPart!.text).toContain("do something")
})
test("should switch from planner to normal message when agent changes", async () => {
@@ -466,13 +474,15 @@ describe("keyword-detector agent-specific ultrawork messages", () => {
await hook["chat.message"]({ sessionID: sisyphusSessionID, agent: "Sisyphus" }, sisyphusOutput)
// #then - each session should have the correct message type
const prometheusPending = collector.getPending(prometheusSessionID)
const prometheusEntry = prometheusPending.entries.find((e) => e.id === "keyword-ultrawork")
expect(prometheusEntry!.content).toContain("YOU ARE A PLANNER, NOT AN IMPLEMENTER")
const prometheusTextPart = prometheusOutput.parts.find(p => p.type === "text")
expect(prometheusTextPart!.text).toContain("YOU ARE A PLANNER, NOT AN IMPLEMENTER")
expect(prometheusTextPart!.text).toContain("---")
expect(prometheusTextPart!.text).toContain("plan")
const sisyphusPending = collector.getPending(sisyphusSessionID)
const sisyphusEntry = sisyphusPending.entries.find((e) => e.id === "keyword-ultrawork")
expect(sisyphusEntry!.content).toContain("YOU MUST LEVERAGE ALL AVAILABLE AGENTS")
const sisyphusTextPart = sisyphusOutput.parts.find(p => p.type === "text")
expect(sisyphusTextPart!.text).toContain("YOU MUST LEVERAGE ALL AVAILABLE AGENTS")
expect(sisyphusTextPart!.text).toContain("---")
expect(sisyphusTextPart!.text).toContain("implement")
})
test("should use session state agent over stale input.agent (bug fix)", async () => {
@@ -493,11 +503,12 @@ describe("keyword-detector agent-specific ultrawork messages", () => {
await hook["chat.message"]({ sessionID, agent: "prometheus" }, output)
// #then - should use Sisyphus from session state, NOT prometheus from stale input
const pending = collector.getPending(sessionID)
const ultraworkEntry = pending.entries.find((e) => e.id === "keyword-ultrawork")
expect(ultraworkEntry).toBeDefined()
expect(ultraworkEntry!.content).toContain("YOU MUST LEVERAGE ALL AVAILABLE AGENTS")
expect(ultraworkEntry!.content).not.toContain("YOU ARE A PLANNER, NOT AN IMPLEMENTER")
const textPart = output.parts.find(p => p.type === "text")
expect(textPart).toBeDefined()
expect(textPart!.text).toContain("YOU MUST LEVERAGE ALL AVAILABLE AGENTS")
expect(textPart!.text).not.toContain("YOU ARE A PLANNER, NOT AN IMPLEMENTER")
expect(textPart!.text).toContain("---")
expect(textPart!.text).toContain("implement this")
// cleanup
clearSessionAgent(sessionID)
@@ -521,9 +532,10 @@ describe("keyword-detector agent-specific ultrawork messages", () => {
await hook["chat.message"]({ sessionID, agent: "prometheus" }, output)
// #then - should use prometheus from input.agent as fallback
const pending = collector.getPending(sessionID)
const ultraworkEntry = pending.entries.find((e) => e.id === "keyword-ultrawork")
expect(ultraworkEntry).toBeDefined()
expect(ultraworkEntry!.content).toContain("YOU ARE A PLANNER, NOT AN IMPLEMENTER")
const textPart = output.parts.find(p => p.type === "text")
expect(textPart).toBeDefined()
expect(textPart!.text).toContain("YOU ARE A PLANNER, NOT AN IMPLEMENTER")
expect(textPart!.text).toContain("---")
expect(textPart!.text).toContain("plan this")
})
})

View File

@@ -80,17 +80,17 @@ export function createKeywordDetectorHook(ctx: PluginInput, collector?: ContextC
)
}
if (collector) {
for (const keyword of detectedKeywords) {
collector.register(input.sessionID, {
id: `keyword-${keyword.type}`,
source: "keyword-detector",
content: keyword.message,
priority: keyword.type === "ultrawork" ? "critical" : "high",
})
}
const textPartIndex = output.parts.findIndex((p) => p.type === "text" && p.text !== undefined)
if (textPartIndex === -1) {
log(`[keyword-detector] No text part found, skipping injection`, { sessionID: input.sessionID })
return
}
const allMessages = detectedKeywords.map((k) => k.message).join("\n\n")
const originalText = output.parts[textPartIndex].text ?? ""
output.parts[textPartIndex].text = `${allMessages}\n\n---\n\n${originalText}`
log(`[keyword-detector] Detected ${detectedKeywords.length} keywords`, {
sessionID: input.sessionID,
types: detectedKeywords.map((k) => k.type),

View File

@@ -37,7 +37,7 @@ export function createNonInteractiveEnvHook(_ctx: PluginInput) {
const bannedCmd = detectBannedCommand(command)
if (bannedCmd) {
output.message = `⚠️ Warning: '${bannedCmd}' is an interactive command that may hang in non-interactive environments.`
output.message = `Warning: '${bannedCmd}' is an interactive command that may hang in non-interactive environments.`
}
// Only prepend env vars for git commands (editor blocking, pager, etc.)

View File

@@ -30,3 +30,48 @@ Return your findings and recommendations. The actual implementation will be hand
---
`
export const PROMETHEUS_WORKFLOW_REMINDER = `
---
${createSystemDirective(SystemDirectiveTypes.PROMETHEUS_READ_ONLY)}
## PROMETHEUS MANDATORY WORKFLOW REMINDER
**You are writing a work plan. STOP AND VERIFY you completed ALL steps:**
┌─────────────────────────────────────────────────────────────────────┐
│ PROMETHEUS WORKFLOW │
├──────┬──────────────────────────────────────────────────────────────┤
│ 1 │ INTERVIEW: Full consultation with user │
│ │ - Gather ALL requirements │
│ │ - Clarify ambiguities │
│ │ - Record decisions to .sisyphus/drafts/ │
├──────┼──────────────────────────────────────────────────────────────┤
│ 2 │ METIS CONSULTATION: Pre-generation gap analysis │
│ │ - delegate_task(agent="Metis (Plan Consultant)", ...) │
│ │ - Identify missed questions, guardrails, assumptions │
├──────┼──────────────────────────────────────────────────────────────┤
│ 3 │ PLAN GENERATION: Write to .sisyphus/plans/*.md │
│ │ <- YOU ARE HERE │
├──────┼──────────────────────────────────────────────────────────────┤
│ 4 │ MOMUS REVIEW (if high accuracy requested) │
│ │ - delegate_task(agent="Momus (Plan Reviewer)", ...) │
│ │ - Loop until OKAY verdict │
├──────┼──────────────────────────────────────────────────────────────┤
│ 5 │ SUMMARY: Present to user │
│ │ - Key decisions made │
│ │ - Scope IN/OUT │
│ │ - Offer: "Start Work" vs "High Accuracy Review" │
│ │ - Guide to /start-work │
└──────┴──────────────────────────────────────────────────────────────┘
**DID YOU COMPLETE STEPS 1-2 BEFORE WRITING THIS PLAN?**
**AFTER WRITING, WILL YOU DO STEPS 4-5?**
If you skipped steps, STOP NOW. Go back and complete them.
---
`

View File

@@ -82,6 +82,47 @@ describe("prometheus-md-only", () => {
).resolves.toBeUndefined()
})
test("should inject workflow reminder when Prometheus writes to .sisyphus/plans/", async () => {
// #given
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
const input = {
tool: "Write",
sessionID: TEST_SESSION_ID,
callID: "call-1",
}
const output: { args: Record<string, unknown>; message?: string } = {
args: { filePath: "/tmp/test/.sisyphus/plans/work-plan.md" },
}
// #when
await hook["tool.execute.before"](input, output)
// #then
expect(output.message).toContain("PROMETHEUS MANDATORY WORKFLOW REMINDER")
expect(output.message).toContain("INTERVIEW")
expect(output.message).toContain("METIS CONSULTATION")
expect(output.message).toContain("MOMUS REVIEW")
})
test("should NOT inject workflow reminder for .sisyphus/drafts/", async () => {
// #given
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
const input = {
tool: "Write",
sessionID: TEST_SESSION_ID,
callID: "call-1",
}
const output: { args: Record<string, unknown>; message?: string } = {
args: { filePath: "/tmp/test/.sisyphus/drafts/notes.md" },
}
// #when
await hook["tool.execute.before"](input, output)
// #then
expect(output.message).toBeUndefined()
})
test("should block Prometheus from writing .md files outside .sisyphus/", async () => {
// #given
const hook = createPrometheusMdOnlyHook(createMockPluginInput())

View File

@@ -1,7 +1,7 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { existsSync, readdirSync } from "node:fs"
import { join, resolve, relative, isAbsolute } from "node:path"
import { HOOK_NAME, PROMETHEUS_AGENTS, ALLOWED_EXTENSIONS, ALLOWED_PATH_PREFIX, BLOCKED_TOOLS, PLANNING_CONSULT_WARNING } from "./constants"
import { HOOK_NAME, PROMETHEUS_AGENTS, ALLOWED_EXTENSIONS, ALLOWED_PATH_PREFIX, BLOCKED_TOOLS, PLANNING_CONSULT_WARNING, PROMETHEUS_WORKFLOW_REMINDER } from "./constants"
import { findNearestMessageWithFields, findFirstMessageWithAgent, MESSAGE_STORAGE } from "../../features/hook-message-injector"
import { getSessionAgent } from "../../features/claude-code-session-state"
import { log } from "../../shared/logger"
@@ -125,6 +125,17 @@ export function createPrometheusMdOnlyHook(ctx: PluginInput) {
)
}
const normalizedPath = filePath.toLowerCase().replace(/\\/g, "/")
if (normalizedPath.includes(".sisyphus/plans/") || normalizedPath.includes(".sisyphus\\plans\\")) {
log(`[${HOOK_NAME}] Injecting workflow reminder for plan write`, {
sessionID: input.sessionID,
tool: toolName,
filePath,
agent: agentName,
})
output.message = (output.message || "") + PROMETHEUS_WORKFLOW_REMINDER
}
log(`[${HOOK_NAME}] Allowed: .sisyphus/*.md write permitted`, {
sessionID: input.sessionID,
tool: toolName,

View File

@@ -73,7 +73,7 @@ import { BackgroundManager } from "./features/background-agent";
import { SkillMcpManager } from "./features/skill-mcp-manager";
import { initTaskToastManager } from "./features/task-toast-manager";
import { type HookName } from "./config";
import { log, detectExternalNotificationPlugin, getNotificationConflictWarning, resetMessageCursor } from "./shared";
import { log, detectExternalNotificationPlugin, getNotificationConflictWarning, resetMessageCursor, includesCaseInsensitive } from "./shared";
import { loadPluginConfig } from "./plugin-config";
import { createModelCacheState, getModelLimit } from "./plugin-state";
import { createConfigHandler } from "./plugin-handlers";
@@ -288,7 +288,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
: null;
const configHandler = createConfigHandler({
ctx,
ctx: { directory: ctx.directory, client: ctx.client },
pluginConfig,
modelCacheState,
});
@@ -489,8 +489,9 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
if (input.tool === "task") {
const args = output.args as Record<string, unknown>;
const subagentType = args.subagent_type as string;
const isExploreOrLibrarian = ["explore", "librarian"].includes(
subagentType
const isExploreOrLibrarian = includesCaseInsensitive(
["explore", "librarian"],
subagentType ?? ""
);
args.tools = {

View File

@@ -1,8 +1,8 @@
# BUILT-IN MCP CONFIGURATIONS
# MCP KNOWLEDGE BASE
## OVERVIEW
3 remote MCP servers for web search, documentation, and code search. All use HTTP/SSE transport, no OAuth.
3 remote MCP servers: web search, documentation, code search. HTTP/SSE transport.
## STRUCTURE
@@ -20,20 +20,19 @@ mcp/
| Name | URL | Purpose | Auth |
|------|-----|---------|------|
| **websearch** | `mcp.exa.ai` | Real-time web search | `EXA_API_KEY` header |
| **context7** | `mcp.context7.com` | Official library docs | None |
| **grep_app** | `mcp.grep.app` | GitHub code search | None |
| websearch | mcp.exa.ai | Real-time web search | EXA_API_KEY |
| context7 | mcp.context7.com | Library docs | None |
| grep_app | mcp.grep.app | GitHub code search | None |
## CONFIG PATTERN
All MCPs follow identical structure:
```typescript
export const mcp_name = {
type: "remote" as const,
url: "https://...",
enabled: true,
oauth: false as const, // Explicit disable
headers?: { ... }, // Optional auth
oauth: false as const,
headers?: { ... },
}
```
@@ -42,29 +41,18 @@ export const mcp_name = {
```typescript
import { createBuiltinMcps } from "./mcp"
// Enable all
const mcps = createBuiltinMcps()
// Disable specific
const mcps = createBuiltinMcps(["websearch"])
const mcps = createBuiltinMcps() // Enable all
const mcps = createBuiltinMcps(["websearch"]) // Disable specific
```
## HOW TO ADD
1. Create `src/mcp/my-mcp.ts`:
```typescript
export const my_mcp = {
type: "remote" as const,
url: "https://mcp.example.com",
enabled: true,
oauth: false as const,
}
```
1. Create `src/mcp/my-mcp.ts`
2. Add to `allBuiltinMcps` in `index.ts`
3. Add to `McpNameSchema` in `types.ts`
## NOTES
- **Remote only**: All built-in MCPs use HTTP/SSE, no stdio
- **Disable config**: User can disable via `disabled_mcps: ["name"]`
- **Exa requires key**: Set `EXA_API_KEY` env var for websearch
- **Remote only**: HTTP/SSE, no stdio
- **Disable**: User can set `disabled_mcps: ["name"]`
- **Exa**: Requires `EXA_API_KEY` env var

View File

@@ -4,7 +4,7 @@ import { OhMyOpenCodeConfigSchema, type OhMyOpenCodeConfig } from "./config";
import {
log,
deepMerge,
getUserConfigDir,
getOpenCodeConfigDir,
addConfigLoadError,
parseJsonc,
detectConfigFile,
@@ -94,12 +94,9 @@ export function loadPluginConfig(
directory: string,
ctx: unknown
): OhMyOpenCodeConfig {
// User-level config path (OS-specific) - prefer .jsonc over .json
const userBasePath = path.join(
getUserConfigDir(),
"opencode",
"oh-my-opencode"
);
// User-level config path - prefer .jsonc over .json
const configDir = getOpenCodeConfigDir({ binary: "opencode" });
const userBasePath = path.join(configDir, "oh-my-opencode");
const userDetected = detectConfigFile(userBasePath);
const userConfigPath =
userDetected.format !== "none"

View File

@@ -12,6 +12,10 @@ import {
loadProjectSkills,
loadOpencodeGlobalSkills,
loadOpencodeProjectSkills,
discoverUserClaudeSkills,
discoverProjectClaudeSkills,
discoverOpencodeGlobalSkills,
discoverOpencodeProjectSkills,
} from "../features/opencode-skill-loader";
import {
loadUserAgents,
@@ -31,7 +35,7 @@ import type { ModelCacheState } from "../plugin-state";
import type { CategoryConfig } from "../config/schema";
export interface ConfigHandlerDeps {
ctx: { directory: string };
ctx: { directory: string; client?: any };
pluginConfig: OhMyOpenCodeConfig;
modelCacheState: ModelCacheState;
}
@@ -116,13 +120,35 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
return AGENT_NAME_MAP[agent.toLowerCase()] ?? AGENT_NAME_MAP[agent] ?? agent
}) as typeof pluginConfig.disabled_agents
const builtinAgents = createBuiltinAgents(
const includeClaudeSkillsForAwareness = pluginConfig.claude_code?.skills ?? true;
const [
discoveredUserSkills,
discoveredProjectSkills,
discoveredOpencodeGlobalSkills,
discoveredOpencodeProjectSkills,
] = await Promise.all([
includeClaudeSkillsForAwareness ? discoverUserClaudeSkills() : Promise.resolve([]),
includeClaudeSkillsForAwareness ? discoverProjectClaudeSkills() : Promise.resolve([]),
discoverOpencodeGlobalSkills(),
discoverOpencodeProjectSkills(),
]);
const allDiscoveredSkills = [
...discoveredOpencodeProjectSkills,
...discoveredProjectSkills,
...discoveredOpencodeGlobalSkills,
...discoveredUserSkills,
];
const builtinAgents = await createBuiltinAgents(
migratedDisabledAgents,
pluginConfig.agents,
ctx.directory,
config.model as string | undefined,
pluginConfig.categories,
pluginConfig.git_master
pluginConfig.git_master,
allDiscoveredSkills,
ctx.client
);
// Claude Code agents: Do NOT apply permission migration

View File

@@ -2,62 +2,64 @@
## OVERVIEW
43 cross-cutting utilities: path resolution, token truncation, config parsing, Claude Code compatibility.
50 cross-cutting utilities: path resolution, token truncation, config parsing, model resolution.
## STRUCTURE
```
shared/
├── logger.ts # File-based logging (tmpdir/oh-my-opencode.log)
├── permission-compat.ts # Agent tool restrictions (ask/allow/deny)
├── dynamic-truncator.ts # Token-aware truncation (50% headroom)
├── frontmatter.ts # YAML frontmatter parsing
├── jsonc-parser.ts # JSON with Comments support
├── data-path.ts # XDG-compliant storage (~/.local/share)
├── opencode-config-dir.ts # ~/.config/opencode resolution
├── claude-config-dir.ts # ~/.claude resolution
├── migration.ts # Legacy config migration (omo → Sisyphus)
├── opencode-version.ts # Version comparison (>= 1.0.150)
├── logger.ts # File-based logging
├── permission-compat.ts # Agent tool restrictions
├── dynamic-truncator.ts # Token-aware truncation
├── frontmatter.ts # YAML frontmatter
├── jsonc-parser.ts # JSON with Comments
├── data-path.ts # XDG-compliant storage
├── opencode-config-dir.ts # ~/.config/opencode
├── claude-config-dir.ts # ~/.claude
├── migration.ts # Legacy config migration
├── opencode-version.ts # Version comparison
├── external-plugin-detector.ts # OAuth spoofing detection
├── env-expander.ts # ${VAR} expansion in configs
├── system-directive.ts # System directive types
├── hook-utils.ts # Hook helper functions
── *.test.ts # Test files (colocated)
├── env-expander.ts # ${VAR} expansion
├── model-requirements.ts # Agent/Category requirements
├── model-availability.ts # Models fetch + fuzzy match
── model-resolver.ts # 3-step resolution
├── shell-env.ts # Cross-platform shell
├── prompt-parts-helper.ts # Prompt manipulation
└── *.test.ts # Colocated tests
```
## WHEN TO USE
| Task | Utility |
|------|---------|
| Debug logging | `log(message, data)` in `logger.ts` |
| Debug logging | `log(message, data)` |
| Limit context | `dynamicTruncate(ctx, sessionId, output)` |
| Parse frontmatter | `parseFrontmatter(content)` |
| Load JSONC config | `parseJsonc(text)` or `readJsoncFile(path)` |
| Restrict agent tools | `createAgentToolAllowlist(tools)` |
| Resolve paths | `getOpenCodeConfigDir()`, `getClaudeConfigDir()` |
| Migrate config | `migrateConfigFile(path, rawConfig)` |
| Load JSONC | `parseJsonc(text)` or `readJsoncFile(path)` |
| Restrict tools | `createAgentToolAllowlist(tools)` |
| Resolve paths | `getOpenCodeConfigDir()` |
| Compare versions | `isOpenCodeVersionAtLeast("1.1.0")` |
| Resolve model | `resolveModelWithFallback()` |
## KEY PATTERNS
## PATTERNS
```typescript
// Token-aware truncation
const { result } = await dynamicTruncate(ctx, sessionID, largeBuffer)
const { result } = await dynamicTruncate(ctx, sessionID, buffer)
// JSONC config loading
// JSONC config
const settings = readJsoncFile<Settings>(configPath)
// Version-gated features
if (isOpenCodeVersionAtLeast("1.1.0")) { /* new feature */ }
// Version-gated
if (isOpenCodeVersionAtLeast("1.1.0")) { /* ... */ }
// Tool permission normalization
const permissions = migrateToolsToPermission(legacyTools)
// Model resolution
const model = await resolveModelWithFallback(client, requirements, override)
```
## ANTI-PATTERNS
- **Raw JSON.parse**: Use `jsonc-parser.ts` for config files
- **Hardcoded paths**: Use `*-config-dir.ts` utilities
- **console.log**: Use `logger.ts` for background agents
- **Unbounded output**: Always use `dynamic-truncator.ts`
- **Manual version parse**: Use `opencode-version.ts`
- **Raw JSON.parse**: Use `jsonc-parser.ts`
- **Hardcoded paths**: Use `*-config-dir.ts`
- **console.log**: Use `logger.ts` for background
- **Unbounded output**: Use `dynamic-truncator.ts`

View File

@@ -4,6 +4,8 @@
* true = tool allowed, false = tool denied.
*/
import { findCaseInsensitive } from "./case-insensitive"
const EXPLORATION_AGENT_DENYLIST: Record<string, boolean> = {
write: false,
edit: false,
@@ -35,10 +37,10 @@ const AGENT_RESTRICTIONS: Record<string, Record<string, boolean>> = {
}
export function getAgentToolRestrictions(agentName: string): Record<string, boolean> {
return AGENT_RESTRICTIONS[agentName] ?? {}
return findCaseInsensitive(AGENT_RESTRICTIONS, agentName) ?? {}
}
export function hasAgentToolRestrictions(agentName: string): boolean {
const restrictions = AGENT_RESTRICTIONS[agentName]
const restrictions = findCaseInsensitive(AGENT_RESTRICTIONS, agentName)
return restrictions !== undefined && Object.keys(restrictions).length > 0
}

View File

@@ -0,0 +1,169 @@
import { describe, test, expect } from "bun:test"
import {
findCaseInsensitive,
includesCaseInsensitive,
findByNameCaseInsensitive,
equalsIgnoreCase,
} from "./case-insensitive"
describe("findCaseInsensitive", () => {
test("returns undefined for empty/undefined object", () => {
// #given - undefined object
const obj = undefined
// #when - lookup any key
const result = findCaseInsensitive(obj, "key")
// #then - returns undefined
expect(result).toBeUndefined()
})
test("finds exact match first", () => {
// #given - object with exact key
const obj = { Oracle: "value1", oracle: "value2" }
// #when - lookup with exact case
const result = findCaseInsensitive(obj, "Oracle")
// #then - returns exact match
expect(result).toBe("value1")
})
test("finds case-insensitive match when no exact match", () => {
// #given - object with lowercase key
const obj = { oracle: "value" }
// #when - lookup with uppercase
const result = findCaseInsensitive(obj, "ORACLE")
// #then - returns case-insensitive match
expect(result).toBe("value")
})
test("returns undefined when key not found", () => {
// #given - object without target key
const obj = { other: "value" }
// #when - lookup missing key
const result = findCaseInsensitive(obj, "oracle")
// #then - returns undefined
expect(result).toBeUndefined()
})
})
describe("includesCaseInsensitive", () => {
test("returns true for exact match", () => {
// #given - array with exact value
const arr = ["explore", "librarian"]
// #when - check exact match
const result = includesCaseInsensitive(arr, "explore")
// #then - returns true
expect(result).toBe(true)
})
test("returns true for case-insensitive match", () => {
// #given - array with lowercase values
const arr = ["explore", "librarian"]
// #when - check uppercase value
const result = includesCaseInsensitive(arr, "EXPLORE")
// #then - returns true
expect(result).toBe(true)
})
test("returns true for mixed case match", () => {
// #given - array with mixed case values
const arr = ["Oracle", "Sisyphus"]
// #when - check different case
const result = includesCaseInsensitive(arr, "oracle")
// #then - returns true
expect(result).toBe(true)
})
test("returns false when value not found", () => {
// #given - array without target value
const arr = ["explore", "librarian"]
// #when - check missing value
const result = includesCaseInsensitive(arr, "oracle")
// #then - returns false
expect(result).toBe(false)
})
test("returns false for empty array", () => {
// #given - empty array
const arr: string[] = []
// #when - check any value
const result = includesCaseInsensitive(arr, "explore")
// #then - returns false
expect(result).toBe(false)
})
})
describe("findByNameCaseInsensitive", () => {
test("finds element by exact name", () => {
// #given - array with named objects
const arr = [{ name: "Oracle", value: 1 }, { name: "explore", value: 2 }]
// #when - find by exact name
const result = findByNameCaseInsensitive(arr, "Oracle")
// #then - returns matching element
expect(result).toEqual({ name: "Oracle", value: 1 })
})
test("finds element by case-insensitive name", () => {
// #given - array with named objects
const arr = [{ name: "Oracle", value: 1 }, { name: "explore", value: 2 }]
// #when - find by different case
const result = findByNameCaseInsensitive(arr, "oracle")
// #then - returns matching element
expect(result).toEqual({ name: "Oracle", value: 1 })
})
test("returns undefined when name not found", () => {
// #given - array without target name
const arr = [{ name: "Oracle", value: 1 }]
// #when - find missing name
const result = findByNameCaseInsensitive(arr, "librarian")
// #then - returns undefined
expect(result).toBeUndefined()
})
})
describe("equalsIgnoreCase", () => {
test("returns true for same case", () => {
// #given - same strings
// #when - compare
// #then - returns true
expect(equalsIgnoreCase("oracle", "oracle")).toBe(true)
})
test("returns true for different case", () => {
// #given - strings with different case
// #when - compare
// #then - returns true
expect(equalsIgnoreCase("Oracle", "ORACLE")).toBe(true)
expect(equalsIgnoreCase("Sisyphus-Junior", "sisyphus-junior")).toBe(true)
})
test("returns false for different strings", () => {
// #given - different strings
// #when - compare
// #then - returns false
expect(equalsIgnoreCase("oracle", "explore")).toBe(false)
})
})

View File

@@ -0,0 +1,46 @@
/**
* Case-insensitive lookup and comparison utilities for agent/config names.
* Used throughout the codebase to allow "Oracle", "oracle", "ORACLE" to work the same.
*/
/**
* Find a value in an object using case-insensitive key matching.
* First tries exact match, then falls back to lowercase comparison.
*/
export function findCaseInsensitive<T>(obj: Record<string, T> | undefined, key: string): T | undefined {
if (!obj) return undefined
const exactMatch = obj[key]
if (exactMatch !== undefined) return exactMatch
const lowerKey = key.toLowerCase()
for (const [k, v] of Object.entries(obj)) {
if (k.toLowerCase() === lowerKey) return v
}
return undefined
}
/**
* Check if an array includes a value using case-insensitive comparison.
*/
export function includesCaseInsensitive(arr: string[], value: string): boolean {
const lowerValue = value.toLowerCase()
return arr.some((item) => item.toLowerCase() === lowerValue)
}
/**
* Find an element in array using case-insensitive name matching.
* Useful for finding agents/categories by name.
*/
export function findByNameCaseInsensitive<T extends { name: string }>(
arr: T[],
name: string
): T | undefined {
const lowerName = name.toLowerCase()
return arr.find((item) => item.name.toLowerCase() === lowerName)
}
/**
* Check if two strings are equal (case-insensitive).
*/
export function equalsIgnoreCase(a: string, b: string): boolean {
return a.toLowerCase() === b.toLowerCase()
}

View File

@@ -4,11 +4,7 @@ import * as fs from "fs"
/**
* Returns the user-level config directory based on the OS.
* - Linux/macOS: XDG_CONFIG_HOME or ~/.config
* - Windows: Checks ~/.config first (cross-platform), then %APPDATA% (fallback)
*
* On Windows, prioritizes ~/.config for cross-platform consistency.
* Falls back to %APPDATA% for backward compatibility with existing installations.
* @deprecated Use getOpenCodeConfigDir() from opencode-config-dir.ts instead.
*/
export function getUserConfigDir(): string {
if (process.platform === "win32") {

View File

@@ -121,7 +121,7 @@ export function detectExternalNotificationPlugin(directory: string): ExternalNot
export function getNotificationConflictWarning(pluginName: string): string {
return `[oh-my-opencode] External notification plugin detected: ${pluginName}
⚠️ Both oh-my-opencode and ${pluginName} listen to session.idle events.
Both oh-my-opencode and ${pluginName} listen to session.idle events.
Running both simultaneously can cause crashes on Windows.
oh-my-opencode's session-notification has been auto-disabled.

View File

@@ -26,4 +26,7 @@ export * from "./session-cursor"
export * from "./shell-env"
export * from "./system-directive"
export * from "./agent-tool-restrictions"
export * from "./model-requirements"
export * from "./model-resolver"
export * from "./model-availability"
export * from "./case-insensitive"

View File

@@ -118,13 +118,14 @@ describe("migrateHookNames", () => {
const hooks = ["anthropic-auto-compact", "comment-checker"]
// #when: Migrate hook names
const { migrated, changed } = migrateHookNames(hooks)
const { migrated, changed, removed } = migrateHookNames(hooks)
// #then: Legacy hook name should be migrated
expect(changed).toBe(true)
expect(migrated).toContain("anthropic-context-window-limit-recovery")
expect(migrated).toContain("comment-checker")
expect(migrated).not.toContain("anthropic-auto-compact")
expect(removed).toEqual([])
})
test("preserves current hook names unchanged", () => {
@@ -136,11 +137,12 @@ describe("migrateHookNames", () => {
]
// #when: Migrate hook names
const { migrated, changed } = migrateHookNames(hooks)
const { migrated, changed, removed } = migrateHookNames(hooks)
// #then: Current names should remain unchanged
expect(changed).toBe(false)
expect(migrated).toEqual(hooks)
expect(removed).toEqual([])
})
test("handles empty hooks array", () => {
@@ -148,11 +150,12 @@ describe("migrateHookNames", () => {
const hooks: string[] = []
// #when: Migrate hook names
const { migrated, changed } = migrateHookNames(hooks)
const { migrated, changed, removed } = migrateHookNames(hooks)
// #then: Should return empty array with no changes
expect(changed).toBe(false)
expect(migrated).toEqual([])
expect(removed).toEqual([])
})
test("migrates multiple legacy hook names", () => {
@@ -166,6 +169,51 @@ describe("migrateHookNames", () => {
expect(changed).toBe(true)
expect(migrated).toEqual(["anthropic-context-window-limit-recovery"])
})
test("migrates sisyphus-orchestrator to atlas", () => {
// #given: Config with legacy sisyphus-orchestrator hook
const hooks = ["sisyphus-orchestrator", "comment-checker"]
// #when: Migrate hook names
const { migrated, changed, removed } = migrateHookNames(hooks)
// #then: sisyphus-orchestrator should be migrated to atlas
expect(changed).toBe(true)
expect(migrated).toContain("atlas")
expect(migrated).toContain("comment-checker")
expect(migrated).not.toContain("sisyphus-orchestrator")
expect(removed).toEqual([])
})
test("removes obsolete hooks and returns them in removed array", () => {
// #given: Config with removed hooks from v3.0.0
const hooks = ["preemptive-compaction", "empty-message-sanitizer", "comment-checker"]
// #when: Migrate hook names
const { migrated, changed, removed } = migrateHookNames(hooks)
// #then: Removed hooks should be filtered out
expect(changed).toBe(true)
expect(migrated).toEqual(["comment-checker"])
expect(removed).toContain("preemptive-compaction")
expect(removed).toContain("empty-message-sanitizer")
expect(removed).toHaveLength(2)
})
test("handles mixed migration and removal", () => {
// #given: Config with both legacy rename and removed hooks
const hooks = ["anthropic-auto-compact", "preemptive-compaction", "sisyphus-orchestrator"]
// #when: Migrate hook names
const { migrated, changed, removed } = migrateHookNames(hooks)
// #then: Legacy should be renamed, removed should be filtered
expect(changed).toBe(true)
expect(migrated).toContain("anthropic-context-window-limit-recovery")
expect(migrated).toContain("atlas")
expect(migrated).not.toContain("preemptive-compaction")
expect(removed).toEqual(["preemptive-compaction"])
})
})
describe("migrateConfigFile", () => {

View File

@@ -36,9 +36,15 @@ export const BUILTIN_AGENT_NAMES = new Set([
])
// Migration map: old hook names → new hook names (for backward compatibility)
export const HOOK_NAME_MAP: Record<string, string> = {
// null means the hook was removed and should be filtered out from disabled_hooks
export const HOOK_NAME_MAP: Record<string, string | null> = {
// Legacy names (backward compatibility)
"anthropic-auto-compact": "anthropic-context-window-limit-recovery",
"sisyphus-orchestrator": "atlas",
// Removed hooks (v3.0.0) - will be filtered out and user warned
"preemptive-compaction": null,
"empty-message-sanitizer": null,
}
/**
@@ -77,19 +83,28 @@ export function migrateAgentNames(agents: Record<string, unknown>): { migrated:
return { migrated, changed }
}
export function migrateHookNames(hooks: string[]): { migrated: string[]; changed: boolean } {
export function migrateHookNames(hooks: string[]): { migrated: string[]; changed: boolean; removed: string[] } {
const migrated: string[] = []
const removed: string[] = []
let changed = false
for (const hook of hooks) {
const newHook = HOOK_NAME_MAP[hook] ?? hook
const mapping = HOOK_NAME_MAP[hook]
if (mapping === null) {
removed.push(hook)
changed = true
continue
}
const newHook = mapping ?? hook
if (newHook !== hook) {
changed = true
}
migrated.push(newHook)
}
return { migrated, changed }
return { migrated, changed, removed }
}
export function migrateAgentConfigToCategory(config: Record<string, unknown>): {
@@ -167,11 +182,14 @@ export function migrateConfigFile(configPath: string, rawConfig: Record<string,
}
if (rawConfig.disabled_hooks && Array.isArray(rawConfig.disabled_hooks)) {
const { migrated, changed } = migrateHookNames(rawConfig.disabled_hooks as string[])
const { migrated, changed, removed } = migrateHookNames(rawConfig.disabled_hooks as string[])
if (changed) {
rawConfig.disabled_hooks = migrated
needsWrite = true
}
if (removed.length > 0) {
log(`Removed obsolete hooks from disabled_hooks: ${removed.join(", ")} (these hooks no longer exist in v3.0.0)`)
}
}
if (needsWrite) {

View File

@@ -0,0 +1,251 @@
import { describe, it, expect, beforeEach } from "bun:test"
import { fetchAvailableModels, fuzzyMatchModel, __resetModelCache } from "./model-availability"
describe("fetchAvailableModels", () => {
let mockClient: any
beforeEach(() => {
__resetModelCache()
})
it("#given API returns list of models #when fetchAvailableModels called #then returns Set of model IDs", async () => {
const mockModels = [
{ id: "openai/gpt-5.2", name: "GPT-5.2" },
{ id: "anthropic/claude-opus-4-5", name: "Claude Opus 4.5" },
{ id: "google/gemini-3-pro", name: "Gemini 3 Pro" },
]
mockClient = {
model: {
list: async () => mockModels,
},
}
const result = await fetchAvailableModels(mockClient)
expect(result).toBeInstanceOf(Set)
expect(result.size).toBe(3)
expect(result.has("openai/gpt-5.2")).toBe(true)
expect(result.has("anthropic/claude-opus-4-5")).toBe(true)
expect(result.has("google/gemini-3-pro")).toBe(true)
})
it("#given API fails #when fetchAvailableModels called #then returns empty Set without throwing", async () => {
mockClient = {
model: {
list: async () => {
throw new Error("API connection failed")
},
},
}
const result = await fetchAvailableModels(mockClient)
expect(result).toBeInstanceOf(Set)
expect(result.size).toBe(0)
})
it("#given API called twice #when second call made #then uses cached result without re-fetching", async () => {
let callCount = 0
const mockModels = [
{ id: "openai/gpt-5.2", name: "GPT-5.2" },
{ id: "anthropic/claude-opus-4-5", name: "Claude Opus 4.5" },
]
mockClient = {
model: {
list: async () => {
callCount++
return mockModels
},
},
}
const result1 = await fetchAvailableModels(mockClient)
const result2 = await fetchAvailableModels(mockClient)
expect(callCount).toBe(1)
expect(result1).toEqual(result2)
expect(result1.has("openai/gpt-5.2")).toBe(true)
})
it("#given empty model list from API #when fetchAvailableModels called #then returns empty Set", async () => {
mockClient = {
model: {
list: async () => [],
},
}
const result = await fetchAvailableModels(mockClient)
expect(result).toBeInstanceOf(Set)
expect(result.size).toBe(0)
})
it("#given API returns models with various formats #when fetchAvailableModels called #then extracts all IDs correctly", async () => {
const mockModels = [
{ id: "openai/gpt-5.2-codex", name: "GPT-5.2 Codex" },
{ id: "anthropic/claude-sonnet-4-5", name: "Claude Sonnet 4.5" },
{ id: "google/gemini-3-flash", name: "Gemini 3 Flash" },
{ id: "opencode/grok-code", name: "Grok Code" },
]
mockClient = {
model: {
list: async () => mockModels,
},
}
const result = await fetchAvailableModels(mockClient)
expect(result.size).toBe(4)
expect(result.has("openai/gpt-5.2-codex")).toBe(true)
expect(result.has("anthropic/claude-sonnet-4-5")).toBe(true)
expect(result.has("google/gemini-3-flash")).toBe(true)
expect(result.has("opencode/grok-code")).toBe(true)
})
})
describe("fuzzyMatchModel", () => {
// #given available models from multiple providers
// #when searching for a substring match
// #then return the matching model
it("should match substring in model name", () => {
const available = new Set([
"openai/gpt-5.2",
"openai/gpt-5.2-codex",
"anthropic/claude-opus-4-5",
])
const result = fuzzyMatchModel("gpt-5.2", available)
expect(result).toBe("openai/gpt-5.2")
})
// #given available models with partial matches
// #when searching for a substring
// #then return exact match if it exists
it("should prefer exact match over substring match", () => {
const available = new Set([
"openai/gpt-5.2",
"openai/gpt-5.2-codex",
"openai/gpt-5.2-ultra",
])
const result = fuzzyMatchModel("gpt-5.2", available)
expect(result).toBe("openai/gpt-5.2")
})
// #given available models with multiple substring matches
// #when searching for a substring
// #then return the shorter model name (more specific)
it("should prefer shorter model name when multiple matches exist", () => {
const available = new Set([
"openai/gpt-5.2-ultra",
"openai/gpt-5.2-ultra-mega",
])
const result = fuzzyMatchModel("gpt-5.2", available)
expect(result).toBe("openai/gpt-5.2-ultra")
})
// #given available models with claude variants
// #when searching for claude-opus
// #then return matching claude-opus model
it("should match claude-opus to claude-opus-4-5", () => {
const available = new Set([
"anthropic/claude-opus-4-5",
"anthropic/claude-sonnet-4-5",
])
const result = fuzzyMatchModel("claude-opus", available)
expect(result).toBe("anthropic/claude-opus-4-5")
})
// #given available models from multiple providers
// #when providers filter is specified
// #then only search models from specified providers
it("should filter by provider when providers array is given", () => {
const available = new Set([
"openai/gpt-5.2",
"anthropic/claude-opus-4-5",
"google/gemini-3",
])
const result = fuzzyMatchModel("gpt", available, ["openai"])
expect(result).toBe("openai/gpt-5.2")
})
// #given available models from multiple providers
// #when providers filter excludes matching models
// #then return null
it("should return null when provider filter excludes all matches", () => {
const available = new Set([
"openai/gpt-5.2",
"anthropic/claude-opus-4-5",
])
const result = fuzzyMatchModel("claude", available, ["openai"])
expect(result).toBeNull()
})
// #given available models
// #when no substring match exists
// #then return null
it("should return null when no match found", () => {
const available = new Set([
"openai/gpt-5.2",
"anthropic/claude-opus-4-5",
])
const result = fuzzyMatchModel("gemini", available)
expect(result).toBeNull()
})
// #given available models with different cases
// #when searching with different case
// #then match case-insensitively
it("should match case-insensitively", () => {
const available = new Set([
"openai/gpt-5.2",
"anthropic/claude-opus-4-5",
])
const result = fuzzyMatchModel("GPT-5.2", available)
expect(result).toBe("openai/gpt-5.2")
})
// #given available models with exact match and longer variants
// #when searching for exact match
// #then return exact match first
it("should prioritize exact match over longer variants", () => {
const available = new Set([
"anthropic/claude-opus-4-5",
"anthropic/claude-opus-4-5-extended",
])
const result = fuzzyMatchModel("claude-opus-4-5", available)
expect(result).toBe("anthropic/claude-opus-4-5")
})
// #given available models with multiple providers
// #when multiple providers are specified
// #then search all specified providers
it("should search all specified providers", () => {
const available = new Set([
"openai/gpt-5.2",
"anthropic/claude-opus-4-5",
"google/gemini-3",
])
const result = fuzzyMatchModel("gpt", available, ["openai", "google"])
expect(result).toBe("openai/gpt-5.2")
})
// #given available models with provider prefix
// #when searching with provider filter
// #then only match models with correct provider prefix
it("should only match models with correct provider prefix", () => {
const available = new Set([
"openai/gpt-5.2",
"anthropic/gpt-something",
])
const result = fuzzyMatchModel("gpt", available, ["openai"])
expect(result).toBe("openai/gpt-5.2")
})
// #given empty available set
// #when searching
// #then return null
it("should return null for empty available set", () => {
const available = new Set<string>()
const result = fuzzyMatchModel("gpt", available)
expect(result).toBeNull()
})
})

View File

@@ -0,0 +1,125 @@
/**
* Fuzzy matching utility for model names
* Supports substring matching with provider filtering and priority-based selection
*/
import { log } from "./logger"
/**
* Fuzzy match a target model name against available models
*
* @param target - The model name or substring to search for (e.g., "gpt-5.2", "claude-opus")
* @param available - Set of available model names in format "provider/model-name"
* @param providers - Optional array of provider names to filter by (e.g., ["openai", "anthropic"])
* @returns The matched model name or null if no match found
*
* Matching priority:
* 1. Exact match (if exists)
* 2. Shorter model name (more specific)
*
* Matching is case-insensitive substring match.
* If providers array is given, only models starting with "provider/" are considered.
*
* @example
* const available = new Set(["openai/gpt-5.2", "openai/gpt-5.2-codex", "anthropic/claude-opus-4-5"])
* fuzzyMatchModel("gpt-5.2", available) // → "openai/gpt-5.2"
* fuzzyMatchModel("claude", available, ["openai"]) // → null (provider filter excludes anthropic)
*/
function normalizeModelName(name: string): string {
return name
.toLowerCase()
.replace(/claude-(opus|sonnet|haiku)-4-5/g, "claude-$1-4.5")
.replace(/claude-(opus|sonnet|haiku)-4\.5/g, "claude-$1-4.5")
}
export function fuzzyMatchModel(
target: string,
available: Set<string>,
providers?: string[],
): string | null {
log("[fuzzyMatchModel] called", { target, availableCount: available.size, providers })
if (available.size === 0) {
log("[fuzzyMatchModel] empty available set")
return null
}
const targetNormalized = normalizeModelName(target)
// Filter by providers if specified
let candidates = Array.from(available)
if (providers && providers.length > 0) {
const providerSet = new Set(providers)
candidates = candidates.filter((model) => {
const [provider] = model.split("/")
return providerSet.has(provider)
})
log("[fuzzyMatchModel] filtered by providers", { candidateCount: candidates.length, candidates: candidates.slice(0, 10) })
}
if (candidates.length === 0) {
log("[fuzzyMatchModel] no candidates after filter")
return null
}
// Find all matches (case-insensitive substring match with normalization)
const matches = candidates.filter((model) =>
normalizeModelName(model).includes(targetNormalized),
)
log("[fuzzyMatchModel] substring matches", { targetNormalized, matchCount: matches.length, matches })
if (matches.length === 0) {
return null
}
// Priority 1: Exact match (normalized)
const exactMatch = matches.find((model) => normalizeModelName(model) === targetNormalized)
if (exactMatch) {
log("[fuzzyMatchModel] exact match found", { exactMatch })
return exactMatch
}
// Priority 2: Shorter model name (more specific)
const result = matches.reduce((shortest, current) =>
current.length < shortest.length ? current : shortest,
)
log("[fuzzyMatchModel] shortest match", { result })
return result
}
let cachedModels: Set<string> | null = null
export async function fetchAvailableModels(client: any): Promise<Set<string>> {
if (cachedModels !== null) {
log("[fetchAvailableModels] returning cached models", { count: cachedModels.size, models: Array.from(cachedModels).slice(0, 20) })
return cachedModels
}
try {
const models = await client.model.list()
const modelSet = new Set<string>()
log("[fetchAvailableModels] raw response", { isArray: Array.isArray(models), length: Array.isArray(models) ? models.length : 0, sample: Array.isArray(models) ? models.slice(0, 5) : models })
if (Array.isArray(models)) {
for (const model of models) {
if (model.id && typeof model.id === "string") {
modelSet.add(model.id)
}
}
}
log("[fetchAvailableModels] parsed models", { count: modelSet.size, models: Array.from(modelSet) })
cachedModels = modelSet
return modelSet
} catch (err) {
log("[fetchAvailableModels] error", { error: String(err) })
return new Set<string>()
}
}
export function __resetModelCache(): void {
cachedModels = null
}

View File

@@ -0,0 +1,417 @@
import { describe, expect, test } from "bun:test"
import {
AGENT_MODEL_REQUIREMENTS,
CATEGORY_MODEL_REQUIREMENTS,
type FallbackEntry,
type ModelRequirement,
} from "./model-requirements"
describe("AGENT_MODEL_REQUIREMENTS", () => {
test("oracle has valid fallbackChain with gpt-5.2 as primary", () => {
// #given - oracle agent requirement
const oracle = AGENT_MODEL_REQUIREMENTS["oracle"]
// #when - accessing oracle requirement
// #then - fallbackChain exists with gpt-5.2 as first entry
expect(oracle).toBeDefined()
expect(oracle.fallbackChain).toBeArray()
expect(oracle.fallbackChain.length).toBeGreaterThan(0)
const primary = oracle.fallbackChain[0]
expect(primary.providers).toContain("openai")
expect(primary.model).toBe("gpt-5.2")
expect(primary.variant).toBe("high")
})
test("Sisyphus has valid fallbackChain with claude-opus-4-5 as primary", () => {
// #given - Sisyphus agent requirement
const sisyphus = AGENT_MODEL_REQUIREMENTS["Sisyphus"]
// #when - accessing Sisyphus requirement
// #then - fallbackChain exists with claude-opus-4-5 as first entry
expect(sisyphus).toBeDefined()
expect(sisyphus.fallbackChain).toBeArray()
expect(sisyphus.fallbackChain.length).toBeGreaterThan(0)
const primary = sisyphus.fallbackChain[0]
expect(primary.providers[0]).toBe("anthropic")
expect(primary.model).toBe("claude-opus-4-5")
expect(primary.variant).toBe("max")
})
test("librarian has valid fallbackChain with glm-4.7 as primary", () => {
// #given - librarian agent requirement
const librarian = AGENT_MODEL_REQUIREMENTS["librarian"]
// #when - accessing librarian requirement
// #then - fallbackChain exists with glm-4.7 as first entry
expect(librarian).toBeDefined()
expect(librarian.fallbackChain).toBeArray()
expect(librarian.fallbackChain.length).toBeGreaterThan(0)
const primary = librarian.fallbackChain[0]
expect(primary.providers[0]).toBe("zai-coding-plan")
expect(primary.model).toBe("glm-4.7")
})
test("explore has valid fallbackChain with gemini-3-flash-preview as primary", () => {
// #given - explore agent requirement
const explore = AGENT_MODEL_REQUIREMENTS["explore"]
// #when - accessing explore requirement
// #then - fallbackChain exists with gemini-3-flash-preview as first entry
expect(explore).toBeDefined()
expect(explore.fallbackChain).toBeArray()
expect(explore.fallbackChain.length).toBeGreaterThan(0)
const primary = explore.fallbackChain[0]
expect(primary.providers).toContain("google")
expect(primary.model).toBe("gemini-3-flash-preview")
})
test("multimodal-looker has valid fallbackChain with gemini-3-flash-preview as primary", () => {
// #given - multimodal-looker agent requirement
const multimodalLooker = AGENT_MODEL_REQUIREMENTS["multimodal-looker"]
// #when - accessing multimodal-looker requirement
// #then - fallbackChain exists with gemini-3-flash-preview as first entry
expect(multimodalLooker).toBeDefined()
expect(multimodalLooker.fallbackChain).toBeArray()
expect(multimodalLooker.fallbackChain.length).toBeGreaterThan(0)
const primary = multimodalLooker.fallbackChain[0]
expect(primary.providers[0]).toBe("google")
expect(primary.model).toBe("gemini-3-flash-preview")
})
test("Prometheus (Planner) has valid fallbackChain with claude-opus-4-5 as primary", () => {
// #given - Prometheus agent requirement
const prometheus = AGENT_MODEL_REQUIREMENTS["Prometheus (Planner)"]
// #when - accessing Prometheus requirement
// #then - fallbackChain exists with claude-opus-4-5 as first entry
expect(prometheus).toBeDefined()
expect(prometheus.fallbackChain).toBeArray()
expect(prometheus.fallbackChain.length).toBeGreaterThan(0)
const primary = prometheus.fallbackChain[0]
expect(primary.model).toBe("claude-opus-4-5")
expect(primary.providers[0]).toBe("anthropic")
expect(primary.variant).toBe("max")
})
test("Metis (Plan Consultant) has valid fallbackChain with claude-opus-4-5 as primary", () => {
// #given - Metis agent requirement
const metis = AGENT_MODEL_REQUIREMENTS["Metis (Plan Consultant)"]
// #when - accessing Metis requirement
// #then - fallbackChain exists with claude-opus-4-5 as first entry
expect(metis).toBeDefined()
expect(metis.fallbackChain).toBeArray()
expect(metis.fallbackChain.length).toBeGreaterThan(0)
const primary = metis.fallbackChain[0]
expect(primary.model).toBe("claude-opus-4-5")
expect(primary.providers[0]).toBe("anthropic")
expect(primary.variant).toBe("max")
})
test("Momus (Plan Reviewer) has valid fallbackChain with gpt-5.2 as primary", () => {
// #given - Momus agent requirement
const momus = AGENT_MODEL_REQUIREMENTS["Momus (Plan Reviewer)"]
// #when - accessing Momus requirement
// #then - fallbackChain exists with gpt-5.2 as first entry, variant medium
expect(momus).toBeDefined()
expect(momus.fallbackChain).toBeArray()
expect(momus.fallbackChain.length).toBeGreaterThan(0)
const primary = momus.fallbackChain[0]
expect(primary.model).toBe("gpt-5.2")
expect(primary.variant).toBe("medium")
expect(primary.providers[0]).toBe("openai")
})
test("Atlas has valid fallbackChain with claude-sonnet-4-5 as primary", () => {
// #given - Atlas agent requirement
const atlas = AGENT_MODEL_REQUIREMENTS["Atlas"]
// #when - accessing Atlas requirement
// #then - fallbackChain exists with claude-sonnet-4-5 as first entry
expect(atlas).toBeDefined()
expect(atlas.fallbackChain).toBeArray()
expect(atlas.fallbackChain.length).toBeGreaterThan(0)
const primary = atlas.fallbackChain[0]
expect(primary.model).toBe("claude-sonnet-4-5")
expect(primary.providers[0]).toBe("anthropic")
})
test("all 9 builtin agents have valid fallbackChain arrays", () => {
// #given - list of 9 agent names
const expectedAgents = [
"Sisyphus",
"oracle",
"librarian",
"explore",
"multimodal-looker",
"Prometheus (Planner)",
"Metis (Plan Consultant)",
"Momus (Plan Reviewer)",
"Atlas",
]
// #when - checking AGENT_MODEL_REQUIREMENTS
const definedAgents = Object.keys(AGENT_MODEL_REQUIREMENTS)
// #then - all agents present with valid fallbackChain
expect(definedAgents).toHaveLength(9)
for (const agent of expectedAgents) {
const requirement = AGENT_MODEL_REQUIREMENTS[agent]
expect(requirement).toBeDefined()
expect(requirement.fallbackChain).toBeArray()
expect(requirement.fallbackChain.length).toBeGreaterThan(0)
for (const entry of requirement.fallbackChain) {
expect(entry.providers).toBeArray()
expect(entry.providers.length).toBeGreaterThan(0)
expect(typeof entry.model).toBe("string")
expect(entry.model.length).toBeGreaterThan(0)
}
}
})
})
describe("CATEGORY_MODEL_REQUIREMENTS", () => {
test("ultrabrain has valid fallbackChain with gpt-5.2-codex as primary", () => {
// #given - ultrabrain category requirement
const ultrabrain = CATEGORY_MODEL_REQUIREMENTS["ultrabrain"]
// #when - accessing ultrabrain requirement
// #then - fallbackChain exists with gpt-5.2-codex as first entry
expect(ultrabrain).toBeDefined()
expect(ultrabrain.fallbackChain).toBeArray()
expect(ultrabrain.fallbackChain.length).toBeGreaterThan(0)
const primary = ultrabrain.fallbackChain[0]
expect(primary.variant).toBe("xhigh")
expect(primary.model).toBe("gpt-5.2-codex")
expect(primary.providers[0]).toBe("openai")
})
test("visual-engineering has valid fallbackChain with gemini-3-pro-preview as primary", () => {
// #given - visual-engineering category requirement
const visualEngineering = CATEGORY_MODEL_REQUIREMENTS["visual-engineering"]
// #when - accessing visual-engineering requirement
// #then - fallbackChain exists with gemini-3-pro-preview as first entry
expect(visualEngineering).toBeDefined()
expect(visualEngineering.fallbackChain).toBeArray()
expect(visualEngineering.fallbackChain.length).toBeGreaterThan(0)
const primary = visualEngineering.fallbackChain[0]
expect(primary.providers[0]).toBe("google")
expect(primary.model).toBe("gemini-3-pro-preview")
})
test("quick has valid fallbackChain with claude-haiku-4-5 as primary", () => {
// #given - quick category requirement
const quick = CATEGORY_MODEL_REQUIREMENTS["quick"]
// #when - accessing quick requirement
// #then - fallbackChain exists with claude-haiku-4-5 as first entry
expect(quick).toBeDefined()
expect(quick.fallbackChain).toBeArray()
expect(quick.fallbackChain.length).toBeGreaterThan(0)
const primary = quick.fallbackChain[0]
expect(primary.model).toBe("claude-haiku-4-5")
expect(primary.providers[0]).toBe("anthropic")
})
test("unspecified-low has valid fallbackChain with claude-sonnet-4-5 as primary", () => {
// #given - unspecified-low category requirement
const unspecifiedLow = CATEGORY_MODEL_REQUIREMENTS["unspecified-low"]
// #when - accessing unspecified-low requirement
// #then - fallbackChain exists with claude-sonnet-4-5 as first entry
expect(unspecifiedLow).toBeDefined()
expect(unspecifiedLow.fallbackChain).toBeArray()
expect(unspecifiedLow.fallbackChain.length).toBeGreaterThan(0)
const primary = unspecifiedLow.fallbackChain[0]
expect(primary.model).toBe("claude-sonnet-4-5")
expect(primary.providers[0]).toBe("anthropic")
})
test("unspecified-high has valid fallbackChain with claude-opus-4-5 as primary", () => {
// #given - unspecified-high category requirement
const unspecifiedHigh = CATEGORY_MODEL_REQUIREMENTS["unspecified-high"]
// #when - accessing unspecified-high requirement
// #then - fallbackChain exists with claude-opus-4-5 as first entry
expect(unspecifiedHigh).toBeDefined()
expect(unspecifiedHigh.fallbackChain).toBeArray()
expect(unspecifiedHigh.fallbackChain.length).toBeGreaterThan(0)
const primary = unspecifiedHigh.fallbackChain[0]
expect(primary.model).toBe("claude-opus-4-5")
expect(primary.variant).toBe("max")
expect(primary.providers[0]).toBe("anthropic")
})
test("artistry has valid fallbackChain with gemini-3-pro-preview as primary", () => {
// #given - artistry category requirement
const artistry = CATEGORY_MODEL_REQUIREMENTS["artistry"]
// #when - accessing artistry requirement
// #then - fallbackChain exists with gemini-3-pro-preview as first entry
expect(artistry).toBeDefined()
expect(artistry.fallbackChain).toBeArray()
expect(artistry.fallbackChain.length).toBeGreaterThan(0)
const primary = artistry.fallbackChain[0]
expect(primary.model).toBe("gemini-3-pro-preview")
expect(primary.variant).toBe("max")
expect(primary.providers[0]).toBe("google")
})
test("writing has valid fallbackChain with gemini-3-flash-preview as primary", () => {
// #given - writing category requirement
const writing = CATEGORY_MODEL_REQUIREMENTS["writing"]
// #when - accessing writing requirement
// #then - fallbackChain exists with gemini-3-flash-preview as first entry
expect(writing).toBeDefined()
expect(writing.fallbackChain).toBeArray()
expect(writing.fallbackChain.length).toBeGreaterThan(0)
const primary = writing.fallbackChain[0]
expect(primary.model).toBe("gemini-3-flash-preview")
expect(primary.providers[0]).toBe("google")
})
test("all 7 categories have valid fallbackChain arrays", () => {
// #given - list of 7 category names
const expectedCategories = [
"visual-engineering",
"ultrabrain",
"artistry",
"quick",
"unspecified-low",
"unspecified-high",
"writing",
]
// #when - checking CATEGORY_MODEL_REQUIREMENTS
const definedCategories = Object.keys(CATEGORY_MODEL_REQUIREMENTS)
// #then - all categories present with valid fallbackChain
expect(definedCategories).toHaveLength(7)
for (const category of expectedCategories) {
const requirement = CATEGORY_MODEL_REQUIREMENTS[category]
expect(requirement).toBeDefined()
expect(requirement.fallbackChain).toBeArray()
expect(requirement.fallbackChain.length).toBeGreaterThan(0)
for (const entry of requirement.fallbackChain) {
expect(entry.providers).toBeArray()
expect(entry.providers.length).toBeGreaterThan(0)
expect(typeof entry.model).toBe("string")
expect(entry.model.length).toBeGreaterThan(0)
}
}
})
})
describe("FallbackEntry type", () => {
test("FallbackEntry structure is correct", () => {
// #given - a valid FallbackEntry object
const entry: FallbackEntry = {
providers: ["anthropic", "github-copilot", "opencode"],
model: "claude-opus-4-5",
variant: "high",
}
// #when - accessing properties
// #then - all properties are accessible
expect(entry.providers).toEqual(["anthropic", "github-copilot", "opencode"])
expect(entry.model).toBe("claude-opus-4-5")
expect(entry.variant).toBe("high")
})
test("FallbackEntry variant is optional", () => {
// #given - a FallbackEntry without variant
const entry: FallbackEntry = {
providers: ["opencode", "anthropic"],
model: "glm-4.7-free",
}
// #when - accessing variant
// #then - variant is undefined
expect(entry.variant).toBeUndefined()
})
})
describe("ModelRequirement type", () => {
test("ModelRequirement structure with fallbackChain is correct", () => {
// #given - a valid ModelRequirement object
const requirement: ModelRequirement = {
fallbackChain: [
{ providers: ["anthropic", "github-copilot"], model: "claude-opus-4-5", variant: "max" },
{ providers: ["openai", "github-copilot"], model: "gpt-5.2", variant: "high" },
],
}
// #when - accessing properties
// #then - fallbackChain is accessible with correct structure
expect(requirement.fallbackChain).toBeArray()
expect(requirement.fallbackChain).toHaveLength(2)
expect(requirement.fallbackChain[0].model).toBe("claude-opus-4-5")
expect(requirement.fallbackChain[1].model).toBe("gpt-5.2")
})
test("ModelRequirement variant is optional", () => {
// #given - a ModelRequirement without top-level variant
const requirement: ModelRequirement = {
fallbackChain: [{ providers: ["opencode"], model: "glm-4.7-free" }],
}
// #when - accessing variant
// #then - variant is undefined
expect(requirement.variant).toBeUndefined()
})
test("no model in fallbackChain has provider prefix", () => {
// #given - all agent and category requirements
const allRequirements = [
...Object.values(AGENT_MODEL_REQUIREMENTS),
...Object.values(CATEGORY_MODEL_REQUIREMENTS),
]
// #when - checking each model in fallbackChain
// #then - none contain "/" (provider prefix)
for (const req of allRequirements) {
for (const entry of req.fallbackChain) {
expect(entry.model).not.toContain("/")
}
}
})
test("all fallbackChain entries have non-empty providers array", () => {
// #given - all agent and category requirements
const allRequirements = [
...Object.values(AGENT_MODEL_REQUIREMENTS),
...Object.values(CATEGORY_MODEL_REQUIREMENTS),
]
// #when - checking each entry in fallbackChain
// #then - all have non-empty providers array
for (const req of allRequirements) {
for (const entry of req.fallbackChain) {
expect(entry.providers).toBeArray()
expect(entry.providers.length).toBeGreaterThan(0)
}
}
})
})

View File

@@ -0,0 +1,128 @@
export type FallbackEntry = {
providers: string[]
model: string
variant?: string // Entry-specific variant (e.g., GPT→high, Opus→max)
}
export type ModelRequirement = {
fallbackChain: FallbackEntry[]
variant?: string // Default variant (used when entry doesn't specify one)
}
export const AGENT_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
Sisyphus: {
fallbackChain: [
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-5", variant: "max" },
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2", variant: "high" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro-preview" },
],
},
oracle: {
fallbackChain: [
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2", variant: "high" },
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-5", variant: "max" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro-preview" },
],
},
librarian: {
fallbackChain: [
{ providers: ["zai-coding-plan"], model: "glm-4.7" },
{ providers: ["opencode"], model: "glm-4.7-free" },
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-sonnet-4-5" },
],
},
explore: {
fallbackChain: [
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-flash-preview" },
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-haiku-4-5" },
{ providers: ["opencode", "github-copilot"], model: "grok-code" },
],
},
"multimodal-looker": {
fallbackChain: [
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-flash-preview" },
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-haiku-4-5" },
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2" },
],
},
"Prometheus (Planner)": {
fallbackChain: [
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-5", variant: "max" },
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2", variant: "high" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro-preview" },
],
},
"Metis (Plan Consultant)": {
fallbackChain: [
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-5", variant: "max" },
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2", variant: "high" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro-preview" },
],
},
"Momus (Plan Reviewer)": {
fallbackChain: [
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2", variant: "medium" },
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-5" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro-preview" },
],
},
Atlas: {
fallbackChain: [
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-sonnet-4-5" },
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro-preview" },
],
},
}
export const CATEGORY_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
"visual-engineering": {
fallbackChain: [
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro-preview" },
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-5", variant: "max" },
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2", variant: "high" },
],
},
ultrabrain: {
fallbackChain: [
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2-codex", variant: "xhigh" },
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-5", variant: "max" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro-preview" },
],
},
artistry: {
fallbackChain: [
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro-preview", variant: "max" },
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-5", variant: "max" },
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2" },
],
},
quick: {
fallbackChain: [
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-haiku-4-5" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-flash-preview" },
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.1-codex-mini" },
],
},
"unspecified-low": {
fallbackChain: [
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-sonnet-4-5" },
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-flash-preview" },
],
},
"unspecified-high": {
fallbackChain: [
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-5", variant: "max" },
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2", variant: "high" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro-preview" },
],
},
writing: {
fallbackChain: [
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-flash-preview" },
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-sonnet-4-5" },
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2" },
],
},
}

View File

@@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test";
import { resolveModel, type ModelResolutionInput } from "./model-resolver";
import { describe, expect, test, spyOn, beforeEach, afterEach } from "bun:test"
import { resolveModel, resolveModelWithFallback, type ModelResolutionInput, type ExtendedModelResolutionInput, type ModelResolutionResult, type ModelSource } from "./model-resolver"
import * as logger from "./logger"
describe("resolveModel", () => {
describe("priority chain", () => {
@@ -9,14 +10,14 @@ describe("resolveModel", () => {
userModel: "anthropic/claude-opus-4-5",
inheritedModel: "openai/gpt-5.2",
systemDefault: "google/gemini-3-pro",
};
}
// #when
const result = resolveModel(input);
const result = resolveModel(input)
// #then
expect(result).toBe("anthropic/claude-opus-4-5");
});
expect(result).toBe("anthropic/claude-opus-4-5")
})
test("returns inheritedModel when userModel is undefined", () => {
// #given
@@ -24,14 +25,14 @@ describe("resolveModel", () => {
userModel: undefined,
inheritedModel: "openai/gpt-5.2",
systemDefault: "google/gemini-3-pro",
};
}
// #when
const result = resolveModel(input);
const result = resolveModel(input)
// #then
expect(result).toBe("openai/gpt-5.2");
});
expect(result).toBe("openai/gpt-5.2")
})
test("returns systemDefault when both userModel and inheritedModel are undefined", () => {
// #given
@@ -39,15 +40,15 @@ describe("resolveModel", () => {
userModel: undefined,
inheritedModel: undefined,
systemDefault: "google/gemini-3-pro",
};
}
// #when
const result = resolveModel(input);
const result = resolveModel(input)
// #then
expect(result).toBe("google/gemini-3-pro");
});
});
expect(result).toBe("google/gemini-3-pro")
})
})
describe("empty string handling", () => {
test("treats empty string as unset, uses fallback", () => {
@@ -56,14 +57,14 @@ describe("resolveModel", () => {
userModel: "",
inheritedModel: "openai/gpt-5.2",
systemDefault: "google/gemini-3-pro",
};
}
// #when
const result = resolveModel(input);
const result = resolveModel(input)
// #then
expect(result).toBe("openai/gpt-5.2");
});
expect(result).toBe("openai/gpt-5.2")
})
test("treats whitespace-only string as unset, uses fallback", () => {
// #given
@@ -71,15 +72,15 @@ describe("resolveModel", () => {
userModel: " ",
inheritedModel: "",
systemDefault: "google/gemini-3-pro",
};
}
// #when
const result = resolveModel(input);
const result = resolveModel(input)
// #then
expect(result).toBe("google/gemini-3-pro");
});
});
expect(result).toBe("google/gemini-3-pro")
})
})
describe("purity", () => {
test("same input returns same output (referential transparency)", () => {
@@ -88,14 +89,384 @@ describe("resolveModel", () => {
userModel: "anthropic/claude-opus-4-5",
inheritedModel: "openai/gpt-5.2",
systemDefault: "google/gemini-3-pro",
};
}
// #when
const result1 = resolveModel(input);
const result2 = resolveModel(input);
const result1 = resolveModel(input)
const result2 = resolveModel(input)
// #then
expect(result1).toBe(result2);
});
});
});
expect(result1).toBe(result2)
})
})
})
describe("resolveModelWithFallback", () => {
let logSpy: ReturnType<typeof spyOn>
beforeEach(() => {
logSpy = spyOn(logger, "log")
})
afterEach(() => {
logSpy.mockRestore()
})
describe("Step 1: Override", () => {
test("returns userModel with override source when userModel is provided", () => {
// #given
const input: ExtendedModelResolutionInput = {
userModel: "anthropic/claude-opus-4-5",
fallbackChain: [
{ providers: ["anthropic", "github-copilot"], model: "claude-opus-4-5" },
],
availableModels: new Set(["anthropic/claude-opus-4-5", "github-copilot/claude-opus-4-5-preview"]),
systemDefaultModel: "google/gemini-3-pro",
}
// #when
const result = resolveModelWithFallback(input)
// #then
expect(result.model).toBe("anthropic/claude-opus-4-5")
expect(result.source).toBe("override")
expect(logSpy).toHaveBeenCalledWith("Model resolved via override", { model: "anthropic/claude-opus-4-5" })
})
test("override takes priority even if model not in availableModels", () => {
// #given
const input: ExtendedModelResolutionInput = {
userModel: "custom/my-model",
fallbackChain: [
{ providers: ["anthropic"], model: "claude-opus-4-5" },
],
availableModels: new Set(["anthropic/claude-opus-4-5"]),
systemDefaultModel: "google/gemini-3-pro",
}
// #when
const result = resolveModelWithFallback(input)
// #then
expect(result.model).toBe("custom/my-model")
expect(result.source).toBe("override")
})
test("whitespace-only userModel is treated as not provided", () => {
// #given
const input: ExtendedModelResolutionInput = {
userModel: " ",
fallbackChain: [
{ providers: ["anthropic"], model: "claude-opus-4-5" },
],
availableModels: new Set(["anthropic/claude-opus-4-5"]),
systemDefaultModel: "google/gemini-3-pro",
}
// #when
const result = resolveModelWithFallback(input)
// #then
expect(result.source).not.toBe("override")
})
test("empty string userModel is treated as not provided", () => {
// #given
const input: ExtendedModelResolutionInput = {
userModel: "",
fallbackChain: [
{ providers: ["anthropic"], model: "claude-opus-4-5" },
],
availableModels: new Set(["anthropic/claude-opus-4-5"]),
systemDefaultModel: "google/gemini-3-pro",
}
// #when
const result = resolveModelWithFallback(input)
// #then
expect(result.source).not.toBe("override")
})
})
describe("Step 2: Provider fallback chain", () => {
test("tries providers in order within entry and returns first match", () => {
// #given
const input: ExtendedModelResolutionInput = {
fallbackChain: [
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-5" },
],
availableModels: new Set(["github-copilot/claude-opus-4-5-preview", "opencode/claude-opus-4-7"]),
systemDefaultModel: "google/gemini-3-pro",
}
// #when
const result = resolveModelWithFallback(input)
// #then
expect(result.model).toBe("github-copilot/claude-opus-4-5-preview")
expect(result.source).toBe("provider-fallback")
expect(logSpy).toHaveBeenCalledWith("Model resolved via fallback chain (availability confirmed)", {
provider: "github-copilot",
model: "claude-opus-4-5",
match: "github-copilot/claude-opus-4-5-preview",
variant: undefined,
})
})
test("respects provider priority order within entry", () => {
// #given
const input: ExtendedModelResolutionInput = {
fallbackChain: [
{ providers: ["openai", "anthropic", "google"], model: "gpt-5.2" },
],
availableModels: new Set(["openai/gpt-5.2", "anthropic/claude-opus-4-5", "google/gemini-3-pro"]),
systemDefaultModel: "google/gemini-3-pro",
}
// #when
const result = resolveModelWithFallback(input)
// #then
expect(result.model).toBe("openai/gpt-5.2")
expect(result.source).toBe("provider-fallback")
})
test("tries next provider when first provider has no match", () => {
// #given
const input: ExtendedModelResolutionInput = {
fallbackChain: [
{ providers: ["anthropic", "opencode", "github-copilot"], model: "grok-code" },
],
availableModels: new Set(["opencode/grok-code", "github-copilot/grok-code-preview"]),
systemDefaultModel: "google/gemini-3-pro",
}
// #when
const result = resolveModelWithFallback(input)
// #then
expect(result.model).toBe("opencode/grok-code")
expect(result.source).toBe("provider-fallback")
})
test("uses fuzzy matching within provider", () => {
// #given
const input: ExtendedModelResolutionInput = {
fallbackChain: [
{ providers: ["anthropic", "github-copilot"], model: "claude-opus" },
],
availableModels: new Set(["anthropic/claude-opus-4-5", "github-copilot/claude-opus-4-5-preview"]),
systemDefaultModel: "google/gemini-3-pro",
}
// #when
const result = resolveModelWithFallback(input)
// #then
expect(result.model).toBe("anthropic/claude-opus-4-5")
expect(result.source).toBe("provider-fallback")
})
test("skips fallback chain when not provided", () => {
// #given
const input: ExtendedModelResolutionInput = {
availableModels: new Set(["anthropic/claude-opus-4-5"]),
systemDefaultModel: "google/gemini-3-pro",
}
// #when
const result = resolveModelWithFallback(input)
// #then
expect(result.source).toBe("system-default")
})
test("skips fallback chain when empty", () => {
// #given
const input: ExtendedModelResolutionInput = {
fallbackChain: [],
availableModels: new Set(["anthropic/claude-opus-4-5"]),
systemDefaultModel: "google/gemini-3-pro",
}
// #when
const result = resolveModelWithFallback(input)
// #then
expect(result.source).toBe("system-default")
})
test("case-insensitive fuzzy matching", () => {
// #given
const input: ExtendedModelResolutionInput = {
fallbackChain: [
{ providers: ["anthropic"], model: "CLAUDE-OPUS" },
],
availableModels: new Set(["anthropic/claude-opus-4-5"]),
systemDefaultModel: "google/gemini-3-pro",
}
// #when
const result = resolveModelWithFallback(input)
// #then
expect(result.model).toBe("anthropic/claude-opus-4-5")
expect(result.source).toBe("provider-fallback")
})
})
describe("Step 3: First fallback entry (no availability match)", () => {
test("returns first fallbackChain entry when no availability match found", () => {
// #given
const input: ExtendedModelResolutionInput = {
fallbackChain: [
{ providers: ["anthropic"], model: "nonexistent-model" },
],
availableModels: new Set(["openai/gpt-5.2", "anthropic/claude-opus-4-5"]),
systemDefaultModel: "google/gemini-3-pro",
}
// #when
const result = resolveModelWithFallback(input)
// #then
expect(result.model).toBe("anthropic/nonexistent-model")
expect(result.source).toBe("provider-fallback")
expect(logSpy).toHaveBeenCalledWith("Model resolved via fallback chain first entry (no availability match)", { model: "anthropic/nonexistent-model", variant: undefined })
})
test("returns first fallbackChain entry when availableModels is empty", () => {
// #given
const input: ExtendedModelResolutionInput = {
fallbackChain: [
{ providers: ["anthropic"], model: "claude-opus-4-5" },
],
availableModels: new Set(),
systemDefaultModel: "google/gemini-3-pro",
}
// #when
const result = resolveModelWithFallback(input)
// #then
expect(result.model).toBe("anthropic/claude-opus-4-5")
expect(result.source).toBe("provider-fallback")
})
test("returns system default when fallbackChain is not provided", () => {
// #given
const input: ExtendedModelResolutionInput = {
availableModels: new Set(["openai/gpt-5.2"]),
systemDefaultModel: "google/gemini-3-pro",
}
// #when
const result = resolveModelWithFallback(input)
// #then
expect(result.model).toBe("google/gemini-3-pro")
expect(result.source).toBe("system-default")
})
})
describe("Multi-entry fallbackChain", () => {
test("resolves to claude-opus when OpenAI unavailable but Anthropic available (oracle scenario)", () => {
// #given
const availableModels = new Set(["anthropic/claude-opus-4-5"])
// #when
const result = resolveModelWithFallback({
fallbackChain: [
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2", variant: "high" },
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-5", variant: "max" },
],
availableModels,
systemDefaultModel: "system/default",
})
// #then
expect(result.model).toBe("anthropic/claude-opus-4-5")
expect(result.source).toBe("provider-fallback")
})
test("tries all providers in first entry before moving to second entry", () => {
// #given
const availableModels = new Set(["google/gemini-3-pro-preview"])
// #when
const result = resolveModelWithFallback({
fallbackChain: [
{ providers: ["openai", "anthropic"], model: "gpt-5.2" },
{ providers: ["google"], model: "gemini-3-pro-preview" },
],
availableModels,
systemDefaultModel: "system/default",
})
// #then
expect(result.model).toBe("google/gemini-3-pro-preview")
expect(result.source).toBe("provider-fallback")
})
test("returns first matching entry even if later entries have better matches", () => {
// #given
const availableModels = new Set([
"openai/gpt-5.2",
"anthropic/claude-opus-4-5",
])
// #when
const result = resolveModelWithFallback({
fallbackChain: [
{ providers: ["openai"], model: "gpt-5.2" },
{ providers: ["anthropic"], model: "claude-opus-4-5" },
],
availableModels,
systemDefaultModel: "system/default",
})
// #then
expect(result.model).toBe("openai/gpt-5.2")
expect(result.source).toBe("provider-fallback")
})
test("falls through to first fallbackChain entry when none match availability", () => {
// #given
const availableModels = new Set(["other/model"])
// #when
const result = resolveModelWithFallback({
fallbackChain: [
{ providers: ["openai"], model: "gpt-5.2" },
{ providers: ["anthropic"], model: "claude-opus-4-5" },
{ providers: ["google"], model: "gemini-3-pro" },
],
availableModels,
systemDefaultModel: "system/default",
})
// #then
expect(result.model).toBe("openai/gpt-5.2")
expect(result.source).toBe("provider-fallback")
})
})
describe("Type safety", () => {
test("result has correct ModelResolutionResult shape", () => {
// #given
const input: ExtendedModelResolutionInput = {
userModel: "anthropic/claude-opus-4-5",
availableModels: new Set(),
systemDefaultModel: "google/gemini-3-pro",
}
// #when
const result: ModelResolutionResult = resolveModelWithFallback(input)
// #then
expect(typeof result.model).toBe("string")
expect(["override", "provider-fallback", "system-default"]).toContain(result.source)
})
})
})

View File

@@ -1,35 +1,80 @@
/**
* Input for model resolution.
* All model strings are optional except systemDefault which is the terminal fallback.
*/
import { log } from "./logger"
import { fuzzyMatchModel } from "./model-availability"
import type { FallbackEntry } from "./model-requirements"
export type ModelResolutionInput = {
/** Model from user category config */
userModel?: string;
/** Model inherited from parent task/session */
inheritedModel?: string;
/** System default model from OpenCode config - always required */
systemDefault: string;
};
userModel?: string
inheritedModel?: string
systemDefault: string
}
export type ModelSource =
| "override"
| "provider-fallback"
| "system-default"
export type ModelResolutionResult = {
model: string
source: ModelSource
variant?: string
}
export type ExtendedModelResolutionInput = {
userModel?: string
fallbackChain?: FallbackEntry[]
availableModels: Set<string>
systemDefaultModel: string
}
/**
* Normalizes a model string.
* Trims whitespace and treats empty/whitespace-only as undefined.
*/
function normalizeModel(model?: string): string | undefined {
const trimmed = model?.trim();
return trimmed || undefined;
const trimmed = model?.trim()
return trimmed || undefined
}
/**
* Resolves the effective model using priority chain:
* userModel → inheritedModel → systemDefault
*
* Empty strings and whitespace-only strings are treated as unset.
*/
export function resolveModel(input: ModelResolutionInput): string {
return (
normalizeModel(input.userModel) ??
normalizeModel(input.inheritedModel) ??
input.systemDefault
);
return (
normalizeModel(input.userModel) ??
normalizeModel(input.inheritedModel) ??
input.systemDefault
)
}
export function resolveModelWithFallback(
input: ExtendedModelResolutionInput,
): ModelResolutionResult {
const { userModel, fallbackChain, availableModels, systemDefaultModel } = input
// Step 1: Override
const normalizedUserModel = normalizeModel(userModel)
if (normalizedUserModel) {
log("Model resolved via override", { model: normalizedUserModel })
return { model: normalizedUserModel, source: "override" }
}
// Step 2: Provider fallback chain (with availability check)
if (fallbackChain && fallbackChain.length > 0) {
for (const entry of fallbackChain) {
for (const provider of entry.providers) {
const fullModel = `${provider}/${entry.model}`
const match = fuzzyMatchModel(fullModel, availableModels, [provider])
if (match) {
log("Model resolved via fallback chain (availability confirmed)", { provider, model: entry.model, match, variant: entry.variant })
return { model: match, source: "provider-fallback", variant: entry.variant }
}
}
}
// Step 3: Use first entry in fallbackChain as fallback (no availability match found)
// This ensures category/agent intent is honored even if availableModels is incomplete
const firstEntry = fallbackChain[0]
if (firstEntry.providers.length > 0) {
const fallbackModel = `${firstEntry.providers[0]}/${firstEntry.model}`
log("Model resolved via fallback chain first entry (no availability match)", { model: fallbackModel, variant: firstEntry.variant })
return { model: fallbackModel, source: "provider-fallback", variant: firstEntry.variant }
}
}
// Step 4: System default
log("Model resolved via system default", { model: systemDefaultModel })
return { model: systemDefaultModel, source: "system-default" }
}

View File

@@ -144,6 +144,7 @@ describe("opencode-config-dir", () => {
// #given opencode CLI binary detected, platform is Linux
Object.defineProperty(process, "platform", { value: "linux" })
delete process.env.XDG_CONFIG_HOME
delete process.env.OPENCODE_CONFIG_DIR
// #when getOpenCodeConfigDir is called with binary="opencode"
const result = getOpenCodeConfigDir({ binary: "opencode", version: "1.0.200" })
@@ -156,6 +157,7 @@ describe("opencode-config-dir", () => {
// #given opencode CLI binary detected, platform is Linux with XDG_CONFIG_HOME set
Object.defineProperty(process, "platform", { value: "linux" })
process.env.XDG_CONFIG_HOME = "/custom/config"
delete process.env.OPENCODE_CONFIG_DIR
// #when getOpenCodeConfigDir is called with binary="opencode"
const result = getOpenCodeConfigDir({ binary: "opencode", version: "1.0.200" })
@@ -168,6 +170,7 @@ describe("opencode-config-dir", () => {
// #given opencode CLI binary detected, platform is macOS
Object.defineProperty(process, "platform", { value: "darwin" })
delete process.env.XDG_CONFIG_HOME
delete process.env.OPENCODE_CONFIG_DIR
// #when getOpenCodeConfigDir is called with binary="opencode"
const result = getOpenCodeConfigDir({ binary: "opencode", version: "1.0.200" })
@@ -180,6 +183,7 @@ describe("opencode-config-dir", () => {
// #given opencode CLI binary detected, platform is Windows
Object.defineProperty(process, "platform", { value: "win32" })
delete process.env.APPDATA
delete process.env.OPENCODE_CONFIG_DIR
// #when getOpenCodeConfigDir is called with binary="opencode"
const result = getOpenCodeConfigDir({ binary: "opencode", version: "1.0.200", checkExisting: false })
@@ -257,6 +261,7 @@ describe("opencode-config-dir", () => {
// #given opencode CLI binary on Linux
Object.defineProperty(process, "platform", { value: "linux" })
delete process.env.XDG_CONFIG_HOME
delete process.env.OPENCODE_CONFIG_DIR
// #when getOpenCodeConfigPaths is called
const paths = getOpenCodeConfigPaths({ binary: "opencode", version: "1.0.200" })

View File

@@ -2,7 +2,7 @@
## OVERVIEW
20+ tools: LSP (11), AST-Grep (2), Search (2), Session (4), Agent delegation (3), System (2). High-performance C++ bindings via @ast-grep/napi.
20+ tools: LSP (6), AST-Grep (2), Search (2), Session (4), Agent delegation (4), System (2), Skill (3).
## STRUCTURE
@@ -10,17 +10,17 @@
tools/
├── [tool-name]/
│ ├── index.ts # Barrel export
│ ├── tools.ts # Business logic, ToolDefinition
│ ├── tools.ts # ToolDefinition
│ ├── types.ts # Zod schemas
│ └── constants.ts # Fixed values, descriptions
├── lsp/ # 11 tools: goto_definition, references, symbols, diagnostics, rename
├── ast-grep/ # 2 tools: search, replace (25 languages via NAPI)
├── delegate-task/ # Category-based agent routing (761 lines)
│ └── constants.ts # Fixed values
├── lsp/ # 6 tools: definition, references, symbols, diagnostics, rename
├── ast-grep/ # 2 tools: search, replace (25 languages)
├── delegate-task/ # Category-based routing (1038 lines)
├── session-manager/ # 4 tools: list, read, search, info
├── grep/ # Custom grep with timeout/truncation
├── glob/ # Custom glob with 60s timeout, 100 file limit
├── grep/ # Custom grep with timeout
├── glob/ # 60s timeout, 100 file limit
├── interactive-bash/ # Tmux session management
├── look-at/ # Multimodal PDF/image analysis
├── look-at/ # Multimodal PDF/image
├── skill/ # Skill execution
├── skill-mcp/ # Skill MCP operations
├── slashcommand/ # Slash command dispatch
@@ -32,43 +32,33 @@ tools/
| Category | Tools | Purpose |
|----------|-------|---------|
| **LSP** | lsp_goto_definition, lsp_find_references, lsp_symbols, lsp_diagnostics, lsp_prepare_rename, lsp_rename | Semantic code intelligence |
| **Search** | ast_grep_search, ast_grep_replace, grep, glob | Pattern discovery |
| **Session** | session_list, session_read, session_search, session_info | History navigation |
| **Agent** | delegate_task, call_omo_agent, background_output, background_cancel | Task orchestration |
| **System** | interactive_bash, look_at | CLI, multimodal |
| **Skill** | skill, skill_mcp, slashcommand | Skill execution |
| LSP | lsp_goto_definition, lsp_find_references, lsp_symbols, lsp_diagnostics, lsp_prepare_rename, lsp_rename | Semantic code intelligence |
| Search | ast_grep_search, ast_grep_replace, grep, glob | Pattern discovery |
| Session | session_list, session_read, session_search, session_info | History navigation |
| Agent | delegate_task, call_omo_agent, background_output, background_cancel | Task orchestration |
| System | interactive_bash, look_at | CLI, multimodal |
| Skill | skill, skill_mcp, slashcommand | Skill execution |
## HOW TO ADD
1. Create `src/tools/[name]/` with standard files
2. Use `tool()` from `@opencode-ai/plugin/tool`:
```typescript
export const myTool: ToolDefinition = tool({
description: "...",
args: { param: tool.schema.string() },
execute: async (args) => { /* ... */ }
})
```
2. Use `tool()` from `@opencode-ai/plugin/tool`
3. Export from `src/tools/index.ts`
4. Add to `builtinTools` object
## LSP SPECIFICS
- **Client**: `client.ts` manages stdio lifecycle, JSON-RPC
- **Client**: `client.ts` manages stdio, JSON-RPC
- **Singleton**: `LSPServerManager` with ref counting
- **Protocol**: Standard LSP methods mapped to tool responses
- **Capabilities**: definition, references, symbols, diagnostics, rename
## AST-GREP SPECIFICS
- **Engine**: `@ast-grep/napi` for 25+ languages
- **Patterns**: Meta-variables `$VAR` (single), `$$$` (multiple)
- **Performance**: Rust/C++ layer for structural matching
- **Patterns**: `$VAR` (single), `$$$` (multiple)
## ANTI-PATTERNS
- **Sequential bash**: Use `&&` or delegation, not loops
- **Sequential bash**: Use `&&` or delegation
- **Raw file ops**: Never mkdir/touch in tool logic
- **Sleep**: Use polling loops, tool-specific wait flags
- **Heavy sync**: Keep PreToolUse light, computation in tools.ts
- **Sleep**: Use polling loops

View File

@@ -233,9 +233,9 @@ export function formatEnvironmentCheck(result: EnvironmentCheckResult): string {
// CLI status
if (result.cli.available) {
lines.push(` CLI: Available (${result.cli.path})`)
lines.push(`[OK] CLI: Available (${result.cli.path})`)
} else {
lines.push(` CLI: Not available`)
lines.push(`[X] CLI: Not available`)
if (result.cli.error) {
lines.push(` Error: ${result.cli.error}`)
}
@@ -244,9 +244,9 @@ export function formatEnvironmentCheck(result: EnvironmentCheckResult): string {
// NAPI status
if (result.napi.available) {
lines.push(` NAPI: Available`)
lines.push(`[OK] NAPI: Available`)
} else {
lines.push(` NAPI: Not available`)
lines.push(`[X] NAPI: Not available`)
if (result.napi.error) {
lines.push(` Error: ${result.napi.error}`)
}

View File

@@ -15,17 +15,17 @@ function getEmptyResultHint(pattern: string, lang: CliLanguage): string | null {
if (lang === "python") {
if (src.startsWith("class ") && src.endsWith(":")) {
const withoutColon = src.slice(0, -1)
return `💡 Hint: Remove trailing colon. Try: "${withoutColon}"`
return `Hint: Remove trailing colon. Try: "${withoutColon}"`
}
if ((src.startsWith("def ") || src.startsWith("async def ")) && src.endsWith(":")) {
const withoutColon = src.slice(0, -1)
return `💡 Hint: Remove trailing colon. Try: "${withoutColon}"`
return `Hint: Remove trailing colon. Try: "${withoutColon}"`
}
}
if (["javascript", "typescript", "tsx"].includes(lang)) {
if (/^(export\s+)?(async\s+)?function\s+\$[A-Z_]+\s*$/i.test(src)) {
return `💡 Hint: Function patterns need params and body. Try "function $NAME($$$) { $$$ }"`
return `Hint: Function patterns need params and body. Try "function $NAME($$$) { $$$ }"`
}
}

View File

@@ -17,7 +17,7 @@ export function formatSearchResult(result: SgResult): string {
: result.truncatedReason === "max_output_bytes"
? "output exceeded 1MB limit"
: "search timed out"
lines.push(`⚠️ Results truncated (${reason})\n`)
lines.push(`[TRUNCATED] Results truncated (${reason})\n`)
}
lines.push(`Found ${result.matches.length} match(es)${result.truncated ? ` (truncated from ${result.totalMatches})` : ""}:\n`)
@@ -50,7 +50,7 @@ export function formatReplaceResult(result: SgResult, isDryRun: boolean): string
: result.truncatedReason === "max_output_bytes"
? "output exceeded 1MB limit"
: "search timed out"
lines.push(`⚠️ Results truncated (${reason})\n`)
lines.push(`[TRUNCATED] Results truncated (${reason})\n`)
}
lines.push(`${prefix}${result.matches.length} replacement(s):\n`)

View File

@@ -60,7 +60,7 @@ export function createBackgroundTask(manager: BackgroundManager): ToolDefinition
const ctx = toolContext as ToolContextWithMetadata
if (!args.agent || args.agent.trim() === "") {
return ` Agent parameter is required. Please specify which agent to use (e.g., "explore", "librarian", "build", etc.)`
return `[ERROR] Agent parameter is required. Please specify which agent to use (e.g., "explore", "librarian", "build", etc.)`
}
try {
@@ -112,7 +112,7 @@ Use \`background_output\` tool with task_id="${task.id}" to check progress:
- block=true: Wait for completion (rarely needed since system notifies)`
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return ` Failed to launch background task: ${message}`
return `[ERROR] Failed to launch background task: ${message}`
}
},
})
@@ -395,7 +395,7 @@ export function createBackgroundCancel(manager: BackgroundManager, client: Openc
const cancelAll = args.all === true
if (!cancelAll && !args.taskId) {
return ` Invalid arguments: Either provide a taskId or set all=true to cancel all running tasks.`
return `[ERROR] Invalid arguments: Either provide a taskId or set all=true to cancel all running tasks.`
}
if (cancelAll) {
@@ -403,7 +403,7 @@ export function createBackgroundCancel(manager: BackgroundManager, client: Openc
const cancellableTasks = tasks.filter(t => t.status === "running" || t.status === "pending")
if (cancellableTasks.length === 0) {
return `No running or pending background tasks to cancel.`
return `No running or pending background tasks to cancel.`
}
const results: string[] = []
@@ -424,18 +424,18 @@ export function createBackgroundCancel(manager: BackgroundManager, client: Openc
}
}
return `Cancelled ${cancellableTasks.length} background task(s):
return `Cancelled ${cancellableTasks.length} background task(s):
${results.join("\n")}`
}
const task = manager.getTask(args.taskId!)
if (!task) {
return ` Task not found: ${args.taskId}`
return `[ERROR] Task not found: ${args.taskId}`
}
if (task.status !== "running" && task.status !== "pending") {
return ` Cannot cancel task: current status is "${task.status}".
return `[ERROR] Cannot cancel task: current status is "${task.status}".
Only running or pending tasks can be cancelled.`
}
@@ -443,10 +443,10 @@ Only running or pending tasks can be cancelled.`
// Pending task: use manager method (no session to abort, no slot to release)
const cancelled = manager.cancelPendingTask(task.id)
if (!cancelled) {
return ` Failed to cancel pending task: ${task.id}`
return `[ERROR] Failed to cancel pending task: ${task.id}`
}
return `Pending task cancelled successfully
return `Pending task cancelled successfully
Task ID: ${task.id}
Description: ${task.description}
@@ -465,14 +465,14 @@ Status: ${task.status}`
task.status = "cancelled"
task.completedAt = new Date()
return `Task cancelled successfully
return `Task cancelled successfully
Task ID: ${task.id}
Description: ${task.description}
Session ID: ${task.sessionID}
Status: ${task.status}`
} catch (error) {
return ` Error cancelling task: ${error instanceof Error ? error.message : String(error)}`
return `[ERROR] Error cancelling task: ${error instanceof Error ? error.message : String(error)}`
}
},
})

View File

@@ -4,7 +4,7 @@ import { join } from "node:path"
import { ALLOWED_AGENTS, CALL_OMO_AGENT_DESCRIPTION } from "./constants"
import type { CallOmoAgentArgs } from "./types"
import type { BackgroundManager } from "../../features/background-agent"
import { log, getAgentToolRestrictions } from "../../shared"
import { log, getAgentToolRestrictions, includesCaseInsensitive } from "../../shared"
import { consumeNewMessages } from "../../shared/session-cursor"
import { findFirstMessageWithAgent, findNearestMessageWithFields, MESSAGE_STORAGE } from "../../features/hook-message-injector"
import { getSessionAgent } from "../../features/claude-code-session-state"
@@ -46,7 +46,7 @@ export function createCallOmoAgent(
description: tool.schema.string().describe("A short (3-5 words) description of the task"),
prompt: tool.schema.string().describe("The task for the agent to perform"),
subagent_type: tool.schema
.enum(ALLOWED_AGENTS)
.string()
.describe("The type of specialized agent to use for this task (explore or librarian only)"),
run_in_background: tool.schema
.boolean()
@@ -57,9 +57,13 @@ export function createCallOmoAgent(
const toolCtx = toolContext as ToolContextWithMetadata
log(`[call_omo_agent] Starting with agent: ${args.subagent_type}, background: ${args.run_in_background}`)
if (!ALLOWED_AGENTS.includes(args.subagent_type as typeof ALLOWED_AGENTS[number])) {
// Case-insensitive agent validation - allows "Explore", "EXPLORE", "explore" etc.
if (!includesCaseInsensitive([...ALLOWED_AGENTS], args.subagent_type)) {
return `Error: Invalid agent type "${args.subagent_type}". Only ${ALLOWED_AGENTS.join(", ")} are allowed.`
}
const normalizedAgent = args.subagent_type.toLowerCase() as typeof ALLOWED_AGENTS[number]
args = { ...args, subagent_type: normalizedAgent }
if (args.run_in_background) {
if (args.session_id) {

View File

@@ -185,21 +185,4 @@ export const CATEGORY_DESCRIPTIONS: Record<string, string> = {
writing: "Documentation, prose, technical writing",
}
const BUILTIN_CATEGORIES = Object.keys(DEFAULT_CATEGORIES).join(", ")
export const DELEGATE_TASK_DESCRIPTION = `Spawn agent task with category-based or direct agent selection.
MUTUALLY EXCLUSIVE: Provide EITHER category OR agent, not both (unless resuming).
- category: Use predefined category (${BUILTIN_CATEGORIES}) → Spawns Sisyphus-Junior with category config
- agent: Use specific agent directly (e.g., "oracle", "explore")
- background: true=async (returns task_id), false=sync (waits for result). Default: false. Use background=true ONLY for parallel exploration with 5+ independent queries.
- resume: Session ID to resume (from previous task output). Continues agent with FULL CONTEXT PRESERVED - saves tokens, maintains continuity.
- skills: Array of skill names to prepend to prompt (e.g., ["playwright", "frontend-ui-ux"]). Use [] (empty array) if no skills needed.
**WHEN TO USE resume:**
- Task failed/incomplete → resume with "fix: [specific issue]"
- Need follow-up on previous result → resume with additional question
- Multi-turn conversation with same agent → always resume instead of new task
Prompts MUST be in English.`

View File

@@ -1,12 +1,18 @@
import { describe, test, expect } from "bun:test"
import { DEFAULT_CATEGORIES, CATEGORY_PROMPT_APPENDS, CATEGORY_DESCRIPTIONS, DELEGATE_TASK_DESCRIPTION } from "./constants"
import { describe, test, expect, beforeEach } from "bun:test"
import { DEFAULT_CATEGORIES, CATEGORY_PROMPT_APPENDS, CATEGORY_DESCRIPTIONS } from "./constants"
import { resolveCategoryConfig } from "./tools"
import type { CategoryConfig } from "../../config/schema"
import { __resetModelCache } from "../../shared/model-availability"
// Test constants - systemDefaultModel is required by resolveCategoryConfig
const SYSTEM_DEFAULT_MODEL = "anthropic/claude-sonnet-4-5"
describe("sisyphus-task", () => {
// Reset model cache before each test to prevent cross-test pollution
beforeEach(() => {
__resetModelCache()
})
describe("DEFAULT_CATEGORIES", () => {
test("visual-engineering category has model config", () => {
// #given
@@ -70,19 +76,6 @@ describe("sisyphus-task", () => {
})
})
describe("DELEGATE_TASK_DESCRIPTION", () => {
test("documents background parameter as required with default false", () => {
// #given / #when / #then
expect(DELEGATE_TASK_DESCRIPTION).toContain("background")
expect(DELEGATE_TASK_DESCRIPTION).toContain("Default: false")
})
test("warns about parallel exploration usage", () => {
// #given / #when / #then
expect(DELEGATE_TASK_DESCRIPTION).toContain("5+")
})
})
describe("category delegation config validation", () => {
test("returns error when systemDefaultModel is not configured", async () => {
// #given a mock client with no model in config
@@ -118,7 +111,7 @@ describe("sisyphus-task", () => {
prompt: "Do something",
category: "ultrabrain",
run_in_background: false,
skills: [],
load_skills: ["git-master"],
},
toolContext
)
@@ -333,7 +326,7 @@ describe("sisyphus-task", () => {
prompt: "Do something",
category: "ultrabrain",
run_in_background: true,
skills: [],
load_skills: ["git-master"],
},
toolContext
)
@@ -394,7 +387,7 @@ describe("sisyphus-task", () => {
prompt: "Do something",
category: "unspecified-high",
run_in_background: true,
skills: [],
load_skills: ["git-master"],
},
toolContext
)
@@ -451,7 +444,7 @@ describe("sisyphus-task", () => {
prompt: "Do something",
category: "unspecified-high",
run_in_background: false,
skills: [],
load_skills: ["git-master"],
},
toolContext
)
@@ -466,14 +459,7 @@ describe("sisyphus-task", () => {
})
describe("skills parameter", () => {
test("DELEGATE_TASK_DESCRIPTION documents skills parameter with empty array option", () => {
// #given / #when / #then
expect(DELEGATE_TASK_DESCRIPTION).toContain("skills")
expect(DELEGATE_TASK_DESCRIPTION).toContain("Array of skill names")
expect(DELEGATE_TASK_DESCRIPTION).toContain("[] (empty array) if no skills needed")
})
test("skills parameter is required - returns error when not provided", async () => {
test("skills parameter is required - throws error when not provided", async () => {
// #given
const { createDelegateTask } = require("./tools")
@@ -501,7 +487,8 @@ describe("sisyphus-task", () => {
}
// #when - skills not provided (undefined)
const result = await tool.execute(
// #then - should throw error about missing skills
await expect(tool.execute(
{
description: "Test task",
prompt: "Do something",
@@ -509,14 +496,10 @@ describe("sisyphus-task", () => {
run_in_background: false,
},
toolContext
)
// #then - should return error about missing skills
expect(result).toContain("skills")
expect(result).toContain("REQUIRED")
)).rejects.toThrow("IT IS HIGHLY RECOMMENDED")
})
test("null skills returns error", async () => {
test("null skills throws error", async () => {
// #given
const { createDelegateTask } = require("./tools")
@@ -544,22 +527,17 @@ describe("sisyphus-task", () => {
}
// #when - null passed
const result = await tool.execute(
// #then - should throw error about null
await expect(tool.execute(
{
description: "Test task",
prompt: "Do something",
category: "ultrabrain",
run_in_background: false,
skills: null,
load_skills: null,
},
toolContext
)
// #then - should return error about null
expect(result).toContain("Invalid arguments")
expect(result).toContain("skills=null")
expect(result).toContain("not allowed")
expect(result).toContain("skills=[]")
)).rejects.toThrow("IT IS HIGHLY RECOMMENDED")
})
test("empty array [] is allowed and proceeds without skill content", async () => {
@@ -597,14 +575,14 @@ describe("sisyphus-task", () => {
abort: new AbortController().signal,
}
// #when - empty array skills passed
// #when - empty array passed
await tool.execute(
{
description: "Test task",
prompt: "Do something",
category: "ultrabrain",
run_in_background: false,
skills: [],
load_skills: [],
},
toolContext
)
@@ -670,7 +648,7 @@ describe("sisyphus-task", () => {
prompt: "Continue the task",
resume: "ses_resume_test",
run_in_background: false,
skills: [],
load_skills: ["git-master"],
},
toolContext
)
@@ -725,7 +703,7 @@ describe("sisyphus-task", () => {
prompt: "Continue in background",
resume: "ses_bg_resume",
run_in_background: true,
skills: [],
load_skills: ["git-master"],
},
toolContext
)
@@ -780,7 +758,7 @@ describe("sisyphus-task", () => {
prompt: "Do something",
category: "ultrabrain",
run_in_background: false,
skills: [],
load_skills: ["git-master"],
},
toolContext
)
@@ -840,7 +818,7 @@ describe("sisyphus-task", () => {
prompt: "Do something",
category: "ultrabrain",
run_in_background: false,
skills: [],
load_skills: ["git-master"],
},
toolContext
)
@@ -893,7 +871,7 @@ describe("sisyphus-task", () => {
prompt: "Do something",
category: "ultrabrain",
run_in_background: false,
skills: [],
load_skills: ["git-master"],
},
toolContext
)
@@ -947,7 +925,7 @@ describe("sisyphus-task", () => {
prompt: "test",
category: "custom-cat",
run_in_background: false,
skills: []
load_skills: ["git-master"]
}, toolContext)
// #then
@@ -1012,14 +990,14 @@ describe("sisyphus-task", () => {
prompt: "Do something visual",
category: "visual-engineering",
run_in_background: false,
skills: [],
load_skills: ["git-master"],
},
toolContext
)
// #then - should launch as background BUT wait for and return actual result
expect(launchCalled).toBe(true)
expect(result).toContain("UNSTABLE AGENT")
expect(result).toContain("SUPERVISED TASK COMPLETED")
expect(result).toContain("Gemini task completed successfully")
}, { timeout: 20000 })
@@ -1070,7 +1048,7 @@ describe("sisyphus-task", () => {
prompt: "Do something visual",
category: "visual-engineering",
run_in_background: true, // User explicitly says true - normal background
skills: [],
load_skills: ["git-master"],
},
toolContext
)
@@ -1131,7 +1109,7 @@ describe("sisyphus-task", () => {
prompt: "Do something smart",
category: "ultrabrain",
run_in_background: false,
skills: [],
load_skills: ["git-master"],
},
toolContext
)
@@ -1195,14 +1173,14 @@ describe("sisyphus-task", () => {
prompt: "Do something artistic",
category: "artistry",
run_in_background: false,
skills: [],
load_skills: ["git-master"],
},
toolContext
)
// #then - should launch as background BUT wait for and return actual result
expect(launchCalled).toBe(true)
expect(result).toContain("UNSTABLE AGENT")
expect(result).toContain("SUPERVISED TASK COMPLETED")
expect(result).toContain("Artistry result here")
}, { timeout: 20000 })
@@ -1259,14 +1237,14 @@ describe("sisyphus-task", () => {
prompt: "Write something",
category: "writing",
run_in_background: false,
skills: [],
load_skills: ["git-master"],
},
toolContext
)
// #then - should launch as background BUT wait for and return actual result
expect(launchCalled).toBe(true)
expect(result).toContain("UNSTABLE AGENT")
expect(result).toContain("SUPERVISED TASK COMPLETED")
expect(result).toContain("Writing result here")
}, { timeout: 20000 })
@@ -1329,14 +1307,14 @@ describe("sisyphus-task", () => {
prompt: "Do something",
category: "my-unstable-cat",
run_in_background: false,
skills: [],
load_skills: ["git-master"],
},
toolContext
)
// #then - should launch as background BUT wait for and return actual result
expect(launchCalled).toBe(true)
expect(result).toContain("UNSTABLE AGENT")
expect(result).toContain("SUPERVISED TASK COMPLETED")
expect(result).toContain("Custom unstable result")
}, { timeout: 20000 })
})

View File

@@ -4,19 +4,21 @@ import { join } from "node:path"
import type { BackgroundManager } from "../../features/background-agent"
import type { DelegateTaskArgs } from "./types"
import type { CategoryConfig, CategoriesConfig, GitMasterConfig } from "../../config/schema"
import { DELEGATE_TASK_DESCRIPTION, DEFAULT_CATEGORIES, CATEGORY_PROMPT_APPENDS } from "./constants"
import { DEFAULT_CATEGORIES, CATEGORY_PROMPT_APPENDS, CATEGORY_DESCRIPTIONS } from "./constants"
import { findNearestMessageWithFields, findFirstMessageWithAgent, MESSAGE_STORAGE } from "../../features/hook-message-injector"
import { resolveMultipleSkillsAsync } from "../../features/opencode-skill-loader/skill-content"
import { discoverSkills } from "../../features/opencode-skill-loader"
import { getTaskToastManager } from "../../features/task-toast-manager"
import type { ModelFallbackInfo } from "../../features/task-toast-manager/types"
import { subagentSessions, getSessionAgent } from "../../features/claude-code-session-state"
import { log, getAgentToolRestrictions, resolveModel, getOpenCodeConfigPaths } from "../../shared"
import { log, getAgentToolRestrictions, resolveModel, getOpenCodeConfigPaths, findByNameCaseInsensitive, equalsIgnoreCase } from "../../shared"
import { fetchAvailableModels } from "../../shared/model-availability"
import { resolveModelWithFallback } from "../../shared/model-resolver"
import { CATEGORY_MODEL_REQUIREMENTS } from "../../shared/model-requirements"
type OpencodeClient = PluginInput["client"]
const SISYPHUS_JUNIOR_AGENT = "Sisyphus-Junior"
const CATEGORY_EXAMPLES = Object.keys(DEFAULT_CATEGORIES).map(k => `'${k}'`).join(", ")
function parseModelString(model: string): { providerID: string; modelID: string } | undefined {
const parts = model.split("/")
@@ -83,7 +85,7 @@ function formatDetailedError(error: unknown, ctx: ErrorContext): string {
lines.push(`- category: ${ctx.args.category ?? "(none)"}`)
lines.push(`- subagent_type: ${ctx.args.subagent_type ?? "(none)"}`)
lines.push(`- run_in_background: ${ctx.args.run_in_background}`)
lines.push(`- skills: [${ctx.args.skills?.join(", ") ?? ""}]`)
lines.push(`- load_skills: [${ctx.args.load_skills?.join(", ") ?? ""}]`)
if (ctx.args.resume) {
lines.push(`- resume: ${ctx.args.resume}`)
}
@@ -178,33 +180,63 @@ export function buildSystemContent(input: BuildSystemContentInput): string | und
export function createDelegateTask(options: DelegateTaskToolOptions): ToolDefinition {
const { manager, client, directory, userCategories, gitMasterConfig } = options
const allCategories = { ...DEFAULT_CATEGORIES, ...userCategories }
const categoryNames = Object.keys(allCategories)
const categoryExamples = categoryNames.map(k => `'${k}'`).join(", ")
const categoryList = categoryNames.map(name => {
const userDesc = userCategories?.[name]?.description
const builtinDesc = CATEGORY_DESCRIPTIONS[name]
const desc = userDesc || builtinDesc
return desc ? ` - ${name}: ${desc}` : ` - ${name}`
}).join("\n")
const description = `Spawn agent task with category-based or direct agent selection.
MUTUALLY EXCLUSIVE: Provide EITHER category OR subagent_type, not both (unless resuming).
- load_skills: ALWAYS REQUIRED. Pass at least one skill name (e.g., ["playwright"], ["git-master", "frontend-ui-ux"]).
- category: Use predefined category → Spawns Sisyphus-Junior with category config
Available categories:
${categoryList}
- subagent_type: Use specific agent directly (e.g., "oracle", "explore")
- run_in_background: true=async (returns task_id), false=sync (waits for result). Default: false. Use background=true ONLY for parallel exploration with 5+ independent queries.
- resume: Session ID to resume (from previous task output). Continues agent with FULL CONTEXT PRESERVED - saves tokens, maintains continuity.
**WHEN TO USE resume:**
- Task failed/incomplete → resume with "fix: [specific issue]"
- Need follow-up on previous result → resume with additional question
- Multi-turn conversation with same agent → always resume instead of new task
Prompts MUST be in English.`
return tool({
description: DELEGATE_TASK_DESCRIPTION,
description,
args: {
description: tool.schema.string().describe("Short task description"),
load_skills: tool.schema.array(tool.schema.string()).describe("Skill names to inject. REQUIRED - pass [] if no skills needed, but IT IS HIGHLY RECOMMENDED to pass proper skills like [\"playwright\"], [\"git-master\"] for best results."),
description: tool.schema.string().describe("Short task description (3-5 words)"),
prompt: tool.schema.string().describe("Full detailed prompt for the agent"),
category: tool.schema.string().optional().describe(`Category name (e.g., ${CATEGORY_EXAMPLES}). Mutually exclusive with subagent_type.`),
subagent_type: tool.schema.string().optional().describe("Agent name directly (e.g., 'oracle', 'explore'). Mutually exclusive with category."),
run_in_background: tool.schema.boolean().describe("Run in background. MUST be explicitly set. Use false for task delegation, true only for parallel exploration."),
resume: tool.schema.string().optional().describe("Session ID to resume - continues previous agent session with full context"),
skills: tool.schema.array(tool.schema.string()).describe("Array of skill names to prepend to the prompt. Use [] (empty array) if no skills needed."),
run_in_background: tool.schema.boolean().describe("true=async (returns task_id), false=sync (waits). Default: false"),
category: tool.schema.string().optional().describe(`Category (e.g., ${categoryExamples}). Mutually exclusive with subagent_type.`),
subagent_type: tool.schema.string().optional().describe("Agent name (e.g., 'oracle', 'explore'). Mutually exclusive with category."),
resume: tool.schema.string().optional().describe("Session ID to resume"),
},
async execute(args: DelegateTaskArgs, toolContext) {
const ctx = toolContext as ToolContextWithMetadata
if (args.run_in_background === undefined) {
return `Invalid arguments: 'run_in_background' parameter is REQUIRED. Use run_in_background=false for task delegation, run_in_background=true only for parallel exploration.`
throw new Error(`Invalid arguments: 'run_in_background' parameter is REQUIRED. Use run_in_background=false for task delegation, run_in_background=true only for parallel exploration.`)
}
if (args.skills === undefined) {
return `Invalid arguments: 'skills' parameter is REQUIRED. Use skills=[] if no skills are needed, or provide an array of skill names.`
if (args.load_skills === undefined) {
throw new Error(`Invalid arguments: 'load_skills' parameter is REQUIRED. Pass [] if no skills needed, but IT IS HIGHLY RECOMMENDED to pass proper skills like ["playwright"], ["git-master"] for best results.`)
}
if (args.skills === null) {
return `Invalid arguments: skills=null is not allowed. Use skills=[] (empty array) if no skills are needed.`
if (args.load_skills === null) {
throw new Error(`Invalid arguments: load_skills=null is not allowed. Pass [] if no skills needed, but IT IS HIGHLY RECOMMENDED to pass proper skills.`)
}
const runInBackground = args.run_in_background === true
let skillContent: string | undefined
if (args.skills.length > 0) {
const { resolved, notFound } = await resolveMultipleSkillsAsync(args.skills, { gitMasterConfig })
if (args.load_skills.length > 0) {
const { resolved, notFound } = await resolveMultipleSkillsAsync(args.load_skills, { gitMasterConfig })
if (notFound.length > 0) {
const allSkills = await discoverSkills({ includeClaudeCodePaths: true })
const available = allSkills.map(s => s.name).join(", ")
@@ -218,7 +250,7 @@ export function createDelegateTask(options: DelegateTaskToolOptions): ToolDefini
const firstMessageAgent = messageDir ? findFirstMessageWithAgent(messageDir) : null
const sessionAgent = getSessionAgent(ctx.sessionID)
const parentAgent = ctx.agent ?? sessionAgent ?? firstMessageAgent ?? prevMessage?.agent
log("[delegate_task] parentAgent resolution", {
sessionID: ctx.sessionID,
messageDir,
@@ -246,7 +278,14 @@ export function createDelegateTask(options: DelegateTaskToolOptions): ToolDefini
ctx.metadata?.({
title: `Resume: ${task.description}`,
metadata: { sessionId: task.sessionID },
metadata: {
prompt: args.prompt,
agent: task.agent,
load_skills: args.load_skills,
description: args.description,
run_in_background: args.run_in_background,
sessionId: task.sessionID,
},
})
return `Background task resumed.
@@ -283,7 +322,14 @@ Use \`background_output\` with task_id="${task.id}" to check progress.`
ctx.metadata?.({
title: `Resume: ${args.description}`,
metadata: { sessionId: args.resume, sync: true },
metadata: {
prompt: args.prompt,
load_skills: args.load_skills,
description: args.description,
run_in_background: args.run_in_background,
sessionId: args.resume,
sync: true,
},
})
try {
@@ -344,7 +390,7 @@ Use \`background_output\` with task_id="${task.id}" to check progress.`
while (Date.now() - pollStart < 60000) {
await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS))
const elapsed = Date.now() - pollStart
if (elapsed < MIN_STABILITY_TIME_MS) continue
@@ -402,7 +448,10 @@ Session ID: ${args.resume}
---
${textContent || "(No text output)"}`
${textContent || "(No text output)"}
---
To resume this session: resume="${args.resume}"`
}
if (args.category && args.subagent_type) {
@@ -413,79 +462,113 @@ ${textContent || "(No text output)"}`
return `Invalid arguments: Must provide either category or subagent_type.`
}
// Fetch OpenCode config at boundary to get system default model
let systemDefaultModel: string | undefined
try {
const openCodeConfig = await client.config.get()
systemDefaultModel = (openCodeConfig as { data?: { model?: string } })?.data?.model
} catch {
// Config fetch failed, proceed without system default
systemDefaultModel = undefined
}
// Fetch OpenCode config at boundary to get system default model
let systemDefaultModel: string | undefined
try {
const openCodeConfig = await client.config.get()
systemDefaultModel = (openCodeConfig as { data?: { model?: string } })?.data?.model
} catch {
// Config fetch failed, proceed without system default
systemDefaultModel = undefined
}
let agentToUse: string
let categoryModel: { providerID: string; modelID: string; variant?: string } | undefined
let categoryPromptAppend: string | undefined
let agentToUse: string
let categoryModel: { providerID: string; modelID: string; variant?: string } | undefined
let categoryPromptAppend: string | undefined
const inheritedModel = parentModel
? `${parentModel.providerID}/${parentModel.modelID}`
: undefined
const inheritedModel = parentModel
? `${parentModel.providerID}/${parentModel.modelID}`
: undefined
let modelInfo: ModelFallbackInfo | undefined
let modelInfo: ModelFallbackInfo | undefined
if (args.category) {
// Guard: require system default model for category delegation
if (!systemDefaultModel) {
const paths = getOpenCodeConfigPaths({ binary: "opencode", version: null })
return (
'oh-my-opencode requires a default model.\n\n' +
`Add this to ${paths.configJsonc}:\n\n` +
' "model": "anthropic/claude-sonnet-4-5"\n\n' +
'(Replace with your preferred provider/model)'
)
}
if (args.category) {
// Guard: require system default model for category delegation
if (!systemDefaultModel) {
const paths = getOpenCodeConfigPaths({ binary: "opencode", version: null })
return (
'oh-my-opencode requires a default model.\n\n' +
`Add this to ${paths.configJsonc}:\n\n` +
' "model": "anthropic/claude-sonnet-4-5"\n\n' +
'(Replace with your preferred provider/model)'
)
}
const resolved = resolveCategoryConfig(args.category, {
userCategories,
inheritedModel,
systemDefaultModel,
const availableModels = await fetchAvailableModels(client)
const resolved = resolveCategoryConfig(args.category, {
userCategories,
inheritedModel,
systemDefaultModel,
})
if (!resolved) {
return `Unknown category: "${args.category}". Available: ${Object.keys({ ...DEFAULT_CATEGORIES, ...userCategories }).join(", ")}`
}
const requirement = CATEGORY_MODEL_REQUIREMENTS[args.category]
let actualModel: string
if (!requirement) {
actualModel = resolved.model
modelInfo = { model: actualModel, type: "system-default", source: "system-default" }
} else {
const { model: resolvedModel, source, variant: resolvedVariant } = resolveModelWithFallback({
userModel: userCategories?.[args.category]?.model,
fallbackChain: requirement.fallbackChain,
availableModels,
systemDefaultModel,
})
actualModel = resolvedModel
if (!parseModelString(actualModel)) {
return `Invalid model format "${actualModel}". Expected "provider/model" format (e.g., "anthropic/claude-sonnet-4-5").`
}
let type: "user-defined" | "inherited" | "category-default" | "system-default"
switch (source) {
case "override":
type = "user-defined"
break
case "provider-fallback":
type = "category-default"
break
case "system-default":
type = "system-default"
break
}
modelInfo = { model: actualModel, type, source }
const parsedModel = parseModelString(actualModel)
const variantToUse = userCategories?.[args.category]?.variant ?? resolvedVariant
categoryModel = parsedModel
? (variantToUse ? { ...parsedModel, variant: variantToUse } : parsedModel)
: undefined
}
agentToUse = SISYPHUS_JUNIOR_AGENT
if (!categoryModel) {
const parsedModel = parseModelString(actualModel)
categoryModel = parsedModel ?? undefined
}
categoryPromptAppend = resolved.promptAppend || undefined
const isUnstableAgent = resolved.config.is_unstable_agent === true || actualModel.toLowerCase().includes("gemini")
// Handle both boolean false and string "false" due to potential serialization
const isRunInBackgroundExplicitlyFalse = args.run_in_background === false || args.run_in_background === "false" as unknown as boolean
log("[delegate_task] unstable agent detection", {
category: args.category,
actualModel,
isUnstableAgent,
run_in_background_value: args.run_in_background,
run_in_background_type: typeof args.run_in_background,
isRunInBackgroundExplicitlyFalse,
willForceBackground: isUnstableAgent && isRunInBackgroundExplicitlyFalse,
})
if (!resolved) {
return `Unknown category: "${args.category}". Available: ${Object.keys({ ...DEFAULT_CATEGORIES, ...userCategories }).join(", ")}`
}
// Determine model source by comparing against the actual resolved model
const actualModel = resolved.model
const userDefinedModel = userCategories?.[args.category]?.model
if (!parseModelString(actualModel)) {
return `Invalid model format "${actualModel}". Expected "provider/model" format (e.g., "anthropic/claude-sonnet-4-5").`
}
switch (actualModel) {
case userDefinedModel:
modelInfo = { model: actualModel, type: "user-defined" }
break
case inheritedModel:
modelInfo = { model: actualModel, type: "inherited" }
break
case systemDefaultModel:
modelInfo = { model: actualModel, type: "system-default" }
break
}
agentToUse = SISYPHUS_JUNIOR_AGENT
const parsedModel = parseModelString(actualModel)
categoryModel = parsedModel
? (resolved.config.variant
? { ...parsedModel, variant: resolved.config.variant }
: parsedModel)
: undefined
categoryPromptAppend = resolved.promptAppend || undefined
// Unstable agent detection - launch as background for monitoring but wait for result
const isUnstableAgent = resolved.config.is_unstable_agent === true || actualModel.toLowerCase().includes("gemini")
if (isUnstableAgent && args.run_in_background === false) {
if (isUnstableAgent && isRunInBackgroundExplicitlyFalse) {
const systemContent = buildSystemContent({ skillContent, categoryPromptAppend })
try {
@@ -498,14 +581,26 @@ ${textContent || "(No text output)"}`
parentModel,
parentAgent,
model: categoryModel,
skills: args.skills.length > 0 ? args.skills : undefined,
skills: args.load_skills.length > 0 ? args.load_skills : undefined,
skillContent: systemContent,
})
// Wait for sessionID to be set (task transitions from pending to running)
// launch() returns immediately with status="pending", sessionID is set async in startTask()
const WAIT_FOR_SESSION_INTERVAL_MS = 100
const WAIT_FOR_SESSION_TIMEOUT_MS = 30000
const waitStart = Date.now()
while (!task.sessionID && Date.now() - waitStart < WAIT_FOR_SESSION_TIMEOUT_MS) {
if (ctx.abort?.aborted) {
return `Task aborted while waiting for session to start.\n\nTask ID: ${task.id}`
}
await new Promise(resolve => setTimeout(resolve, WAIT_FOR_SESSION_INTERVAL_MS))
}
const sessionID = task.sessionID
if (!sessionID) {
return formatDetailedError(new Error("Background task launched but no sessionID returned"), {
operation: "Launch background task (unstable agent)",
return formatDetailedError(new Error(`Task failed to start within timeout (30s). Task ID: ${task.id}, Status: ${task.status}`), {
operation: "Launch monitored background task",
args,
agent: agentToUse,
category: args.category,
@@ -514,7 +609,15 @@ ${textContent || "(No text output)"}`
ctx.metadata?.({
title: args.description,
metadata: { sessionId: sessionID, category: args.category },
metadata: {
prompt: args.prompt,
agent: agentToUse,
category: args.category,
load_skills: args.load_skills,
description: args.description,
run_in_background: args.run_in_background,
sessionId: sessionID,
},
})
const startTime = new Date()
@@ -530,7 +633,7 @@ ${textContent || "(No text output)"}`
while (Date.now() - pollStart < MAX_POLL_TIME_MS) {
if (ctx.abort?.aborted) {
return `[UNSTABLE AGENT] Task aborted.\n\nSession ID: ${sessionID}`
return `Task aborted (was running in background mode).\n\nSession ID: ${sessionID}`
}
await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS))
@@ -572,25 +675,39 @@ ${textContent || "(No text output)"}`
const lastMessage = assistantMessages[0]
if (!lastMessage) {
return `[UNSTABLE AGENT] No assistant response found.\n\nSession ID: ${sessionID}`
return `No assistant response found (task ran in background mode).\n\nSession ID: ${sessionID}`
}
const textParts = lastMessage?.parts?.filter((p) => p.type === "text" || p.type === "reasoning") ?? []
const textContent = textParts.map((p) => p.text ?? "").filter(Boolean).join("\n")
const duration = formatDuration(startTime)
return `[UNSTABLE AGENT] Task completed in ${duration}.
return `SUPERVISED TASK COMPLETED SUCCESSFULLY
Model: ${actualModel} (unstable/experimental - launched via background for monitoring)
IMPORTANT: This model (${actualModel}) is marked as unstable/experimental.
Your run_in_background=false was automatically converted to background mode for reliability monitoring.
Duration: ${duration}
Agent: ${agentToUse}${args.category ? ` (category: ${args.category})` : ""}
Session ID: ${sessionID}
MONITORING INSTRUCTIONS:
- The task was monitored and completed successfully
- If you observe this agent behaving erratically in future calls, actively monitor its progress
- Use background_cancel(task_id="...") to abort if the agent seems stuck or producing garbage output
- Do NOT retry automatically if you see this message - the task already succeeded
---
${textContent || "(No text output)"}`
RESULT:
${textContent || "(No text output)"}
---
To resume this session: resume="${sessionID}"`
} catch (error) {
return formatDetailedError(error, {
operation: "Launch background task (unstable agent)",
operation: "Launch monitored background task",
args,
agent: agentToUse,
category: args.category,
@@ -602,28 +719,42 @@ ${textContent || "(No text output)"}`
return `Agent name cannot be empty.`
}
const agentName = args.subagent_type.trim()
if (equalsIgnoreCase(agentName, SISYPHUS_JUNIOR_AGENT)) {
return `Cannot use subagent_type="${SISYPHUS_JUNIOR_AGENT}" directly. Use category parameter instead (e.g., ${categoryExamples}).
Sisyphus-Junior is spawned automatically when you specify a category. Pick the appropriate category for your task domain.`
}
agentToUse = agentName
// Validate agent exists and is callable (not a primary agent)
// Uses case-insensitive matching to allow "Oracle", "oracle", "ORACLE" etc.
try {
const agentsResult = await client.app.agents()
type AgentInfo = { name: string; mode?: "subagent" | "primary" | "all" }
const agents = (agentsResult as { data?: AgentInfo[] }).data ?? agentsResult as unknown as AgentInfo[]
const callableAgents = agents.filter((a) => a.mode !== "primary")
const callableNames = callableAgents.map((a) => a.name)
if (!callableNames.includes(agentToUse)) {
const isPrimaryAgent = agents.some((a) => a.name === agentToUse && a.mode === "primary")
const matchedAgent = findByNameCaseInsensitive(callableAgents, agentToUse)
if (!matchedAgent) {
const isPrimaryAgent = findByNameCaseInsensitive(
agents.filter((a) => a.mode === "primary"),
agentToUse
)
if (isPrimaryAgent) {
return `Cannot call primary agent "${agentToUse}" via delegate_task. Primary agents are top-level orchestrators.`
return `Cannot call primary agent "${isPrimaryAgent.name}" via delegate_task. Primary agents are top-level orchestrators.`
}
const availableAgents = callableNames
const availableAgents = callableAgents
.map((a) => a.name)
.sort()
.join(", ")
return `Unknown agent: "${agentToUse}". Available agents: ${availableAgents}`
}
// Use the canonical agent name from registration
agentToUse = matchedAgent.name
} catch {
// If we can't fetch agents, proceed anyway - the session.prompt will fail with a clearer error
}
@@ -642,13 +773,21 @@ ${textContent || "(No text output)"}`
parentModel,
parentAgent,
model: categoryModel,
skills: args.skills.length > 0 ? args.skills : undefined,
skills: args.load_skills.length > 0 ? args.load_skills : undefined,
skillContent: systemContent,
})
ctx.metadata?.({
title: args.description,
metadata: { sessionId: task.sessionID, category: args.category },
metadata: {
prompt: args.prompt,
agent: task.agent,
category: args.category,
load_skills: args.load_skills,
description: args.description,
run_in_background: args.run_in_background,
sessionId: task.sessionID,
},
})
return `Background task launched.
@@ -659,7 +798,8 @@ Description: ${task.description}
Agent: ${task.agent}${args.category ? ` (category: ${args.category})` : ""}
Status: ${task.status}
System notifies on completion. Use \`background_output\` with task_id="${task.id}" to check.`
System notifies on completion. Use \`background_output\` with task_id="${task.id}" to check.
To resume this session: resume="${task.sessionID}"`
} catch (error) {
return formatDetailedError(error, {
operation: "Launch background task",
@@ -706,14 +846,24 @@ System notifies on completion. Use \`background_output\` with task_id="${task.id
description: args.description,
agent: agentToUse,
isBackground: false,
skills: args.skills.length > 0 ? args.skills : undefined,
category: args.category,
skills: args.load_skills,
modelInfo,
})
}
ctx.metadata?.({
title: args.description,
metadata: { sessionId: sessionID, category: args.category, sync: true },
metadata: {
prompt: args.prompt,
agent: agentToUse,
category: args.category,
load_skills: args.load_skills,
description: args.description,
run_in_background: args.run_in_background,
sessionId: sessionID,
sync: true,
},
})
try {
@@ -840,11 +990,11 @@ System notifies on completion. Use \`background_output\` with task_id="${task.id
.filter((m) => m.info?.role === "assistant")
.sort((a, b) => (b.info?.time?.created ?? 0) - (a.info?.time?.created ?? 0))
const lastMessage = assistantMessages[0]
if (!lastMessage) {
return `No assistant response found.\n\nSession ID: ${sessionID}`
}
// Extract text from both "text" and "reasoning" parts (thinking models use "reasoning")
const textParts = lastMessage?.parts?.filter((p) => p.type === "text" || p.type === "reasoning") ?? []
const textContent = textParts.map((p) => p.text ?? "").filter(Boolean).join("\n")
@@ -864,7 +1014,10 @@ Session ID: ${sessionID}
---
${textContent || "(No text output)"}`
${textContent || "(No text output)"}
---
To resume this session: resume="${sessionID}"`
} catch (error) {
if (toastManager && taskId !== undefined) {
toastManager.removeTask(taskId)

View File

@@ -5,5 +5,5 @@ export interface DelegateTaskArgs {
subagent_type?: string
run_in_background: boolean
resume?: string
skills: string[]
load_skills: string[]
}

View File

@@ -1,8 +1,8 @@
import { existsSync, readFileSync } from "fs"
import { join } from "path"
import { homedir } from "os"
import { BUILTIN_SERVERS, EXT_TO_LANG, LSP_INSTALL_HINTS } from "./constants"
import type { ResolvedServer, ServerLookupResult } from "./types"
import { getOpenCodeConfigDir } from "../../shared"
interface LspEntry {
disabled?: boolean
@@ -34,10 +34,11 @@ function loadJsonFile<T>(path: string): T | null {
function getConfigPaths(): { project: string; user: string; opencode: string } {
const cwd = process.cwd()
const configDir = getOpenCodeConfigDir({ binary: "opencode" })
return {
project: join(cwd, ".opencode", "oh-my-opencode.json"),
user: join(homedir(), ".config", "opencode", "oh-my-opencode.json"),
opencode: join(homedir(), ".config", "opencode", "opencode.json"),
user: join(configDir, "oh-my-opencode.json"),
opencode: join(configDir, "opencode.json"),
}
}
@@ -199,10 +200,11 @@ export function isServerInstalled(command: string[]): boolean {
}
const cwd = process.cwd()
const configDir = getOpenCodeConfigDir({ binary: "opencode" })
const additionalBases = [
join(cwd, "node_modules", ".bin"),
join(homedir(), ".config", "opencode", "bin"),
join(homedir(), ".config", "opencode", "node_modules", ".bin"),
join(configDir, "bin"),
join(configDir, "node_modules", ".bin"),
]
for (const base of additionalBases) {

View File

@@ -75,7 +75,7 @@ export function formatSessionMessages(
if (includeTodos && todos && todos.length > 0) {
lines.push("\n\n=== Todos ===")
for (const todo of todos) {
const status = todo.status === "completed" ? "" : todo.status === "in_progress" ? "" : ""
const status = todo.status === "completed" ? "[x]" : todo.status === "in_progress" ? "[-]" : "[ ]"
lines.push(`${status} [${todo.status}] ${todo.content}`)
}
}

Some files were not shown because too many files have changed in this diff Show More