Compare commits

..

69 Commits

Author SHA1 Message Date
github-actions[bot]
b8a0eee92d release: v3.0.0 2026-01-24 13:23:25 +00:00
justsisyphus
1486ebbc87 docs: update READMEs for 3.0 stable release
- Update TIP banner from beta.10 to stable 3.0 in all languages
- Add Korean language link to Japanese and Chinese READMEs
- Add DeepWiki badge to Japanese and Chinese READMEs
- Adjust DeepWiki badge position in Korean README for consistency
2026-01-24 21:58:53 +09:00
justsisyphus
063c759275 feat: show detailed task info and resume instructions on background_cancel(all=true) (#1062)
Co-authored-by: justsisyphus <justsisyphus@users.noreply.github.com>
2026-01-24 17:15:31 +09:00
justsisyphus
6e9ebaf3ee fix: add missing gemini-3-flash to writing category migration (#1061)
MODEL_TO_CATEGORY_MAP was missing the mapping for google/gemini-3-flash
to the 'writing' category. Users who had configured agents with
model: 'google/gemini-3-flash' would not get auto-migrated to
category: 'writing'.

Ref: PR #1057 review comment

Co-authored-by: justsisyphus <justsisyphus@users.noreply.github.com>
2026-01-24 17:05:14 +09:00
justsisyphus
0e1d4e52e1 chore: remove website directory (fixes CI test failures) 2026-01-24 16:37:46 +09:00
sisyphus-dev-ai
c0fb4b79bd chore: changes by sisyphus-dev-ai 2026-01-24 07:12:01 +00:00
justsisyphus
ec32dd65c2 fix(question-label-truncator): fix type errors and add test coverage
- Remove invalid Pick<Plugin> type usage
- Add explicit input/output type annotations
- Add comprehensive test suite (5 tests)
- Tests verify truncation at 30 chars with '...' suffix
2026-01-24 16:07:08 +09:00
Ssoon-m
04fb339622 fix: add model fallback from agent/category configs 2026-01-24 16:03:12 +09:00
yimingll
3a22c24cf4 fix: auto-truncate question option labels exceeding 30 characters
When AI generates AskUserQuestion tool calls with option labels longer
than 30 characters, opencode validation rejects them with "too_big" error.

This fix adds a pre-tool-use hook that automatically truncates labels
to 30 characters (with "..." suffix) before the validation occurs.

Fixes the error:
"The question tool was called with invalid arguments: expected string
to have <=30 characters"

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 15:59:45 +09:00
Stephen Wang
cf2320480f Fix MCP disabled flag not removing previously loaded servers (#985)
When a later-loaded MCP config (e.g., .claude/.mcp.json) marks a server
as disabled, it now properly removes that server from both the servers
object and loadedServers array.

Previously, disabled servers were only skipped during loading, which
meant they wouldn't override servers loaded from earlier configs. This
made it impossible to disable project-level MCPs using local overrides.

Now the disabled flag works as expected: local configs can disable
servers defined in project or user configs.
2026-01-24 15:55:59 +09:00
Rouven Hi!
9532680879 fix(slashcommand): include built-in commands (like start-work) in discovery (#1031)
This ensures that commands defined in src/features/builtin-commands/commands.ts
(like /start-work, /refactor, /init-deep) are visible to the slashcommand tool
and the agent. Previously, only markdown-based commands were discovered.
2026-01-24 15:55:31 +09:00
justsisyphus
2a945ddbf5 fix(background-task): pass config to BackgroundManager for concurrency limits
The background_task config (providerConcurrency, modelConcurrency, etc.)
was not being passed to BackgroundManager, causing all models to use
the hardcoded default limit of 5 instead of user-configured values.
2026-01-24 15:50:44 +09:00
justsisyphus
58bb92134d fix(todo-continuation): filter compaction agent to prevent infinite loop
- Add 'compaction' to DEFAULT_SKIP_AGENTS
- Skip compaction agent messages when resolving agent info
- Skip injection when compaction occurred but no real agent resolved
- Replace cooldown-based approach with agent-based filtering
2026-01-24 15:50:44 +09:00
Sungho Park
f1a279a10a Add xhigh reasoningEffort to config schema (#965)
* test: cover xhigh reasoningEffort

* feat: add xhigh reasoningEffort option

* test: make reasoningEffort xhigh test model-agnostic
2026-01-24 15:48:15 +09:00
YeonGyu-Kim
faf172a91d fix(multimodal-looker): update fallback chain order (#1050)
New order:
1. google/gemini-3-flash
2. openai/gpt-5.2
3. zai-coding-plan/glm-4.6v
4. anthropic/claude-haiku-4-5
5. opencode/gpt-5-nano (FREE, ultimate fallback)

Co-authored-by: justsisyphus <justsisyphus@users.noreply.github.com>
2026-01-24 15:40:24 +09:00
YeonGyu-Kim
04633ba208 fix(models): update model names to match OpenCode Zen catalog (#1048)
* fix(models): update model names to match OpenCode Zen catalog

OpenCode Zen recently updated their official model catalog, deprecating
several preview and free model variants:

DEPRECATED → NEW (Official Zen Names):
- gemini-3-pro-preview → gemini-3-pro
- gemini-3-flash-preview → gemini-3-flash
- grok-code → gpt-5-nano (FREE tier maintained)
- glm-4.7-free → big-pickle (FREE tier maintained)
- glm-4.6v → glm-4.6

Changes:
- Updated 6 source files (model-requirements, delegate-task, think-mode, etc.)
- Updated 9 documentation files (installation, configurations, features, etc.)
- Updated 14 test files with new model references
- Regenerated snapshots to reflect catalog changes
- Removed duplicate think-mode entries for preview variants

Impact:
- FREE tier access preserved via gpt-5-nano and big-pickle
- All 55 model-related tests passing
- Zero breaking changes - pure string replacement
- Aligns codebase with official OpenCode Zen model catalog

Verified:
- Zero deprecated model names in codebase
- All model-related tests pass (55/55)
- Snapshots regenerated and validated

Affects: 30 files (6 source, 9 docs, 14 tests, 1 snapshot)

* fix(multimodal-looker): update fallback chain with glm-4.6v and gpt-5-nano

- Change glm-4.6 to glm-4.6v for zai-coding-plan provider
- Add opencode/gpt-5-nano as 4th fallback (FREE tier)
- Push gpt-5.2 to 5th position

Fallback chain now:
1. gemini-3-flash (google, github-copilot, opencode)
2. claude-haiku-4-5 (anthropic, github-copilot, opencode)
3. glm-4.6v (zai-coding-plan)
4. gpt-5-nano (opencode) - FREE
5. gpt-5.2 (openai, github-copilot, opencode)

* chore: update bun.lock

---------

Co-authored-by: justsisyphus <justsisyphus@users.noreply.github.com>
2026-01-24 15:30:35 +09:00
justsisyphus
58459e692b feat(website): add layout with header, sidebar, footer and navigation
- Create Header component with logo, nav, theme toggle, language switcher
- Create Sidebar component with doc navigation from config
- Create Footer component
- Create MobileNav component with hamburger menu
- Create navigation config file (docsConfig)
- Integrate all layout components into [locale]/layout.tsx
- Add framer-motion for mobile nav animations
- All tests passing, build successful
2026-01-24 14:25:05 +09:00
justsisyphus
894a0fa849 feat(website): add next-intl i18n and dark mode support 2026-01-24 14:25:05 +09:00
justsisyphus
21c7d29c1d fix(website): resolve @opennextjs/cloudflare and test configuration issues
- Successfully installed @opennextjs/cloudflare v1.15.1
- Fixed Vitest configuration to exclude e2e tests
- Renamed e2e test files from .spec.ts to .e2e.ts to avoid Bun test runner conflicts
- Updated eslint.config.mjs and playwright.config.ts
- All tests passing: Vitest (1/1), Playwright (6/6)
- Production bundle size: ~5MB < 10MiB limit
- Marked TODO 0 complete in plan
2026-01-24 14:25:05 +09:00
justsisyphus
ba93c42943 feat(website): initialize Next.js 15 project with @opennextjs/cloudflare 2026-01-24 14:25:05 +09:00
github-actions[bot]
5c7dd40751 @AndersHsueh has signed the CLA in code-yeongyu/oh-my-opencode#1042 2026-01-24 04:41:56 +00:00
github-actions[bot]
acc7b8b2f7 @gongxh0901 has signed the CLA in code-yeongyu/oh-my-opencode#1037 2026-01-24 02:27:36 +00:00
github-actions[bot]
8c90838f3b @RouHim has signed the CLA in code-yeongyu/oh-my-opencode#1031 2026-01-23 19:32:14 +00:00
github-actions[bot]
0b784d24f2 release: v3.0.0-beta.16 2026-01-23 18:12:07 +00:00
justsisyphus
444fbe396a fix(delegate-task): use lowercase sisyphus-junior agent name in API calls
Previous fix (7ed7bf5c) only updated Atlas → atlas, but missed Sisyphus-Junior.
OpenCode does case-sensitive agent lookup, causing crash when delegate_task
tried to spawn 'Sisyphus-Junior' (registered as 'sisyphus-junior').

- SISYPHUS_JUNIOR_AGENT constant: 'Sisyphus-Junior' → 'sisyphus-junior'
- agent-tool-restrictions key: 'Sisyphus-Junior' → 'sisyphus-junior'
- Updated related test mocks
2026-01-24 03:00:58 +09:00
github-actions[bot]
ad86e58077 release: v3.0.0-beta.15 2026-01-23 17:44:45 +00:00
justsisyphus
7ed7bf5c66 fix(agents): use lowercase agent names in API calls
- atlas/index.ts: agent: 'Atlas' -> 'atlas'
- start-work/index.ts: updateSessionAgent(..., 'Atlas') -> 'atlas'
- builtin-commands/commands.ts: agent: 'Atlas' -> 'atlas'
- Updated tests to match lowercase convention
2026-01-24 02:39:12 +09:00
github-actions[bot]
1c562a95d5 release: v3.0.0-beta.14 2026-01-23 17:09:52 +00:00
justsisyphus
c2247aec60 refactor(agents): add prometheus agent and normalize agent key lookups
- Add 'prometheus' to BuiltinAgentNameSchema enum
- Update delegate_task parameter names in documentation (agent → subagent_type, background → run_in_background)
- Make agent name comparison case-insensitive in Atlas hook
- Implement case-insensitive agent config lookup in shared utilities
- Relax type signature for disabled agents parameter

🤖 Generated with assistance of OhMyOpenCode
2026-01-24 02:00:17 +09:00
justsisyphus
1c9588ff33 test: add integration tests for agent key normalization 2026-01-23 21:54:27 +09:00
justsisyphus
5d73ac819d test: update CLI tests for lowercase agent keys 2026-01-23 21:47:21 +09:00
justsisyphus
dfc57d0426 refactor(model-requirements): use lowercase agent keys 2026-01-23 21:41:55 +09:00
justsisyphus
12c9029ed7 refactor(plugin): use lowercase agent keys throughout 2026-01-23 21:32:17 +09:00
justsisyphus
91060c35ab refactor(agents): use lowercase config keys in utils 2026-01-23 21:27:26 +09:00
justsisyphus
90292db4c4 refactor(prometheus-hook): use lowercase config key 2026-01-23 20:49:17 +09:00
justsisyphus
cc4deed8ee refactor(schema): use lowercase agent config keys 2026-01-23 20:46:09 +09:00
justsisyphus
4e4288807d refactor(migration): normalize agent keys to lowercase 2026-01-23 19:01:10 +09:00
justsisyphus
629a4d3e1b feat(shared): add agent display names module 2026-01-23 18:50:03 +09:00
justsisyphus
8806ed17dc feat(publish): add platform binary verification steps
- Add STEP 8.5: Wait for publish-platform workflow completion
- Add STEP 8.6: Verify all 7 platform binary packages on npm
- Update TODO list with platform verification tasks
- Add error handling for platform-specific failures
2026-01-23 17:35:24 +09:00
github-actions[bot]
e2f8729731 @veetase has signed the CLA in code-yeongyu/oh-my-opencode#985 2026-01-23 08:27:12 +00:00
justsisyphus
bee8b3736d docs: add model configuration section to overview and quick start to configurations 2026-01-23 17:05:45 +09:00
justsisyphus
37e1a065d8 feat(agents): add aggressive resume instructions to Atlas prompt 2026-01-23 17:04:14 +09:00
justsisyphus
fc47a7a490 docs: update multimodal-looker model name and fallback chain 2026-01-23 17:02:11 +09:00
justsisyphus
9b12e2a9b5 fix(cli): update zai-coding-plan hints to include multimodal-looker 2026-01-23 17:00:22 +09:00
justsisyphus
3062277a99 feat(agents): add zai-coding-plan/glm-4.6v fallback for multimodal-looker 2026-01-23 16:58:33 +09:00
yimingll
7093583ec5 fix(lsp): add data dir to LSP server detection paths (#992)
OpenCode downloads LSP servers (like clangd) to ~/.local/share/opencode/bin,
but isServerInstalled() only checked ~/.config/opencode/bin. This caused
LSP tools to report servers as 'not installed' even when OpenCode had
successfully downloaded them.

Add ~/.local/share/opencode/bin to the detection paths to match OpenCode's
actual behavior.

Co-authored-by: yimingll <yimingll@users.noreply.github.com>
2026-01-23 16:37:40 +09:00
justsisyphus
ec61df8c17 Merge pull request #913 from carlory/fix-doctor
fix(doctor): handle file:// protocol for local dev plugin detection
2026-01-23 16:36:16 +09:00
justsisyphus
6312d2da52 Merge pull request #962 from popododo0720/fix/issues-898-919
fix(doctor): improve AST-Grep NAPI detection for bunx environments
2026-01-23 16:36:05 +09:00
justsisyphus
810dd93da2 fix(skill): enforce agent restriction in createSkillTool (#1018)
* fix(skill): enforce agent restriction in createSkillTool

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

* fix(skill): block restricted skills when agent context missing

Addresses cubic review feedback: previously agent-restricted skills
could be invoked when ctx or ctx.agent was undefined because the
guard only ran when ctx?.agent was truthy.

Changed condition from:
  skill.definition.agent && ctx?.agent && skill.definition.agent !== ctx.agent
To:
  skill.definition.agent && (!ctx?.agent || skill.definition.agent !== ctx.agent)

This ensures restricted skills are blocked unless the exact matching
agent is present in the context.

---------

Co-authored-by: justsisyphus <justsisyphus@users.noreply.github.com>
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-01-23 16:32:41 +09:00
justsisyphus
1a901a50ac fix(ci): build Windows binary natively to fix segfault (#1019)
Bun cross-compilation from Linux to Windows produces binaries that crash
with 'Segmentation fault at address 0xFFFFFFFFFFFFFFFF'.

Root cause: oven-sh/bun#18416

Solution:
- Use windows-latest runner for Windows platform in publish-platform.yml
- Set shell: bash for consistent behavior across runners

This is a simpler fix than PR #938 which modified publish.yml (wrong workflow).
The platform binaries are built and published by publish-platform.yml.

Fixes #873
Fixes #844

Co-authored-by: justsisyphus <justsisyphus@users.noreply.github.com>
2026-01-23 16:30:47 +09:00
justsisyphus
f8155e7d45 fix(session): preserve custom agent after switching (#1017)
Use setSessionAgent (first-write wins) instead of updateSessionAgent in chat.message handler. This prevents the default agent from overwriting a custom agent that was set via UI switch.

Fixes #893

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

Co-authored-by: justsisyphus <justsisyphus@users.noreply.github.com>
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-01-23 16:25:26 +09:00
YeonGyu-Kim
39d2d44e22 fix(tools): conditionally register look_at when multimodal-looker enabled (#1016)
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: justsisyphus <justsisyphus@users.noreply.github.com>
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-01-23 16:25:17 +09:00
YeonGyu-Kim
15c4637e0a fix(hooks): use unix shell syntax for bash tool on all platforms (#1015)
The bash tool always runs in a Unix-like shell (bash/sh), even on Windows (via Git Bash, WSL, etc.), so we should always use unix export syntax instead of detecting the shell type dynamically.

Fixes #983

Fixes #889

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

Co-authored-by: justsisyphus <justsisyphus@users.noreply.github.com>
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-01-23 16:24:58 +09:00
justsisyphus
262c7118da docs(agents): update AGENTS.md with current commit hash and line counts 2026-01-23 16:00:38 +09:00
justsisyphus
599fad0e86 fix(atlas): capture stderr from git commands to prevent help text leak
When git commands fail (e.g., not in a repo, invalid HEAD), git outputs
help text to stderr. Without explicit stdio option, execSync inherits
the parent's stdio causing help text to appear on terminal during
delegate_task execution.

Add stdio: ['pipe', 'pipe', 'pipe'] to capture stderr instead of
letting it leak to terminal.
2026-01-23 15:42:35 +09:00
justsisyphus
afbdf69037 fix(model-resolver): use first fallback entry when model cache unavailable
When availableModels is empty (no cache in CI), use the first entry
from fallbackChain directly instead of falling back to systemDefault.
This ensures categories and agents use their configured models even
when the model cache file doesn't exist.

Fixes:
- model-resolution check returning 'warn' instead of 'pass' in CI
- DEFAULT_CATEGORIES not being used when no cache available
- Unstable agent detection failing (models falling back to non-gemini)
2026-01-23 15:39:07 +09:00
github-actions[bot]
af9beee83c @Ssoon-m has signed the CLA in code-yeongyu/oh-my-opencode#1014 2026-01-23 06:31:37 +00:00
Nguyen Khac Trung Kien
6973a75bf2 Merge pull request #999 from l3aro/dev 2026-01-23 13:14:02 +07:00
justsisyphus
c6d6bd197e refactor(models): update agent/category fallback chains
- quick: replace openai fallback with opencode/grok-code
- writing: add zai-coding-plan/glm-4.7 between sonnet and gpt
- unspecified-low: gpt-5.2 → gpt-5.2-codex (medium)
- Sisyphus: add zai/glm-4.7 before openai, use gpt-5.2-codex (medium)
- Momus & Metis: add variant 'max' to gemini-3-pro
- explore: simplify to haiku (anthropic/opencode) → grok-code (opencode)
2026-01-23 15:07:58 +09:00
justsisyphus
57b10439a4 fix(agents): use resolved variant from fallback chain instead of requirement default
resolveModelWithFallback() returns entry-specific variant but it was being
ignored. Agents like oracle now correctly get variant 'high' from their
fallback chain entry instead of undefined.
2026-01-23 14:44:02 +09:00
justsisyphus
6dfe091a88 refactor(atlas): rewrite prompt with lean orchestrator structure
- Reduce prompt from ~1280 to ~280 lines (78% reduction)
- Apply prompt engineering principles: remove model-already-knows content
- Use clean XML sections: identity, mission, delegation_system, workflow, etc.
- Adopt 6-section delegation format (TASK, EXPECTED OUTCOME, REQUIRED TOOLS, MUST DO, MUST NOT DO, CONTEXT)
- Preserve: identity, category+skills system, notepad protocol, parallelization, project-level QA
- Consolidate critical overrides at end with strong framing
2026-01-23 14:37:52 +09:00
justsisyphus
75158caded fix(atlas): register tool.execute.before and pass backgroundManager
- Add atlasHook?.['tool.execute.before'] call in tool.execute.before handler
- Pass backgroundManager option to createAtlasHook for proper bg task checking
- Move atlasHook declaration after backgroundManager initialization
2026-01-23 14:25:59 +09:00
justsisyphus
e16bbbcc05 feat: show warning toast when model cache is not available
- Added isModelCacheAvailable() to check if cache file exists
- Shows warning toast on session start if cache is missing
- Suggests running 'opencode models --refresh' or restarting
2026-01-23 14:20:38 +09:00
justsisyphus
ab3e622baa fix: use cache file for model availability instead of SDK calls
- Changed fetchAvailableModels to read from ~/.cache/opencode/models.json
- Prevents plugin startup hanging caused by SDK client.config.providers() call
- Updated doctor model-resolution check to show available models from cache
- Added cache info display: provider count, model count, refresh command
2026-01-23 14:09:37 +09:00
justsisyphus
f4348885f2 fix: model fallback properly falls through to system default
- Remove Step 3 in model-resolver that forced first fallbackChain entry
  even when unavailable, blocking system default fallback
- Add sisyphusJuniorModel option to delegate_task so agents["Sisyphus-Junior"]
  model override is respected in category-based delegation
- Update tests to reflect new fallback behavior
2026-01-23 10:56:31 +09:00
github-actions[bot]
2c81c8e58e @l3aro has signed the CLA in code-yeongyu/oh-my-opencode#999 2026-01-22 19:52:54 +00:00
l3aro
3268782730 docs: rename Orchestrator-Sisyphus to Atlas 2026-01-23 02:40:13 +07:00
popododo0720
be9d6c0061 fix(doctor): improve AST-Grep NAPI detection for bunx environments
Use dynamic import instead of require.resolve() to detect @ast-grep/napi
installation. This fixes false negatives when running via bunx where the
module exists in ~/.config/opencode/node_modules but isn't resolvable
from the temporary execution directory.

Also adds fallback path checks for common installation locations.

Fixes #898
2026-01-21 15:42:21 +09:00
carlory
45fe9578ec fix(doctor): handle file:// protocol for local dev plugin detection 2026-01-19 16:09:46 +08:00
95 changed files with 3297 additions and 2373 deletions

View File

@@ -29,7 +29,12 @@ permissions:
jobs:
publish-platform:
runs-on: ubuntu-latest
# Use windows-latest for Windows to avoid cross-compilation segfault (oven-sh/bun#18416)
# Fixes: #873, #844
runs-on: ${{ matrix.platform == 'windows-x64' && 'windows-latest' || 'ubuntu-latest' }}
defaults:
run:
shell: bash
strategy:
fail-fast: false
max-parallel: 2

View File

@@ -35,6 +35,8 @@ You are the release manager for oh-my-opencode. Execute the FULL publish workflo
{ "id": "draft-release-notes", "content": "Draft enhanced release notes content", "status": "pending", "priority": "high" },
{ "id": "update-release-notes", "content": "Update GitHub release with enhanced notes", "status": "pending", "priority": "high" },
{ "id": "verify-npm", "content": "Verify npm package published successfully", "status": "pending", "priority": "high" },
{ "id": "wait-platform-workflow", "content": "Wait for publish-platform workflow completion", "status": "pending", "priority": "high" },
{ "id": "verify-platform-binaries", "content": "Verify all 7 platform binary packages published", "status": "pending", "priority": "high" },
{ "id": "final-confirmation", "content": "Final confirmation to user with links", "status": "pending", "priority": "low" }
]
```
@@ -219,12 +221,64 @@ Compare with expected version. If not matching after 2 minutes, warn user about
---
## STEP 8.5: WAIT FOR PLATFORM WORKFLOW COMPLETION
The main publish workflow triggers a separate `publish-platform` workflow for platform-specific binaries.
1. Find the publish-platform workflow run triggered by the main workflow:
```bash
gh run list --workflow=publish-platform --limit=1 --json databaseId,status,conclusion --jq '.[0]'
```
2. Poll workflow status every 30 seconds until completion:
```bash
gh run view {platform_run_id} --json status,conclusion --jq '{status: .status, conclusion: .conclusion}'
```
**IMPORTANT: Use polling loop, NOT sleep commands.**
If conclusion is `failure`, show error logs:
```bash
gh run view {platform_run_id} --log-failed
```
---
## STEP 8.6: VERIFY PLATFORM BINARY PACKAGES
After publish-platform workflow completes, verify all 7 platform packages are published:
```bash
PLATFORMS="darwin-arm64 darwin-x64 linux-x64 linux-arm64 linux-x64-musl linux-arm64-musl windows-x64"
for PLATFORM in $PLATFORMS; do
npm view "oh-my-opencode-${PLATFORM}" version
done
```
All 7 packages should show the same version as the main package (`${NEW_VERSION}`).
**Expected packages:**
| Package | Description |
|---------|-------------|
| `oh-my-opencode-darwin-arm64` | macOS Apple Silicon |
| `oh-my-opencode-darwin-x64` | macOS Intel |
| `oh-my-opencode-linux-x64` | Linux x64 (glibc) |
| `oh-my-opencode-linux-arm64` | Linux ARM64 (glibc) |
| `oh-my-opencode-linux-x64-musl` | Linux x64 (musl/Alpine) |
| `oh-my-opencode-linux-arm64-musl` | Linux ARM64 (musl/Alpine) |
| `oh-my-opencode-windows-x64` | Windows x64 |
If any platform package version doesn't match, warn the user and suggest checking the publish-platform workflow logs.
---
## STEP 9: FINAL CONFIRMATION
Report success to user with:
- New version number
- GitHub release URL: https://github.com/code-yeongyu/oh-my-opencode/releases/tag/v{version}
- npm package URL: https://www.npmjs.com/package/oh-my-opencode
- Platform packages status: List all 7 platform packages with their versions
---
@@ -234,6 +288,8 @@ Report success to user with:
- **Release not found**: Wait and retry, may be propagation delay
- **npm not updated**: npm can take 1-5 minutes to propagate, inform user
- **Permission denied**: User may need to re-authenticate with `gh auth login`
- **Platform workflow fails**: Show logs from publish-platform workflow, check which platform failed
- **Platform package missing**: Some platforms may fail due to cross-compilation issues, suggest re-running publish-platform workflow manually
## LANGUAGE

View File

@@ -1,12 +1,12 @@
# PROJECT KNOWLEDGE BASE
**Generated:** 2026-01-23T02:09:00+09:00
**Commit:** 0e18efc7
**Generated:** 2026-01-23T15:59:00+09:00
**Commit:** 599fad0e
**Branch:** dev
## OVERVIEW
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.
OpenCode plugin: multi-model agent orchestration (Claude Opus 4.5, GPT-5.2, Gemini 3 Flash, Grok Code, 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
@@ -21,7 +21,7 @@ oh-my-opencode/
│ ├── 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 (590 lines)
│ └── index.ts # Main plugin entry (593 lines)
├── script/ # build-schema.ts, build-binaries.ts
├── packages/ # 7 platform-specific binaries
└── dist/ # Build output (ESM + .d.ts)
@@ -38,7 +38,7 @@ oh-my-opencode/
| Add skill | `src/features/builtin-skills/` | Create dir with SKILL.md |
| 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) |
| Orchestrator | `src/hooks/atlas/` | Main orchestration hook (773 lines) |
## TDD (Test-Driven Development)
@@ -88,8 +88,8 @@ oh-my-opencode/
| 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 |
| librarian | opencode/big-pickle | Docs, GitHub search |
| explore | opencode/gpt-5-nano | Fast codebase grep |
| multimodal-looker | google/gemini-3-flash | PDF/image analysis |
| Prometheus | anthropic/claude-opus-4-5 | Strategic planning |
@@ -113,12 +113,12 @@ bun test # 90 test files
| File | Lines | Description |
|------|-------|-------------|
| `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 |
| `src/tools/delegate-task/tools.ts` | 1039 | Category-based delegation |
| `src/hooks/atlas/index.ts` | 773 | Orchestrator hook |
| `src/cli/config-manager.ts` | 641 | JSONC config parsing |
## MCP ARCHITECTURE

View File

@@ -16,8 +16,8 @@
> [!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`を使用してインストールしてください。**
> [![Oh My OpenCode 3.0が正式リリースされました!](./.github/assets/orchestrator-atlas.png?v=3)](https://github.com/code-yeongyu/oh-my-opencode/releases/tag/v3.0.0)
> > **Oh My OpenCode 3.0が正式リリースされました`oh-my-opencode@latest`を使用してインストールしてください。**
>
> 一緒に歩みましょう!
>
@@ -73,7 +73,9 @@
[![GitHub Issues](https://img.shields.io/github/issues/code-yeongyu/oh-my-opencode?color=ff80eb&labelColor=black&style=flat-square)](https://github.com/code-yeongyu/oh-my-opencode/issues)
[![License](https://img.shields.io/badge/license-SUL--1.0-white?labelColor=black&style=flat-square)](https://github.com/code-yeongyu/oh-my-opencode/blob/master/LICENSE.md)
[English](README.md) | [日本語](README.ja.md) | [简体中文](README.zh-cn.md)
[English](README.md) | [한국어](README.ko.md) | [日本語](README.ja.md) | [简体中文](README.zh-cn.md)
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/code-yeongyu/oh-my-opencode)
</div>

View File

@@ -16,8 +16,8 @@
>
> [!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`을 사용하세요.**
> [![Oh My OpenCode 3.0이 정식 출시되었습니다!](./.github/assets/orchestrator-atlas.png?v=3)](https://github.com/code-yeongyu/oh-my-opencode/releases/tag/v3.0.0)
> > **Oh My OpenCode 3.0이 정식 출시되었습니다! `oh-my-opencode@latest`를 사용하여 설치하세요.**
>
> 함께해요!
>
@@ -73,10 +73,11 @@
[![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)
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/code-yeongyu/oh-my-opencode)
</div>
<!-- </CENTERED SECTION FOR GITHUB DISPLAY> -->

View File

@@ -16,8 +16,8 @@
> [!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)
> > **The Orchestrator is now available in beta. Use `oh-my-opencode@3.0.0-beta.10` to install it.**
> [![Oh My OpenCode 3.0 is now stable!](./.github/assets/orchestrator-atlas.png?v=3)](https://github.com/code-yeongyu/oh-my-opencode/releases/tag/v3.0.0)
> > **Oh My OpenCode 3.0 is now stable! Use `oh-my-opencode@latest` to install it.**
>
> Be with us!
>

View File

@@ -16,8 +16,8 @@
> [!TIP]
>
> [![Orchestrator 现已进入测试阶段。](./.github/assets/orchestrator-atlas.png?v=3)](https://github.com/code-yeongyu/oh-my-opencode/releases/tag/v3.0.0-beta.10)
> > **Orchestrator 现已进入测试阶段。使用 `oh-my-opencode@3.0.0-beta.10` 安装。**
> [![Oh My OpenCode 3.0 正式发布!](./.github/assets/orchestrator-atlas.png?v=3)](https://github.com/code-yeongyu/oh-my-opencode/releases/tag/v3.0.0)
> > **Oh My OpenCode 3.0 正式发布!使用 `oh-my-opencode@latest` 安装。**
>
> 加入我们!
>
@@ -74,7 +74,9 @@
[![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)
[![许可证](https://img.shields.io/badge/license-SUL--1.0-white?labelColor=black&style=flat-square)](https://github.com/code-yeongyu/oh-my-opencode/blob/master/LICENSE.md)
[English](README.md) | [日本語](README.ja.md) | [简体中文](README.zh-cn.md)
[English](README.md) | [한국어](README.ko.md) | [日本語](README.ja.md) | [简体中文](README.zh-cn.md)
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/code-yeongyu/oh-my-opencode)
</div>

View File

@@ -20,14 +20,15 @@
"items": {
"type": "string",
"enum": [
"Sisyphus",
"sisyphus",
"prometheus",
"oracle",
"librarian",
"explore",
"multimodal-looker",
"Metis (Plan Consultant)",
"Momus (Plan Reviewer)",
"Atlas"
"metis",
"momus",
"atlas"
]
}
},
@@ -345,7 +346,7 @@
}
}
},
"Sisyphus": {
"sisyphus": {
"type": "object",
"properties": {
"model": {
@@ -471,7 +472,7 @@
}
}
},
"Sisyphus-Junior": {
"sisyphus-junior": {
"type": "object",
"properties": {
"model": {
@@ -723,7 +724,7 @@
}
}
},
"Prometheus (Planner)": {
"prometheus": {
"type": "object",
"properties": {
"model": {
@@ -849,7 +850,7 @@
}
}
},
"Metis (Plan Consultant)": {
"metis": {
"type": "object",
"properties": {
"model": {
@@ -975,7 +976,7 @@
}
}
},
"Momus (Plan Reviewer)": {
"momus": {
"type": "object",
"properties": {
"model": {
@@ -1605,7 +1606,7 @@
}
}
},
"Atlas": {
"atlas": {
"type": "object",
"properties": {
"model": {
@@ -1786,7 +1787,8 @@
"enum": [
"low",
"medium",
"high"
"high",
"xhigh"
]
},
"textVerbosity": {

View File

@@ -27,13 +27,13 @@
"typescript": "^5.7.3",
},
"optionalDependencies": {
"oh-my-opencode-darwin-arm64": "3.0.0-beta.11",
"oh-my-opencode-darwin-x64": "3.0.0-beta.11",
"oh-my-opencode-linux-arm64": "3.0.0-beta.11",
"oh-my-opencode-linux-arm64-musl": "3.0.0-beta.11",
"oh-my-opencode-linux-x64": "3.0.0-beta.11",
"oh-my-opencode-linux-x64-musl": "3.0.0-beta.11",
"oh-my-opencode-windows-x64": "3.0.0-beta.11",
"oh-my-opencode-darwin-arm64": "3.0.0-beta.16",
"oh-my-opencode-darwin-x64": "3.0.0-beta.16",
"oh-my-opencode-linux-arm64": "3.0.0-beta.16",
"oh-my-opencode-linux-arm64-musl": "3.0.0-beta.16",
"oh-my-opencode-linux-x64": "3.0.0-beta.16",
"oh-my-opencode-linux-x64-musl": "3.0.0-beta.16",
"oh-my-opencode-windows-x64": "3.0.0-beta.16",
},
},
},
@@ -225,19 +225,19 @@
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
"oh-my-opencode-darwin-arm64": ["oh-my-opencode-darwin-arm64@3.0.0-beta.11", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-7cFv2bbz9HTY7sshgVTu+IhvYf7CT0czDYqHEB+dYfEqFU6TaoSMimq6uHqcWegUUR1T7PNmc0dyjYVw69FeVA=="],
"oh-my-opencode-darwin-arm64": ["oh-my-opencode-darwin-arm64@3.0.0-beta.16", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-1gfnTsKpYxTMXpbuV98wProR3RMe6BI/muuSVa3Xy68EEkBJsuRAne6IzFq/yxIMbx9OiQaS5cTE0mxFtxcCGA=="],
"oh-my-opencode-darwin-x64": ["oh-my-opencode-darwin-x64@3.0.0-beta.11", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-rGAbDdUySWITIdm2yiuNFB9lFYaSXT8LMtg97LTlOO5vZbI3M+obIS3QlIkBtAhgOTIPB7Ni+T0W44OmJpHoYA=="],
"oh-my-opencode-darwin-x64": ["oh-my-opencode-darwin-x64@3.0.0-beta.16", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-/h7kBZAN5Ut9kL7gEtwVVZ49Kw4gZoSVJdrpnh7Wij0a3mlOwqbkgGilK7oUiJ2N8fsxvxEBbTscYOLAdhyVBw=="],
"oh-my-opencode-linux-arm64": ["oh-my-opencode-linux-arm64@3.0.0-beta.11", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-F9dqwWwGAdqeSkE7Tre5DmHQXwDpU2Z8Jk0lwTJMLj+kMqYFDVPjLPo4iVUdwPpxpmm0pR84u/oonG/2+84/zw=="],
"oh-my-opencode-linux-arm64": ["oh-my-opencode-linux-arm64@3.0.0-beta.16", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-jW7pl76WerBa7FucKCYcthpbKbhJQSVe6rqUFSbVobjOP9VWslrGdxc9Y8BeiMx9SJEFYwA8/2ROhnOHpH3TxA=="],
"oh-my-opencode-linux-arm64-musl": ["oh-my-opencode-linux-arm64-musl@3.0.0-beta.11", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-H+zOtHkHd+TmdPj64M1A0zLOk7OHIK4C8yqfLFhfizOIBffT1yOhAs6EpK3EqPhfPLu54ADgcQcu8W96VP24UA=="],
"oh-my-opencode-linux-arm64-musl": ["oh-my-opencode-linux-arm64-musl@3.0.0-beta.16", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-cXXka0zQDBiFu9mmxa45o3g812w8q/jZRYgdwJsLbj3nm24WXv6uRP7nnVVoZiVmJ2GQbLE1nyGCMkBXFwRGGA=="],
"oh-my-opencode-linux-x64": ["oh-my-opencode-linux-x64@3.0.0-beta.11", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-IG+KODTJ8rs6cEJ2wN6Zpr6YtvCS5OpYP6jBdGJltmUpjQdMhdMsaY3ysZk+9Vxpx2KC3xj5KLHV1USg3uBTeg=="],
"oh-my-opencode-linux-x64": ["oh-my-opencode-linux-x64@3.0.0-beta.16", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-4VS1V6DiXdWHQ/AGc3rB1sCxFUlD1REex0Ai/y4tEgA2M0FD0Bu+tjXHhDghUvC8f0kQBRfijnTrtc1Lh7hIrA=="],
"oh-my-opencode-linux-x64-musl": ["oh-my-opencode-linux-x64-musl@3.0.0-beta.11", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-irV+AuWrHqNm7VT7HO56qgymR0+vEfJbtB3vCq68kprH2V4NQmGp2MNKIYPnUCYL7NEK3H2NX+h06YFZJ/8ELQ=="],
"oh-my-opencode-linux-x64-musl": ["oh-my-opencode-linux-x64-musl@3.0.0-beta.16", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-PGVe7vyUK3hjSNfvu1fBXTbgbe0OPh7JgB/TZR2U5R54X1k3NBkb1VHX9yxEUSA0VsNR+inE2x+DfEA+7KIruQ=="],
"oh-my-opencode-windows-x64": ["oh-my-opencode-windows-x64@3.0.0-beta.11", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-exZ/NEwGBlxyWszN7dvOfzbYX0cuhBZXftqAAFOlVP26elDHdo+AmSmLR/4cJyzpR9nCWz4xvl/RYF84bY6OEA=="],
"oh-my-opencode-windows-x64": ["oh-my-opencode-windows-x64@3.0.0-beta.16", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-1lN/8y4laQnSJDvyARuV5YaETAwBb+PK06QHQzpoK/0asiFoEIBcKNgjaRwau+nBsdRUrQocE2xc6g2ZNH4HUw=="],
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],

View File

@@ -21,13 +21,13 @@ A Category is an agent configuration preset optimized for specific domains.
| Category | Default Model | Use Cases |
|----------|---------------|-----------|
| `visual-engineering` | `google/gemini-3-pro-preview` | Frontend, UI/UX, design, styling, animation |
| `visual-engineering` | `google/gemini-3-pro` | 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 |
| `artistry` | `google/gemini-3-pro` (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 |
| `writing` | `google/gemini-3-flash` | Documentation, prose, technical writing |
### Usage
@@ -177,7 +177,7 @@ You can fine-tune categories in `oh-my-opencode.json`.
"categories": {
// 1. Define new custom category
"korean-writer": {
"model": "google/gemini-3-flash-preview",
"model": "google/gemini-3-flash",
"temperature": 0.5,
"prompt_append": "You are a Korean technical writer. Maintain a friendly and clear tone."
},

View File

@@ -175,7 +175,7 @@ Configuration files support **JSONC (JSON with Comments)** format. You can use c
/* Category customization */
"categories": {
"visual-engineering": {
"model": "google/gemini-3-pro-preview",
"model": "google/gemini-3-pro",
},
},
}

View File

@@ -2,6 +2,39 @@
Highly opinionated, but adjustable to taste.
## Quick Start
**Most users don't need to configure anything manually.** Run the interactive installer:
```bash
bunx oh-my-opencode install
```
It asks about your providers (Claude, OpenAI, Gemini, etc.) and generates optimal config automatically.
**Want to customize?** Here's the common patterns:
```jsonc
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
// Override specific agent models
"agents": {
"oracle": { "model": "openai/gpt-5.2" }, // Use GPT for debugging
"librarian": { "model": "zai-coding-plan/glm-4.7" }, // Cheap model for research
"explore": { "model": "opencode/gpt-5-nano" } // Free model for grep
},
// Override category models (used by delegate_task)
"categories": {
"quick": { "model": "opencode/gpt-5-nano" }, // Fast/cheap for trivial tasks
"visual-engineering": { "model": "google/gemini-3-pro" } // Gemini for UI
}
}
```
**Find available models:** Run `opencode models` to see all models in your environment.
## Config File Locations
Config file locations (priority order):
@@ -42,7 +75,7 @@ When both `oh-my-opencode.jsonc` and `oh-my-opencode.json` files exist, `.jsonc`
"model": "openai/gpt-5.2" // GPT for strategic reasoning
},
"explore": {
"model": "opencode/grok-code" // Free & fast for exploration
"model": "opencode/gpt-5-nano" // Free & fast for exploration
},
},
}
@@ -272,7 +305,7 @@ Categories enable domain-specific task delegation via the `delegate_task` tool.
| Category | Model | Description |
| ---------------- | ----------------------------- | ---------------------------------------------------------------------------- |
| `visual` | `google/gemini-3-pro-preview` | Frontend, UI/UX, design-focused tasks. High creativity (temp 0.7). |
| `visual` | `google/gemini-3-pro` | Frontend, UI/UX, design-focused tasks. High creativity (temp 0.7). |
| `business-logic` | `openai/gpt-5.2` | Backend logic, architecture, strategic reasoning. Low creativity (temp 0.1). |
**Usage:**
@@ -299,7 +332,7 @@ Add custom categories in `oh-my-opencode.json`:
"prompt_append": "Focus on data analysis, ML pipelines, and statistical methods."
},
"visual": {
"model": "google/gemini-3-pro-preview",
"model": "google/gemini-3-pro",
"prompt_append": "Use shadcn/ui components and Tailwind CSS."
}
}
@@ -370,9 +403,9 @@ Each agent has a defined provider priority chain. The system tries providers in
|-------|-------------------|-------------------------|
| **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 |
| **librarian** | `big-pickle` | opencode → github-copilot → anthropic |
| **explore** | `gpt-5-nano` | opencode → anthropic → github-copilot |
| **multimodal-looker** | `gemini-3-flash` | google → openai → zai-coding-plan → anthropic → 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 |
@@ -384,13 +417,13 @@ Categories follow the same resolution logic:
| Category | Model (no prefix) | Provider Priority Chain |
|----------|-------------------|-------------------------|
| **visual-engineering** | `gemini-3-pro-preview` | google → openai → anthropic → github-copilot → opencode |
| **visual-engineering** | `gemini-3-pro` | 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 |
| **artistry** | `gemini-3-pro` | 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 |
| **writing** | `gemini-3-flash` | google → openai → anthropic → github-copilot → opencode |
### Checking Your Configuration

View File

@@ -12,8 +12,8 @@ Oh-My-OpenCode provides 10 specialized AI agents. Each has distinct expertise, o
|-------|-------|---------|
| **Sisyphus** | `anthropic/claude-opus-4-5` | **The default orchestrator.** Plans, delegates, and executes complex tasks using specialized subagents with aggressive parallel execution. Todo-driven workflow with extended thinking (32k budget). |
| **oracle** | `openai/gpt-5.2` | Architecture decisions, code review, debugging. Read-only consultation - stellar logical reasoning and deep analysis. Inspired by AmpCode. |
| **librarian** | `opencode/glm-4.7-free` | Multi-repo analysis, documentation lookup, OSS implementation examples. Deep codebase understanding with evidence-based answers. Inspired by AmpCode. |
| **explore** | `opencode/grok-code` | Fast codebase exploration and contextual grep. Uses Gemini 3 Flash when Antigravity auth is configured, Haiku when Claude max20 is available, otherwise Grok. Inspired by Claude Code. |
| **librarian** | `opencode/big-pickle` | Multi-repo analysis, documentation lookup, OSS implementation examples. Deep codebase understanding with evidence-based answers. Inspired by AmpCode. |
| **explore** | `opencode/gpt-5-nano` | Fast codebase exploration and contextual grep. Uses Gemini 3 Flash when Antigravity auth is configured, Haiku when Claude max20 is available, otherwise Grok. Inspired by Claude Code. |
| **multimodal-looker** | `google/gemini-3-flash` | Visual content specialist. Analyzes PDFs, images, diagrams to extract information. Saves tokens by having another agent process media. |
### Planning Agents

View File

@@ -154,7 +154,7 @@ The `opencode-antigravity-auth` plugin uses different model names than the built
}
```
**Available model names**: `google/antigravity-gemini-3-pro-high`, `google/antigravity-gemini-3-pro-low`, `google/antigravity-gemini-3-flash`, `google/antigravity-claude-sonnet-4-5`, `google/antigravity-claude-sonnet-4-5-thinking-low`, `google/antigravity-claude-sonnet-4-5-thinking-medium`, `google/antigravity-claude-sonnet-4-5-thinking-high`, `google/antigravity-claude-opus-4-5-thinking-low`, `google/antigravity-claude-opus-4-5-thinking-medium`, `google/antigravity-claude-opus-4-5-thinking-high`, `google/gemini-3-pro-preview`, `google/gemini-3-flash-preview`, `google/gemini-2.5-pro`, `google/gemini-2.5-flash`
**Available model names**: `google/antigravity-gemini-3-pro-high`, `google/antigravity-gemini-3-pro-low`, `google/antigravity-gemini-3-flash`, `google/antigravity-claude-sonnet-4-5`, `google/antigravity-claude-sonnet-4-5-thinking-low`, `google/antigravity-claude-sonnet-4-5-thinking-medium`, `google/antigravity-claude-sonnet-4-5-thinking-high`, `google/antigravity-claude-opus-4-5-thinking-low`, `google/antigravity-claude-opus-4-5-thinking-medium`, `google/antigravity-claude-opus-4-5-thinking-high`, `google/gemini-3-pro`, `google/gemini-3-flash`, `google/gemini-2.5-pro`, `google/gemini-2.5-flash`
Then authenticate:
@@ -183,7 +183,7 @@ When GitHub Copilot is the best available provider, oh-my-opencode uses these mo
| ------------- | -------------------------------- |
| **Sisyphus** | `github-copilot/claude-opus-4.5` |
| **Oracle** | `github-copilot/gpt-5.2` |
| **Explore** | `github-copilot/grok-code-fast-1`|
| **Explore** | `github-copilot/gpt-5-nano-fast-1`|
| **Librarian** | `zai-coding-plan/glm-4.7` (if Z.ai available) or fallback |
GitHub Copilot acts as a proxy provider, routing requests to underlying models based on your subscription.
@@ -203,7 +203,7 @@ If Z.ai is the only provider available, all agents will use GLM models:
#### OpenCode Zen
OpenCode Zen provides access to `opencode/` prefixed models including `opencode/claude-opus-4-5`, `opencode/gpt-5.2`, `opencode/grok-code`, and `opencode/glm-4.7-free`.
OpenCode Zen provides access to `opencode/` prefixed models including `opencode/claude-opus-4-5`, `opencode/gpt-5.2`, `opencode/gpt-5-nano`, and `opencode/big-pickle`.
When OpenCode Zen is the best available provider (no native or Copilot), these models are used:
@@ -211,8 +211,8 @@ When OpenCode Zen is the best available provider (no native or Copilot), these m
| ------------- | -------------------------------- |
| **Sisyphus** | `opencode/claude-opus-4-5` |
| **Oracle** | `opencode/gpt-5.2` |
| **Explore** | `opencode/grok-code` |
| **Librarian** | `opencode/glm-4.7-free` |
| **Explore** | `opencode/gpt-5-nano` |
| **Librarian** | `opencode/big-pickle` |
##### Setup

View File

@@ -54,7 +54,7 @@ For complex or critical tasks, press **Tab** to switch to Prometheus (Planner) m
2. **Plan generation** - Based on the interview, Prometheus generates a detailed work plan with tasks, acceptance criteria, and guardrails. Optionally reviewed by Momus (plan reviewer) for high-accuracy validation.
3. **Run `/start-work`** - The Orchestrator-Sisyphus takes over:
3. **Run `/start-work`** - The Atlas takes over:
- Distributes tasks to specialized sub-agents
- Verifies each task completion independently
- Accumulates learnings across tasks
@@ -84,7 +84,78 @@ The orchestrator is designed to execute work plans created by Prometheus. Using
4. Run /start-work → Orchestrator executes
```
**Prometheus and Orchestrator-Sisyphus are a pair. Always use them together.**
**Prometheus and Atlas are a pair. Always use them together.**
---
## Model Configuration
Oh My OpenCode automatically configures models based on your available providers. You don't need to manually specify every model.
### How Models Are Determined
**1. At Installation Time (Interactive Installer)**
When you run `bunx oh-my-opencode install`, the installer asks which providers you have:
- Claude Pro/Max subscription?
- OpenAI/ChatGPT Plus?
- Google Gemini?
- GitHub Copilot?
- OpenCode Zen?
- Z.ai Coding Plan?
Based on your answers, it generates `~/.config/opencode/oh-my-opencode.json` with optimal model assignments for each agent and category.
**2. At Runtime (Fallback Chain)**
Each agent has a **provider priority chain**. The system tries providers in order until it finds an available model:
```
Example: multimodal-looker
google → openai → zai-coding-plan → anthropic → opencode
↓ ↓ ↓ ↓ ↓
gemini gpt-5.2 glm-4.6v haiku gpt-5-nano
```
If you have Gemini, it uses `google/gemini-3-flash`. No Gemini but have Claude? Uses `anthropic/claude-haiku-4-5`. And so on.
### Example Configuration
Here's a real-world config for a user with **Claude, OpenAI, Gemini, and Z.ai** all available:
```jsonc
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
"agents": {
// Override specific agents only - rest use fallback chain
"Atlas": { "model": "anthropic/claude-sonnet-4-5", "variant": "max" },
"librarian": { "model": "zai-coding-plan/glm-4.7" },
"explore": { "model": "opencode/gpt-5-nano" },
"multimodal-looker": { "model": "zai-coding-plan/glm-4.6v" }
},
"categories": {
// Override categories for cost optimization
"quick": { "model": "opencode/gpt-5-nano" },
"unspecified-low": { "model": "zai-coding-plan/glm-4.7" }
},
"experimental": {
"aggressive_truncation": true
}
}
```
**Key points:**
- You only need to override what you want to change
- Unspecified agents/categories use the automatic fallback chain
- Mix providers freely (Claude for main work, Z.ai for cheap tasks, etc.)
### Finding Available Models
Run `opencode models` to see all available models in your environment. Model names follow the format `provider/model-name`.
### Learn More
For detailed configuration options including per-agent settings, category customization, and more, see the [Configuration Guide](../configurations.md).
---

View File

@@ -1,6 +1,6 @@
# Understanding the Orchestration System
Oh My OpenCode's orchestration system transforms a simple AI agent into a coordinated development team. This document explains how the Prometheus → Orchestrator → Junior workflow creates high-quality, reliable code output.
Oh My OpenCode's orchestration system transforms a simple AI agent into a coordinated development team. This document explains how the Prometheus → Atlas → Junior workflow creates high-quality, reliable code output.
---
@@ -29,7 +29,7 @@ flowchart TB
end
subgraph Execution["Execution Layer (Orchestrator)"]
Orchestrator["⚡ Orchestrator-Sisyphus<br/>(Conductor)<br/>Claude Opus 4.5"]
Orchestrator["⚡ Atlas<br/>(Conductor)<br/>Claude Opus 4.5"]
end
subgraph Workers["Worker Layer (Specialized Agents)"]
@@ -152,7 +152,7 @@ If REJECTED, Prometheus fixes issues and resubmits. **No maximum retry limit.**
---
## Layer 2: Execution (Orchestrator-Sisyphus)
## Layer 2: Execution (Atlas)
### The Conductor Mindset
@@ -160,7 +160,7 @@ The Orchestrator is like an orchestra conductor: **it doesn't play instruments,
```mermaid
flowchart LR
subgraph Orchestrator["Orchestrator-Sisyphus"]
subgraph Orchestrator["Atlas"]
Read["1. Read Plan"]
Analyze["2. Analyze Tasks"]
Wisdom["3. Accumulate Wisdom"]
@@ -352,7 +352,7 @@ delegate_task(
```mermaid
sequenceDiagram
participant User
participant Orchestrator as Orchestrator-Sisyphus
participant Orchestrator as Atlas
participant Junior as Sisyphus-Junior
participant Notepad as .sisyphus/notepads/
@@ -392,7 +392,7 @@ sequenceDiagram
### 1. Separation of Concerns
- **Planning** (Prometheus): High reasoning, interview, strategic thinking
- **Orchestration** (Sisyphus): Coordination, verification, wisdom accumulation
- **Orchestration** (Atlas): Coordination, verification, wisdom accumulation
- **Execution** (Junior): Focused implementation, no distractions
### 2. Explicit Over Implicit

View File

@@ -6,9 +6,10 @@
|------------|----------|-------------|
| **Simple** | Just prompt | Simple tasks, quick fixes, single-file changes |
| **Complex + Lazy** | Just type `ulw` or `ultrawork` | Complex tasks where explaining context is tedious. Agent figures it out. |
| **Complex + Precise** | `@plan``/start-work` | Precise, multi-step work requiring true orchestration. Prometheus plans, Sisyphus executes. |
| **Complex + Precise** | `@plan``/start-work` | Precise, multi-step work requiring true orchestration. Prometheus plans, Atlas executes. |
**Decision Flow:**
```
Is it a quick fix or simple task?
└─ YES → Just prompt normally
@@ -30,7 +31,7 @@ Traditional AI agents often mix planning and execution, leading to context pollu
Oh-My-OpenCode solves this by clearly separating two roles:
1. **Prometheus (Planner)**: A pure strategist who never writes code. Establishes perfect plans through interviews and analysis.
2. **Sisyphus (Executor)**: An orchestrator who executes plans. Delegates work to specialized agents and never stops until completion.
2. **Atlas (Executor)**: An orchestrator who executes plans. Delegates work to specialized agents and never stops until completion.
---
@@ -52,10 +53,10 @@ flowchart TD
StartWork --> BoulderState[boulder.json]
subgraph Execution Phase
BoulderState --> Sisyphus[Sisyphus<br>Orchestrator]
Sisyphus --> Oracle[Oracle]
Sisyphus --> Frontend[Frontend<br>Engineer]
Sisyphus --> Explore[Explore]
BoulderState --> Atlas[Atlas<br>Orchestrator]
Atlas --> Oracle[Oracle]
Atlas --> Frontend[Frontend<br>Engineer]
Atlas --> Explore[Explore]
end
```
@@ -64,22 +65,26 @@ flowchart TD
## 3. Key Components
### 🔮 Prometheus (The Planner)
- **Model**: `anthropic/claude-opus-4-5`
- **Role**: Strategic planning, requirements interviews, work plan creation
- **Constraint**: **READ-ONLY**. Can only create/modify markdown files within `.sisyphus/` directory.
- **Characteristic**: Never writes code directly, focuses solely on "how to do it".
### 🦉 Metis (The Consultant)
### 🦉 Metis (The Plan Consultant)
- **Role**: Pre-analysis and gap detection
- **Function**: Identifies hidden user intent, prevents AI over-engineering, eliminates ambiguity.
- **Workflow**: Metis consultation is mandatory before plan creation.
### ⚖️ Momus (The Reviewer)
### ⚖️ Momus (The Plan Reviewer)
- **Role**: High-precision plan validation (High Accuracy Mode)
- **Function**: Rejects and demands revisions until the plan is perfect.
- **Trigger**: Activated when user requests "high accuracy".
### 🪨 Sisyphus (The Orchestrator)
### ⚡ Atlas (The Plan Executor)
- **Model**: `anthropic/claude-opus-4-5` (Extended Thinking 32k)
- **Role**: Execution and delegation
- **Characteristic**: Doesn't do everything directly, actively delegates to specialized agents (Frontend, Librarian, etc.).
@@ -89,6 +94,7 @@ flowchart TD
## 4. Workflow
### Phase 1: Interview and Planning (Interview Mode)
Prometheus starts in **interview mode** by default. Instead of immediately creating a plan, it collects sufficient context.
1. **Intent Identification**: Classifies whether the user's request is Refactoring or New Feature.
@@ -96,6 +102,7 @@ Prometheus starts in **interview mode** by default. Instead of immediately creat
3. **Draft Creation**: Continuously records discussion content in `.sisyphus/drafts/`.
### Phase 2: Plan Generation
When the user requests "Make it a plan", plan generation begins.
1. **Metis Consultation**: Confirms any missed requirements or risk factors.
@@ -103,10 +110,11 @@ When the user requests "Make it a plan", plan generation begins.
3. **Handoff**: Once plan creation is complete, guides user to use `/start-work` command.
### Phase 3: Execution
When the user enters `/start-work`, the execution phase begins.
1. **State Management**: Creates `boulder.json` file to track current plan and session ID.
2. **Task Execution**: Sisyphus reads the plan and processes TODOs one by one.
2. **Task Execution**: Atlas reads the plan and processes TODOs one by one.
3. **Delegation**: UI work is delegated to Frontend agent, complex logic to Oracle.
4. **Continuity**: Even if the session is interrupted, work continues in the next session through `boulder.json`.
@@ -115,11 +123,15 @@ When the user enters `/start-work`, the execution phase begins.
## 5. Commands and Usage
### `@plan [request]`
Invokes Prometheus to start a planning session.
- Example: `@plan "I want to refactor the authentication system to NextAuth"`
### `/start-work`
Executes the generated plan.
- Function: Finds plan in `.sisyphus/plans/` and enters execution mode.
- If there's interrupted work, automatically resumes from where it left off.
@@ -132,7 +144,7 @@ You can control related features in `oh-my-opencode.json`.
```jsonc
{
"sisyphus_agent": {
"disabled": false, // Enable Sisyphus orchestration (default: false)
"disabled": false, // Enable Atlas orchestration (default: false)
"planner_enabled": true, // Enable Prometheus (default: true)
"replace_plan": true // Replace default plan agent with Prometheus (default: true)
},

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode",
"version": "3.0.0-beta.13",
"version": "3.0.0",
"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.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"
"oh-my-opencode-darwin-arm64": "3.0.0",
"oh-my-opencode-darwin-x64": "3.0.0",
"oh-my-opencode-linux-arm64": "3.0.0",
"oh-my-opencode-linux-arm64-musl": "3.0.0",
"oh-my-opencode-linux-x64": "3.0.0",
"oh-my-opencode-linux-x64-musl": "3.0.0",
"oh-my-opencode-windows-x64": "3.0.0"
},
"trustedDependencies": [
"@ast-grep/cli",

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-darwin-arm64",
"version": "3.0.0-beta.13",
"version": "3.0.0",
"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.13",
"version": "3.0.0",
"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.13",
"version": "3.0.0",
"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.13",
"version": "3.0.0",
"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.13",
"version": "3.0.0",
"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.13",
"version": "3.0.0",
"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.13",
"version": "3.0.0",
"description": "Platform-specific binary for oh-my-opencode (windows-x64)",
"license": "MIT",
"repository": {

View File

@@ -711,6 +711,62 @@
"created_at": "2026-01-22T12:39:26Z",
"repoId": 1108837393,
"pullRequestNo": 989
},
{
"name": "l3aro",
"id": 25253808,
"comment_id": 3786383804,
"created_at": "2026-01-22T19:52:42Z",
"repoId": 1108837393,
"pullRequestNo": 999
},
{
"name": "Ssoon-m",
"id": 89559826,
"comment_id": 3788539617,
"created_at": "2026-01-23T06:31:24Z",
"repoId": 1108837393,
"pullRequestNo": 1014
},
{
"name": "veetase",
"id": 2784250,
"comment_id": 3789028002,
"created_at": "2026-01-23T08:27:02Z",
"repoId": 1108837393,
"pullRequestNo": 985
},
{
"name": "RouHim",
"id": 3582050,
"comment_id": 3791988227,
"created_at": "2026-01-23T19:32:01Z",
"repoId": 1108837393,
"pullRequestNo": 1031
},
{
"name": "gongxh0901",
"id": 15622561,
"comment_id": 3793478620,
"created_at": "2026-01-24T02:15:02Z",
"repoId": 1108837393,
"pullRequestNo": 1037
},
{
"name": "gongxh0901",
"id": 15622561,
"comment_id": 3793521632,
"created_at": "2026-01-24T02:23:34Z",
"repoId": 1108837393,
"pullRequestNo": 1037
},
{
"name": "AndersHsueh",
"id": 121805544,
"comment_id": 3793787614,
"created_at": "2026-01-24T04:41:46Z",
"repoId": 1108837393,
"pullRequestNo": 1042
}
]
}

View File

@@ -8,7 +8,7 @@
```
agents/
├── atlas.ts # Master Orchestrator (1383 lines)
├── atlas.ts # Master Orchestrator (543 lines)
├── sisyphus.ts # Main prompt (615 lines)
├── sisyphus-junior.ts # Delegated task executor
├── dynamic-agent-prompt-builder.ts # Dynamic prompt generation
@@ -31,8 +31,8 @@ agents/
| 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 |
| librarian | opencode/big-pickle | 0.1 | Docs, GitHub search |
| explore | opencode/gpt-5-nano | 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 |
| Metis | anthropic/claude-sonnet-4-5 | 0.3 | Pre-planning analysis |

File diff suppressed because it is too large Load Diff

View File

@@ -319,8 +319,8 @@ Or should I just note down this single fix?"
**Research First:**
\`\`\`typescript
delegate_task(agent="explore", prompt="Find all usages of [target] using lsp_find_references pattern...", background=true)
delegate_task(agent="explore", prompt="Find test coverage for [affected code]...", background=true)
delegate_task(subagent_type="explore", prompt="Find all usages of [target] using lsp_find_references pattern...", run_in_background=true)
delegate_task(subagent_type="explore", prompt="Find test coverage for [affected code]...", run_in_background=true)
\`\`\`
**Interview Focus:**
@@ -343,9 +343,9 @@ delegate_task(agent="explore", prompt="Find test coverage for [affected code]...
**Pre-Interview Research (MANDATORY):**
\`\`\`typescript
// Launch BEFORE asking user questions
delegate_task(agent="explore", prompt="Find similar implementations in codebase...", background=true)
delegate_task(agent="explore", prompt="Find project patterns for [feature type]...", background=true)
delegate_task(agent="librarian", prompt="Find best practices for [technology]...", background=true)
delegate_task(subagent_type="explore", prompt="Find similar implementations in codebase...", run_in_background=true)
delegate_task(subagent_type="explore", prompt="Find project patterns for [feature type]...", run_in_background=true)
delegate_task(subagent_type="librarian", prompt="Find best practices for [technology]...", run_in_background=true)
\`\`\`
**Interview Focus** (AFTER research):
@@ -384,7 +384,7 @@ Based on your stack, I'd recommend NextAuth.js - it integrates well with Next.js
Run this check:
\`\`\`typescript
delegate_task(agent="explore", prompt="Find test infrastructure: package.json test scripts, test config files (jest.config, vitest.config, pytest.ini, etc.), existing test files (*.test.*, *.spec.*, test_*). Report: 1) Does test infra exist? 2) What framework? 3) Example test file patterns.", background=true)
delegate_task(subagent_type="explore", prompt="Find test infrastructure: package.json test scripts, test config files (jest.config, vitest.config, pytest.ini, etc.), existing test files (*.test.*, *.spec.*, test_*). Report: 1) Does test infra exist? 2) What framework? 3) Example test file patterns.", run_in_background=true)
\`\`\`
#### Step 2: Ask the Test Question (MANDATORY)
@@ -473,13 +473,13 @@ Add to draft immediately:
**Research First:**
\`\`\`typescript
delegate_task(agent="explore", prompt="Find current system architecture and patterns...", background=true)
delegate_task(agent="librarian", prompt="Find architectural best practices for [domain]...", background=true)
delegate_task(subagent_type="explore", prompt="Find current system architecture and patterns...", run_in_background=true)
delegate_task(subagent_type="librarian", prompt="Find architectural best practices for [domain]...", run_in_background=true)
\`\`\`
**Oracle Consultation** (recommend when stakes are high):
\`\`\`typescript
delegate_task(agent="oracle", prompt="Architecture consultation needed: [context]...", background=false)
delegate_task(subagent_type="oracle", prompt="Architecture consultation needed: [context]...", run_in_background=false)
\`\`\`
**Interview Focus:**
@@ -496,9 +496,9 @@ delegate_task(agent="oracle", prompt="Architecture consultation needed: [context
**Parallel Investigation:**
\`\`\`typescript
delegate_task(agent="explore", prompt="Find how X is currently handled...", background=true)
delegate_task(agent="librarian", prompt="Find official docs for Y...", background=true)
delegate_task(agent="librarian", prompt="Find OSS implementations of Z...", background=true)
delegate_task(subagent_type="explore", prompt="Find how X is currently handled...", run_in_background=true)
delegate_task(subagent_type="librarian", prompt="Find official docs for Y...", run_in_background=true)
delegate_task(subagent_type="librarian", prompt="Find OSS implementations of Z...", run_in_background=true)
\`\`\`
**Interview Focus:**
@@ -524,17 +524,17 @@ delegate_task(agent="librarian", prompt="Find OSS implementations of Z...", back
**For Understanding Codebase:**
\`\`\`typescript
delegate_task(agent="explore", prompt="Find all files related to [topic]. Show patterns, conventions, and structure.", background=true)
delegate_task(subagent_type="explore", prompt="Find all files related to [topic]. Show patterns, conventions, and structure.", run_in_background=true)
\`\`\`
**For External Knowledge:**
\`\`\`typescript
delegate_task(agent="librarian", prompt="Find official documentation for [library]. Focus on [specific feature] and best practices.", background=true)
delegate_task(subagent_type="librarian", prompt="Find official documentation for [library]. Focus on [specific feature] and best practices.", run_in_background=true)
\`\`\`
**For Implementation Examples:**
\`\`\`typescript
delegate_task(agent="librarian", prompt="Find open source implementations of [feature]. Look for production-quality examples.", background=true)
delegate_task(subagent_type="librarian", prompt="Find open source implementations of [feature]. Look for production-quality examples.", run_in_background=true)
\`\`\`
## Interview Mode Anti-Patterns
@@ -631,7 +631,7 @@ todoWrite([
\`\`\`typescript
delegate_task(
agent="Metis (Plan Consultant)",
subagent_type="metis",
prompt=\`Review this planning session before I generate the work plan:
**User's Goal**: {summarize what user wants}
@@ -652,7 +652,7 @@ delegate_task(
4. Assumptions I'm making that need validation
5. Missing acceptance criteria
6. Edge cases not addressed\`,
background=false
run_in_background=false
)
\`\`\`
@@ -797,9 +797,9 @@ Question({
// After generating initial plan
while (true) {
const result = delegate_task(
agent="Momus (Plan Reviewer)",
subagent_type="momus",
prompt=".sisyphus/plans/{name}.md",
background=false
run_in_background=false
)
if (result.verdict === "OKAY") {

View File

@@ -205,6 +205,34 @@ AFTER THE WORK YOU DELEGATED SEEMS DONE, ALWAYS VERIFY THE RESULTS AS FOLLOWING:
**Vague prompts = rejected. Be exhaustive.**
### Session Continuity (MANDATORY)
Every \`delegate_task()\` output includes a session_id. **USE IT.**
**ALWAYS resume when:**
| Scenario | Action |
|----------|--------|
| Task failed/incomplete | \`resume="{session_id}", prompt="Fix: {specific error}"\` |
| Follow-up question on result | \`resume="{session_id}", prompt="Also: {question}"\` |
| Multi-turn with same agent | \`resume="{session_id}"\` - NEVER start fresh |
| Verification failed | \`resume="{session_id}", prompt="Failed verification: {error}. Fix."\` |
**Why resume is CRITICAL:**
- Subagent has FULL conversation context preserved
- No repeated file reads, exploration, or setup
- Saves 70%+ tokens on follow-ups
- Subagent knows what it already tried/learned
\`\`\`typescript
// WRONG: Starting fresh loses all context
delegate_task(category="quick", prompt="Fix the type error in auth.ts...")
// CORRECT: Resume preserves everything
delegate_task(resume="ses_abc123", prompt="Fix: Type error on line 42")
\`\`\`
**After EVERY delegation, STORE the session_id for potential resume.**
### Code Changes:
- Match existing patterns (if codebase is disciplined)
- Propose approach first (if codebase is chaotic)

View File

@@ -57,14 +57,14 @@ export function isGptModel(model: string): boolean {
}
export type BuiltinAgentName =
| "Sisyphus"
| "sisyphus"
| "oracle"
| "librarian"
| "explore"
| "multimodal-looker"
| "Metis (Plan Consultant)"
| "Momus (Plan Reviewer)"
| "Atlas"
| "metis"
| "momus"
| "atlas"
export type OverridableAgentName =
| "build"

View File

@@ -12,46 +12,46 @@ describe("createBuiltinAgents with model overrides", () => {
const agents = await createBuiltinAgents([], {}, undefined, TEST_DEFAULT_MODEL)
// #then
expect(agents.Sisyphus.model).toBe("anthropic/claude-opus-4-5")
expect(agents.Sisyphus.thinking).toEqual({ type: "enabled", budgetTokens: 32000 })
expect(agents.Sisyphus.reasoningEffort).toBeUndefined()
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("Sisyphus with GPT model override has reasoningEffort, no thinking", async () => {
// #given
const overrides = {
Sisyphus: { model: "github-copilot/gpt-5.2" },
sisyphus: { model: "github-copilot/gpt-5.2" },
}
// #when
const agents = await createBuiltinAgents([], overrides, undefined, TEST_DEFAULT_MODEL)
// #then
expect(agents.Sisyphus.model).toBe("github-copilot/gpt-5.2")
expect(agents.Sisyphus.reasoningEffort).toBe("medium")
expect(agents.Sisyphus.thinking).toBeUndefined()
expect(agents.sisyphus.model).toBe("github-copilot/gpt-5.2")
expect(agents.sisyphus.reasoningEffort).toBe("medium")
expect(agents.sisyphus.thinking).toBeUndefined()
})
test("Sisyphus uses first fallbackChain entry when no availableModels provided", async () => {
test("Sisyphus uses system default when no availableModels provided", async () => {
// #given
const systemDefaultModel = "openai/gpt-5.2"
const systemDefaultModel = "anthropic/claude-opus-4-5"
// #when
const agents = await createBuiltinAgents([], {}, undefined, systemDefaultModel)
// #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()
// #then - falls back to system default when no availability match
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 uses first fallbackChain entry when no availableModels provided", async () => {
// #given - Oracle's first fallbackChain entry is openai/gpt-5.2
test("Oracle uses first fallback entry when no availableModels provided (no cache scenario)", async () => {
// #given - no available models simulates CI without model cache
// #when
const agents = await createBuiltinAgents([], {}, undefined, TEST_DEFAULT_MODEL)
// #then - Oracle first fallbackChain entry is openai/gpt-5.2
// #then - uses first fallback entry (openai/gpt-5.2) instead of system default
expect(agents.oracle.model).toBe("openai/gpt-5.2")
expect(agents.oracle.reasoningEffort).toBe("medium")
expect(agents.oracle.textVerbosity).toBe("high")
@@ -90,19 +90,19 @@ describe("createBuiltinAgents with model overrides", () => {
expect(agents.oracle.textVerbosity).toBeUndefined()
})
test("non-model overrides are still applied after factory rebuild", async () => {
// #given
const overrides = {
Sisyphus: { model: "github-copilot/gpt-5.2", temperature: 0.5 },
}
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 = await createBuiltinAgents([], overrides, undefined, TEST_DEFAULT_MODEL)
// #when
const agents = await createBuiltinAgents([], overrides, undefined, TEST_DEFAULT_MODEL)
// #then
expect(agents.Sisyphus.model).toBe("github-copilot/gpt-5.2")
expect(agents.Sisyphus.temperature).toBe(0.5)
})
// #then
expect(agents.sisyphus.model).toBe("github-copilot/gpt-5.2")
expect(agents.sisyphus.temperature).toBe(0.5)
})
})
describe("buildAgent with category and skills", () => {
@@ -123,7 +123,7 @@ describe("buildAgent with category and skills", () => {
const agent = buildAgent(source["test-agent"], TEST_MODEL)
// #then - category's built-in model is applied
expect(agent.model).toBe("google/gemini-3-pro-preview")
expect(agent.model).toBe("google/gemini-3-pro")
})
test("agent with category and existing model keeps existing model", () => {

View File

@@ -19,16 +19,16 @@ import type { LoadedSkill, SkillScope } from "../features/opencode-skill-loader/
type AgentSource = AgentFactory | AgentConfig
const agentSources: Record<BuiltinAgentName, AgentSource> = {
Sisyphus: createSisyphusAgent,
sisyphus: createSisyphusAgent,
oracle: createOracleAgent,
librarian: createLibrarianAgent,
explore: createExploreAgent,
"multimodal-looker": createMultimodalLookerAgent,
"Metis (Plan Consultant)": createMetisAgent,
"Momus (Plan Reviewer)": createMomusAgent,
metis: createMetisAgent,
momus: createMomusAgent,
// Note: Atlas is handled specially in createBuiltinAgents()
// because it needs OrchestratorContext, not just a model string
Atlas: createAtlasAgent as unknown as AgentFactory,
atlas: createAtlasAgent as unknown as AgentFactory,
}
/**
@@ -139,7 +139,7 @@ function mapScopeToLocation(scope: SkillScope): AvailableSkill["location"] {
}
export async function createBuiltinAgents(
disabledAgents: BuiltinAgentName[] = [],
disabledAgents: string[] = [],
agentOverrides: AgentOverrides = {},
directory?: string,
systemDefaultModel?: string,
@@ -186,18 +186,18 @@ export async function createBuiltinAgents(
const availableSkills: AvailableSkill[] = [...builtinAvailable, ...discoveredAvailable]
for (const [name, source] of Object.entries(agentSources)) {
const agentName = name as BuiltinAgentName
for (const [name, source] of Object.entries(agentSources)) {
const agentName = name as BuiltinAgentName
if (agentName === "Sisyphus") continue
if (agentName === "Atlas") continue
if (includesCaseInsensitive(disabledAgents, agentName)) continue
if (agentName === "sisyphus") continue
if (agentName === "atlas") continue
if (includesCaseInsensitive(disabledAgents, agentName)) continue
const override = findCaseInsensitive(agentOverrides, agentName)
const requirement = AGENT_MODEL_REQUIREMENTS[agentName]
// Use resolver to determine model
const { model } = resolveModelWithFallback({
const { model, variant: resolvedVariant } = resolveModelWithFallback({
userModel: override?.model,
fallbackChain: requirement?.fallbackChain,
availableModels,
@@ -206,11 +206,11 @@ export async function createBuiltinAgents(
let config = buildAgent(source, model, mergedCategories, gitMasterConfig)
// Apply variant from override or requirement
// Apply variant from override or resolved fallback chain
if (override?.variant) {
config = { ...config, variant: override.variant }
} else if (requirement?.variant) {
config = { ...config, variant: requirement.variant }
} else if (resolvedVariant) {
config = { ...config, variant: resolvedVariant }
}
if (agentName === "librarian" && directory && config.prompt) {
@@ -234,12 +234,12 @@ export async function createBuiltinAgents(
}
}
if (!disabledAgents.includes("Sisyphus")) {
const sisyphusOverride = agentOverrides["Sisyphus"]
const sisyphusRequirement = AGENT_MODEL_REQUIREMENTS["Sisyphus"]
if (!disabledAgents.includes("sisyphus")) {
const sisyphusOverride = agentOverrides["sisyphus"]
const sisyphusRequirement = AGENT_MODEL_REQUIREMENTS["sisyphus"]
// Use resolver to determine model
const { model: sisyphusModel } = resolveModelWithFallback({
const { model: sisyphusModel, variant: sisyphusResolvedVariant } = resolveModelWithFallback({
userModel: sisyphusOverride?.model,
fallbackChain: sisyphusRequirement?.fallbackChain,
availableModels,
@@ -254,11 +254,11 @@ export async function createBuiltinAgents(
availableCategories
)
// Apply variant from override or requirement
// Apply variant from override or resolved fallback chain
if (sisyphusOverride?.variant) {
sisyphusConfig = { ...sisyphusConfig, variant: sisyphusOverride.variant }
} else if (sisyphusRequirement?.variant) {
sisyphusConfig = { ...sisyphusConfig, variant: sisyphusRequirement.variant }
} else if (sisyphusResolvedVariant) {
sisyphusConfig = { ...sisyphusConfig, variant: sisyphusResolvedVariant }
}
if (directory && sisyphusConfig.prompt) {
@@ -270,15 +270,15 @@ export async function createBuiltinAgents(
sisyphusConfig = mergeAgentConfig(sisyphusConfig, sisyphusOverride)
}
result["Sisyphus"] = sisyphusConfig
}
result["sisyphus"] = sisyphusConfig
}
if (!disabledAgents.includes("Atlas")) {
const orchestratorOverride = agentOverrides["Atlas"]
const atlasRequirement = AGENT_MODEL_REQUIREMENTS["Atlas"]
if (!disabledAgents.includes("atlas")) {
const orchestratorOverride = agentOverrides["atlas"]
const atlasRequirement = AGENT_MODEL_REQUIREMENTS["atlas"]
// Use resolver to determine model
const { model: atlasModel } = resolveModelWithFallback({
const { model: atlasModel, variant: atlasResolvedVariant } = resolveModelWithFallback({
userModel: orchestratorOverride?.model,
fallbackChain: atlasRequirement?.fallbackChain,
availableModels,
@@ -292,19 +292,19 @@ export async function createBuiltinAgents(
userCategories: categories,
})
// Apply variant from override or requirement
// Apply variant from override or resolved fallback chain
if (orchestratorOverride?.variant) {
orchestratorConfig = { ...orchestratorConfig, variant: orchestratorOverride.variant }
} else if (atlasRequirement?.variant) {
orchestratorConfig = { ...orchestratorConfig, variant: atlasRequirement.variant }
} else if (atlasResolvedVariant) {
orchestratorConfig = { ...orchestratorConfig, variant: atlasResolvedVariant }
}
if (orchestratorOverride) {
orchestratorConfig = mergeAgentConfig(orchestratorConfig, orchestratorOverride)
}
result["Atlas"] = orchestratorConfig
}
result["atlas"] = orchestratorConfig
}
return result
}
return result
}

File diff suppressed because it is too large Load Diff

View File

@@ -219,7 +219,7 @@ describe("generateOmoConfig - model fallback system", () => {
// #then should use native anthropic sonnet (cost-efficient for standard plan)
expect(result.$schema).toBe("https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json")
expect(result.agents).toBeDefined()
expect((result.agents as Record<string, { model: string }>).Sisyphus.model).toBe("anthropic/claude-sonnet-4-5")
expect((result.agents as Record<string, { model: string }>).sisyphus.model).toBe("anthropic/claude-sonnet-4-5")
})
test("generates native opus models when Claude max20 subscription", () => {
@@ -238,7 +238,7 @@ describe("generateOmoConfig - model fallback system", () => {
const result = generateOmoConfig(config)
// #then should use native anthropic opus (max power for max20 plan)
expect((result.agents as Record<string, { model: string }>).Sisyphus.model).toBe("anthropic/claude-opus-4-5")
expect((result.agents as Record<string, { model: string }>).sisyphus.model).toBe("anthropic/claude-opus-4-5")
})
test("uses github-copilot sonnet fallback when only copilot available", () => {
@@ -257,7 +257,7 @@ describe("generateOmoConfig - model fallback system", () => {
const result = generateOmoConfig(config)
// #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")
expect((result.agents as Record<string, { model: string }>).sisyphus.model).toBe("github-copilot/claude-sonnet-4.5")
})
test("uses ultimate fallback when no providers configured", () => {
@@ -277,7 +277,7 @@ describe("generateOmoConfig - model fallback system", () => {
// #then should use ultimate fallback for all agents
expect(result.$schema).toBe("https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json")
expect((result.agents as Record<string, { model: string }>).Sisyphus.model).toBe("opencode/glm-4.7-free")
expect((result.agents as Record<string, { model: string }>).sisyphus.model).toBe("opencode/big-pickle")
})
test("uses zai-coding-plan/glm-4.7 for librarian when Z.ai available", () => {
@@ -298,7 +298,7 @@ describe("generateOmoConfig - model fallback system", () => {
// #then librarian should use zai-coding-plan/glm-4.7
expect((result.agents as Record<string, { model: string }>).librarian.model).toBe("zai-coding-plan/glm-4.7")
// #then other agents should use native opus (max20 plan)
expect((result.agents as Record<string, { model: string }>).Sisyphus.model).toBe("anthropic/claude-opus-4-5")
expect((result.agents as Record<string, { model: string }>).sisyphus.model).toBe("anthropic/claude-opus-4-5")
})
test("uses native OpenAI models when only ChatGPT available", () => {
@@ -317,7 +317,7 @@ describe("generateOmoConfig - model fallback system", () => {
const result = generateOmoConfig(config)
// #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")
expect((result.agents as Record<string, { model: string }>).sisyphus.model).toBe("openai/gpt-5.2")
// #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)
@@ -343,7 +343,7 @@ describe("generateOmoConfig - model fallback system", () => {
expect((result.agents as Record<string, { model: string }>).explore.model).toBe("anthropic/claude-haiku-4-5")
})
test("uses grok-code for explore when not max20", () => {
test("uses haiku for explore regardless of max20 flag", () => {
// #given user has Claude but not max20
const config: InstallConfig = {
hasClaude: true,
@@ -358,7 +358,7 @@ describe("generateOmoConfig - model fallback system", () => {
// #when generating config
const result = generateOmoConfig(config)
// #then explore should use grok-code (preserve Claude quota)
expect((result.agents as Record<string, { model: string }>).explore.model).toBe("opencode/grok-code")
// #then explore should use haiku (isMax20 doesn't affect explore anymore)
expect((result.agents as Record<string, { model: string }>).explore.model).toBe("anthropic/claude-haiku-4-5")
})
})

View File

@@ -16,10 +16,10 @@ describe("dependencies check", () => {
})
describe("checkAstGrepNapi", () => {
it("returns dependency info", () => {
it("returns dependency info", async () => {
// #given
// #when checking ast-grep napi
const info = deps.checkAstGrepNapi()
const info = await deps.checkAstGrepNapi()
// #then should return valid info
expect(info.name).toBe("AST-Grep NAPI")
@@ -95,7 +95,7 @@ describe("dependencies check", () => {
it("returns pass when installed", async () => {
// #given napi installed
checkSpy = spyOn(deps, "checkAstGrepNapi").mockReturnValue({
checkSpy = spyOn(deps, "checkAstGrepNapi").mockResolvedValue({
name: "AST-Grep NAPI",
required: false,
installed: true,

View File

@@ -56,9 +56,10 @@ export async function checkAstGrepCli(): Promise<DependencyInfo> {
}
}
export function checkAstGrepNapi(): DependencyInfo {
export async function checkAstGrepNapi(): Promise<DependencyInfo> {
// Try dynamic import first (works in bunx temporary environments)
try {
require.resolve("@ast-grep/napi")
await import("@ast-grep/napi")
return {
name: "AST-Grep NAPI",
required: false,
@@ -67,6 +68,28 @@ export function checkAstGrepNapi(): DependencyInfo {
path: null,
}
} catch {
// Fallback: check common installation paths
const { existsSync } = await import("fs")
const { join } = await import("path")
const { homedir } = await import("os")
const pathsToCheck = [
join(homedir(), ".config", "opencode", "node_modules", "@ast-grep", "napi"),
join(process.cwd(), "node_modules", "@ast-grep", "napi"),
]
for (const napiPath of pathsToCheck) {
if (existsSync(napiPath)) {
return {
name: "AST-Grep NAPI",
required: false,
installed: true,
version: null,
path: napiPath,
}
}
}
return {
name: "AST-Grep NAPI",
required: false,
@@ -127,7 +150,7 @@ export async function checkDependencyAstGrepCli(): Promise<CheckResult> {
}
export async function checkDependencyAstGrepNapi(): Promise<CheckResult> {
const info = checkAstGrepNapi()
const info = await checkAstGrepNapi()
return dependencyToCheckResult(info, CHECK_NAMES[CHECK_IDS.DEP_AST_GREP_NAPI])
}

View File

@@ -12,7 +12,7 @@ describe("model-resolution check", () => {
const info = getModelResolutionInfo()
// #then: Should have agent entries
const sisyphus = info.agents.find((a) => a.name === "Sisyphus")
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")
@@ -27,7 +27,7 @@ describe("model-resolution check", () => {
// #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]?.model).toBe("gemini-3-pro")
expect(visual!.requirement.fallbackChain[0]?.providers).toContain("google")
})
})
@@ -84,7 +84,7 @@ describe("model-resolution check", () => {
const info = getModelResolutionInfoWithOverrides(mockConfig)
// #then: Should show provider fallback chain
const sisyphus = info.agents.find((a) => a.name === "Sisyphus")
const sisyphus = info.agents.find((a) => a.name === "sisyphus")
expect(sisyphus).toBeDefined()
expect(sisyphus!.userOverride).toBeUndefined()
expect(sisyphus!.effectiveResolution).toContain("Provider fallback:")
@@ -97,13 +97,14 @@ describe("model-resolution check", () => {
// #when: Running the model resolution check
// #then: Returns pass with details showing resolution flow
it("returns pass status with agent and category counts", async () => {
it("returns pass or warn 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")
// #then: Should pass (with cache) or warn (no cache) and show counts
// In CI without model cache, status is "warn"; locally with cache, status is "pass"
expect(["pass", "warn"]).toContain(result.status)
expect(result.message).toMatch(/\d+ agents?, \d+ categories?/)
})
@@ -115,8 +116,9 @@ describe("model-resolution check", () => {
// #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)
// Should have Available Models and Configured Models headers
expect(result.details!.some((d) => d.includes("Available Models"))).toBe(true)
expect(result.details!.some((d) => d.includes("Configured 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

View File

@@ -1,4 +1,4 @@
import { readFileSync } from "node:fs"
import { readFileSync, existsSync } from "node:fs"
import type { CheckResult, CheckDefinition } from "../types"
import { CHECK_IDS, CHECK_NAMES } from "../constants"
import { parseJsonc, detectConfigFile } from "../../../shared"
@@ -10,6 +10,38 @@ import {
import { homedir } from "node:os"
import { join } from "node:path"
function getOpenCodeCacheDir(): string {
const xdgCache = process.env.XDG_CACHE_HOME
if (xdgCache) return join(xdgCache, "opencode")
return join(homedir(), ".cache", "opencode")
}
function loadAvailableModels(): { providers: string[]; modelCount: number; cacheExists: boolean } {
const cacheFile = join(getOpenCodeCacheDir(), "models.json")
if (!existsSync(cacheFile)) {
return { providers: [], modelCount: 0, cacheExists: false }
}
try {
const content = readFileSync(cacheFile, "utf-8")
const data = JSON.parse(content) as Record<string, { models?: Record<string, unknown> }>
const providers = Object.keys(data)
let modelCount = 0
for (const providerId of providers) {
const models = data[providerId]?.models
if (models && typeof models === "object") {
modelCount += Object.keys(models).length
}
}
return { providers, modelCount, cacheExists: true }
} catch {
return { providers: [], modelCount: 0, cacheExists: false }
}
}
const PACKAGE_NAME = "oh-my-opencode"
const USER_CONFIG_DIR = join(homedir(), ".config", "opencode")
const USER_CONFIG_BASE = join(USER_CONFIG_DIR, PACKAGE_NAME)
@@ -155,10 +187,28 @@ function getEffectiveVariant(requirement: ModelRequirement): string | undefined
return firstEntry?.variant ?? requirement.variant
}
function buildDetailsArray(info: ModelResolutionInfo): string[] {
interface AvailableModelsInfo {
providers: string[]
modelCount: number
cacheExists: boolean
}
function buildDetailsArray(info: ModelResolutionInfo, available: AvailableModelsInfo): string[] {
const details: string[] = []
details.push("═══ Current Models ═══")
details.push("═══ Available Models (from cache) ═══")
details.push("")
if (available.cacheExists) {
details.push(` Providers: ${available.providers.length} (${available.providers.slice(0, 8).join(", ")}${available.providers.length > 8 ? "..." : ""})`)
details.push(` Total models: ${available.modelCount}`)
details.push(` Cache: ~/.cache/opencode/models.json`)
details.push(` Refresh: opencode models --refresh`)
} else {
details.push(" ⚠ Cache not found. Run 'opencode' to populate.")
}
details.push("")
details.push("═══ Configured Models ═══")
details.push("")
details.push("Agents:")
for (const agent of info.agents) {
@@ -182,6 +232,7 @@ function buildDetailsArray(info: ModelResolutionInfo): string[] {
export async function checkModelResolution(): Promise<CheckResult> {
const config = loadConfig() ?? {}
const info = getModelResolutionInfoWithOverrides(config)
const available = loadAvailableModels()
const agentCount = info.agents.length
const categoryCount = info.categories.length
@@ -190,12 +241,13 @@ export async function checkModelResolution(): Promise<CheckResult> {
const totalOverrides = agentOverrides + categoryOverrides
const overrideNote = totalOverrides > 0 ? ` (${totalOverrides} override${totalOverrides > 1 ? "s" : ""})` : ""
const cacheNote = available.cacheExists ? `, ${available.modelCount} available` : ", cache not found"
return {
name: CHECK_NAMES[CHECK_IDS.MODEL_RESOLUTION],
status: "pass",
message: `${agentCount} agents, ${categoryCount} categories${overrideNote}`,
details: buildDetailsArray(info),
status: available.cacheExists ? "pass" : "warn",
message: `${agentCount} agents, ${categoryCount} categories${overrideNote}${cacheNote}`,
details: buildDetailsArray(info, available),
}
}

View File

@@ -22,6 +22,9 @@ function findPluginEntry(plugins: string[]): { entry: string; isPinned: boolean;
const version = isPinned ? plugin.split("@")[1] : null
return { entry: plugin, isPinned, version }
}
if (plugin.startsWith("file://") && plugin.includes(PACKAGE_NAME)) {
return { entry: plugin, isPinned: false, version: "local-dev" }
}
}
return null
}

View File

@@ -44,7 +44,7 @@ function formatConfigSummary(config: InstallConfig): string {
lines.push(formatProvider("Gemini", config.hasGemini))
lines.push(formatProvider("GitHub Copilot", config.hasCopilot, "fallback"))
lines.push(formatProvider("OpenCode Zen", config.hasOpencodeZen, "opencode/ models"))
lines.push(formatProvider("Z.ai Coding Plan", config.hasZaiCodingPlan, "Librarian: glm-4.7"))
lines.push(formatProvider("Z.ai Coding Plan", config.hasZaiCodingPlan, "Librarian/Multimodal"))
lines.push("")
lines.push(color.dim("─".repeat(40)))
@@ -178,7 +178,7 @@ async function runTuiMode(detected: DetectedConfig): Promise<InstallConfig | nul
const claude = await p.select({
message: "Do you have a Claude Pro/Max subscription?",
options: [
{ value: "no" as const, label: "No", hint: "Will use opencode/glm-4.7-free as fallback" },
{ value: "no" as const, label: "No", hint: "Will use opencode/big-pickle as fallback" },
{ value: "yes" as const, label: "Yes (standard)", hint: "Claude Opus 4.5 for orchestration" },
{ value: "max20" as const, label: "Yes (max20 mode)", hint: "Full power with Claude Sonnet 4.5 for Librarian" },
],
@@ -250,7 +250,7 @@ async function runTuiMode(detected: DetectedConfig): Promise<InstallConfig | nul
message: "Do you have a Z.ai Coding Plan subscription?",
options: [
{ value: "no" as const, label: "No", hint: "Will use other configured providers" },
{ value: "yes" as const, label: "Yes", hint: "zai-coding-plan/glm-4.7 for Librarian" },
{ value: "yes" as const, label: "Yes", hint: "Fallback for Librarian and Multimodal Looker" },
],
initialValue: initial.zaiCodingPlan,
})
@@ -363,7 +363,7 @@ async function runNonTuiInstall(args: InstallArgs): Promise<number> {
}
if (!config.hasClaude && !config.hasOpenAI && !config.hasGemini && !config.hasCopilot && !config.hasOpencodeZen) {
printWarning("No model providers configured. Using opencode/glm-4.7-free as fallback.")
printWarning("No model providers configured. Using opencode/big-pickle as fallback.")
}
console.log(`${SYMBOLS.star} ${color.bold(color.green(isUpdate ? "Configuration updated!" : "Installation complete!"))}`)
@@ -480,7 +480,7 @@ export async function install(args: InstallArgs): Promise<number> {
}
if (!config.hasClaude && !config.hasOpenAI && !config.hasGemini && !config.hasCopilot && !config.hasOpencodeZen) {
p.log.warn("No model providers configured. Using opencode/glm-4.7-free as fallback.")
p.log.warn("No model providers configured. Using opencode/big-pickle as fallback.")
}
p.note(formatConfigSummary(config), isUpdate ? "Updated Configuration" : "Installation Complete")

View File

@@ -310,19 +310,19 @@ describe("generateModelConfig", () => {
})
describe("explore agent special cases", () => {
test("explore uses Gemini flash when Gemini available", () => {
// #given Gemini is available
test("explore uses gpt-5-nano when only Gemini available (no Claude)", () => {
// #given only Gemini is available (no Claude)
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")
// #then explore should use gpt-5-nano (Claude haiku not available)
expect(result.agents?.explore?.model).toBe("opencode/gpt-5-nano")
})
test("explore uses Claude haiku when Claude + isMax20 but no Gemini", () => {
// #given Claude is available with Max 20 plan but no Gemini
test("explore uses Claude haiku when Claude available", () => {
// #given Claude is available
const config = createConfig({ hasClaude: true, isMax20: true })
// #when generateModelConfig is called
@@ -332,26 +332,26 @@ describe("generateModelConfig", () => {
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
test("explore uses Claude haiku regardless of isMax20 flag", () => {
// #given Claude is available without Max 20 plan
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")
// #then explore should use claude-haiku-4-5 (isMax20 doesn't affect explore)
expect(result.agents?.explore?.model).toBe("anthropic/claude-haiku-4-5")
})
test("explore uses grok-code when only OpenAI available", () => {
test("explore uses gpt-5-nano 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")
// #then explore should use gpt-5-nano (fallback)
expect(result.agents?.explore?.model).toBe("opencode/gpt-5-nano")
})
})
@@ -364,7 +364,7 @@ describe("generateModelConfig", () => {
const result = generateModelConfig(config)
// #then Sisyphus should use opus (sisyphus-high)
expect(result.agents?.Sisyphus?.model).toBe("anthropic/claude-opus-4-5")
expect(result.agents?.sisyphus?.model).toBe("anthropic/claude-opus-4-5")
})
test("Sisyphus uses sisyphus-low capability when isMax20 is false", () => {
@@ -375,7 +375,7 @@ describe("generateModelConfig", () => {
const result = generateModelConfig(config)
// #then Sisyphus should use sonnet (sisyphus-low)
expect(result.agents?.Sisyphus?.model).toBe("anthropic/claude-sonnet-4-5")
expect(result.agents?.sisyphus?.model).toBe("anthropic/claude-sonnet-4-5")
})
})

View File

@@ -36,7 +36,7 @@ export interface GeneratedOmoConfig {
const ZAI_MODEL = "zai-coding-plan/glm-4.7"
const ULTIMATE_FALLBACK = "opencode/glm-4.7-free"
const ULTIMATE_FALLBACK = "opencode/big-pickle"
const SCHEMA_URL = "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json"
function toProviderAvailability(config: InstallConfig): ProviderAvailability {
@@ -97,13 +97,13 @@ function resolveModelFromChain(
function getSisyphusFallbackChain(isMaxPlan: boolean): FallbackEntry[] {
// Sisyphus uses opus when isMaxPlan, sonnet otherwise
if (isMaxPlan) {
return AGENT_MODEL_REQUIREMENTS.Sisyphus.fallbackChain
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" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro" },
]
}
@@ -139,21 +139,21 @@ export function generateModelConfig(config: InstallConfig): GeneratedOmoConfig {
continue
}
// Special case: explore has custom Gemini → Claude → Grok logic
// Special case: explore uses Claude haiku → OpenCode gpt-5-nano
if (role === "explore") {
if (avail.native.gemini) {
agents[role] = { model: "google/gemini-3-flash-preview" }
} else if (avail.native.claude && avail.isMaxPlan) {
if (avail.native.claude) {
agents[role] = { model: "anthropic/claude-haiku-4-5" }
} else if (avail.opencodeZen) {
agents[role] = { model: "opencode/claude-haiku-4-5" }
} else {
agents[role] = { model: "opencode/grok-code" }
agents[role] = { model: "opencode/gpt-5-nano" }
}
continue
}
// Special case: Sisyphus uses different fallbackChain based on isMaxPlan
const fallbackChain =
role === "Sisyphus" ? getSisyphusFallbackChain(avail.isMaxPlan) : req.fallbackChain
role === "sisyphus" ? getSisyphusFallbackChain(avail.isMaxPlan) : req.fallbackChain
const resolved = resolveModelFromChain(fallbackChain, avail)
if (resolved) {

View File

@@ -345,6 +345,20 @@ describe("CategoryConfigSchema", () => {
}
})
test("accepts reasoningEffort as optional string with xhigh", () => {
// #given
const config = { reasoningEffort: "xhigh" }
// #when
const result = CategoryConfigSchema.safeParse(config)
// #then
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.reasoningEffort).toBe("xhigh")
}
})
test("rejects non-string variant", () => {
// #given
const config = { model: "openai/gpt-5.2", variant: 123 }
@@ -375,7 +389,7 @@ describe("Sisyphus-Junior agent override", () => {
// #given
const config = {
agents: {
"Sisyphus-Junior": {
"sisyphus-junior": {
model: "openai/gpt-5.2",
temperature: 0.2,
},
@@ -388,18 +402,18 @@ describe("Sisyphus-Junior agent override", () => {
// #then
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.agents?.["Sisyphus-Junior"]).toBeDefined()
expect(result.data.agents?.["Sisyphus-Junior"]?.model).toBe("openai/gpt-5.2")
expect(result.data.agents?.["Sisyphus-Junior"]?.temperature).toBe(0.2)
expect(result.data.agents?.["sisyphus-junior"]).toBeDefined()
expect(result.data.agents?.["sisyphus-junior"]?.model).toBe("openai/gpt-5.2")
expect(result.data.agents?.["sisyphus-junior"]?.temperature).toBe(0.2)
}
})
test("schema accepts Sisyphus-Junior with prompt_append", () => {
test("schema accepts sisyphus-junior with prompt_append", () => {
// #given
const config = {
agents: {
"Sisyphus-Junior": {
prompt_append: "Additional instructions for Sisyphus-Junior",
"sisyphus-junior": {
prompt_append: "Additional instructions for sisyphus-junior",
},
},
}
@@ -410,17 +424,17 @@ describe("Sisyphus-Junior agent override", () => {
// #then
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.agents?.["Sisyphus-Junior"]?.prompt_append).toBe(
"Additional instructions for Sisyphus-Junior"
expect(result.data.agents?.["sisyphus-junior"]?.prompt_append).toBe(
"Additional instructions for sisyphus-junior"
)
}
})
test("schema accepts Sisyphus-Junior with tools override", () => {
test("schema accepts sisyphus-junior with tools override", () => {
// #given
const config = {
agents: {
"Sisyphus-Junior": {
"sisyphus-junior": {
tools: {
read: true,
write: false,
@@ -435,10 +449,62 @@ describe("Sisyphus-Junior agent override", () => {
// #then
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.agents?.["Sisyphus-Junior"]?.tools).toEqual({
expect(result.data.agents?.["sisyphus-junior"]?.tools).toEqual({
read: true,
write: false,
})
}
})
test("schema accepts lowercase agent names (sisyphus, atlas, prometheus)", () => {
// #given
const config = {
agents: {
sisyphus: {
temperature: 0.1,
},
atlas: {
temperature: 0.2,
},
prometheus: {
temperature: 0.3,
},
},
}
// #when
const result = OhMyOpenCodeConfigSchema.safeParse(config)
// #then
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.agents?.sisyphus?.temperature).toBe(0.1)
expect(result.data.agents?.atlas?.temperature).toBe(0.2)
expect(result.data.agents?.prometheus?.temperature).toBe(0.3)
}
})
test("schema accepts lowercase metis and momus agent names", () => {
// #given
const config = {
agents: {
metis: {
category: "ultrabrain",
},
momus: {
category: "quick",
},
},
}
// #when
const result = OhMyOpenCodeConfigSchema.safeParse(config)
// #then
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.agents?.metis?.category).toBe("ultrabrain")
expect(result.data.agents?.momus?.category).toBe("quick")
}
})
})

View File

@@ -17,14 +17,15 @@ const AgentPermissionSchema = z.object({
})
export const BuiltinAgentNameSchema = z.enum([
"Sisyphus",
"sisyphus",
"prometheus",
"oracle",
"librarian",
"explore",
"multimodal-looker",
"Metis (Plan Consultant)",
"Momus (Plan Reviewer)",
"Atlas",
"metis",
"momus",
"atlas",
])
export const BuiltinSkillNameSchema = z.enum([
@@ -36,17 +37,17 @@ export const BuiltinSkillNameSchema = z.enum([
export const OverridableAgentNameSchema = z.enum([
"build",
"plan",
"Sisyphus",
"Sisyphus-Junior",
"sisyphus",
"sisyphus-junior",
"OpenCode-Builder",
"Prometheus (Planner)",
"Metis (Plan Consultant)",
"Momus (Plan Reviewer)",
"prometheus",
"metis",
"momus",
"oracle",
"librarian",
"explore",
"multimodal-looker",
"Atlas",
"atlas",
])
export const AgentNameSchema = BuiltinAgentNameSchema
@@ -117,17 +118,17 @@ export const AgentOverrideConfigSchema = z.object({
export const AgentOverridesSchema = z.object({
build: AgentOverrideConfigSchema.optional(),
plan: AgentOverrideConfigSchema.optional(),
Sisyphus: AgentOverrideConfigSchema.optional(),
"Sisyphus-Junior": AgentOverrideConfigSchema.optional(),
sisyphus: AgentOverrideConfigSchema.optional(),
"sisyphus-junior": AgentOverrideConfigSchema.optional(),
"OpenCode-Builder": AgentOverrideConfigSchema.optional(),
"Prometheus (Planner)": AgentOverrideConfigSchema.optional(),
"Metis (Plan Consultant)": AgentOverrideConfigSchema.optional(),
"Momus (Plan Reviewer)": AgentOverrideConfigSchema.optional(),
prometheus: AgentOverrideConfigSchema.optional(),
metis: AgentOverrideConfigSchema.optional(),
momus: AgentOverrideConfigSchema.optional(),
oracle: AgentOverrideConfigSchema.optional(),
librarian: AgentOverrideConfigSchema.optional(),
explore: AgentOverrideConfigSchema.optional(),
"multimodal-looker": AgentOverrideConfigSchema.optional(),
Atlas: AgentOverrideConfigSchema.optional(),
atlas: AgentOverrideConfigSchema.optional(),
})
export const ClaudeCodeConfigSchema = z.object({
@@ -159,7 +160,7 @@ export const CategoryConfigSchema = z.object({
type: z.enum(["enabled", "disabled"]),
budgetTokens: z.number().optional(),
}).optional(),
reasoningEffort: z.enum(["low", "medium", "high"]).optional(),
reasoningEffort: z.enum(["low", "medium", "high", "xhigh"]).optional(),
textVerbosity: z.enum(["low", "medium", "high"]).optional(),
tools: z.record(z.string(), z.boolean()).optional(),
prompt_append: z.string().optional(),

View File

@@ -55,7 +55,7 @@ ${REFACTOR_TEMPLATE}
},
"start-work": {
description: "(builtin) Start Sisyphus work session from Prometheus plan",
agent: "Atlas",
agent: "atlas",
template: `<command-instruction>
${START_WORK_TEMPLATE}
</command-instruction>

View File

@@ -77,7 +77,13 @@ export async function loadMcpConfigs(): Promise<McpLoadResult> {
for (const [name, serverConfig] of Object.entries(config.mcpServers)) {
if (serverConfig.disabled) {
log(`Skipping disabled MCP server "${name}"`, { path })
log(`Disabling MCP server "${name}"`, { path })
delete servers[name]
const existingIndex = loadedServers.findIndex((s) => s.name === name)
if (existingIndex !== -1) {
loadedServers.splice(existingIndex, 1)
log(`Removed previously loaded MCP server "${name}"`, { path })
}
continue
}

View File

@@ -123,4 +123,40 @@ describe("claude-code-session-state", () => {
expect(getSessionAgent(sessionID)).toBeUndefined()
})
})
describe("issue #893: custom agent switch reset", () => {
test("should preserve custom agent when default agent is sent on subsequent messages", () => {
// #given - user switches to custom agent "MyCustomAgent"
const sessionID = "test-session-custom"
const customAgent = "MyCustomAgent"
const defaultAgent = "Sisyphus"
// User switches to custom agent (via UI)
setSessionAgent(sessionID, customAgent)
expect(getSessionAgent(sessionID)).toBe(customAgent)
// #when - first message after switch sends default agent
// This simulates the bug: input.agent = "Sisyphus" on first message
// Using setSessionAgent (first-write wins) should preserve custom agent
setSessionAgent(sessionID, defaultAgent)
// #then - custom agent should be preserved, NOT overwritten
expect(getSessionAgent(sessionID)).toBe(customAgent)
})
test("should allow explicit agent update via updateSessionAgent", () => {
// #given - custom agent is set
const sessionID = "test-session-explicit"
const customAgent = "MyCustomAgent"
const newAgent = "AnotherAgent"
setSessionAgent(sessionID, customAgent)
// #when - explicit update (user intentionally switches)
updateSessionAgent(sessionID, newAgent)
// #then - should be updated
expect(getSessionAgent(sessionID)).toBe(newAgent)
})
})
})

View File

@@ -30,7 +30,7 @@ describe("TaskToastManager", () => {
const task = {
id: "task_1",
description: "Test task",
agent: "Sisyphus-Junior",
agent: "sisyphus-junior",
isBackground: true,
skills: ["playwright", "git-master"],
}
@@ -127,7 +127,7 @@ describe("TaskToastManager", () => {
const task = {
id: "task_1",
description: "Full info task",
agent: "Sisyphus-Junior",
agent: "sisyphus-junior",
isBackground: true,
skills: ["frontend-ui-ux"],
}
@@ -149,9 +149,9 @@ describe("TaskToastManager", () => {
const task = {
id: "task_1",
description: "Task with category default model",
agent: "Sisyphus-Junior",
agent: "sisyphus-junior",
isBackground: false,
modelInfo: { model: "google/gemini-3-pro-preview", type: "category-default" as const },
modelInfo: { model: "google/gemini-3-pro", type: "category-default" as const },
}
// #when - addTask is called
@@ -169,7 +169,7 @@ describe("TaskToastManager", () => {
const task = {
id: "task_1b",
description: "Task with system default model",
agent: "Sisyphus-Junior",
agent: "sisyphus-junior",
isBackground: false,
modelInfo: { model: "anthropic/claude-sonnet-4-5", type: "system-default" as const },
}
@@ -190,7 +190,7 @@ describe("TaskToastManager", () => {
const task = {
id: "task_2",
description: "Task with inherited model",
agent: "Sisyphus-Junior",
agent: "sisyphus-junior",
isBackground: false,
modelInfo: { model: "cliproxy/claude-opus-4-5", type: "inherited" as const },
}
@@ -211,7 +211,7 @@ describe("TaskToastManager", () => {
const task = {
id: "task_3",
description: "Task with user model",
agent: "Sisyphus-Junior",
agent: "sisyphus-junior",
isBackground: false,
modelInfo: { model: "my-provider/my-model", type: "user-defined" as const },
}

View File

@@ -8,7 +8,7 @@
```
hooks/
├── atlas/ # Main orchestration (771 lines)
├── atlas/ # Main orchestration (773 lines)
├── anthropic-context-window-limit-recovery/ # Auto-summarize
├── todo-continuation-enforcer.ts # Force TODO completion
├── ralph-loop/ # Self-referential dev loop

View File

@@ -123,7 +123,7 @@ describe("atlas hook", () => {
test("should append standalone verification when no boulder state but caller is Atlas", async () => {
// #given - no boulder state, but caller is Atlas
const sessionID = "session-no-boulder-test"
setupMessageStorage(sessionID, "Atlas")
setupMessageStorage(sessionID, "atlas")
const hook = createAtlasHook(createMockPluginInput())
const output = {
@@ -149,7 +149,7 @@ describe("atlas hook", () => {
test("should transform output when caller is Atlas with boulder state", async () => {
// #given - Atlas caller with boulder state
const sessionID = "session-transform-test"
setupMessageStorage(sessionID, "Atlas")
setupMessageStorage(sessionID, "atlas")
const planPath = join(TEST_DIR, "test-plan.md")
writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [x] Task 2")
@@ -188,7 +188,7 @@ describe("atlas hook", () => {
test("should still transform when plan is complete (shows progress)", async () => {
// #given - boulder state with complete plan, Atlas caller
const sessionID = "session-complete-plan-test"
setupMessageStorage(sessionID, "Atlas")
setupMessageStorage(sessionID, "atlas")
const planPath = join(TEST_DIR, "complete-plan.md")
writeFileSync(planPath, "# Plan\n- [x] Task 1\n- [x] Task 2")
@@ -225,7 +225,7 @@ describe("atlas hook", () => {
test("should append session ID to boulder state if not present", async () => {
// #given - boulder state without session-append-test, Atlas caller
const sessionID = "session-append-test"
setupMessageStorage(sessionID, "Atlas")
setupMessageStorage(sessionID, "atlas")
const planPath = join(TEST_DIR, "test-plan.md")
writeFileSync(planPath, "# Plan\n- [ ] Task 1")
@@ -261,7 +261,7 @@ describe("atlas hook", () => {
test("should not duplicate existing session ID", async () => {
// #given - boulder state already has session-dup-test, Atlas caller
const sessionID = "session-dup-test"
setupMessageStorage(sessionID, "Atlas")
setupMessageStorage(sessionID, "atlas")
const planPath = join(TEST_DIR, "test-plan.md")
writeFileSync(planPath, "# Plan\n- [ ] Task 1")
@@ -298,7 +298,7 @@ describe("atlas hook", () => {
test("should include boulder.json path and notepad path in transformed output", async () => {
// #given - boulder state, Atlas caller
const sessionID = "session-path-test"
setupMessageStorage(sessionID, "Atlas")
setupMessageStorage(sessionID, "atlas")
const planPath = join(TEST_DIR, "my-feature.md")
writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [ ] Task 2\n- [x] Task 3")
@@ -335,7 +335,7 @@ describe("atlas hook", () => {
test("should include resume and checkbox instructions in reminder", async () => {
// #given - boulder state, Atlas caller
const sessionID = "session-resume-test"
setupMessageStorage(sessionID, "Atlas")
setupMessageStorage(sessionID, "atlas")
const planPath = join(TEST_DIR, "test-plan.md")
writeFileSync(planPath, "# Plan\n- [ ] Task 1")

View File

@@ -274,6 +274,7 @@ function getGitDiffStats(directory: string): GitFileStat[] {
cwd: directory,
encoding: "utf-8",
timeout: 5000,
stdio: ["pipe", "pipe", "pipe"],
}).trim()
if (!output) return []
@@ -282,6 +283,7 @@ function getGitDiffStats(directory: string): GitFileStat[] {
cwd: directory,
encoding: "utf-8",
timeout: 5000,
stdio: ["pipe", "pipe", "pipe"],
}).trim()
const statusMap = new Map<string, "modified" | "added" | "deleted">()
@@ -397,7 +399,7 @@ function isCallerOrchestrator(sessionID?: string): boolean {
const messageDir = getMessageDir(sessionID)
if (!messageDir) return false
const nearest = findNearestMessageWithFields(messageDir)
return nearest?.agent === "Atlas"
return nearest?.agent?.toLowerCase() === "atlas"
}
interface SessionState {
@@ -496,7 +498,7 @@ export function createAtlasHook(
await ctx.client.session.prompt({
path: { id: sessionID },
body: {
agent: "Atlas",
agent: "atlas",
...(model !== undefined ? { model } : {}),
parts: [{ type: "text", text: prompt }],
},

View File

@@ -5,6 +5,7 @@ import { PACKAGE_NAME } from "./constants"
import { log } from "../../shared/logger"
import { getConfigLoadErrors, clearConfigLoadErrors } from "../../shared/config-errors"
import { runBunInstall } from "../../cli/config-manager"
import { isModelCacheAvailable } from "../../shared/model-availability"
import type { AutoUpdateCheckerOptions } from "./types"
const SISYPHUS_SPINNER = ["·", "•", "●", "○", "◌", "◦", " "]
@@ -75,6 +76,7 @@ export function createAutoUpdateCheckerHook(ctx: PluginInput, options: AutoUpdat
const displayVersion = localDevVersion ?? cachedVersion
await showConfigErrorsIfAny(ctx)
await showModelCacheWarningIfNeeded(ctx)
if (localDevVersion) {
if (showStartupToast) {
@@ -167,6 +169,23 @@ async function runBunInstallSafe(): Promise<boolean> {
}
}
async function showModelCacheWarningIfNeeded(ctx: PluginInput): Promise<void> {
if (isModelCacheAvailable()) return
await ctx.client.tui
.showToast({
body: {
title: "Model Cache Not Found",
message: "Run 'opencode models --refresh' or restart OpenCode to populate the models cache for optimal agent model selection.",
variant: "warning" as const,
duration: 10000,
},
})
.catch(() => {})
log("[auto-update-checker] Model cache warning shown")
}
async function showConfigErrorsIfAny(ctx: PluginInput): Promise<void> {
const errors = getConfigLoadErrors()
if (errors.length === 0) return

View File

@@ -30,3 +30,4 @@ export { createTaskResumeInfoHook } from "./task-resume-info";
export { createStartWorkHook } from "./start-work";
export { createAtlasHook } from "./atlas";
export { createDelegateTaskRetryHook } from "./delegate-task-retry";
export { createQuestionLabelTruncatorHook } from "./question-label-truncator";

View File

@@ -178,7 +178,11 @@ describe("non-interactive-env hook", () => {
})
})
describe("cross-platform shell support", () => {
describe("bash tool always uses unix shell syntax", () => {
// The bash tool always runs in a Unix-like shell (bash/sh), even on Windows
// (via Git Bash, WSL, etc.), so we should always use unix export syntax.
// This fixes GitHub issues #983 and #889.
test("#given macOS platform #when git command executes #then uses unix export syntax", async () => {
delete process.env.PSModulePath
process.env.SHELL = "/bin/zsh"
@@ -221,7 +225,9 @@ describe("non-interactive-env hook", () => {
expect(cmd).toContain("; git commit")
})
test("#given Windows with PowerShell #when git command executes #then uses powershell $env syntax", async () => {
test("#given Windows with PowerShell env #when bash tool git command executes #then still uses unix export syntax", async () => {
// Even when PSModulePath is set (indicating PowerShell environment),
// the bash tool runs in a Unix-like shell, so we use export syntax
process.env.PSModulePath = "C:\\Program Files\\PowerShell\\Modules"
Object.defineProperty(process, "platform", { value: "win32" })
@@ -236,13 +242,16 @@ describe("non-interactive-env hook", () => {
)
const cmd = output.args.command as string
expect(cmd).toContain("$env:")
// Should use unix export syntax, NOT PowerShell $env: syntax
expect(cmd).toStartWith("export ")
expect(cmd).toContain("; git status")
expect(cmd).not.toStartWith("export ")
expect(cmd).not.toContain("$env:")
expect(cmd).not.toContain("set ")
})
test("#given Windows without PowerShell #when git command executes #then uses cmd set syntax", async () => {
test("#given Windows without SHELL env #when bash tool git command executes #then still uses unix export syntax", async () => {
// Even when detectShellType() would return "cmd" (no SHELL, no PSModulePath, win32),
// the bash tool runs in a Unix-like shell, so we use export syntax
delete process.env.PSModulePath
delete process.env.SHELL
Object.defineProperty(process, "platform", { value: "win32" })
@@ -258,14 +267,18 @@ describe("non-interactive-env hook", () => {
)
const cmd = output.args.command as string
expect(cmd).toContain("set ")
expect(cmd).toContain("&&")
expect(cmd).not.toStartWith("export ")
// Should use unix export syntax, NOT cmd.exe set syntax
expect(cmd).toStartWith("export ")
expect(cmd).toContain("; git log")
expect(cmd).not.toContain("set ")
expect(cmd).not.toContain("&&")
expect(cmd).not.toContain("$env:")
})
test("#given PowerShell #when values contain quotes #then escapes correctly", async () => {
process.env.PSModulePath = "C:\\Program Files\\PowerShell\\Modules"
test("#given Windows Git Bash environment #when git command executes #then uses unix export syntax", async () => {
// Simulating Git Bash on Windows: SHELL might be set to /usr/bin/bash
delete process.env.PSModulePath
process.env.SHELL = "/usr/bin/bash"
Object.defineProperty(process, "platform", { value: "win32" })
const hook = createNonInteractiveEnvHook(mockCtx)
@@ -279,32 +292,16 @@ describe("non-interactive-env hook", () => {
)
const cmd = output.args.command as string
expect(cmd).toMatch(/\$env:\w+='[^']*'/)
expect(cmd).toStartWith("export ")
expect(cmd).toContain("; git status")
})
test("#given cmd.exe #when values contain spaces #then escapes correctly", async () => {
test("#given any platform #when chained git commands via bash tool #then uses unix export syntax", async () => {
// Even on Windows, chained commands should use unix syntax
delete process.env.PSModulePath
delete process.env.SHELL
Object.defineProperty(process, "platform", { value: "win32" })
const hook = createNonInteractiveEnvHook(mockCtx)
const output: { args: Record<string, unknown>; message?: string } = {
args: { command: "git status" },
}
await hook["tool.execute.before"](
{ tool: "bash", sessionID: "test", callID: "1" },
output
)
const cmd = output.args.command as string
expect(cmd).toMatch(/set \w+="[^"]*"/)
})
test("#given PowerShell #when chained git commands #then env vars apply to all commands", async () => {
process.env.PSModulePath = "C:\\Program Files\\PowerShell\\Modules"
Object.defineProperty(process, "platform", { value: "win32" })
const hook = createNonInteractiveEnvHook(mockCtx)
const output: { args: Record<string, unknown>; message?: string } = {
args: { command: "git add file && git commit -m 'test'" },
@@ -316,7 +313,7 @@ describe("non-interactive-env hook", () => {
)
const cmd = output.args.command as string
expect(cmd).toContain("$env:")
expect(cmd).toStartWith("export ")
expect(cmd).toContain("; git add file && git commit")
})
})

View File

@@ -1,7 +1,8 @@
import type { PluginInput } from "@opencode-ai/plugin"
import type { ShellType } from "../../shared"
import { HOOK_NAME, NON_INTERACTIVE_ENV, SHELL_COMMAND_PATTERNS } from "./constants"
import { isNonInteractive } from "./detector"
import { log, detectShellType, buildEnvPrefix } from "../../shared"
import { log, buildEnvPrefix } from "../../shared"
export * from "./constants"
export * from "./detector"
@@ -50,7 +51,10 @@ export function createNonInteractiveEnvHook(_ctx: PluginInput) {
return
}
const shellType = detectShellType()
// The bash tool always runs in a Unix-like shell (bash/sh), even on Windows
// (via Git Bash, WSL, etc.), so we always use unix export syntax.
// This fixes GitHub issues #983 and #889.
const shellType: ShellType = "unix"
const envPrefix = buildEnvPrefix(NON_INTERACTIVE_ENV, shellType)
output.args.command = `${envPrefix} ${command}`

View File

@@ -1,8 +1,9 @@
import { createSystemDirective, SystemDirectiveTypes } from "../../shared/system-directive"
import { getAgentDisplayName } from "../../shared/agent-display-names"
export const HOOK_NAME = "prometheus-md-only"
export const PROMETHEUS_AGENTS = ["Prometheus (Planner)"]
export const PROMETHEUS_AGENTS = ["prometheus"]
export const ALLOWED_EXTENSIONS = [".md"]
@@ -16,7 +17,7 @@ export const PLANNING_CONSULT_WARNING = `
${createSystemDirective(SystemDirectiveTypes.PROMETHEUS_READ_ONLY)}
You are being invoked by Prometheus (Planner), a READ-ONLY planning agent.
You are being invoked by ${getAgentDisplayName("prometheus")}, a READ-ONLY planning agent.
**CRITICAL CONSTRAINTS:**
- DO NOT modify any files (no Write, Edit, or any file mutations)

View File

@@ -41,10 +41,10 @@ describe("prometheus-md-only", () => {
}
})
describe("with Prometheus agent in message storage", () => {
beforeEach(() => {
setupMessageStorage(TEST_SESSION_ID, "Prometheus (Planner)")
})
describe("with Prometheus agent in message storage", () => {
beforeEach(() => {
setupMessageStorage(TEST_SESSION_ID, "prometheus")
})
test("should block Prometheus from writing non-.md files", async () => {
// #given
@@ -345,185 +345,195 @@ describe("prometheus-md-only", () => {
setupMessageStorage(TEST_SESSION_ID, "Prometheus (Planner)")
})
test("should allow Windows-style backslash paths under .sisyphus/", async () => {
// #given
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
const input = {
tool: "Write",
sessionID: TEST_SESSION_ID,
callID: "call-1",
}
const output = {
args: { filePath: ".sisyphus\\plans\\work-plan.md" },
}
test("should allow Windows-style backslash paths under .sisyphus/", async () => {
// #given
setupMessageStorage(TEST_SESSION_ID, "prometheus")
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
const input = {
tool: "Write",
sessionID: TEST_SESSION_ID,
callID: "call-1",
}
const output = {
args: { filePath: ".sisyphus\\plans\\work-plan.md" },
}
// #when / #then
await expect(
hook["tool.execute.before"](input, output)
).resolves.toBeUndefined()
})
// #when / #then
await expect(
hook["tool.execute.before"](input, output)
).resolves.toBeUndefined()
})
test("should allow mixed separator paths under .sisyphus/", async () => {
// #given
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
const input = {
tool: "Write",
sessionID: TEST_SESSION_ID,
callID: "call-1",
}
const output = {
args: { filePath: ".sisyphus\\plans/work-plan.MD" },
}
test("should allow mixed separator paths under .sisyphus/", async () => {
// #given
setupMessageStorage(TEST_SESSION_ID, "prometheus")
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
const input = {
tool: "Write",
sessionID: TEST_SESSION_ID,
callID: "call-1",
}
const output = {
args: { filePath: ".sisyphus\\plans/work-plan.MD" },
}
// #when / #then
await expect(
hook["tool.execute.before"](input, output)
).resolves.toBeUndefined()
})
// #when / #then
await expect(
hook["tool.execute.before"](input, output)
).resolves.toBeUndefined()
})
test("should allow uppercase .MD extension", async () => {
// #given
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
const input = {
tool: "Write",
sessionID: TEST_SESSION_ID,
callID: "call-1",
}
const output = {
args: { filePath: ".sisyphus/plans/work-plan.MD" },
}
test("should allow uppercase .MD extension", async () => {
// #given
setupMessageStorage(TEST_SESSION_ID, "prometheus")
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
const input = {
tool: "Write",
sessionID: TEST_SESSION_ID,
callID: "call-1",
}
const output = {
args: { filePath: ".sisyphus/plans/work-plan.MD" },
}
// #when / #then
await expect(
hook["tool.execute.before"](input, output)
).resolves.toBeUndefined()
})
// #when / #then
await expect(
hook["tool.execute.before"](input, output)
).resolves.toBeUndefined()
})
test("should block paths outside workspace root even if containing .sisyphus", async () => {
// #given
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
const input = {
tool: "Write",
sessionID: TEST_SESSION_ID,
callID: "call-1",
}
const output = {
args: { filePath: "/other/project/.sisyphus/plans/x.md" },
}
test("should block paths outside workspace root even if containing .sisyphus", async () => {
// #given
setupMessageStorage(TEST_SESSION_ID, "prometheus")
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
const input = {
tool: "Write",
sessionID: TEST_SESSION_ID,
callID: "call-1",
}
const output = {
args: { filePath: "/other/project/.sisyphus/plans/x.md" },
}
// #when / #then
await expect(
hook["tool.execute.before"](input, output)
).rejects.toThrow("can only write/edit .md files inside .sisyphus/")
})
// #when / #then
await expect(
hook["tool.execute.before"](input, output)
).rejects.toThrow("can only write/edit .md files inside .sisyphus/")
})
test("should allow nested .sisyphus directories (ctx.directory may be parent)", async () => {
// #given - when ctx.directory is parent of actual project, path includes project name
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
const input = {
tool: "Write",
sessionID: TEST_SESSION_ID,
callID: "call-1",
}
const output = {
args: { filePath: "src/.sisyphus/plans/x.md" },
}
test("should allow nested .sisyphus directories (ctx.directory may be parent)", async () => {
// #given - when ctx.directory is parent of actual project, path includes project name
setupMessageStorage(TEST_SESSION_ID, "prometheus")
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
const input = {
tool: "Write",
sessionID: TEST_SESSION_ID,
callID: "call-1",
}
const output = {
args: { filePath: "src/.sisyphus/plans/x.md" },
}
// #when / #then - should allow because .sisyphus is in path
await expect(
hook["tool.execute.before"](input, output)
).resolves.toBeUndefined()
})
// #when / #then - should allow because .sisyphus is in path
await expect(
hook["tool.execute.before"](input, output)
).resolves.toBeUndefined()
})
test("should block path traversal attempts", async () => {
// #given
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
const input = {
tool: "Write",
sessionID: TEST_SESSION_ID,
callID: "call-1",
}
const output = {
args: { filePath: ".sisyphus/../secrets.md" },
}
test("should block path traversal attempts", async () => {
// #given
setupMessageStorage(TEST_SESSION_ID, "prometheus")
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
const input = {
tool: "Write",
sessionID: TEST_SESSION_ID,
callID: "call-1",
}
const output = {
args: { filePath: ".sisyphus/../secrets.md" },
}
// #when / #then
await expect(
hook["tool.execute.before"](input, output)
).rejects.toThrow("can only write/edit .md files inside .sisyphus/")
})
// #when / #then
await expect(
hook["tool.execute.before"](input, output)
).rejects.toThrow("can only write/edit .md files inside .sisyphus/")
})
test("should allow case-insensitive .SISYPHUS directory", async () => {
// #given
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
const input = {
tool: "Write",
sessionID: TEST_SESSION_ID,
callID: "call-1",
}
const output = {
args: { filePath: ".SISYPHUS/plans/work-plan.md" },
}
test("should allow case-insensitive .SISYPHUS directory", async () => {
// #given
setupMessageStorage(TEST_SESSION_ID, "prometheus")
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
const input = {
tool: "Write",
sessionID: TEST_SESSION_ID,
callID: "call-1",
}
const output = {
args: { filePath: ".SISYPHUS/plans/work-plan.md" },
}
// #when / #then
await expect(
hook["tool.execute.before"](input, output)
).resolves.toBeUndefined()
})
// #when / #then
await expect(
hook["tool.execute.before"](input, output)
).resolves.toBeUndefined()
})
test("should allow nested project path with .sisyphus (Windows real-world case)", async () => {
// #given - simulates when ctx.directory is parent of actual project
// User reported: xauusd-dxy-plan\.sisyphus\drafts\supabase-email-templates.md
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
const input = {
tool: "Write",
sessionID: TEST_SESSION_ID,
callID: "call-1",
}
const output = {
args: { filePath: "xauusd-dxy-plan\\.sisyphus\\drafts\\supabase-email-templates.md" },
}
test("should allow nested project path with .sisyphus (Windows real-world case)", async () => {
// #given - simulates when ctx.directory is parent of actual project
// User reported: xauusd-dxy-plan\.sisyphus\drafts\supabase-email-templates.md
setupMessageStorage(TEST_SESSION_ID, "prometheus")
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
const input = {
tool: "Write",
sessionID: TEST_SESSION_ID,
callID: "call-1",
}
const output = {
args: { filePath: "xauusd-dxy-plan\\.sisyphus\\drafts\\supabase-email-templates.md" },
}
// #when / #then
await expect(
hook["tool.execute.before"](input, output)
).resolves.toBeUndefined()
})
// #when / #then
await expect(
hook["tool.execute.before"](input, output)
).resolves.toBeUndefined()
})
test("should allow nested project path with mixed separators", async () => {
// #given
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
const input = {
tool: "Write",
sessionID: TEST_SESSION_ID,
callID: "call-1",
}
const output = {
args: { filePath: "my-project/.sisyphus\\plans/task.md" },
}
test("should allow nested project path with mixed separators", async () => {
// #given
setupMessageStorage(TEST_SESSION_ID, "prometheus")
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
const input = {
tool: "Write",
sessionID: TEST_SESSION_ID,
callID: "call-1",
}
const output = {
args: { filePath: "my-project/.sisyphus\\plans/task.md" },
}
// #when / #then
await expect(
hook["tool.execute.before"](input, output)
).resolves.toBeUndefined()
})
// #when / #then
await expect(
hook["tool.execute.before"](input, output)
).resolves.toBeUndefined()
})
test("should block nested project path without .sisyphus", async () => {
// #given
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
const input = {
tool: "Write",
sessionID: TEST_SESSION_ID,
callID: "call-1",
}
const output = {
args: { filePath: "my-project\\src\\code.ts" },
}
test("should block nested project path without .sisyphus", async () => {
// #given
setupMessageStorage(TEST_SESSION_ID, "prometheus")
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
const input = {
tool: "Write",
sessionID: TEST_SESSION_ID,
callID: "call-1",
}
const output = {
args: { filePath: "my-project\\src\\code.ts" },
}
// #when / #then
await expect(
hook["tool.execute.before"](input, output)
).rejects.toThrow("can only write/edit .md files")
})
// #when / #then
await expect(
hook["tool.execute.before"](input, output)
).rejects.toThrow("can only write/edit .md files")
})
})
})

View File

@@ -6,6 +6,7 @@ import { findNearestMessageWithFields, findFirstMessageWithAgent, MESSAGE_STORAG
import { getSessionAgent } from "../../features/claude-code-session-state"
import { log } from "../../shared/logger"
import { SYSTEM_DIRECTIVE_PREFIX } from "../../shared/system-directive"
import { getAgentDisplayName } from "../../shared/agent-display-names"
export * from "./constants"
@@ -110,20 +111,20 @@ export function createPrometheusMdOnlyHook(ctx: PluginInput) {
return
}
if (!isAllowedFile(filePath, ctx.directory)) {
log(`[${HOOK_NAME}] Blocked: Prometheus can only write to .sisyphus/*.md`, {
sessionID: input.sessionID,
tool: toolName,
filePath,
agent: agentName,
})
throw new Error(
`[${HOOK_NAME}] Prometheus (Planner) can only write/edit .md files inside .sisyphus/ directory. ` +
`Attempted to modify: ${filePath}. ` +
`Prometheus is a READ-ONLY planner. Use /start-work to execute the plan. ` +
`APOLOGIZE TO THE USER, REMIND OF YOUR PLAN WRITING PROCESSES, TELL USER WHAT YOU WILL GOING TO DO AS THE PROCESS, WRITE THE PLAN`
)
}
if (!isAllowedFile(filePath, ctx.directory)) {
log(`[${HOOK_NAME}] Blocked: Prometheus can only write to .sisyphus/*.md`, {
sessionID: input.sessionID,
tool: toolName,
filePath,
agent: agentName,
})
throw new Error(
`[${HOOK_NAME}] ${getAgentDisplayName("prometheus")} can only write/edit .md files inside .sisyphus/ directory. ` +
`Attempted to modify: ${filePath}. ` +
`${getAgentDisplayName("prometheus")} is a READ-ONLY planner. Use /start-work to execute the plan. ` +
`APOLOGIZE TO THE USER, REMIND OF YOUR PLAN WRITING PROCESSES, TELL USER WHAT YOU WILL GOING TO DO AS THE PROCESS, WRITE THE PLAN`
)
}
const normalizedPath = filePath.toLowerCase().replace(/\\/g, "/")
if (normalizedPath.includes(".sisyphus/plans/") || normalizedPath.includes(".sisyphus\\plans\\")) {

View File

@@ -0,0 +1,136 @@
import { describe, it, expect } from "bun:test";
import { createQuestionLabelTruncatorHook } from "./index";
describe("createQuestionLabelTruncatorHook", () => {
const hook = createQuestionLabelTruncatorHook();
describe("tool.execute.before", () => {
it("truncates labels exceeding 30 characters with ellipsis", async () => {
// #given
const longLabel = "This is a very long label that exceeds thirty characters";
const input = { tool: "AskUserQuestion" };
const output = {
args: {
questions: [
{
question: "Choose an option",
options: [
{ label: longLabel, description: "A long option" },
],
},
],
},
};
// #when
await hook["tool.execute.before"]?.(input as any, output as any);
// #then
const truncatedLabel = (output.args as any).questions[0].options[0].label;
expect(truncatedLabel.length).toBeLessThanOrEqual(30);
expect(truncatedLabel).toBe("This is a very long label t...");
expect(truncatedLabel.endsWith("...")).toBe(true);
});
it("preserves labels within 30 characters", async () => {
// #given
const shortLabel = "Short label";
const input = { tool: "AskUserQuestion" };
const output = {
args: {
questions: [
{
question: "Choose an option",
options: [
{ label: shortLabel, description: "A short option" },
],
},
],
},
};
// #when
await hook["tool.execute.before"]?.(input as any, output as any);
// #then
const resultLabel = (output.args as any).questions[0].options[0].label;
expect(resultLabel).toBe(shortLabel);
});
it("handles exactly 30 character labels without truncation", async () => {
// #given
const exactLabel = "Exactly thirty chars here!!!!!"; // 30 chars
expect(exactLabel.length).toBe(30);
const input = { tool: "ask_user_question" };
const output = {
args: {
questions: [
{
question: "Choose",
options: [{ label: exactLabel }],
},
],
},
};
// #when
await hook["tool.execute.before"]?.(input as any, output as any);
// #then
const resultLabel = (output.args as any).questions[0].options[0].label;
expect(resultLabel).toBe(exactLabel);
});
it("ignores non-AskUserQuestion tools", async () => {
// #given
const input = { tool: "Bash" };
const output = {
args: { command: "echo hello" },
};
const originalArgs = { ...output.args };
// #when
await hook["tool.execute.before"]?.(input as any, output as any);
// #then
expect(output.args).toEqual(originalArgs);
});
it("handles multiple questions with multiple options", async () => {
// #given
const input = { tool: "AskUserQuestion" };
const output = {
args: {
questions: [
{
question: "Q1",
options: [
{ label: "Very long label number one that needs truncation" },
{ label: "Short" },
],
},
{
question: "Q2",
options: [
{ label: "Another extremely long label for testing purposes" },
],
},
],
},
};
// #when
await hook["tool.execute.before"]?.(input as any, output as any);
// #then
const q1opts = (output.args as any).questions[0].options;
const q2opts = (output.args as any).questions[1].options;
expect(q1opts[0].label).toBe("Very long label number one ...");
expect(q1opts[0].label.length).toBeLessThanOrEqual(30);
expect(q1opts[1].label).toBe("Short");
expect(q2opts[0].label).toBe("Another extremely long labe...");
expect(q2opts[0].label.length).toBeLessThanOrEqual(30);
});
});
});

View File

@@ -0,0 +1,61 @@
const MAX_LABEL_LENGTH = 30;
interface QuestionOption {
label: string;
description?: string;
}
interface Question {
question: string;
header?: string;
options: QuestionOption[];
multiSelect?: boolean;
}
interface AskUserQuestionArgs {
questions: Question[];
}
function truncateLabel(label: string, maxLength: number = MAX_LABEL_LENGTH): string {
if (label.length <= maxLength) {
return label;
}
return label.substring(0, maxLength - 3) + "...";
}
function truncateQuestionLabels(args: AskUserQuestionArgs): AskUserQuestionArgs {
if (!args.questions || !Array.isArray(args.questions)) {
return args;
}
return {
...args,
questions: args.questions.map((question) => ({
...question,
options: question.options?.map((option) => ({
...option,
label: truncateLabel(option.label),
})) ?? [],
})),
};
}
export function createQuestionLabelTruncatorHook() {
return {
"tool.execute.before": async (
input: { tool: string },
output: { args: Record<string, unknown> }
): Promise<void> => {
const toolName = input.tool?.toLowerCase();
if (toolName === "askuserquestion" || toolName === "ask_user_question") {
const args = output.args as unknown as AskUserQuestionArgs | undefined;
if (args?.questions) {
const truncatedArgs = truncateQuestionLabels(args);
Object.assign(output.args, truncatedArgs);
}
}
},
};
}

View File

@@ -395,7 +395,7 @@ describe("start-work hook", () => {
)
// #then
expect(updateSpy).toHaveBeenCalledWith("ses-prometheus-to-sisyphus", "Atlas")
expect(updateSpy).toHaveBeenCalledWith("ses-prometheus-to-sisyphus", "atlas")
updateSpy.mockRestore()
})
})

View File

@@ -71,7 +71,7 @@ export function createStartWorkHook(ctx: PluginInput) {
sessionID: input.sessionID,
})
updateSessionAgent(input.sessionID, "Atlas")
updateSessionAgent(input.sessionID, "atlas")
const existingState = readBoulderState(ctx.directory)
const sessionId = input.sessionID

View File

@@ -103,7 +103,7 @@ describe("createThinkModeHook integration", () => {
const hook = createThinkModeHook()
const input = createMockInput(
"github-copilot",
"gemini-3-pro-preview",
"gemini-3-pro",
"think about this"
)
@@ -112,7 +112,7 @@ describe("createThinkModeHook integration", () => {
// #then should upgrade to high variant and inject google thinking config
const message = input.message as MessageWithInjectedProps
expect(input.message.model?.modelID).toBe("gemini-3-pro-preview-high")
expect(input.message.model?.modelID).toBe("gemini-3-pro-high")
expect(message.providerOptions).toBeDefined()
const googleOptions = (
message.providerOptions as Record<string, unknown>
@@ -125,7 +125,7 @@ describe("createThinkModeHook integration", () => {
const hook = createThinkModeHook()
const input = createMockInput(
"github-copilot",
"gemini-3-flash-preview",
"gemini-3-flash",
"ultrathink"
)
@@ -134,7 +134,7 @@ describe("createThinkModeHook integration", () => {
// #then should upgrade to high variant
const message = input.message as MessageWithInjectedProps
expect(input.message.model?.modelID).toBe("gemini-3-flash-preview-high")
expect(input.message.model?.modelID).toBe("gemini-3-flash-high")
expect(message.providerOptions).toBeDefined()
})
})

View File

@@ -50,7 +50,7 @@ describe("think-mode switcher", () => {
describe("Gemini models via github-copilot", () => {
it("should resolve github-copilot Gemini Pro to google config", () => {
// #given a github-copilot provider with Gemini Pro model
const config = getThinkingConfig("github-copilot", "gemini-3-pro-preview")
const config = getThinkingConfig("github-copilot", "gemini-3-pro")
// #then should return google thinking config
expect(config).not.toBeNull()
@@ -65,7 +65,7 @@ describe("think-mode switcher", () => {
// #given a github-copilot provider with Gemini Flash model
const config = getThinkingConfig(
"github-copilot",
"gemini-3-flash-preview"
"gemini-3-flash"
)
// #then should return google thinking config
@@ -159,11 +159,11 @@ describe("think-mode switcher", () => {
it("should handle Gemini preview variants", () => {
// #given Gemini preview model IDs
expect(getHighVariant("gemini-3-pro-preview")).toBe(
"gemini-3-pro-preview-high"
expect(getHighVariant("gemini-3-pro")).toBe(
"gemini-3-pro-high"
)
expect(getHighVariant("gemini-3-flash-preview")).toBe(
"gemini-3-flash-preview-high"
expect(getHighVariant("gemini-3-flash")).toBe(
"gemini-3-flash-high"
)
})

View File

@@ -89,12 +89,10 @@ const HIGH_VARIANT_MAP: Record<string, string> = {
// Claude
"claude-sonnet-4-5": "claude-sonnet-4-5-high",
"claude-opus-4-5": "claude-opus-4-5-high",
// Gemini
"gemini-3-pro": "gemini-3-pro-high",
"gemini-3-pro-low": "gemini-3-pro-high",
"gemini-3-pro-preview": "gemini-3-pro-preview-high",
"gemini-3-flash": "gemini-3-flash-high",
"gemini-3-flash-preview": "gemini-3-flash-preview-high",
// Gemini
"gemini-3-pro": "gemini-3-pro-high",
"gemini-3-pro-low": "gemini-3-pro-high",
"gemini-3-flash": "gemini-3-flash-high",
// GPT-5
"gpt-5": "gpt-5-high",
"gpt-5-mini": "gpt-5-mini-high",

View File

@@ -873,4 +873,193 @@ describe("todo-continuation-enforcer", () => {
expect(promptCalls.length).toBe(1)
expect(promptCalls[0].model).toEqual({ providerID: "openai", modelID: "gpt-5.2" })
})
// ============================================================
// COMPACTION AGENT FILTERING TESTS
// These tests verify that compaction agent messages are filtered
// when resolving agent info, preventing infinite continuation loops
// ============================================================
test("should skip compaction agent messages when resolving agent info", async () => {
// #given - session where last message is from compaction agent but previous was Sisyphus
const sessionID = "main-compaction-filter"
setMainSession(sessionID)
const mockMessagesWithCompaction = [
{ info: { id: "msg-1", role: "user", agent: "Sisyphus", model: { providerID: "anthropic", modelID: "claude-sonnet-4-5" } } },
{ info: { id: "msg-2", role: "assistant", agent: "Sisyphus", modelID: "claude-sonnet-4-5", providerID: "anthropic" } },
{ info: { id: "msg-3", role: "assistant", agent: "compaction", modelID: "claude-sonnet-4-5", providerID: "anthropic" } },
]
const mockInput = {
client: {
session: {
todo: async () => ({
data: [{ id: "1", content: "Task 1", status: "pending", priority: "high" }],
}),
messages: async () => ({ data: mockMessagesWithCompaction }),
prompt: async (opts: any) => {
promptCalls.push({
sessionID: opts.path.id,
agent: opts.body.agent,
model: opts.body.model,
text: opts.body.parts[0].text,
})
return {}
},
},
tui: { showToast: async () => ({}) },
},
directory: "/tmp/test",
} as any
const hook = createTodoContinuationEnforcer(mockInput, {
backgroundManager: createMockBackgroundManager(false),
})
// #when - session goes idle
await hook.handler({ event: { type: "session.idle", properties: { sessionID } } })
await new Promise(r => setTimeout(r, 2500))
// #then - continuation uses Sisyphus (skipped compaction agent)
expect(promptCalls.length).toBe(1)
expect(promptCalls[0].agent).toBe("Sisyphus")
})
test("should skip injection when only compaction agent messages exist", async () => {
// #given - session with only compaction agent (post-compaction, no prior agent info)
const sessionID = "main-only-compaction"
setMainSession(sessionID)
const mockMessagesOnlyCompaction = [
{ info: { id: "msg-1", role: "assistant", agent: "compaction" } },
]
const mockInput = {
client: {
session: {
todo: async () => ({
data: [{ id: "1", content: "Task 1", status: "pending", priority: "high" }],
}),
messages: async () => ({ data: mockMessagesOnlyCompaction }),
prompt: async (opts: any) => {
promptCalls.push({
sessionID: opts.path.id,
agent: opts.body.agent,
model: opts.body.model,
text: opts.body.parts[0].text,
})
return {}
},
},
tui: { showToast: async () => ({}) },
},
directory: "/tmp/test",
} as any
const hook = createTodoContinuationEnforcer(mockInput, {})
// #when - session goes idle
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
await new Promise(r => setTimeout(r, 3000))
// #then - no continuation (compaction is in default skipAgents)
expect(promptCalls).toHaveLength(0)
})
test("should skip injection when prometheus agent is after compaction", async () => {
// #given - prometheus session that was compacted
const sessionID = "main-prometheus-compacted"
setMainSession(sessionID)
const mockMessagesPrometheusCompacted = [
{ info: { id: "msg-1", role: "user", agent: "prometheus" } },
{ info: { id: "msg-2", role: "assistant", agent: "prometheus" } },
{ info: { id: "msg-3", role: "assistant", agent: "compaction" } },
]
const mockInput = {
client: {
session: {
todo: async () => ({
data: [{ id: "1", content: "Task 1", status: "pending", priority: "high" }],
}),
messages: async () => ({ data: mockMessagesPrometheusCompacted }),
prompt: async (opts: any) => {
promptCalls.push({
sessionID: opts.path.id,
agent: opts.body.agent,
model: opts.body.model,
text: opts.body.parts[0].text,
})
return {}
},
},
tui: { showToast: async () => ({}) },
},
directory: "/tmp/test",
} as any
const hook = createTodoContinuationEnforcer(mockInput, {})
// #when - session goes idle
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
await new Promise(r => setTimeout(r, 3000))
// #then - no continuation (prometheus found after filtering compaction, prometheus is in skipAgents)
expect(promptCalls).toHaveLength(0)
})
test("should inject when agent info is undefined but skipAgents is empty", async () => {
// #given - session with no agent info but skipAgents is empty
const sessionID = "main-no-agent-no-skip"
setMainSession(sessionID)
const mockMessagesNoAgent = [
{ info: { id: "msg-1", role: "user" } },
{ info: { id: "msg-2", role: "assistant" } },
]
const mockInput = {
client: {
session: {
todo: async () => ({
data: [{ id: "1", content: "Task 1", status: "pending", priority: "high" }],
}),
messages: async () => ({ data: mockMessagesNoAgent }),
prompt: async (opts: any) => {
promptCalls.push({
sessionID: opts.path.id,
agent: opts.body.agent,
model: opts.body.model,
text: opts.body.parts[0].text,
})
return {}
},
},
tui: { showToast: async () => ({}) },
},
directory: "/tmp/test",
} as any
const hook = createTodoContinuationEnforcer(mockInput, {
skipAgents: [],
})
// #when - session goes idle
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
await new Promise(r => setTimeout(r, 3000))
// #then - continuation injected (no agents to skip)
expect(promptCalls.length).toBe(1)
})
})

View File

@@ -13,7 +13,7 @@ import { createSystemDirective, SystemDirectiveTypes } from "../shared/system-di
const HOOK_NAME = "todo-continuation-enforcer"
const DEFAULT_SKIP_AGENTS = ["Prometheus (Planner)"]
const DEFAULT_SKIP_AGENTS = ["prometheus", "compaction"]
export interface TodoContinuationEnforcerOptions {
backgroundManager?: BackgroundManager
@@ -373,6 +373,7 @@ export function createTodoContinuationEnforcer(
}
let resolvedInfo: ResolvedMessageInfo | undefined
let hasCompactionMessage = false
try {
const messagesResp = await ctx.client.session.messages({
path: { id: sessionID },
@@ -388,6 +389,10 @@ export function createTodoContinuationEnforcer(
}>
for (let i = messages.length - 1; i >= 0; i--) {
const info = messages[i].info
if (info?.agent === "compaction") {
hasCompactionMessage = true
continue
}
if (info?.agent || info?.model || (info?.modelID && info?.providerID)) {
resolvedInfo = {
agent: info.agent,
@@ -401,11 +406,15 @@ export function createTodoContinuationEnforcer(
log(`[${HOOK_NAME}] Failed to fetch messages for agent check`, { sessionID, error: String(err) })
}
log(`[${HOOK_NAME}] Agent check`, { sessionID, agentName: resolvedInfo?.agent, skipAgents })
log(`[${HOOK_NAME}] Agent check`, { sessionID, agentName: resolvedInfo?.agent, skipAgents, hasCompactionMessage })
if (resolvedInfo?.agent && skipAgents.includes(resolvedInfo.agent)) {
log(`[${HOOK_NAME}] Skipped: agent in skipAgents list`, { sessionID, agent: resolvedInfo.agent })
return
}
if (hasCompactionMessage && !resolvedInfo?.agent) {
log(`[${HOOK_NAME}] Skipped: compaction occurred but no agent info resolved`, { sessionID })
return
}
startCountdown(sessionID, incompleteCount, todos.length, resolvedInfo)
return

81
src/index.test.ts Normal file
View File

@@ -0,0 +1,81 @@
import { describe, expect, it } from "bun:test"
import { includesCaseInsensitive } from "./shared"
/**
* Tests for conditional tool registration logic in index.ts
*
* The actual plugin initialization is complex to test directly,
* so we test the underlying logic that determines tool registration.
*/
describe("look_at tool conditional registration", () => {
describe("isMultimodalLookerEnabled logic", () => {
// #given multimodal-looker is in disabled_agents
// #when checking if agent is enabled
// #then should return false (disabled)
it("returns false when multimodal-looker is disabled (exact case)", () => {
const disabledAgents = ["multimodal-looker"]
const isEnabled = !includesCaseInsensitive(disabledAgents, "multimodal-looker")
expect(isEnabled).toBe(false)
})
// #given multimodal-looker is in disabled_agents with different case
// #when checking if agent is enabled
// #then should return false (case-insensitive match)
it("returns false when multimodal-looker is disabled (case-insensitive)", () => {
const disabledAgents = ["Multimodal-Looker"]
const isEnabled = !includesCaseInsensitive(disabledAgents, "multimodal-looker")
expect(isEnabled).toBe(false)
})
// #given multimodal-looker is NOT in disabled_agents
// #when checking if agent is enabled
// #then should return true (enabled)
it("returns true when multimodal-looker is not disabled", () => {
const disabledAgents = ["oracle", "librarian"]
const isEnabled = !includesCaseInsensitive(disabledAgents, "multimodal-looker")
expect(isEnabled).toBe(true)
})
// #given disabled_agents is empty
// #when checking if agent is enabled
// #then should return true (enabled by default)
it("returns true when disabled_agents is empty", () => {
const disabledAgents: string[] = []
const isEnabled = !includesCaseInsensitive(disabledAgents, "multimodal-looker")
expect(isEnabled).toBe(true)
})
// #given disabled_agents is undefined (simulated as empty array)
// #when checking if agent is enabled
// #then should return true (enabled by default)
it("returns true when disabled_agents is undefined (fallback to empty)", () => {
const disabledAgents = undefined
const isEnabled = !includesCaseInsensitive(disabledAgents ?? [], "multimodal-looker")
expect(isEnabled).toBe(true)
})
})
describe("conditional tool spread pattern", () => {
// #given lookAt is not null (agent enabled)
// #when spreading into tool object
// #then look_at should be included
it("includes look_at when lookAt is not null", () => {
const lookAt = { execute: () => {} } // mock tool
const tools = {
...(lookAt ? { look_at: lookAt } : {}),
}
expect(tools).toHaveProperty("look_at")
})
// #given lookAt is null (agent disabled)
// #when spreading into tool object
// #then look_at should NOT be included
it("excludes look_at when lookAt is null", () => {
const lookAt = null
const tools = {
...(lookAt ? { look_at: lookAt } : {}),
}
expect(tools).not.toHaveProperty("look_at")
})
})
})

View File

@@ -31,6 +31,7 @@ import {
createStartWorkHook,
createAtlasHook,
createPrometheusMdOnlyHook,
createQuestionLabelTruncatorHook,
} from "./hooks";
import {
contextCollector,
@@ -79,6 +80,7 @@ import { createModelCacheState, getModelLimit } from "./plugin-state";
import { createConfigHandler } from "./plugin-handlers";
const OhMyOpenCodePlugin: Plugin = async (ctx) => {
log("[OhMyOpenCodePlugin] ENTRY - plugin loading", { directory: ctx.directory })
// Start background tmux check immediately
startTmuxCheck();
@@ -198,17 +200,19 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
? createStartWorkHook(ctx)
: null;
const atlasHook = isHookEnabled("atlas")
? createAtlasHook(ctx)
: null;
const prometheusMdOnly = isHookEnabled("prometheus-md-only")
? createPrometheusMdOnlyHook(ctx)
: null;
const questionLabelTruncator = createQuestionLabelTruncatorHook();
const taskResumeInfo = createTaskResumeInfoHook();
const backgroundManager = new BackgroundManager(ctx);
const backgroundManager = new BackgroundManager(ctx, pluginConfig.background_task);
const atlasHook = isHookEnabled("atlas")
? createAtlasHook(ctx, { directory: ctx.directory, backgroundManager })
: null;
initTaskToastManager(ctx.client);
@@ -229,13 +233,18 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
const backgroundTools = createBackgroundTools(backgroundManager, ctx.client);
const callOmoAgent = createCallOmoAgent(ctx, backgroundManager);
const lookAt = createLookAt(ctx);
const isMultimodalLookerEnabled = !includesCaseInsensitive(
pluginConfig.disabled_agents ?? [],
"multimodal-looker"
);
const lookAt = isMultimodalLookerEnabled ? createLookAt(ctx) : null;
const delegateTask = createDelegateTask({
manager: backgroundManager,
client: ctx.client,
directory: ctx.directory,
userCategories: pluginConfig.categories,
gitMasterConfig: pluginConfig.git_master,
sisyphusJuniorModel: pluginConfig.agents?.["sisyphus-junior"]?.model,
});
const disabledSkills = new Set(pluginConfig.disabled_skills ?? []);
const systemMcpNames = getSystemMcpServerNames();
@@ -298,7 +307,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
...builtinTools,
...backgroundTools,
call_omo_agent: callOmoAgent,
look_at: lookAt,
...(lookAt ? { look_at: lookAt } : {}),
delegate_task: delegateTask,
skill: skillTool,
skill_mcp: skillMcpTool,
@@ -308,7 +317,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
"chat.message": async (input, output) => {
if (input.agent) {
updateSessionAgent(input.sessionID, input.agent);
setSessionAgent(input.sessionID, input.agent);
}
const message = (output as { message: { variant?: string } }).message
@@ -478,6 +487,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
},
"tool.execute.before": async (input, output) => {
await questionLabelTruncator["tool.execute.before"]?.(input, output);
await claudeCodeHooks["tool.execute.before"](input, output);
await nonInteractiveEnv?.["tool.execute.before"](input, output);
await commentChecker?.["tool.execute.before"](input, output);
@@ -485,6 +495,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
await directoryReadmeInjector?.["tool.execute.before"]?.(input, output);
await rulesInjector?.["tool.execute.before"]?.(input, output);
await prometheusMdOnly?.["tool.execute.before"]?.(input, output);
await atlasHook?.["tool.execute.before"]?.(input, output);
if (input.tool === "task") {
const args = output.args as Record<string, unknown>;

View File

@@ -27,7 +27,7 @@ describe("mergeConfigs", () => {
temperature: 0.3,
},
visual: {
model: "google/gemini-3-pro-preview",
model: "google/gemini-3-pro",
},
},
} as unknown as OhMyOpenCodeConfig;
@@ -41,7 +41,7 @@ describe("mergeConfigs", () => {
// #then quick should be preserved from base
expect(result.categories?.quick?.model).toBe("anthropic/claude-haiku-4-5");
// #then visual should be added from override
expect(result.categories?.visual?.model).toBe("google/gemini-3-pro-preview");
expect(result.categories?.visual?.model).toBe("google/gemini-3-pro");
});
it("should preserve base categories when override has no categories", () => {

View File

@@ -25,7 +25,7 @@ describe("Prometheus category config resolution", () => {
// #then
expect(config).toBeDefined()
expect(config?.model).toBe("google/gemini-3-pro-preview")
expect(config?.model).toBe("google/gemini-3-pro")
})
test("user categories override default categories", () => {

View File

@@ -106,13 +106,38 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
}
if (!(config.model as string | undefined)?.trim()) {
const paths = getOpenCodeConfigPaths({ binary: "opencode", version: null })
throw new Error(
'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)'
)
let fallbackModel: string | undefined
for (const agentConfig of Object.values(pluginConfig.agents ?? {})) {
const model = (agentConfig as { model?: string })?.model
if (model && typeof model === 'string' && model.trim()) {
fallbackModel = model.trim()
break
}
}
if (!fallbackModel) {
for (const categoryConfig of Object.values(pluginConfig.categories ?? {})) {
const model = (categoryConfig as { model?: string })?.model
if (model && typeof model === 'string' && model.trim()) {
fallbackModel = model.trim()
break
}
}
}
if (fallbackModel) {
config.model = fallbackModel
log(`No default model specified, using fallback from config: ${fallbackModel}`)
} else {
const paths = getOpenCodeConfigPaths({ binary: "opencode", version: null })
throw new Error(
'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)'
)
}
}
// Migrate disabled_agents from old names to new names
@@ -186,20 +211,20 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
explore?: { tools?: Record<string, unknown> };
librarian?: { tools?: Record<string, unknown> };
"multimodal-looker"?: { tools?: Record<string, unknown> };
Atlas?: { tools?: Record<string, unknown> };
Sisyphus?: { tools?: Record<string, unknown> };
atlas?: { tools?: Record<string, unknown> };
sisyphus?: { tools?: Record<string, unknown> };
};
const configAgent = config.agent as AgentConfig | undefined;
if (isSisyphusEnabled && builtinAgents.Sisyphus) {
(config as { default_agent?: string }).default_agent = "Sisyphus";
if (isSisyphusEnabled && builtinAgents.sisyphus) {
(config as { default_agent?: string }).default_agent = "sisyphus";
const agentConfig: Record<string, unknown> = {
Sisyphus: builtinAgents.Sisyphus,
sisyphus: builtinAgents.sisyphus,
};
agentConfig["Sisyphus-Junior"] = createSisyphusJuniorAgentWithOverrides(
pluginConfig.agents?.["Sisyphus-Junior"],
agentConfig["sisyphus-junior"] = createSisyphusJuniorAgentWithOverrides(
pluginConfig.agents?.["sisyphus-junior"],
config.model as string | undefined
);
@@ -228,7 +253,7 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
planConfigWithoutName as Record<string, unknown>
);
const prometheusOverride =
pluginConfig.agents?.["Prometheus (Planner)"] as
pluginConfig.agents?.["prometheus"] as
| (Record<string, unknown> & { category?: string; model?: string })
| undefined;
const defaultModel = config.model as string | undefined;
@@ -275,7 +300,7 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
: {}),
};
agentConfig["Prometheus (Planner)"] = prometheusOverride
agentConfig["prometheus"] = prometheusOverride
? { ...prometheusBase, ...prometheusOverride }
: prometheusBase;
}
@@ -310,7 +335,7 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
config.agent = {
...agentConfig,
...Object.fromEntries(
Object.entries(builtinAgents).filter(([k]) => k !== "Sisyphus")
Object.entries(builtinAgents).filter(([k]) => k !== "sisyphus")
),
...userAgents,
...projectAgents,
@@ -349,20 +374,20 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
const agent = agentResult["multimodal-looker"] as AgentWithPermission;
agent.permission = { ...agent.permission, task: "deny", look_at: "deny" };
}
if (agentResult["Atlas"]) {
const agent = agentResult["Atlas"] as AgentWithPermission;
if (agentResult["atlas"]) {
const agent = agentResult["atlas"] as AgentWithPermission;
agent.permission = { ...agent.permission, task: "deny", call_omo_agent: "deny", delegate_task: "allow" };
}
if (agentResult.Sisyphus) {
const agent = agentResult.Sisyphus as AgentWithPermission;
if (agentResult.sisyphus) {
const agent = agentResult.sisyphus as AgentWithPermission;
agent.permission = { ...agent.permission, call_omo_agent: "deny", delegate_task: "allow", question: "allow" };
}
if (agentResult["Prometheus (Planner)"]) {
const agent = agentResult["Prometheus (Planner)"] as AgentWithPermission;
if (agentResult["prometheus"]) {
const agent = agentResult["prometheus"] as AgentWithPermission;
agent.permission = { ...agent.permission, call_omo_agent: "deny", delegate_task: "allow", question: "allow" };
}
if (agentResult["Sisyphus-Junior"]) {
const agent = agentResult["Sisyphus-Junior"] as AgentWithPermission;
if (agentResult["sisyphus-junior"]) {
const agent = agentResult["sisyphus-junior"] as AgentWithPermission;
agent.permission = { ...agent.permission, delegate_task: "allow" };
}

View File

@@ -0,0 +1,224 @@
import { describe, test, expect } from "bun:test"
import { migrateAgentNames } from "./migration"
import { getAgentDisplayName } from "./agent-display-names"
import { AGENT_MODEL_REQUIREMENTS } from "./model-requirements"
describe("Agent Config Integration", () => {
describe("Old format config migration", () => {
test("migrates old format agent keys to lowercase", () => {
// #given - config with old format keys
const oldConfig = {
Sisyphus: { model: "anthropic/claude-opus-4-5" },
Atlas: { model: "anthropic/claude-opus-4-5" },
"Prometheus (Planner)": { model: "anthropic/claude-opus-4-5" },
"Metis (Plan Consultant)": { model: "anthropic/claude-sonnet-4-5" },
"Momus (Plan Reviewer)": { model: "anthropic/claude-sonnet-4-5" },
}
// #when - migration is applied
const result = migrateAgentNames(oldConfig)
// #then - keys are lowercase
expect(result.migrated).toHaveProperty("sisyphus")
expect(result.migrated).toHaveProperty("atlas")
expect(result.migrated).toHaveProperty("prometheus")
expect(result.migrated).toHaveProperty("metis")
expect(result.migrated).toHaveProperty("momus")
// #then - old keys are removed
expect(result.migrated).not.toHaveProperty("Sisyphus")
expect(result.migrated).not.toHaveProperty("Atlas")
expect(result.migrated).not.toHaveProperty("Prometheus (Planner)")
expect(result.migrated).not.toHaveProperty("Metis (Plan Consultant)")
expect(result.migrated).not.toHaveProperty("Momus (Plan Reviewer)")
// #then - values are preserved
expect(result.migrated.sisyphus).toEqual({ model: "anthropic/claude-opus-4-5" })
expect(result.migrated.atlas).toEqual({ model: "anthropic/claude-opus-4-5" })
expect(result.migrated.prometheus).toEqual({ model: "anthropic/claude-opus-4-5" })
// #then - changed flag is true
expect(result.changed).toBe(true)
})
test("preserves already lowercase keys", () => {
// #given - config with lowercase keys
const config = {
sisyphus: { model: "anthropic/claude-opus-4-5" },
oracle: { model: "openai/gpt-5.2" },
librarian: { model: "opencode/big-pickle" },
}
// #when - migration is applied
const result = migrateAgentNames(config)
// #then - keys remain unchanged
expect(result.migrated).toEqual(config)
// #then - changed flag is false
expect(result.changed).toBe(false)
})
test("handles mixed case config", () => {
// #given - config with mixed old and new format
const mixedConfig = {
Sisyphus: { model: "anthropic/claude-opus-4-5" },
oracle: { model: "openai/gpt-5.2" },
"Prometheus (Planner)": { model: "anthropic/claude-opus-4-5" },
librarian: { model: "opencode/big-pickle" },
}
// #when - migration is applied
const result = migrateAgentNames(mixedConfig)
// #then - all keys are lowercase
expect(result.migrated).toHaveProperty("sisyphus")
expect(result.migrated).toHaveProperty("oracle")
expect(result.migrated).toHaveProperty("prometheus")
expect(result.migrated).toHaveProperty("librarian")
expect(Object.keys(result.migrated).every((key) => key === key.toLowerCase())).toBe(true)
// #then - changed flag is true
expect(result.changed).toBe(true)
})
})
describe("Display name resolution", () => {
test("returns correct display names for all builtin agents", () => {
// #given - lowercase config keys
const agents = ["sisyphus", "atlas", "prometheus", "metis", "momus", "oracle", "librarian", "explore", "multimodal-looker"]
// #when - display names are requested
const displayNames = agents.map((agent) => getAgentDisplayName(agent))
// #then - display names are correct
expect(displayNames).toContain("Sisyphus (Ultraworker)")
expect(displayNames).toContain("Atlas (Plan Execution Orchestrator)")
expect(displayNames).toContain("Prometheus (Plan Builder)")
expect(displayNames).toContain("Metis (Plan Consultant)")
expect(displayNames).toContain("Momus (Plan Reviewer)")
expect(displayNames).toContain("oracle")
expect(displayNames).toContain("librarian")
expect(displayNames).toContain("explore")
expect(displayNames).toContain("multimodal-looker")
})
test("handles lowercase keys case-insensitively", () => {
// #given - various case formats of lowercase keys
const keys = ["Sisyphus", "Atlas", "SISYPHUS", "atlas", "prometheus", "PROMETHEUS"]
// #when - display names are requested
const displayNames = keys.map((key) => getAgentDisplayName(key))
// #then - correct display names are returned
expect(displayNames[0]).toBe("Sisyphus (Ultraworker)")
expect(displayNames[1]).toBe("Atlas (Plan Execution Orchestrator)")
expect(displayNames[2]).toBe("Sisyphus (Ultraworker)")
expect(displayNames[3]).toBe("Atlas (Plan Execution Orchestrator)")
expect(displayNames[4]).toBe("Prometheus (Plan Builder)")
expect(displayNames[5]).toBe("Prometheus (Plan Builder)")
})
test("returns original key for unknown agents", () => {
// #given - unknown agent key
const unknownKey = "custom-agent"
// #when - display name is requested
const displayName = getAgentDisplayName(unknownKey)
// #then - original key is returned
expect(displayName).toBe(unknownKey)
})
})
describe("Model requirements integration", () => {
test("all model requirements use lowercase keys", () => {
// #given - AGENT_MODEL_REQUIREMENTS object
const agentKeys = Object.keys(AGENT_MODEL_REQUIREMENTS)
// #when - checking key format
const allLowercase = agentKeys.every((key) => key === key.toLowerCase())
// #then - all keys are lowercase
expect(allLowercase).toBe(true)
})
test("model requirements include all builtin agents", () => {
// #given - expected builtin agents
const expectedAgents = ["sisyphus", "atlas", "prometheus", "metis", "momus", "oracle", "librarian", "explore", "multimodal-looker"]
// #when - checking AGENT_MODEL_REQUIREMENTS
const agentKeys = Object.keys(AGENT_MODEL_REQUIREMENTS)
// #then - all expected agents are present
for (const agent of expectedAgents) {
expect(agentKeys).toContain(agent)
}
})
test("no uppercase keys in model requirements", () => {
// #given - AGENT_MODEL_REQUIREMENTS object
const agentKeys = Object.keys(AGENT_MODEL_REQUIREMENTS)
// #when - checking for uppercase keys
const uppercaseKeys = agentKeys.filter((key) => key !== key.toLowerCase())
// #then - no uppercase keys exist
expect(uppercaseKeys).toEqual([])
})
})
describe("End-to-end config flow", () => {
test("old config migrates and displays correctly", () => {
// #given - old format config
const oldConfig = {
Sisyphus: { model: "anthropic/claude-opus-4-5", temperature: 0.1 },
"Prometheus (Planner)": { model: "anthropic/claude-opus-4-5" },
}
// #when - config is migrated
const result = migrateAgentNames(oldConfig)
// #then - keys are lowercase
expect(result.migrated).toHaveProperty("sisyphus")
expect(result.migrated).toHaveProperty("prometheus")
// #when - display names are retrieved
const sisyphusDisplay = getAgentDisplayName("sisyphus")
const prometheusDisplay = getAgentDisplayName("prometheus")
// #then - display names are correct
expect(sisyphusDisplay).toBe("Sisyphus (Ultraworker)")
expect(prometheusDisplay).toBe("Prometheus (Plan Builder)")
// #then - config values are preserved
expect(result.migrated.sisyphus).toEqual({ model: "anthropic/claude-opus-4-5", temperature: 0.1 })
expect(result.migrated.prometheus).toEqual({ model: "anthropic/claude-opus-4-5" })
})
test("new config works without migration", () => {
// #given - new format config (already lowercase)
const newConfig = {
sisyphus: { model: "anthropic/claude-opus-4-5" },
atlas: { model: "anthropic/claude-opus-4-5" },
}
// #when - migration is applied (should be no-op)
const result = migrateAgentNames(newConfig)
// #then - config is unchanged
expect(result.migrated).toEqual(newConfig)
// #then - changed flag is false
expect(result.changed).toBe(false)
// #when - display names are retrieved
const sisyphusDisplay = getAgentDisplayName("sisyphus")
const atlasDisplay = getAgentDisplayName("atlas")
// #then - display names are correct
expect(sisyphusDisplay).toBe("Sisyphus (Ultraworker)")
expect(atlasDisplay).toBe("Atlas (Plan Execution Orchestrator)")
})
})
})

View File

@@ -0,0 +1,158 @@
import { describe, it, expect } from "bun:test"
import { AGENT_DISPLAY_NAMES, getAgentDisplayName } from "./agent-display-names"
describe("getAgentDisplayName", () => {
it("returns display name for lowercase config key (new format)", () => {
// #given config key "sisyphus"
const configKey = "sisyphus"
// #when getAgentDisplayName called
const result = getAgentDisplayName(configKey)
// #then returns "Sisyphus (Ultraworker)"
expect(result).toBe("Sisyphus (Ultraworker)")
})
it("returns display name for uppercase config key (old format - case-insensitive)", () => {
// #given config key "Sisyphus" (old format)
const configKey = "Sisyphus"
// #when getAgentDisplayName called
const result = getAgentDisplayName(configKey)
// #then returns "Sisyphus (Ultraworker)" (case-insensitive lookup)
expect(result).toBe("Sisyphus (Ultraworker)")
})
it("returns original key for unknown agents (fallback)", () => {
// #given config key "custom-agent"
const configKey = "custom-agent"
// #when getAgentDisplayName called
const result = getAgentDisplayName(configKey)
// #then returns "custom-agent" (original key unchanged)
expect(result).toBe("custom-agent")
})
it("returns display name for atlas", () => {
// #given config key "atlas"
const configKey = "atlas"
// #when getAgentDisplayName called
const result = getAgentDisplayName(configKey)
// #then returns "Atlas (Plan Execution Orchestrator)"
expect(result).toBe("Atlas (Plan Execution Orchestrator)")
})
it("returns display name for prometheus", () => {
// #given config key "prometheus"
const configKey = "prometheus"
// #when getAgentDisplayName called
const result = getAgentDisplayName(configKey)
// #then returns "Prometheus (Plan Builder)"
expect(result).toBe("Prometheus (Plan Builder)")
})
it("returns display name for sisyphus-junior", () => {
// #given config key "sisyphus-junior"
const configKey = "sisyphus-junior"
// #when getAgentDisplayName called
const result = getAgentDisplayName(configKey)
// #then returns "Sisyphus-Junior"
expect(result).toBe("Sisyphus-Junior")
})
it("returns display name for metis", () => {
// #given config key "metis"
const configKey = "metis"
// #when getAgentDisplayName called
const result = getAgentDisplayName(configKey)
// #then returns "Metis (Plan Consultant)"
expect(result).toBe("Metis (Plan Consultant)")
})
it("returns display name for momus", () => {
// #given config key "momus"
const configKey = "momus"
// #when getAgentDisplayName called
const result = getAgentDisplayName(configKey)
// #then returns "Momus (Plan Reviewer)"
expect(result).toBe("Momus (Plan Reviewer)")
})
it("returns display name for oracle", () => {
// #given config key "oracle"
const configKey = "oracle"
// #when getAgentDisplayName called
const result = getAgentDisplayName(configKey)
// #then returns "oracle"
expect(result).toBe("oracle")
})
it("returns display name for librarian", () => {
// #given config key "librarian"
const configKey = "librarian"
// #when getAgentDisplayName called
const result = getAgentDisplayName(configKey)
// #then returns "librarian"
expect(result).toBe("librarian")
})
it("returns display name for explore", () => {
// #given config key "explore"
const configKey = "explore"
// #when getAgentDisplayName called
const result = getAgentDisplayName(configKey)
// #then returns "explore"
expect(result).toBe("explore")
})
it("returns display name for multimodal-looker", () => {
// #given config key "multimodal-looker"
const configKey = "multimodal-looker"
// #when getAgentDisplayName called
const result = getAgentDisplayName(configKey)
// #then returns "multimodal-looker"
expect(result).toBe("multimodal-looker")
})
})
describe("AGENT_DISPLAY_NAMES", () => {
it("contains all expected agent mappings", () => {
// #given expected mappings
const expectedMappings = {
sisyphus: "Sisyphus (Ultraworker)",
atlas: "Atlas (Plan Execution Orchestrator)",
prometheus: "Prometheus (Plan Builder)",
"sisyphus-junior": "Sisyphus-Junior",
metis: "Metis (Plan Consultant)",
momus: "Momus (Plan Reviewer)",
oracle: "oracle",
librarian: "librarian",
explore: "explore",
"multimodal-looker": "multimodal-looker",
}
// #when checking the constant
// #then contains all expected mappings
expect(AGENT_DISPLAY_NAMES).toEqual(expectedMappings)
})
})

View File

@@ -0,0 +1,37 @@
/**
* Agent config keys to display names mapping.
* Config keys are lowercase (e.g., "sisyphus", "atlas").
* Display names include suffixes for UI/logs (e.g., "Sisyphus (Ultraworker)").
*/
export const AGENT_DISPLAY_NAMES: Record<string, string> = {
sisyphus: "Sisyphus (Ultraworker)",
atlas: "Atlas (Plan Execution Orchestrator)",
prometheus: "Prometheus (Plan Builder)",
"sisyphus-junior": "Sisyphus-Junior",
metis: "Metis (Plan Consultant)",
momus: "Momus (Plan Reviewer)",
oracle: "oracle",
librarian: "librarian",
explore: "explore",
"multimodal-looker": "multimodal-looker",
}
/**
* Get display name for an agent config key.
* Uses case-insensitive lookup for backward compatibility.
* Returns original key if not found.
*/
export function getAgentDisplayName(configKey: string): string {
// Try exact match first
const exactMatch = AGENT_DISPLAY_NAMES[configKey]
if (exactMatch !== undefined) return exactMatch
// Fall back to case-insensitive search
const lowerKey = configKey.toLowerCase()
for (const [k, v] of Object.entries(AGENT_DISPLAY_NAMES)) {
if (k.toLowerCase() === lowerKey) return v
}
// Unknown agent: return original key
return configKey
}

View File

@@ -30,7 +30,7 @@ const AGENT_RESTRICTIONS: Record<string, Record<string, boolean>> = {
read: true,
},
"Sisyphus-Junior": {
"sisyphus-junior": {
task: false,
delegate_task: false,
},

View File

@@ -1,4 +1,5 @@
import type { OhMyOpenCodeConfig } from "../config"
import { findCaseInsensitive } from "./case-insensitive"
export function resolveAgentVariant(
config: OhMyOpenCodeConfig,
@@ -11,7 +12,7 @@ export function resolveAgentVariant(
const agentOverrides = config.agents as
| Record<string, { variant?: string; category?: string }>
| undefined
const agentOverride = agentOverrides?.[agentName]
const agentOverride = agentOverrides ? findCaseInsensitive(agentOverrides, agentName) : undefined
if (!agentOverride) {
return undefined
}

View File

@@ -12,7 +12,7 @@ import {
} from "./migration"
describe("migrateAgentNames", () => {
test("migrates legacy OmO names to Sisyphus", () => {
test("migrates legacy OmO names to lowercase", () => {
// #given: Config with legacy OmO agent names
const agents = {
omo: { model: "anthropic/claude-opus-4-5" },
@@ -23,10 +23,10 @@ describe("migrateAgentNames", () => {
// #when: Migrate agent names
const { migrated, changed } = migrateAgentNames(agents)
// #then: Legacy names should be migrated to Sisyphus/Prometheus
// #then: Legacy names should be migrated to lowercase
expect(changed).toBe(true)
expect(migrated["Sisyphus"]).toEqual({ temperature: 0.5 })
expect(migrated["Prometheus (Planner)"]).toEqual({ prompt: "custom prompt" })
expect(migrated["sisyphus"]).toEqual({ temperature: 0.5 })
expect(migrated["prometheus"]).toEqual({ prompt: "custom prompt" })
expect(migrated["omo"]).toBeUndefined()
expect(migrated["OmO"]).toBeUndefined()
expect(migrated["OmO-Plan"]).toBeUndefined()
@@ -37,7 +37,7 @@ describe("migrateAgentNames", () => {
const agents = {
oracle: { model: "openai/gpt-5.2" },
librarian: { model: "google/gemini-3-flash" },
explore: { model: "opencode/grok-code" },
explore: { model: "opencode/gpt-5-nano" },
}
// #when: Migrate agent names
@@ -47,7 +47,7 @@ describe("migrateAgentNames", () => {
expect(changed).toBe(false)
expect(migrated["oracle"]).toEqual({ model: "openai/gpt-5.2" })
expect(migrated["librarian"]).toEqual({ model: "google/gemini-3-flash" })
expect(migrated["explore"]).toEqual({ model: "opencode/grok-code" })
expect(migrated["explore"]).toEqual({ model: "opencode/gpt-5-nano" })
})
test("handles case-insensitive migration", () => {
@@ -62,9 +62,9 @@ describe("migrateAgentNames", () => {
const { migrated, changed } = migrateAgentNames(agents)
// #then: Case-insensitive lookup should migrate correctly
expect(migrated["Sisyphus"]).toEqual({ model: "test" })
expect(migrated["Prometheus (Planner)"]).toEqual({ prompt: "test" })
expect(migrated["Atlas"]).toEqual({ model: "openai/gpt-5.2" })
expect(migrated["sisyphus"]).toEqual({ model: "test" })
expect(migrated["prometheus"]).toEqual({ prompt: "test" })
expect(migrated["atlas"]).toEqual({ model: "openai/gpt-5.2" })
})
test("passes through unknown agent names unchanged", () => {
@@ -81,7 +81,7 @@ describe("migrateAgentNames", () => {
expect(migrated["custom-agent"]).toEqual({ model: "custom/model" })
})
test("migrates orchestrator-sisyphus to Atlas", () => {
test("migrates orchestrator-sisyphus to atlas", () => {
// #given: Config with legacy orchestrator-sisyphus agent name
const agents = {
"orchestrator-sisyphus": { model: "anthropic/claude-opus-4-5" },
@@ -90,13 +90,13 @@ describe("migrateAgentNames", () => {
// #when: Migrate agent names
const { migrated, changed } = migrateAgentNames(agents)
// #then: orchestrator-sisyphus should be migrated to Atlas
// #then: orchestrator-sisyphus should be migrated to atlas
expect(changed).toBe(true)
expect(migrated["Atlas"]).toEqual({ model: "anthropic/claude-opus-4-5" })
expect(migrated["atlas"]).toEqual({ model: "anthropic/claude-opus-4-5" })
expect(migrated["orchestrator-sisyphus"]).toBeUndefined()
})
test("migrates lowercase atlas to Atlas", () => {
test("migrates lowercase atlas to atlas", () => {
// #given: Config with lowercase atlas agent name
const agents = {
atlas: { model: "anthropic/claude-opus-4-5" },
@@ -105,10 +105,96 @@ describe("migrateAgentNames", () => {
// #when: Migrate agent names
const { migrated, changed } = migrateAgentNames(agents)
// #then: lowercase atlas should be migrated to Atlas
// #then: lowercase atlas should remain atlas (no change needed)
expect(changed).toBe(false)
expect(migrated["atlas"]).toEqual({ model: "anthropic/claude-opus-4-5" })
})
test("migrates Sisyphus variants to lowercase", () => {
// #given agents config with "Sisyphus" key
// #when migrateAgentNames called
// #then key becomes "sisyphus"
const agents = { "Sisyphus": { model: "test" } }
const { migrated, changed } = migrateAgentNames(agents)
expect(changed).toBe(true)
expect(migrated["Atlas"]).toEqual({ model: "anthropic/claude-opus-4-5" })
expect(migrated["atlas"]).toBeUndefined()
expect(migrated["sisyphus"]).toEqual({ model: "test" })
expect(migrated["Sisyphus"]).toBeUndefined()
})
test("migrates omo key to sisyphus", () => {
// #given agents config with "omo" key
// #when migrateAgentNames called
// #then key becomes "sisyphus"
const agents = { "omo": { model: "test" } }
const { migrated, changed } = migrateAgentNames(agents)
expect(changed).toBe(true)
expect(migrated["sisyphus"]).toEqual({ model: "test" })
expect(migrated["omo"]).toBeUndefined()
})
test("migrates Atlas variants to lowercase", () => {
// #given agents config with "Atlas" key
// #when migrateAgentNames called
// #then key becomes "atlas"
const agents = { "Atlas": { model: "test" } }
const { migrated, changed } = migrateAgentNames(agents)
expect(changed).toBe(true)
expect(migrated["atlas"]).toEqual({ model: "test" })
expect(migrated["Atlas"]).toBeUndefined()
})
test("migrates Prometheus variants to lowercase", () => {
// #given agents config with "Prometheus (Planner)" key
// #when migrateAgentNames called
// #then key becomes "prometheus"
const agents = { "Prometheus (Planner)": { model: "test" } }
const { migrated, changed } = migrateAgentNames(agents)
expect(changed).toBe(true)
expect(migrated["prometheus"]).toEqual({ model: "test" })
expect(migrated["Prometheus (Planner)"]).toBeUndefined()
})
test("migrates Metis variants to lowercase", () => {
// #given agents config with "Metis (Plan Consultant)" key
// #when migrateAgentNames called
// #then key becomes "metis"
const agents = { "Metis (Plan Consultant)": { model: "test" } }
const { migrated, changed } = migrateAgentNames(agents)
expect(changed).toBe(true)
expect(migrated["metis"]).toEqual({ model: "test" })
expect(migrated["Metis (Plan Consultant)"]).toBeUndefined()
})
test("migrates Momus variants to lowercase", () => {
// #given agents config with "Momus (Plan Reviewer)" key
// #when migrateAgentNames called
// #then key becomes "momus"
const agents = { "Momus (Plan Reviewer)": { model: "test" } }
const { migrated, changed } = migrateAgentNames(agents)
expect(changed).toBe(true)
expect(migrated["momus"]).toEqual({ model: "test" })
expect(migrated["Momus (Plan Reviewer)"]).toBeUndefined()
})
test("migrates Sisyphus-Junior to lowercase", () => {
// #given agents config with "Sisyphus-Junior" key
// #when migrateAgentNames called
// #then key becomes "sisyphus-junior"
const agents = { "Sisyphus-Junior": { model: "test" } }
const { migrated, changed } = migrateAgentNames(agents)
expect(changed).toBe(true)
expect(migrated["sisyphus-junior"]).toEqual({ model: "test" })
expect(migrated["Sisyphus-Junior"]).toBeUndefined()
})
test("preserves lowercase passthrough", () => {
// #given agents config with "oracle" key
// #when migrateAgentNames called
// #then key remains "oracle" (no change needed)
const agents = { "oracle": { model: "test" } }
const { migrated, changed } = migrateAgentNames(agents)
expect(changed).toBe(false)
expect(migrated["oracle"]).toEqual({ model: "test" })
})
})
@@ -249,7 +335,7 @@ describe("migrateConfigFile", () => {
// #then: Agent names should be migrated
expect(needsWrite).toBe(true)
const agents = rawConfig.agents as Record<string, unknown>
expect(agents["Sisyphus"]).toBeDefined()
expect(agents["sisyphus"]).toBeDefined()
})
test("migrates legacy hook names in disabled_hooks", () => {
@@ -272,7 +358,7 @@ describe("migrateConfigFile", () => {
const rawConfig: Record<string, unknown> = {
sisyphus_agent: { disabled: false },
agents: {
Sisyphus: { model: "test" },
sisyphus: { model: "test" },
},
disabled_hooks: ["anthropic-context-window-limit-recovery"],
}
@@ -303,8 +389,8 @@ describe("migrateConfigFile", () => {
expect(rawConfig.sisyphus_agent).toEqual({ disabled: false })
expect(rawConfig.omo_agent).toBeUndefined()
const agents = rawConfig.agents as Record<string, unknown>
expect(agents["Sisyphus"]).toBeDefined()
expect(agents["Prometheus (Planner)"]).toBeDefined()
expect(agents["sisyphus"]).toBeDefined()
expect(agents["prometheus"]).toBeDefined()
expect(rawConfig.disabled_hooks).toContain("anthropic-context-window-limit-recovery")
})
})
@@ -312,13 +398,13 @@ describe("migrateConfigFile", () => {
describe("migration maps", () => {
test("AGENT_NAME_MAP contains all expected legacy mappings", () => {
// #given/#when: Check AGENT_NAME_MAP
// #then: Should contain all legacy → current mappings
expect(AGENT_NAME_MAP["omo"]).toBe("Sisyphus")
expect(AGENT_NAME_MAP["OmO"]).toBe("Sisyphus")
expect(AGENT_NAME_MAP["OmO-Plan"]).toBe("Prometheus (Planner)")
expect(AGENT_NAME_MAP["omo-plan"]).toBe("Prometheus (Planner)")
expect(AGENT_NAME_MAP["Planner-Sisyphus"]).toBe("Prometheus (Planner)")
expect(AGENT_NAME_MAP["plan-consultant"]).toBe("Metis (Plan Consultant)")
// #then: Should contain all legacy → lowercase mappings
expect(AGENT_NAME_MAP["omo"]).toBe("sisyphus")
expect(AGENT_NAME_MAP["OmO"]).toBe("sisyphus")
expect(AGENT_NAME_MAP["OmO-Plan"]).toBe("prometheus")
expect(AGENT_NAME_MAP["omo-plan"]).toBe("prometheus")
expect(AGENT_NAME_MAP["Planner-Sisyphus"]).toBe("prometheus")
expect(AGENT_NAME_MAP["plan-consultant"]).toBe("metis")
})
test("HOOK_NAME_MAP contains anthropic-auto-compact migration", () => {
@@ -332,7 +418,7 @@ describe("migrateAgentConfigToCategory", () => {
test("migrates model to category when mapping exists", () => {
// #given: Config with a model that has a category mapping
const config = {
model: "google/gemini-3-pro-preview",
model: "google/gemini-3-pro",
temperature: 0.5,
top_p: 0.9,
}
@@ -381,14 +467,15 @@ describe("migrateAgentConfigToCategory", () => {
test("handles all mapped models correctly", () => {
// #given: Configs for each mapped model
const configs = [
{ model: "google/gemini-3-pro-preview" },
{ model: "google/gemini-3-pro" },
{ model: "google/gemini-3-flash" },
{ model: "openai/gpt-5.2" },
{ model: "anthropic/claude-haiku-4-5" },
{ model: "anthropic/claude-opus-4-5" },
{ model: "anthropic/claude-sonnet-4-5" },
]
const expectedCategories = ["visual-engineering", "ultrabrain", "quick", "unspecified-high", "unspecified-low"]
const expectedCategories = ["visual-engineering", "writing", "ultrabrain", "quick", "unspecified-high", "unspecified-low"]
// #when: Migrate each config
const results = configs.map(migrateAgentConfigToCategory)
@@ -450,7 +537,7 @@ describe("shouldDeleteAgentConfig", () => {
// #given: Config with fields matching category defaults
const config = {
category: "visual-engineering",
model: "google/gemini-3-pro-preview",
model: "google/gemini-3-pro",
}
// #when: Check if config should be deleted
@@ -578,7 +665,7 @@ describe("migrateConfigFile with backup", () => {
agents: {
"multimodal-looker": { model: "anthropic/claude-haiku-4-5" },
oracle: { model: "openai/gpt-5.2" },
"my-custom-agent": { model: "google/gemini-3-pro-preview" },
"my-custom-agent": { model: "google/gemini-3-pro" },
},
}
@@ -594,7 +681,7 @@ describe("migrateConfigFile with backup", () => {
const agents = rawConfig.agents as Record<string, Record<string, unknown>>
expect(agents["multimodal-looker"].model).toBe("anthropic/claude-haiku-4-5")
expect(agents.oracle.model).toBe("openai/gpt-5.2")
expect(agents["my-custom-agent"].model).toBe("google/gemini-3-pro-preview")
expect(agents["my-custom-agent"].model).toBe("google/gemini-3-pro")
})
test("preserves category setting when explicitly set", () => {
@@ -622,29 +709,41 @@ describe("migrateConfigFile with backup", () => {
})
test("does not write when no migration needed", () => {
// #given: Config with no migrations needed
const testConfigPath = "/tmp/test-config-no-migration.json"
const rawConfig: Record<string, unknown> = {
agents: {
Sisyphus: { model: "test" },
},
}
// #given: Config with no migrations needed
const testConfigPath = "/tmp/test-config-no-migration.json"
const rawConfig: Record<string, unknown> = {
agents: {
sisyphus: { model: "test" },
},
}
fs.writeFileSync(testConfigPath, globalThis.JSON.stringify({ agents: { Sisyphus: { model: "test" } } }, null, 2))
cleanupPaths.push(testConfigPath)
fs.writeFileSync(testConfigPath, globalThis.JSON.stringify({ agents: { sisyphus: { model: "test" } } }, null, 2))
cleanupPaths.push(testConfigPath)
// #when: Migrate config file
const needsWrite = migrateConfigFile(testConfigPath, rawConfig)
// Clean up any existing backup files from previous test runs
const dir = path.dirname(testConfigPath)
const basename = path.basename(testConfigPath)
const existingFiles = fs.readdirSync(dir)
const existingBackups = existingFiles.filter((f) => f.startsWith(`${basename}.bak.`))
existingBackups.forEach((f) => {
const backupPath = path.join(dir, f)
try {
fs.unlinkSync(backupPath)
cleanupPaths.splice(cleanupPaths.indexOf(backupPath), 1)
} catch {
}
})
// #then: Should not write or create backup
expect(needsWrite).toBe(false)
// #when: Migrate config file
const needsWrite = migrateConfigFile(testConfigPath, rawConfig)
const dir = path.dirname(testConfigPath)
const basename = path.basename(testConfigPath)
const files = fs.readdirSync(dir)
const backupFiles = files.filter((f) => f.startsWith(`${basename}.bak.`))
expect(backupFiles.length).toBe(0)
})
// #then: Should not write or create backup
expect(needsWrite).toBe(false)
const files = fs.readdirSync(dir)
const backupFiles = files.filter((f) => f.startsWith(`${basename}.bak.`))
expect(backupFiles.length).toBe(0)
})
})

View File

@@ -3,35 +3,56 @@ import { log } from "./logger"
// Migration map: old keys → new keys (for backward compatibility)
export const AGENT_NAME_MAP: Record<string, string> = {
omo: "Sisyphus",
"OmO": "Sisyphus",
sisyphus: "Sisyphus",
"OmO-Plan": "Prometheus (Planner)",
"omo-plan": "Prometheus (Planner)",
"Planner-Sisyphus": "Prometheus (Planner)",
"planner-sisyphus": "Prometheus (Planner)",
prometheus: "Prometheus (Planner)",
"plan-consultant": "Metis (Plan Consultant)",
metis: "Metis (Plan Consultant)",
// Sisyphus variants → "sisyphus"
omo: "sisyphus",
OmO: "sisyphus",
Sisyphus: "sisyphus",
sisyphus: "sisyphus",
// Prometheus variants → "prometheus"
"OmO-Plan": "prometheus",
"omo-plan": "prometheus",
"Planner-Sisyphus": "prometheus",
"planner-sisyphus": "prometheus",
"Prometheus (Planner)": "prometheus",
prometheus: "prometheus",
// Atlas variants → "atlas"
"orchestrator-sisyphus": "atlas",
Atlas: "atlas",
atlas: "atlas",
// Metis variants → "metis"
"plan-consultant": "metis",
"Metis (Plan Consultant)": "metis",
metis: "metis",
// Momus variants → "momus"
"Momus (Plan Reviewer)": "momus",
momus: "momus",
// Sisyphus-Junior → "sisyphus-junior"
"Sisyphus-Junior": "sisyphus-junior",
"sisyphus-junior": "sisyphus-junior",
// Already lowercase - passthrough
build: "build",
oracle: "oracle",
librarian: "librarian",
explore: "explore",
"multimodal-looker": "multimodal-looker",
"orchestrator-sisyphus": "Atlas",
atlas: "Atlas",
}
export const BUILTIN_AGENT_NAMES = new Set([
"Sisyphus",
"sisyphus", // was "Sisyphus"
"oracle",
"librarian",
"explore",
"multimodal-looker",
"Metis (Plan Consultant)",
"Momus (Plan Reviewer)",
"Prometheus (Planner)",
"Atlas",
"metis", // was "Metis (Plan Consultant)"
"momus", // was "Momus (Plan Reviewer)"
"prometheus", // was "Prometheus (Planner)"
"atlas", // was "Atlas"
"build",
])
@@ -61,7 +82,8 @@ export const HOOK_NAME_MAP: Record<string, string | null> = {
* This map will be removed in a future major version once migration period ends.
*/
export const MODEL_TO_CATEGORY_MAP: Record<string, string> = {
"google/gemini-3-pro-preview": "visual-engineering",
"google/gemini-3-pro": "visual-engineering",
"google/gemini-3-flash": "writing",
"openai/gpt-5.2": "ultrabrain",
"anthropic/claude-haiku-4-5": "quick",
"anthropic/claude-opus-4-5": "unspecified-high",

View File

@@ -1,26 +1,43 @@
import { describe, it, expect, beforeEach } from "bun:test"
import { describe, it, expect, beforeEach, afterEach } from "bun:test"
import { mkdtempSync, writeFileSync, rmSync } from "fs"
import { tmpdir } from "os"
import { join } from "path"
import { fetchAvailableModels, fuzzyMatchModel, __resetModelCache } from "./model-availability"
describe("fetchAvailableModels", () => {
let mockClient: any
let tempDir: string
let originalXdgCache: string | undefined
beforeEach(() => {
__resetModelCache()
tempDir = mkdtempSync(join(tmpdir(), "opencode-test-"))
originalXdgCache = process.env.XDG_CACHE_HOME
process.env.XDG_CACHE_HOME = tempDir
})
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,
},
afterEach(() => {
if (originalXdgCache !== undefined) {
process.env.XDG_CACHE_HOME = originalXdgCache
} else {
delete process.env.XDG_CACHE_HOME
}
rmSync(tempDir, { recursive: true, force: true })
})
const result = await fetchAvailableModels(mockClient)
function writeModelsCache(data: Record<string, any>) {
const cacheDir = join(tempDir, "opencode")
require("fs").mkdirSync(cacheDir, { recursive: true })
writeFileSync(join(cacheDir, "models.json"), JSON.stringify(data))
}
it("#given cache file with models #when fetchAvailableModels called #then returns Set of model IDs", async () => {
writeModelsCache({
openai: { id: "openai", models: { "gpt-5.2": { id: "gpt-5.2" } } },
anthropic: { id: "anthropic", models: { "claude-opus-4-5": { id: "claude-opus-4-5" } } },
google: { id: "google", models: { "gemini-3-pro": { id: "gemini-3-pro" } } },
})
const result = await fetchAvailableModels()
expect(result).toBeInstanceOf(Set)
expect(result.size).toBe(3)
@@ -29,77 +46,50 @@ describe("fetchAvailableModels", () => {
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)
it("#given cache file not found #when fetchAvailableModels called #then returns empty Set", async () => {
const result = await fetchAvailableModels()
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
},
},
}
it("#given cache read twice #when second call made #then uses cached result", async () => {
writeModelsCache({
openai: { id: "openai", models: { "gpt-5.2": { id: "gpt-5.2" } } },
anthropic: { id: "anthropic", models: { "claude-opus-4-5": { id: "claude-opus-4-5" } } },
})
const result1 = await fetchAvailableModels(mockClient)
const result2 = await fetchAvailableModels(mockClient)
const result1 = await fetchAvailableModels()
const result2 = await fetchAvailableModels()
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 () => [],
},
}
it("#given empty providers in cache #when fetchAvailableModels called #then returns empty Set", async () => {
writeModelsCache({})
const result = await fetchAvailableModels(mockClient)
const result = await fetchAvailableModels()
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,
},
}
it("#given cache file with various providers #when fetchAvailableModels called #then extracts all IDs correctly", async () => {
writeModelsCache({
openai: { id: "openai", models: { "gpt-5.2-codex": { id: "gpt-5.2-codex" } } },
anthropic: { id: "anthropic", models: { "claude-sonnet-4-5": { id: "claude-sonnet-4-5" } } },
google: { id: "google", models: { "gemini-3-flash": { id: "gemini-3-flash" } } },
opencode: { id: "opencode", models: { "gpt-5-nano": { id: "gpt-5-nano" } } },
})
const result = await fetchAvailableModels(mockClient)
const result = await fetchAvailableModels()
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)
expect(result.has("opencode/gpt-5-nano")).toBe(true)
})
})

View File

@@ -3,6 +3,9 @@
* Supports substring matching with provider filtering and priority-based selection
*/
import { existsSync, readFileSync } from "fs"
import { homedir } from "os"
import { join } from "path"
import { log } from "./logger"
/**
@@ -90,36 +93,62 @@ export function fuzzyMatchModel(
let cachedModels: Set<string> | null = null
export async function fetchAvailableModels(client: any): Promise<Set<string>> {
function getOpenCodeCacheDir(): string {
const xdgCache = process.env.XDG_CACHE_HOME
if (xdgCache) return join(xdgCache, "opencode")
return join(homedir(), ".cache", "opencode")
}
export async function fetchAvailableModels(_client?: any): Promise<Set<string>> {
log("[fetchAvailableModels] CALLED")
if (cachedModels !== null) {
log("[fetchAvailableModels] returning cached models", { count: cachedModels.size, models: Array.from(cachedModels).slice(0, 20) })
return cachedModels
}
const modelSet = new Set<string>()
const cacheFile = join(getOpenCodeCacheDir(), "models.json")
log("[fetchAvailableModels] reading cache file", { cacheFile })
if (!existsSync(cacheFile)) {
log("[fetchAvailableModels] cache file not found, returning empty set")
return modelSet
}
try {
const models = await client.model.list()
const modelSet = new Set<string>()
const content = readFileSync(cacheFile, "utf-8")
const data = JSON.parse(content) as Record<string, { id?: string; models?: Record<string, { id?: 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 })
const providerIds = Object.keys(data)
log("[fetchAvailableModels] providers found", { count: providerIds.length, providers: providerIds.slice(0, 10) })
if (Array.isArray(models)) {
for (const model of models) {
if (model.id && typeof model.id === "string") {
modelSet.add(model.id)
}
for (const providerId of providerIds) {
const provider = data[providerId]
const models = provider?.models
if (!models || typeof models !== "object") continue
for (const modelKey of Object.keys(models)) {
modelSet.add(`${providerId}/${modelKey}`)
}
}
log("[fetchAvailableModels] parsed models", { count: modelSet.size, models: Array.from(modelSet) })
log("[fetchAvailableModels] parsed models", { count: modelSet.size, models: Array.from(modelSet).slice(0, 20) })
cachedModels = modelSet
return modelSet
} catch (err) {
log("[fetchAvailableModels] error", { error: String(err) })
return new Set<string>()
return modelSet
}
}
export function __resetModelCache(): void {
cachedModels = null
}
export function isModelCacheAvailable(): boolean {
const cacheFile = join(getOpenCodeCacheDir(), "models.json")
return existsSync(cacheFile)
}

View File

@@ -23,9 +23,9 @@ describe("AGENT_MODEL_REQUIREMENTS", () => {
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"]
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
@@ -54,39 +54,39 @@ describe("AGENT_MODEL_REQUIREMENTS", () => {
expect(primary.model).toBe("glm-4.7")
})
test("explore has valid fallbackChain with gemini-3-flash-preview as primary", () => {
test("explore has valid fallbackChain with claude-haiku-4-5 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
// #then - fallbackChain exists with claude-haiku-4-5 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")
expect(primary.providers).toContain("anthropic")
expect(primary.model).toBe("claude-haiku-4-5")
})
test("multimodal-looker has valid fallbackChain with gemini-3-flash-preview as primary", () => {
test("multimodal-looker has valid fallbackChain with gemini-3-flash 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
// #then - fallbackChain exists with gemini-3-flash 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")
expect(primary.model).toBe("gemini-3-flash")
})
test("Prometheus (Planner) has valid fallbackChain with claude-opus-4-5 as primary", () => {
// #given - Prometheus agent requirement
const prometheus = AGENT_MODEL_REQUIREMENTS["Prometheus (Planner)"]
test("prometheus has valid fallbackChain with claude-opus-4-5 as primary", () => {
// #given - prometheus agent requirement
const prometheus = AGENT_MODEL_REQUIREMENTS["prometheus"]
// #when - accessing Prometheus requirement
// #then - fallbackChain exists with claude-opus-4-5 as first entry
@@ -100,9 +100,9 @@ describe("AGENT_MODEL_REQUIREMENTS", () => {
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)"]
test("metis has valid fallbackChain with claude-opus-4-5 as primary", () => {
// #given - metis agent requirement
const metis = AGENT_MODEL_REQUIREMENTS["metis"]
// #when - accessing Metis requirement
// #then - fallbackChain exists with claude-opus-4-5 as first entry
@@ -116,9 +116,9 @@ describe("AGENT_MODEL_REQUIREMENTS", () => {
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)"]
test("momus has valid fallbackChain with gpt-5.2 as primary", () => {
// #given - momus agent requirement
const momus = AGENT_MODEL_REQUIREMENTS["momus"]
// #when - accessing Momus requirement
// #then - fallbackChain exists with gpt-5.2 as first entry, variant medium
@@ -132,9 +132,9 @@ describe("AGENT_MODEL_REQUIREMENTS", () => {
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"]
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
@@ -150,15 +150,15 @@ describe("AGENT_MODEL_REQUIREMENTS", () => {
test("all 9 builtin agents have valid fallbackChain arrays", () => {
// #given - list of 9 agent names
const expectedAgents = [
"Sisyphus",
"sisyphus",
"oracle",
"librarian",
"explore",
"multimodal-looker",
"Prometheus (Planner)",
"Metis (Plan Consultant)",
"Momus (Plan Reviewer)",
"Atlas",
"prometheus",
"metis",
"momus",
"atlas",
]
// #when - checking AGENT_MODEL_REQUIREMENTS
@@ -199,19 +199,19 @@ describe("CATEGORY_MODEL_REQUIREMENTS", () => {
expect(primary.providers[0]).toBe("openai")
})
test("visual-engineering has valid fallbackChain with gemini-3-pro-preview as primary", () => {
test("visual-engineering has valid fallbackChain with gemini-3-pro 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
// #then - fallbackChain exists with gemini-3-pro 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")
expect(primary.model).toBe("gemini-3-pro")
})
test("quick has valid fallbackChain with claude-haiku-4-5 as primary", () => {
@@ -260,34 +260,34 @@ describe("CATEGORY_MODEL_REQUIREMENTS", () => {
expect(primary.providers[0]).toBe("anthropic")
})
test("artistry has valid fallbackChain with gemini-3-pro-preview as primary", () => {
test("artistry has valid fallbackChain with gemini-3-pro 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
// #then - fallbackChain exists with gemini-3-pro 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.model).toBe("gemini-3-pro")
expect(primary.variant).toBe("max")
expect(primary.providers[0]).toBe("google")
})
test("writing has valid fallbackChain with gemini-3-flash-preview as primary", () => {
test("writing has valid fallbackChain with gemini-3-flash 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
// #then - fallbackChain exists with gemini-3-flash 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.model).toBe("gemini-3-flash")
expect(primary.providers[0]).toBe("google")
})
@@ -344,7 +344,7 @@ describe("FallbackEntry type", () => {
// #given - a FallbackEntry without variant
const entry: FallbackEntry = {
providers: ["opencode", "anthropic"],
model: "glm-4.7-free",
model: "big-pickle",
}
// #when - accessing variant
@@ -374,7 +374,7 @@ describe("ModelRequirement type", () => {
test("ModelRequirement variant is optional", () => {
// #given - a ModelRequirement without top-level variant
const requirement: ModelRequirement = {
fallbackChain: [{ providers: ["opencode"], model: "glm-4.7-free" }],
fallbackChain: [{ providers: ["opencode"], model: "big-pickle" }],
}
// #when - accessing variant

View File

@@ -10,67 +10,69 @@ export type ModelRequirement = {
}
export const AGENT_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
Sisyphus: {
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" },
{ providers: ["zai-coding-plan"], model: "glm-4.7" },
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2-codex", variant: "medium" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro" },
],
},
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" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro" },
],
},
librarian: {
fallbackChain: [
{ providers: ["zai-coding-plan"], model: "glm-4.7" },
{ providers: ["opencode"], model: "big-pickle" },
{ 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" },
{ providers: ["anthropic", "opencode"], model: "claude-haiku-4-5" },
{ providers: ["opencode"], model: "gpt-5-nano" },
],
},
"multimodal-looker": {
fallbackChain: [
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-flash-preview" },
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-haiku-4-5" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-flash" },
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2" },
{ providers: ["zai-coding-plan"], model: "glm-4.6v" },
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-haiku-4-5" },
{ providers: ["opencode"], model: "gpt-5-nano" },
],
},
"Prometheus (Planner)": {
prometheus: {
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" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro" },
],
},
"Metis (Plan Consultant)": {
metis: {
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" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro", variant: "max" },
],
},
"Momus (Plan Reviewer)": {
momus: {
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" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro", variant: "max" },
],
},
Atlas: {
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" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro" },
],
},
}
@@ -78,7 +80,7 @@ export const AGENT_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
export const CATEGORY_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
"visual-engineering": {
fallbackChain: [
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro-preview" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro" },
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-5", variant: "max" },
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2", variant: "high" },
],
@@ -87,12 +89,12 @@ export const CATEGORY_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
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" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro" },
],
},
artistry: {
fallbackChain: [
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro-preview", variant: "max" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro", variant: "max" },
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-opus-4-5", variant: "max" },
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2" },
],
@@ -100,28 +102,29 @@ export const CATEGORY_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
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" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-flash" },
{ providers: ["opencode"], model: "gpt-5-nano" },
],
},
"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" },
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2-codex", variant: "medium" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-flash" },
],
},
"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" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro" },
],
},
writing: {
fallbackChain: [
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-flash-preview" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-flash" },
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-sonnet-4-5" },
{ providers: ["zai-coding-plan"], model: "glm-4.7" },
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2" },
],
},

View File

@@ -236,9 +236,9 @@ describe("resolveModelWithFallback", () => {
// #given
const input: ExtendedModelResolutionInput = {
fallbackChain: [
{ providers: ["anthropic", "opencode", "github-copilot"], model: "grok-code" },
{ providers: ["anthropic", "opencode", "github-copilot"], model: "gpt-5-nano" },
],
availableModels: new Set(["opencode/grok-code", "github-copilot/grok-code-preview"]),
availableModels: new Set(["opencode/gpt-5-nano", "github-copilot/gpt-5-nano-preview"]),
systemDefaultModel: "google/gemini-3-pro",
}
@@ -246,7 +246,7 @@ describe("resolveModelWithFallback", () => {
const result = resolveModelWithFallback(input)
// #then
expect(result.model).toBe("opencode/grok-code")
expect(result.model).toBe("opencode/gpt-5-nano")
expect(result.source).toBe("provider-fallback")
})
@@ -316,8 +316,8 @@ describe("resolveModelWithFallback", () => {
})
})
describe("Step 3: First fallback entry (no availability match)", () => {
test("returns first fallbackChain entry when no availability match found", () => {
describe("Step 3: System default fallback (no availability match)", () => {
test("returns system default when no availability match found in fallback chain", () => {
// #given
const input: ExtendedModelResolutionInput = {
fallbackChain: [
@@ -331,13 +331,13 @@ describe("resolveModelWithFallback", () => {
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 })
expect(result.model).toBe("google/gemini-3-pro")
expect(result.source).toBe("system-default")
expect(logSpy).toHaveBeenCalledWith("No available model found in fallback chain, falling through to system default")
})
test("returns first fallbackChain entry when availableModels is empty", () => {
// #given
test("uses first fallback entry when availableModels is empty (no cache scenario)", () => {
// #given - empty availableModels simulates CI environment without model cache
const input: ExtendedModelResolutionInput = {
fallbackChain: [
{ providers: ["anthropic"], model: "claude-opus-4-5" },
@@ -349,7 +349,7 @@ describe("resolveModelWithFallback", () => {
// #when
const result = resolveModelWithFallback(input)
// #then
// #then - should use first fallback entry, not system default
expect(result.model).toBe("anthropic/claude-opus-4-5")
expect(result.source).toBe("provider-fallback")
})
@@ -392,20 +392,20 @@ describe("resolveModelWithFallback", () => {
test("tries all providers in first entry before moving to second entry", () => {
// #given
const availableModels = new Set(["google/gemini-3-pro-preview"])
const availableModels = new Set(["google/gemini-3-pro"])
// #when
const result = resolveModelWithFallback({
fallbackChain: [
{ providers: ["openai", "anthropic"], model: "gpt-5.2" },
{ providers: ["google"], model: "gemini-3-pro-preview" },
{ providers: ["google"], model: "gemini-3-pro" },
],
availableModels,
systemDefaultModel: "system/default",
})
// #then
expect(result.model).toBe("google/gemini-3-pro-preview")
expect(result.model).toBe("google/gemini-3-pro")
expect(result.source).toBe("provider-fallback")
})
@@ -431,7 +431,7 @@ describe("resolveModelWithFallback", () => {
expect(result.source).toBe("provider-fallback")
})
test("falls through to first fallbackChain entry when none match availability", () => {
test("falls through to system default when none match availability", () => {
// #given
const availableModels = new Set(["other/model"])
@@ -447,8 +447,8 @@ describe("resolveModelWithFallback", () => {
})
// #then
expect(result.model).toBe("openai/gpt-5.2")
expect(result.source).toBe("provider-fallback")
expect(result.model).toBe("system/default")
expect(result.source).toBe("system-default")
})
})

View File

@@ -53,6 +53,15 @@ export function resolveModelWithFallback(
// Step 2: Provider fallback chain (with availability check)
if (fallbackChain && fallbackChain.length > 0) {
// If availableModels is empty (no cache), use first fallback entry directly without availability check
if (availableModels.size === 0) {
const firstEntry = fallbackChain[0]
const firstProvider = firstEntry.providers[0]
const model = `${firstProvider}/${firstEntry.model}`
log("Model resolved via fallback chain (no cache, using first entry)", { provider: firstProvider, model: firstEntry.model, variant: firstEntry.variant })
return { model, source: "provider-fallback", variant: firstEntry.variant }
}
for (const entry of fallbackChain) {
for (const provider of entry.providers) {
const fullModel = `${provider}/${entry.model}`
@@ -63,15 +72,8 @@ export function resolveModelWithFallback(
}
}
}
// 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 }
}
// No match found in fallback chain - fall through to system default
log("No available model found in fallback chain, falling through to system default")
}
// Step 4: System default

View File

@@ -15,7 +15,7 @@ tools/
│ └── 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)
├── delegate-task/ # Category-based routing (1039 lines)
├── session-manager/ # 4 tools: list, read, search, info
├── grep/ # Custom grep with timeout
├── glob/ # 60s timeout, 100 file limit

View File

@@ -406,27 +406,61 @@ export function createBackgroundCancel(manager: BackgroundManager, client: Openc
return `No running or pending background tasks to cancel.`
}
const results: string[] = []
const cancelledInfo: Array<{
id: string
description: string
status: string
sessionID?: string
}> = []
for (const task of cancellableTasks) {
if (task.status === "pending") {
// Pending task: use manager method (no session to abort)
manager.cancelPendingTask(task.id)
results.push(`- ${task.id}: ${task.description} (pending)`)
cancelledInfo.push({
id: task.id,
description: task.description,
status: "pending",
sessionID: undefined,
})
} else if (task.sessionID) {
// Running task: abort session
client.session.abort({
path: { id: task.sessionID },
}).catch(() => {})
task.status = "cancelled"
task.completedAt = new Date()
results.push(`- ${task.id}: ${task.description} (running)`)
cancelledInfo.push({
id: task.id,
description: task.description,
status: "running",
sessionID: task.sessionID,
})
}
}
const tableRows = cancelledInfo
.map(t => `| \`${t.id}\` | ${t.description} | ${t.status} | ${t.sessionID ? `\`${t.sessionID}\`` : "(not started)"} |`)
.join("\n")
const resumableTasks = cancelledInfo.filter(t => t.sessionID)
const resumeSection = resumableTasks.length > 0
? `\n## Resume Instructions
To resume a cancelled task, use:
\`\`\`
delegate_task(resume="<session_id>", prompt="Continue: <your follow-up>")
\`\`\`
Resumable sessions:
${resumableTasks.map(t => `- \`${t.sessionID}\` (${t.description})`).join("\n")}`
: ""
return `Cancelled ${cancellableTasks.length} background task(s):
${results.join("\n")}`
| Task ID | Description | Status | Session ID |
|---------|-------------|--------|------------|
${tableRows}
${resumeSection}`
}
const task = manager.getTask(args.taskId!)

View File

@@ -156,13 +156,13 @@ Approach:
export const DEFAULT_CATEGORIES: Record<string, CategoryConfig> = {
"visual-engineering": { model: "google/gemini-3-pro-preview" },
"visual-engineering": { model: "google/gemini-3-pro" },
ultrabrain: { model: "openai/gpt-5.2-codex", variant: "xhigh" },
artistry: { model: "google/gemini-3-pro-preview", variant: "max" },
artistry: { model: "google/gemini-3-pro", variant: "max" },
quick: { model: "anthropic/claude-haiku-4-5" },
"unspecified-low": { model: "anthropic/claude-sonnet-4-5" },
"unspecified-high": { model: "anthropic/claude-opus-4-5", variant: "max" },
writing: { model: "google/gemini-3-flash-preview" },
writing: { model: "google/gemini-3-flash" },
}
export const CATEGORY_PROMPT_APPENDS: Record<string, string> = {

View File

@@ -20,7 +20,7 @@ describe("sisyphus-task", () => {
// #when / #then
expect(category).toBeDefined()
expect(category.model).toBe("google/gemini-3-pro-preview")
expect(category.model).toBe("google/gemini-3-pro")
})
test("ultrabrain category has model and variant config", () => {
@@ -142,7 +142,7 @@ describe("sisyphus-task", () => {
// #then
expect(result).not.toBeNull()
expect(result!.config.model).toBe("google/gemini-3-pro-preview")
expect(result!.config.model).toBe("google/gemini-3-pro")
expect(result!.promptAppend).toContain("VISUAL/UI")
})
@@ -166,7 +166,7 @@ describe("sisyphus-task", () => {
const categoryName = "visual-engineering"
const userCategories = {
"visual-engineering": {
model: "google/gemini-3-pro-preview",
model: "google/gemini-3-pro",
prompt_append: "Custom instructions here",
},
}
@@ -206,7 +206,7 @@ describe("sisyphus-task", () => {
const categoryName = "visual-engineering"
const userCategories = {
"visual-engineering": {
model: "google/gemini-3-pro-preview",
model: "google/gemini-3-pro",
temperature: 0.3,
},
}
@@ -229,7 +229,7 @@ describe("sisyphus-task", () => {
// #then - category's built-in model wins over inheritedModel
expect(result).not.toBeNull()
expect(result!.config.model).toBe("google/gemini-3-pro-preview")
expect(result!.config.model).toBe("google/gemini-3-pro")
})
test("systemDefaultModel is used as fallback when custom category has no model", () => {
@@ -271,7 +271,7 @@ describe("sisyphus-task", () => {
// #then
expect(result).not.toBeNull()
expect(result!.config.model).toBe("google/gemini-3-pro-preview")
expect(result!.config.model).toBe("google/gemini-3-pro")
})
})
@@ -288,7 +288,7 @@ describe("sisyphus-task", () => {
id: "task-variant",
sessionID: "session-variant",
description: "Variant task",
agent: "Sisyphus-Junior",
agent: "sisyphus-junior",
status: "running",
}
},
@@ -351,7 +351,7 @@ describe("sisyphus-task", () => {
id: "task-default-variant",
sessionID: "session-default-variant",
description: "Default variant task",
agent: "Sisyphus-Junior",
agent: "sisyphus-junior",
status: "running",
}
},
@@ -360,6 +360,7 @@ describe("sisyphus-task", () => {
const mockClient = {
app: { agents: async () => ({ data: [] }) },
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
model: { list: async () => [{ id: "anthropic/claude-opus-4-5" }] },
session: {
create: async () => ({ data: { id: "test-session" } }),
prompt: async () => ({ data: {} }),
@@ -410,6 +411,7 @@ describe("sisyphus-task", () => {
const mockClient = {
app: { agents: async () => ({ data: [] }) },
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
model: { list: async () => [{ id: "anthropic/claude-opus-4-5" }] },
session: {
get: async () => ({ data: { directory: "/project" } }),
create: async () => ({ data: { id: "ses_sync_default_variant" } }),
@@ -949,7 +951,7 @@ describe("sisyphus-task", () => {
id: "task-unstable",
sessionID: "ses_unstable_gemini",
description: "Unstable gemini task",
agent: "Sisyphus-Junior",
agent: "sisyphus-junior",
status: "running",
}
},
@@ -958,6 +960,7 @@ describe("sisyphus-task", () => {
const mockClient = {
app: { agents: async () => ({ data: [] }) },
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
model: { list: async () => [{ id: "google/gemini-3-pro" }] },
session: {
get: async () => ({ data: { directory: "/project" } }),
create: async () => ({ data: { id: "ses_unstable_gemini" } }),
@@ -1013,7 +1016,7 @@ describe("sisyphus-task", () => {
id: "task-normal-bg",
sessionID: "ses_normal_bg",
description: "Normal background task",
agent: "Sisyphus-Junior",
agent: "sisyphus-junior",
status: "running",
}
},
@@ -1132,7 +1135,7 @@ describe("sisyphus-task", () => {
id: "task-artistry",
sessionID: "ses_artistry_gemini",
description: "Artistry gemini task",
agent: "Sisyphus-Junior",
agent: "sisyphus-junior",
status: "running",
}
},
@@ -1141,6 +1144,7 @@ describe("sisyphus-task", () => {
const mockClient = {
app: { agents: async () => ({ data: [] }) },
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
model: { list: async () => [{ id: "google/gemini-3-pro" }] },
session: {
get: async () => ({ data: { directory: "/project" } }),
create: async () => ({ data: { id: "ses_artistry_gemini" } }),
@@ -1166,7 +1170,7 @@ describe("sisyphus-task", () => {
abort: new AbortController().signal,
}
// #when - artistry category (gemini-3-pro-preview with max variant)
// #when - artistry category (gemini-3-pro with max variant)
const result = await tool.execute(
{
description: "Test artistry forced background",
@@ -1185,7 +1189,7 @@ describe("sisyphus-task", () => {
}, { timeout: 20000 })
test("writing category (gemini-flash) with run_in_background=false should force background but wait for result", async () => {
// #given - writing uses gemini-3-flash-preview
// #given - writing uses gemini-3-flash
const { createDelegateTask } = require("./tools")
let launchCalled = false
@@ -1196,7 +1200,7 @@ describe("sisyphus-task", () => {
id: "task-writing",
sessionID: "ses_writing_gemini",
description: "Writing gemini task",
agent: "Sisyphus-Junior",
agent: "sisyphus-junior",
status: "running",
}
},
@@ -1205,6 +1209,7 @@ describe("sisyphus-task", () => {
const mockClient = {
app: { agents: async () => ({ data: [] }) },
config: { get: async () => ({ data: { model: SYSTEM_DEFAULT_MODEL } }) },
model: { list: async () => [{ id: "google/gemini-3-flash" }] },
session: {
get: async () => ({ data: { directory: "/project" } }),
create: async () => ({ data: { id: "ses_writing_gemini" } }),
@@ -1230,7 +1235,7 @@ describe("sisyphus-task", () => {
abort: new AbortController().signal,
}
// #when - writing category (gemini-3-flash-preview)
// #when - writing category (gemini-3-flash)
const result = await tool.execute(
{
description: "Test writing forced background",
@@ -1260,7 +1265,7 @@ describe("sisyphus-task", () => {
id: "task-custom-unstable",
sessionID: "ses_custom_unstable",
description: "Custom unstable task",
agent: "Sisyphus-Junior",
agent: "sisyphus-junior",
status: "running",
}
},
@@ -1530,9 +1535,9 @@ describe("sisyphus-task", () => {
// #when resolveCategoryConfig is called
const resolved = resolveCategoryConfig(categoryName, { userCategories, inheritedModel, systemDefaultModel: SYSTEM_DEFAULT_MODEL })
// #then should use category's built-in model (gemini-3-pro-preview for visual-engineering)
// #then should use category's built-in model (gemini-3-pro for visual-engineering)
expect(resolved).not.toBeNull()
expect(resolved!.model).toBe("google/gemini-3-pro-preview")
expect(resolved!.model).toBe("google/gemini-3-pro")
})
test("systemDefaultModel is used when no other model is available", () => {

View File

@@ -18,7 +18,7 @@ import { CATEGORY_MODEL_REQUIREMENTS } from "../../shared/model-requirements"
type OpencodeClient = PluginInput["client"]
const SISYPHUS_JUNIOR_AGENT = "Sisyphus-Junior"
const SISYPHUS_JUNIOR_AGENT = "sisyphus-junior"
function parseModelString(model: string): { providerID: string; modelID: string } | undefined {
const parts = model.split("/")
@@ -156,6 +156,7 @@ export interface DelegateTaskToolOptions {
directory: string
userCategories?: CategoriesConfig
gitMasterConfig?: GitMasterConfig
sisyphusJuniorModel?: string
}
export interface BuildSystemContentInput {
@@ -178,7 +179,7 @@ export function buildSystemContent(input: BuildSystemContentInput): string | und
}
export function createDelegateTask(options: DelegateTaskToolOptions): ToolDefinition {
const { manager, client, directory, userCategories, gitMasterConfig } = options
const { manager, client, directory, userCategories, gitMasterConfig, sisyphusJuniorModel } = options
const allCategories = { ...DEFAULT_CATEGORIES, ...userCategories }
const categoryNames = Object.keys(allCategories)
@@ -513,7 +514,7 @@ To resume this session: resume="${args.resume}"`
modelInfo = { model: actualModel, type: "system-default", source: "system-default" }
} else {
const { model: resolvedModel, source, variant: resolvedVariant } = resolveModelWithFallback({
userModel: userCategories?.[args.category]?.model,
userModel: userCategories?.[args.category]?.model ?? sisyphusJuniorModel,
fallbackChain: requirement.fallbackChain,
availableModels,
systemDefaultModel,

View File

@@ -2,7 +2,7 @@ import { existsSync, readFileSync } from "fs"
import { join } from "path"
import { BUILTIN_SERVERS, EXT_TO_LANG, LSP_INSTALL_HINTS } from "./constants"
import type { ResolvedServer, ServerLookupResult } from "./types"
import { getOpenCodeConfigDir } from "../../shared"
import { getOpenCodeConfigDir, getDataDir } from "../../shared"
interface LspEntry {
disabled?: boolean
@@ -201,10 +201,12 @@ export function isServerInstalled(command: string[]): boolean {
const cwd = process.cwd()
const configDir = getOpenCodeConfigDir({ binary: "opencode" })
const dataDir = join(getDataDir(), "opencode")
const additionalBases = [
join(cwd, "node_modules", ".bin"),
join(configDir, "bin"),
join(configDir, "node_modules", ".bin"),
join(dataDir, "bin"),
]
for (const base of additionalBases) {

View File

@@ -20,6 +20,21 @@ Test skill body content`
},
}))
function createMockSkill(name: string, options: { agent?: string } = {}): LoadedSkill {
return {
name,
path: `/test/skills/${name}/SKILL.md`,
resolvedPath: `/test/skills/${name}`,
definition: {
name,
description: `Test skill ${name}`,
template: "Test template",
agent: options.agent,
},
scope: "opencode-project",
}
}
function createMockSkillWithMcp(name: string, mcpServers: Record<string, unknown>): LoadedSkill {
return {
name,
@@ -42,6 +57,59 @@ const mockContext = {
abort: new AbortController().signal,
}
describe("skill tool - agent restriction", () => {
it("allows skill without agent restriction to any agent", async () => {
// #given
const loadedSkills = [createMockSkill("public-skill")]
const tool = createSkillTool({ skills: loadedSkills })
const context = { ...mockContext, agent: "any-agent" }
// #when
const result = await tool.execute({ name: "public-skill" }, context)
// #then
expect(result).toContain("public-skill")
})
it("allows skill when agent matches restriction", async () => {
// #given
const loadedSkills = [createMockSkill("restricted-skill", { agent: "sisyphus" })]
const tool = createSkillTool({ skills: loadedSkills })
const context = { ...mockContext, agent: "sisyphus" }
// #when
const result = await tool.execute({ name: "restricted-skill" }, context)
// #then
expect(result).toContain("restricted-skill")
})
it("throws error when agent does not match restriction", async () => {
// #given
const loadedSkills = [createMockSkill("sisyphus-only-skill", { agent: "sisyphus" })]
const tool = createSkillTool({ skills: loadedSkills })
const context = { ...mockContext, agent: "oracle" }
// #when / #then
await expect(tool.execute({ name: "sisyphus-only-skill" }, context)).rejects.toThrow(
'Skill "sisyphus-only-skill" is restricted to agent "sisyphus"'
)
})
it("throws error when context agent is undefined for restricted skill", async () => {
// #given
const loadedSkills = [createMockSkill("sisyphus-only-skill", { agent: "sisyphus" })]
const tool = createSkillTool({ skills: loadedSkills })
const contextWithoutAgent = { ...mockContext, agent: undefined as unknown as string }
// #when / #then
await expect(tool.execute({ name: "sisyphus-only-skill" }, contextWithoutAgent)).rejects.toThrow(
'Skill "sisyphus-only-skill" is restricted to agent "sisyphus"'
)
})
})
describe("skill tool - MCP schema display", () => {
let manager: SkillMcpManager
let loadedSkills: LoadedSkill[]

View File

@@ -156,7 +156,7 @@ export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition
args: {
name: tool.schema.string().describe("The skill identifier from available_skills (e.g., 'code-review')"),
},
async execute(args: SkillArgs) {
async execute(args: SkillArgs, ctx?: { agent?: string }) {
const skills = await getSkills()
const skill = skills.find(s => s.name === args.name)
@@ -165,6 +165,10 @@ export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition
throw new Error(`Skill "${args.name}" not found. Available skills: ${available || "none"}`)
}
if (skill.definition.agent && (!ctx?.agent || skill.definition.agent !== ctx.agent)) {
throw new Error(`Skill "${args.name}" is restricted to agent "${skill.definition.agent}"`)
}
let body = await extractSkillBody(skill)
if (args.name === "git-master") {

View File

@@ -6,6 +6,7 @@ import type { CommandFrontmatter } from "../../features/claude-code-command-load
import { isMarkdownFile } from "../../shared/file-utils"
import { getClaudeConfigDir } from "../../shared"
import { discoverAllSkills, type LoadedSkill } from "../../features/opencode-skill-loader"
import { loadBuiltinCommands } from "../../features/builtin-commands"
import type { CommandScope, CommandMetadata, CommandInfo, SlashcommandToolOptions } from "./types"
function discoverCommandsFromDir(commandsDir: string, scope: CommandScope): CommandInfo[] {
@@ -63,7 +64,22 @@ export function discoverCommandsSync(): CommandInfo[] {
const projectCommands = discoverCommandsFromDir(projectCommandsDir, "project")
const opencodeProjectCommands = discoverCommandsFromDir(opencodeProjectDir, "opencode-project")
return [...opencodeProjectCommands, ...projectCommands, ...opencodeGlobalCommands, ...userCommands]
const builtinCommandsMap = loadBuiltinCommands()
const builtinCommands: CommandInfo[] = Object.values(builtinCommandsMap).map(cmd => ({
name: cmd.name,
metadata: {
name: cmd.name,
description: cmd.description || "",
argumentHint: cmd.argumentHint,
model: cmd.model,
agent: cmd.agent,
subtask: cmd.subtask
},
content: cmd.template,
scope: "builtin"
}))
return [...builtinCommands, ...opencodeProjectCommands, ...projectCommands, ...opencodeGlobalCommands, ...userCommands]
}
function skillToCommandInfo(skill: LoadedSkill): CommandInfo {
@@ -234,7 +250,7 @@ export function createSlashcommandTool(options: SlashcommandToolOptions = {}): T
if (partialMatches.length > 0) {
const matchList = partialMatches.map((cmd) => `/${cmd.name}`).join(", ")
return (
`No exact match for "/${cmdName}". Did you mean: ${matchList}?\n\n` +
`No exact match for "/${cmdName}\". Did you mean: ${matchList}?\n\n` +
formatCommandList(allItems)
)
}