Compare commits

...

46 Commits

Author SHA1 Message Date
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
74 changed files with 2494 additions and 2104 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)
@@ -90,7 +90,7 @@ oh-my-opencode/
| oracle | openai/gpt-5.2 | Consultation, debugging |
| librarian | opencode/glm-4.7-free | Docs, GitHub search |
| explore | opencode/grok-code | Fast codebase grep |
| multimodal-looker | google/gemini-3-flash | PDF/image analysis |
| multimodal-looker | google/gemini-3-flash-preview | PDF/image analysis |
| Prometheus | anthropic/claude-opus-4-5 | Strategic planning |
## COMMANDS
@@ -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

@@ -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": {

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/grok-code" } // Free model for grep
},
// Override category models (used by delegate_task)
"categories": {
"quick": { "model": "opencode/grok-code" }, // Fast/cheap for trivial tasks
"visual-engineering": { "model": "google/gemini-3-pro-preview" } // Gemini for UI
}
}
```
**Find available models:** Run `opencode models` to see all models in your environment.
## Config File Locations
Config file locations (priority order):
@@ -372,7 +405,7 @@ Each agent has a defined provider priority chain. The system tries providers in
| **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 |
| **multimodal-looker** | `gemini-3-flash-preview` | google → anthropic → zai → openai → github-copilot → opencode |
| **Prometheus (Planner)** | `claude-opus-4-5` | anthropic → github-copilot → opencode → antigravity → google |
| **Metis (Plan Consultant)** | `claude-sonnet-4-5` | anthropic → github-copilot → opencode → antigravity → google |
| **Momus (Plan Reviewer)** | `claude-opus-4-5` | anthropic → github-copilot → opencode → antigravity → google |

View File

@@ -14,7 +14,7 @@ Oh-My-OpenCode provides 10 specialized AI agents. Each has distinct expertise, o
| **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. |
| **multimodal-looker** | `google/gemini-3-flash` | Visual content specialist. Analyzes PDFs, images, diagrams to extract information. Saves tokens by having another agent process media. |
| **multimodal-looker** | `google/gemini-3-flash-preview` | Visual content specialist. Analyzes PDFs, images, diagrams to extract information. Saves tokens by having another agent process media. |
### Planning Agents

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 → anthropic → zai → openai → github-copilot → opencode
↓ ↓ ↓ ↓ ↓ ↓
gemini haiku glm-4.6v gpt-5.2 fallback fallback
```
If you have Gemini, it uses `google/gemini-3-flash-preview`. 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/grok-code" },
"multimodal-looker": { "model": "zai-coding-plan/glm-4.6v" }
},
"categories": {
// Override categories for cost optimization
"quick": { "model": "opencode/grok-code" },
"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-beta.16",
"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-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"
},
"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-beta.16",
"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-beta.16",
"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-beta.16",
"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-beta.16",
"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-beta.16",
"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-beta.16",
"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-beta.16",
"description": "Platform-specific binary for oh-my-opencode (windows-x64)",
"license": "MIT",
"repository": {

View File

@@ -711,6 +711,30 @@
"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
}
]
}

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
@@ -33,7 +33,7 @@ agents/
| oracle | openai/gpt-5.2 | 0.1 | Consultation, debugging |
| librarian | opencode/glm-4.7-free | 0.1 | Docs, GitHub search |
| explore | opencode/grok-code | 0.1 | Fast contextual grep |
| multimodal-looker | google/gemini-3-flash | 0.1 | PDF/image analysis |
| multimodal-looker | google/gemini-3-flash-preview | 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 |
| Momus | anthropic/claude-sonnet-4-5 | 0.1 | Plan validation |

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", () => {

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/glm-4.7-free")
})
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")
@@ -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)))
@@ -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,
})

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 grok-code 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 grok-code (Claude haiku not available)
expect(result.agents?.explore?.model).toBe("opencode/grok-code")
})
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,15 +332,15 @@ 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", () => {
@@ -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

@@ -97,7 +97,7 @@ 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 [
@@ -139,12 +139,12 @@ export function generateModelConfig(config: InstallConfig): GeneratedOmoConfig {
continue
}
// Special case: explore has custom Gemini → Claude → Grok logic
// Special case: explore uses Claude haiku → OpenCode grok-code
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" }
}
@@ -153,7 +153,7 @@ export function generateModelConfig(config: InstallConfig): GeneratedOmoConfig {
// 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

@@ -375,7 +375,7 @@ describe("Sisyphus-Junior agent override", () => {
// #given
const config = {
agents: {
"Sisyphus-Junior": {
"sisyphus-junior": {
model: "openai/gpt-5.2",
temperature: 0.2,
},
@@ -388,18 +388,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 +410,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 +435,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({

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

@@ -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,7 +149,7 @@ 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 },
}
@@ -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

@@ -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

@@ -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

@@ -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"]
export interface TodoContinuationEnforcerOptions {
backgroundManager?: BackgroundManager

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

@@ -79,6 +79,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,10 +199,6 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
? createStartWorkHook(ctx)
: null;
const atlasHook = isHookEnabled("atlas")
? createAtlasHook(ctx)
: null;
const prometheusMdOnly = isHookEnabled("prometheus-md-only")
? createPrometheusMdOnlyHook(ctx)
: null;
@@ -210,6 +207,10 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
const backgroundManager = new BackgroundManager(ctx);
const atlasHook = isHookEnabled("atlas")
? createAtlasHook(ctx, { directory: ctx.directory, backgroundManager })
: null;
initTaskToastManager(ctx.client);
const todoContinuationEnforcer = isHookEnabled("todo-continuation-enforcer")
@@ -229,13 +230,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 +304,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 +314,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
@@ -485,6 +491,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

@@ -186,20 +186,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 +228,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 +275,7 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
: {}),
};
agentConfig["Prometheus (Planner)"] = prometheusOverride
agentConfig["prometheus"] = prometheusOverride
? { ...prometheusBase, ...prometheusOverride }
: prometheusBase;
}
@@ -310,7 +310,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 +349,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/glm-4.7-free" },
}
// #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/glm-4.7-free" },
}
// #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()
@@ -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", () => {
@@ -622,29 +708,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",
])

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: { "grok-code": { id: "grok-code" } } },
})
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/grok-code")).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,19 +54,19 @@ 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", () => {
@@ -84,9 +84,9 @@ describe("AGENT_MODEL_REQUIREMENTS", () => {
expect(primary.model).toBe("gemini-3-flash-preview")
})
test("Prometheus (Planner) has valid fallbackChain with claude-opus-4-5 as primary", () => {
// #given - Prometheus agent requirement
const prometheus = AGENT_MODEL_REQUIREMENTS["Prometheus (Planner)"]
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

View File

@@ -10,10 +10,11 @@ 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: ["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-preview" },
],
},
@@ -33,40 +34,40 @@ export const AGENT_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
},
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: "grok-code" },
],
},
"multimodal-looker": {
fallbackChain: [
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-flash-preview" },
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-haiku-4-5" },
{ providers: ["zai-coding-plan"], model: "glm-4.6v" },
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2" },
],
},
"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" },
],
},
"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-preview", 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-preview", variant: "max" },
],
},
Atlas: {
atlas: {
fallbackChain: [
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-sonnet-4-5" },
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2" },
@@ -101,13 +102,13 @@ export const CATEGORY_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
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: ["opencode"], model: "grok-code" },
],
},
"unspecified-low": {
fallbackChain: [
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-sonnet-4-5" },
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2" },
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2-codex", variant: "medium" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-flash-preview" },
],
},
@@ -122,6 +123,7 @@ export const CATEGORY_MODEL_REQUIREMENTS: Record<string, ModelRequirement> = {
fallbackChain: [
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-flash-preview" },
{ 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

@@ -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")
})
@@ -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

@@ -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-preview" }] },
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-preview" }] },
session: {
get: async () => ({ data: { directory: "/project" } }),
create: async () => ({ data: { id: "ses_artistry_gemini" } }),
@@ -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-preview" }] },
session: {
get: async () => ({ data: { directory: "/project" } }),
create: async () => ({ data: { id: "ses_writing_gemini" } }),
@@ -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",
}
},

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") {