Compare commits

..

89 Commits

Author SHA1 Message Date
github-actions[bot]
a5db86ee15 release: v3.0.1 2026-01-25 05:04:20 +00:00
justsisyphus
14f450bd25 refactor: sync delegate_task schema with OpenCode Task tool (resume→session_id, add command param) 2026-01-25 13:57:45 +09:00
justsisyphus
5a1da39def refactor(ultrawork): replace vague plan agent references with explicit delegate_task(subagent_type="plan") invocation syntax 2026-01-25 13:57:45 +09:00
Sisyphus
24d065c43a fix: update documentation to use load_skills instead of skills parameter (#1088)
All documentation, agent prompts, and skill descriptions were still
referencing the old 'skills' parameter name for delegate_task, but the
tool implementation requires 'load_skills' (renamed in commit aa2b052).
This caused confusion and errors for users following the docs.

Fixes #1008

Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
2026-01-25 13:45:00 +09:00
justsisyphus
fd72ce5ce7 docs: update AGENTS.md knowledge base (043b1a33)
- Add 7 missing hooks, remove deleted background-compaction
- Update line counts (atlas 572, sisyphus 450, config-manager 664)
- Add 18 undocumented shared utilities, remove stale references
- Add task-toast-manager, remove-deadcode command
- Update test count 90→95, add 4 complexity hotspots
2026-01-25 13:12:40 +09:00
justsisyphus
043b1a3377 refactor: remove dead re-exports from tools barrel (getTmuxPath, DelegateTaskToolOptions, DEFAULT_CATEGORIES, CATEGORY_PROMPT_APPENDS) 2026-01-25 12:59:19 +09:00
justsisyphus
512952f66d refactor: remove deprecated config-path.ts (dead code, 0 references) 2026-01-25 12:58:40 +09:00
justsisyphus
d9723e76ab refactor: remove unused background-compaction hook module 2026-01-25 12:58:05 +09:00
justsisyphus
212baa6674 feat(commands): add /remove-deadcode slash command for LSP-verified dead code removal 2026-01-25 12:46:37 +09:00
justsisyphus
1c76e0513a fix: add missing name property in loadBuiltinCommands causing TypeError on slashcommand 2026-01-25 12:46:03 +09:00
justsisyphus
c8cc94cd3c fix: remove github-copilot association from gpt-5-nano model mapping
explore agent uses opencode/gpt-5-nano exclusively — github-copilot
should not be associated with gpt-5-nano in docs, tests, or fallback chains.
2026-01-25 12:46:03 +09:00
Sisyphus
20cca35157 fix(ralph-loop): skip user messages in transcript completion detection (#622) (#1086)
* fix(ralph-loop): skip user messages in transcript completion detection (#622)

The transcript-based completion detection was searching the entire JSONL
file for <promise>DONE</promise>, including user message entries. The
RALPH_LOOP_TEMPLATE instructional text contains this literal pattern,
which gets recorded as a user message, causing false positive completion
detection on every iteration. This made the loop always terminate at
iteration 1.

Fix: Parse JSONL entries line-by-line and skip entries with type 'user'
so only tool_result/assistant entries are checked for the completion
promise. Also remove the hardcoded <promise>DONE</promise> from the
template exit conditions as defense-in-depth.

* chore: changes by sisyphus-dev-ai

---------

Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
2026-01-25 12:34:42 +09:00
sisyphus-dev-ai
81d27afadb chore: changes by sisyphus-dev-ai 2026-01-25 03:27:56 +00:00
github-actions[bot]
6cb2f3031c @kvokka has signed the CLA in code-yeongyu/oh-my-opencode#1084 2026-01-25 03:14:31 +00:00
github-actions[bot]
f116ea1d43 @potb has signed the CLA in code-yeongyu/oh-my-opencode#1083 2026-01-25 02:38:28 +00:00
github-actions[bot]
6aa0674000 @jsl9208 has signed the CLA in code-yeongyu/oh-my-opencode#1082 2026-01-24 21:44:22 +00:00
github-actions[bot]
2b828624a0 @sadnow has signed the CLA in code-yeongyu/oh-my-opencode#1080 2026-01-24 20:49:38 +00:00
github-actions[bot]
e60ccb93fb @ThanhNguyxn has signed the CLA in code-yeongyu/oh-my-opencode#1075 2026-01-24 17:42:03 +00:00
justsisyphus
aa244e8098 docs: fix atlas agent name case in example config 2026-01-24 22:46:40 +09:00
github-actions[bot]
6f60f03433 @AamiRobin has signed the CLA in code-yeongyu/oh-my-opencode#1067 2026-01-24 13:28:32 +00:00
github-actions[bot]
b8a0eee92d release: v3.0.0 2026-01-24 13:23:25 +00:00
justsisyphus
1486ebbc87 docs: update READMEs for 3.0 stable release
- Update TIP banner from beta.10 to stable 3.0 in all languages
- Add Korean language link to Japanese and Chinese READMEs
- Add DeepWiki badge to Japanese and Chinese READMEs
- Adjust DeepWiki badge position in Korean README for consistency
2026-01-24 21:58:53 +09:00
justsisyphus
063c759275 feat: show detailed task info and resume instructions on background_cancel(all=true) (#1062)
Co-authored-by: justsisyphus <justsisyphus@users.noreply.github.com>
2026-01-24 17:15:31 +09:00
justsisyphus
6e9ebaf3ee fix: add missing gemini-3-flash to writing category migration (#1061)
MODEL_TO_CATEGORY_MAP was missing the mapping for google/gemini-3-flash
to the 'writing' category. Users who had configured agents with
model: 'google/gemini-3-flash' would not get auto-migrated to
category: 'writing'.

Ref: PR #1057 review comment

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

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

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

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

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

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

* feat: add xhigh reasoningEffort option

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

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

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

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

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

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

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

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

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

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

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

* chore: update bun.lock

---------

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

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

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

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

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

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

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

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

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

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

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

---------

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

Root cause: oven-sh/bun#18416

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

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

Fixes #873
Fixes #844

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

Fixes #893

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

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

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

Fixes #983

Fixes #889

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

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

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

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

Also adds fallback path checks for common installation locations.

Fixes #898
2026-01-21 15:42:21 +09:00
carlory
45fe9578ec fix(doctor): handle file:// protocol for local dev plugin detection 2026-01-19 16:09:46 +08:00
114 changed files with 4078 additions and 2676 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

@@ -0,0 +1,342 @@
---
description: Remove unused code from this project with ultrawork mode, LSP-verified safety, atomic commits
---
<command-instruction>
You are a dead code removal specialist. Execute the FULL dead code removal workflow using ultrawork mode.
Your core weapon: **LSP FindReferences**. If a symbol has ZERO external references, it's dead. Remove it.
## CRITICAL RULES
1. **LSP is law.** Never guess. Always verify with `LspFindReferences` before removing ANYTHING.
2. **One removal = one commit.** Every dead code removal gets its own atomic commit.
3. **Test after every removal.** Run `bun test` after each. If it fails, REVERT and skip.
4. **Leaf-first order.** Remove deepest unused symbols first, then work up the dependency chain. Removing a leaf may expose new dead code upstream.
5. **Never remove entry points.** `src/index.ts`, `src/cli/index.ts`, test files, config files, and files in `packages/` are off-limits unless explicitly targeted.
---
## STEP 0: REGISTER TODO LIST (MANDATORY FIRST ACTION)
```
TodoWrite([
{"id": "scan", "content": "PHASE 1: Scan codebase for dead code candidates using LSP + explore agents", "status": "pending", "priority": "high"},
{"id": "verify", "content": "PHASE 2: Verify each candidate with LspFindReferences - zero false positives", "status": "pending", "priority": "high"},
{"id": "plan", "content": "PHASE 3: Plan removal order (leaf-first dependency order)", "status": "pending", "priority": "high"},
{"id": "remove", "content": "PHASE 4: Remove dead code one-by-one (remove -> test -> commit loop)", "status": "pending", "priority": "high"},
{"id": "final", "content": "PHASE 5: Final verification - full test suite + build + typecheck", "status": "pending", "priority": "high"}
])
```
---
## PHASE 1: SCAN FOR DEAD CODE CANDIDATES
**Mark scan as in_progress.**
### 1.1: Launch Parallel Explore Agents (ALL BACKGROUND)
Fire ALL simultaneously:
```
// Agent 1: Find all exported symbols
delegate_task(subagent_type="explore", run_in_background=true,
prompt="Find ALL exported functions, classes, types, interfaces, and constants across src/.
List each with: file path, line number, symbol name, export type (named/default).
EXCLUDE: src/index.ts root exports, test files.
Return as structured list.")
// Agent 2: Find potentially unused files
delegate_task(subagent_type="explore", run_in_background=true,
prompt="Find files in src/ that are NOT imported by any other file.
Check import/require statements across the entire codebase.
EXCLUDE: index.ts files, test files, entry points, config files, .md files.
Return list of potentially orphaned files.")
// Agent 3: Find unused imports within files
delegate_task(subagent_type="explore", run_in_background=true,
prompt="Find unused imports across src/**/*.ts files.
Look for import statements where the imported symbol is never referenced in the file body.
Return: file path, line number, imported symbol name.")
// Agent 4: Find functions/variables only used in their own declaration
delegate_task(subagent_type="explore", run_in_background=true,
prompt="Find private/non-exported functions, variables, and types in src/**/*.ts that appear
to have zero usage beyond their declaration. Return: file path, line number, symbol name.")
```
### 1.2: Direct AST-Grep Scans (WHILE AGENTS RUN)
```typescript
// Find unused imports pattern
ast_grep_search(pattern="import { $NAME } from '$PATH'", lang="typescript", paths=["src/"])
// Find empty export objects
ast_grep_search(pattern="export {}", lang="typescript", paths=["src/"])
```
### 1.3: Collect All Results
Collect background agent results. Compile into a master candidate list:
```
## DEAD CODE CANDIDATES
| # | File | Line | Symbol | Type | Confidence |
|---|------|------|--------|------|------------|
| 1 | src/foo.ts | 42 | unusedFunc | function | HIGH |
| 2 | src/bar.ts | 10 | OldType | type | MEDIUM |
```
**Mark scan as completed.**
---
## PHASE 2: VERIFY WITH LSP (ZERO FALSE POSITIVES)
**Mark verify as in_progress.**
For EVERY candidate from Phase 1, run this verification:
### 2.1: The LSP Verification Protocol
For each candidate symbol:
```typescript
// Step 1: Find the symbol's exact position
LspDocumentSymbols(filePath) // Get line/character of the symbol
// Step 2: Find ALL references across the ENTIRE workspace
LspFindReferences(filePath, line, character, includeDeclaration=false)
// includeDeclaration=false → only counts USAGES, not the definition itself
// Step 3: Evaluate
// 0 references → CONFIRMED DEAD CODE
// 1+ references → NOT dead, remove from candidate list
```
### 2.2: False Positive Guards
**NEVER mark as dead code if:**
- Symbol is in `src/index.ts` (package entry point)
- Symbol is in any `index.ts` that re-exports (barrel file check: look if it's re-exported)
- Symbol is referenced in test files (tests are valid consumers)
- Symbol has `@public` or `@api` JSDoc tags
- Symbol is in a file listed in `package.json` exports
- Symbol is a hook factory (`createXXXHook`) registered in `src/index.ts`
- Symbol is a tool factory (`createXXXTool`) registered in tool loading
- Symbol is an agent definition registered in `agentSources`
- File is a command template, skill definition, or MCP config
### 2.3: Build Confirmed Dead Code List
After verification, produce:
```
## CONFIRMED DEAD CODE (LSP-verified, 0 external references)
| # | File | Line | Symbol | Type | Safe to Remove |
|---|------|------|--------|------|----------------|
| 1 | src/foo.ts | 42 | unusedFunc | function | YES |
```
**If ZERO confirmed dead code found: Report "No dead code found" and STOP.**
**Mark verify as completed.**
---
## PHASE 3: PLAN REMOVAL ORDER
**Mark plan as in_progress.**
### 3.1: Dependency Analysis
For each confirmed dead symbol:
1. Check if removing it would expose other dead code
2. Check if other dead symbols depend on this one
3. Build removal dependency graph
### 3.2: Order by Leaf-First
```
Removal Order:
1. [Leaf symbols - no other dead code depends on them]
2. [Intermediate symbols - depended on only by already-removed dead code]
3. [Dead files - entire files with no live exports]
```
### 3.3: Register Granular Todos
Create one todo per removal:
```
TodoWrite([
{"id": "remove-1", "content": "Remove unusedFunc from src/foo.ts:42", "status": "pending", "priority": "high"},
{"id": "remove-2", "content": "Remove OldType from src/bar.ts:10", "status": "pending", "priority": "high"},
// ... one per confirmed dead symbol
])
```
**Mark plan as completed.**
---
## PHASE 4: ITERATIVE REMOVAL LOOP
**Mark remove as in_progress.**
For EACH dead code item, execute this exact loop:
### 4.1: Pre-Removal Check
```typescript
// Re-verify it's still dead (previous removals may have changed things)
LspFindReferences(filePath, line, character, includeDeclaration=false)
// If references > 0 now → SKIP (previous removal exposed a new consumer)
```
### 4.2: Remove the Dead Code
Use appropriate tool:
**For unused imports:**
```typescript
Edit(filePath, oldString="import { deadSymbol } from '...';\n", newString="")
// Or if it's one of many imports, remove just the symbol from the import list
```
**For unused functions/classes/types:**
```typescript
// Read the full symbol extent first
Read(filePath, offset=startLine, limit=endLine-startLine+1)
// Then remove it
Edit(filePath, oldString="[full symbol text]", newString="")
```
**For dead files:**
```bash
# Only after confirming ZERO imports point to this file
rm "path/to/dead-file.ts"
```
**After removal, also clean up:**
- Remove any imports that were ONLY used by the removed code
- Remove any now-empty import statements
- Fix any trailing whitespace / double blank lines left behind
### 4.3: Post-Removal Verification
```typescript
// 1. LSP diagnostics on changed file
LspDiagnostics(filePath, severity="error")
// Must be clean (or only pre-existing errors)
// 2. Run tests
bash("bun test")
// Must pass
// 3. Typecheck
bash("bun run typecheck")
// Must pass
```
### 4.4: Handle Failures
If ANY verification fails:
1. **REVERT** the change immediately (`git checkout -- [file]`)
2. Mark this removal todo as `cancelled` with note: "Removal caused [error]. Skipped."
3. Proceed to next item
### 4.5: Commit
```bash
git add [changed-files]
git commit -m "refactor: remove unused [symbolType] [symbolName] from [filePath]"
```
Mark this removal todo as `completed`.
### 4.6: Re-scan After Removal
After removing a symbol, check if its removal exposed NEW dead code:
- Were there imports that only existed to serve the removed symbol?
- Are there other symbols in the same file now unreferenced?
If new dead code is found, add it to the removal queue.
**Repeat 4.1-4.6 for every item. Mark remove as completed when done.**
---
## PHASE 5: FINAL VERIFICATION
**Mark final as in_progress.**
### 5.1: Full Test Suite
```bash
bun test
```
### 5.2: Full Typecheck
```bash
bun run typecheck
```
### 5.3: Full Build
```bash
bun run build
```
### 5.4: Summary Report
```markdown
## Dead Code Removal Complete
### Removed
| # | Symbol | File | Type | Commit |
|---|--------|------|------|--------|
| 1 | unusedFunc | src/foo.ts | function | abc1234 |
### Skipped (caused failures)
| # | Symbol | File | Reason |
|---|--------|------|--------|
| 1 | riskyFunc | src/bar.ts | Test failure: [details] |
### Verification
- Tests: PASSED (X/Y passing)
- Typecheck: CLEAN
- Build: SUCCESS
- Total dead code removed: N symbols across M files
- Total commits: K atomic commits
```
**Mark final as completed.**
---
## SCOPE CONTROL
**If $ARGUMENTS is provided**, narrow the scan to the specified scope:
- File path: Only scan that file
- Directory: Only scan that directory
- Symbol name: Only check that specific symbol
- "all" or empty: Full project scan (default)
## ABORT CONDITIONS
**STOP and report to user if:**
- 3 consecutive removals cause test failures
- Build breaks and cannot be fixed by reverting
- More than 50 candidates found (ask user to narrow scope)
## LANGUAGE
Use English for commit messages and technical output.
</command-instruction>
<user-request>
$ARGUMENTS
</user-request>

View File

@@ -1,12 +1,12 @@
# PROJECT KNOWLEDGE BASE
**Generated:** 2026-01-23T02:09:00+09:00
**Commit:** 0e18efc7
**Generated:** 2026-01-25T13:10:00+09:00
**Commit:** 043b1a33
**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 (601 lines)
├── script/ # build-schema.ts, build-binaries.ts
├── packages/ # 7 platform-specific binaries
└── dist/ # Build output (ESM + .d.ts)
@@ -36,9 +36,10 @@ oh-my-opencode/
| Add tool | `src/tools/` | Dir with index/types/constants/tools.ts |
| Add MCP | `src/mcp/` | Create config, add to index.ts |
| Add skill | `src/features/builtin-skills/` | Create dir with SKILL.md |
| Add command | `src/features/builtin-commands/` | Add template + register in commands.ts |
| 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)
@@ -60,7 +61,7 @@ oh-my-opencode/
- **Build**: `bun build` (ESM) + `tsc --emitDeclarationOnly`
- **Exports**: Barrel pattern via index.ts
- **Naming**: kebab-case dirs, `createXXXHook`/`createXXXTool` factories
- **Testing**: BDD comments, 90 test files
- **Testing**: BDD comments, 95 test files
- **Temperature**: 0.1 for code agents, max 0.3
## ANTI-PATTERNS
@@ -88,8 +89,8 @@ oh-my-opencode/
| Sisyphus | anthropic/claude-opus-4-5 | Primary orchestrator |
| Atlas | anthropic/claude-opus-4-5 | Master orchestrator |
| oracle | openai/gpt-5.2 | Consultation, debugging |
| librarian | opencode/glm-4.7-free | Docs, GitHub search |
| explore | opencode/grok-code | Fast codebase grep |
| librarian | opencode/big-pickle | Docs, GitHub search |
| explore | opencode/gpt-5-nano | Fast codebase grep |
| multimodal-looker | google/gemini-3-flash | PDF/image analysis |
| Prometheus | anthropic/claude-opus-4-5 | Strategic planning |
@@ -99,7 +100,7 @@ oh-my-opencode/
bun run typecheck # Type check
bun run build # ESM + declarations + schema
bun run rebuild # Clean + Build
bun test # 90 test files
bun test # 95 test files
```
## DEPLOYMENT
@@ -113,12 +114,16 @@ 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` | 664 | JSONC config parsing |
| `src/features/builtin-commands/templates/refactor.ts` | 619 | Refactor command template |
| `src/index.ts` | 601 | Main plugin entry |
| `src/tools/lsp/client.ts` | 596 | LSP JSON-RPC client |
| `src/agents/atlas.ts` | 572 | Atlas orchestrator agent |
## MCP ARCHITECTURE

View File

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

View File

@@ -16,8 +16,8 @@
>
> [!TIP]
>
> [![The Orchestrator is now available in beta.](./.github/assets/orchestrator-atlas.png?v=3)](https://github.com/code-yeongyu/oh-my-opencode/releases/tag/v3.0.0-beta.10)
> > **오케스트레이터가 베타 버전으로 사용 가능합니다. 설치하려면 `oh-my-opencode@3.0.0-beta.10`을 사용하세요.**
> [![Oh My OpenCode 3.0이 정식 출시되었습니다!](./.github/assets/orchestrator-atlas.png?v=3)](https://github.com/code-yeongyu/oh-my-opencode/releases/tag/v3.0.0)
> > **Oh My OpenCode 3.0이 정식 출시되었습니다! `oh-my-opencode@latest`를 사용하여 설치하세요.**
>
> 함께해요!
>
@@ -73,10 +73,11 @@
[![GitHub Stars](https://img.shields.io/github/stars/code-yeongyu/oh-my-opencode?color=ffcb47&labelColor=black&style=flat-square)](https://github.com/code-yeongyu/oh-my-opencode/stargazers)
[![GitHub Issues](https://img.shields.io/github/issues/code-yeongyu/oh-my-opencode?color=ff80eb&labelColor=black&style=flat-square)](https://github.com/code-yeongyu/oh-my-opencode/issues)
[![License](https://img.shields.io/badge/license-SUL--1.0-white?labelColor=black&style=flat-square)](https://github.com/code-yeongyu/oh-my-opencode/blob/master/LICENSE.md)
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/code-yeongyu/oh-my-opencode)
[English](README.md) | [한국어](README.ko.md) | [日本語](README.ja.md) | [简体中文](README.zh-cn.md)
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/code-yeongyu/oh-my-opencode)
</div>
<!-- </CENTERED SECTION FOR GITHUB DISPLAY> -->

View File

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

View File

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

View File

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

View File

@@ -27,13 +27,13 @@
"typescript": "^5.7.3",
},
"optionalDependencies": {
"oh-my-opencode-darwin-arm64": "3.0.0-beta.11",
"oh-my-opencode-darwin-x64": "3.0.0-beta.11",
"oh-my-opencode-linux-arm64": "3.0.0-beta.11",
"oh-my-opencode-linux-arm64-musl": "3.0.0-beta.11",
"oh-my-opencode-linux-x64": "3.0.0-beta.11",
"oh-my-opencode-linux-x64-musl": "3.0.0-beta.11",
"oh-my-opencode-windows-x64": "3.0.0-beta.11",
"oh-my-opencode-darwin-arm64": "3.0.0",
"oh-my-opencode-darwin-x64": "3.0.0",
"oh-my-opencode-linux-arm64": "3.0.0",
"oh-my-opencode-linux-arm64-musl": "3.0.0",
"oh-my-opencode-linux-x64": "3.0.0",
"oh-my-opencode-linux-x64-musl": "3.0.0",
"oh-my-opencode-windows-x64": "3.0.0",
},
},
},
@@ -225,19 +225,19 @@
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
"oh-my-opencode-darwin-arm64": ["oh-my-opencode-darwin-arm64@3.0.0-beta.11", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-7cFv2bbz9HTY7sshgVTu+IhvYf7CT0czDYqHEB+dYfEqFU6TaoSMimq6uHqcWegUUR1T7PNmc0dyjYVw69FeVA=="],
"oh-my-opencode-darwin-arm64": ["oh-my-opencode-darwin-arm64@3.0.0", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-zelvb7qz5GsS+Dhyz9rACZrkUMtWbAZGijiHSQqmRcjlN/sRPNhXtsL55VheDjlPM3VP+t3+psv+se0WA/aw5w=="],
"oh-my-opencode-darwin-x64": ["oh-my-opencode-darwin-x64@3.0.0-beta.11", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-rGAbDdUySWITIdm2yiuNFB9lFYaSXT8LMtg97LTlOO5vZbI3M+obIS3QlIkBtAhgOTIPB7Ni+T0W44OmJpHoYA=="],
"oh-my-opencode-darwin-x64": ["oh-my-opencode-darwin-x64@3.0.0", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-dRMD1U5zIrb6BsiKQJZtAFtuD8clAQquZyU2LajMoFTHBNhcBDIgsaBBwvMBIq7dTe8rnFq91ExiFA8OfdrzBA=="],
"oh-my-opencode-linux-arm64": ["oh-my-opencode-linux-arm64@3.0.0-beta.11", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-F9dqwWwGAdqeSkE7Tre5DmHQXwDpU2Z8Jk0lwTJMLj+kMqYFDVPjLPo4iVUdwPpxpmm0pR84u/oonG/2+84/zw=="],
"oh-my-opencode-linux-arm64": ["oh-my-opencode-linux-arm64@3.0.0", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-Wx6Cx2Nu2T69mfZa3FQ3gk0OFONvMh48rMVYK0Cp8VX5W4Zb/GZgTUFmZlYsApyxqP+7J9m18skd46qPOhzuEQ=="],
"oh-my-opencode-linux-arm64-musl": ["oh-my-opencode-linux-arm64-musl@3.0.0-beta.11", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-H+zOtHkHd+TmdPj64M1A0zLOk7OHIK4C8yqfLFhfizOIBffT1yOhAs6EpK3EqPhfPLu54ADgcQcu8W96VP24UA=="],
"oh-my-opencode-linux-arm64-musl": ["oh-my-opencode-linux-arm64-musl@3.0.0", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-mfOlptgLoXLVuhFRcXgZU7BYGuL1axZOMOOjONgncNzOp/BQYU5B9BRFihBUXdDsWGmeMiLowrYGBhVpSv3NlA=="],
"oh-my-opencode-linux-x64": ["oh-my-opencode-linux-x64@3.0.0-beta.11", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-IG+KODTJ8rs6cEJ2wN6Zpr6YtvCS5OpYP6jBdGJltmUpjQdMhdMsaY3ysZk+9Vxpx2KC3xj5KLHV1USg3uBTeg=="],
"oh-my-opencode-linux-x64": ["oh-my-opencode-linux-x64@3.0.0", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-vVjshfaz0UC9NrGD9FfjlYK5NvckIW0sZaE/wRv/LKjrukHFH1jJpJa5KKXxBWLsEJjt6ooJRguXXxtfNXpAWw=="],
"oh-my-opencode-linux-x64-musl": ["oh-my-opencode-linux-x64-musl@3.0.0-beta.11", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-irV+AuWrHqNm7VT7HO56qgymR0+vEfJbtB3vCq68kprH2V4NQmGp2MNKIYPnUCYL7NEK3H2NX+h06YFZJ/8ELQ=="],
"oh-my-opencode-linux-x64-musl": ["oh-my-opencode-linux-x64-musl@3.0.0", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-N6cNJ7+Dj0a5dWqPf6OKfB39o8HWw5HQ3hB4omgYqc6Gzo6nChA4KIiVefEC3+tIL98x4XvMeD7OU+UYgwxHnQ=="],
"oh-my-opencode-windows-x64": ["oh-my-opencode-windows-x64@3.0.0-beta.11", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-exZ/NEwGBlxyWszN7dvOfzbYX0cuhBZXftqAAFOlVP26elDHdo+AmSmLR/4cJyzpR9nCWz4xvl/RYF84bY6OEA=="],
"oh-my-opencode-windows-x64": ["oh-my-opencode-windows-x64@3.0.0", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-TaC0hiHpnsS42GWTVUKoTwCb+QzNLBlQtTkIQ0PjlkDYFjlEC2LuR2FFcscik055PRRIGishyB9A1n/8XAgcvA=="],
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],

View File

@@ -21,13 +21,13 @@ A Category is an agent configuration preset optimized for specific domains.
| Category | Default Model | Use Cases |
|----------|---------------|-----------|
| `visual-engineering` | `google/gemini-3-pro-preview` | Frontend, UI/UX, design, styling, animation |
| `visual-engineering` | `google/gemini-3-pro` | Frontend, UI/UX, design, styling, animation |
| `ultrabrain` | `openai/gpt-5.2-codex` (xhigh) | Deep logical reasoning, complex architecture decisions requiring extensive analysis |
| `artistry` | `google/gemini-3-pro-preview` (max) | Highly creative/artistic tasks, novel ideas |
| `artistry` | `google/gemini-3-pro` (max) | Highly creative/artistic tasks, novel ideas |
| `quick` | `anthropic/claude-haiku-4-5` | Trivial tasks - single file changes, typo fixes, simple modifications |
| `unspecified-low` | `anthropic/claude-sonnet-4-5` | Tasks that don't fit other categories, low effort required |
| `unspecified-high` | `anthropic/claude-opus-4-5` (max) | Tasks that don't fit other categories, high effort required |
| `writing` | `google/gemini-3-flash-preview` | Documentation, prose, technical writing |
| `writing` | `google/gemini-3-flash` | Documentation, prose, technical writing |
### Usage
@@ -70,12 +70,12 @@ A Skill is a mechanism that injects **specialized knowledge (Context)** and **to
### Usage
Add desired skill names to the `skills` array.
Add desired skill names to the `load_skills` array.
```typescript
delegate_task(
category="quick",
skills=["git-master"],
load_skills=["git-master"],
prompt="Commit current changes. Follow commit message style."
)
```
@@ -110,17 +110,17 @@ You can create powerful specialized agents by combining Categories and Skills.
### 🎨 The Designer (UI Implementation)
- **Category**: `visual-engineering`
- **Skills**: `["frontend-ui-ux", "playwright"]`
- **load_skills**: `["frontend-ui-ux", "playwright"]`
- **Effect**: Implements aesthetic UI and verifies rendering results directly in browser.
### 🏗️ The Architect (Design Review)
- **Category**: `ultrabrain`
- **Skills**: `[]` (pure reasoning)
- **load_skills**: `[]` (pure reasoning)
- **Effect**: Leverages GPT-5.2's logical reasoning for in-depth system architecture analysis.
### ⚡ The Maintainer (Quick Fixes)
- **Category**: `quick`
- **Skills**: `["git-master"]`
- **load_skills**: `["git-master"]`
- **Effect**: Uses cost-effective models to quickly fix code and generate clean commits.
---
@@ -131,7 +131,7 @@ When delegating, **clear and specific** prompts are essential. Include these 7 e
1. **TASK**: What needs to be done? (single objective)
2. **EXPECTED OUTCOME**: What is the deliverable?
3. **REQUIRED SKILLS**: Which skills should be used?
3. **REQUIRED SKILLS**: Which skills should be loaded via `load_skills`?
4. **REQUIRED TOOLS**: Which tools must be used? (whitelist)
5. **MUST DO**: What must be done (constraints)
6. **MUST NOT DO**: What must never be done
@@ -177,7 +177,7 @@ You can fine-tune categories in `oh-my-opencode.json`.
"categories": {
// 1. Define new custom category
"korean-writer": {
"model": "google/gemini-3-flash-preview",
"model": "google/gemini-3-flash",
"temperature": 0.5,
"prompt_append": "You are a Korean technical writer. Maintain a friendly and clear tone."
},

View File

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

View File

@@ -2,6 +2,39 @@
Highly opinionated, but adjustable to taste.
## Quick Start
**Most users don't need to configure anything manually.** Run the interactive installer:
```bash
bunx oh-my-opencode install
```
It asks about your providers (Claude, OpenAI, Gemini, etc.) and generates optimal config automatically.
**Want to customize?** Here's the common patterns:
```jsonc
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
// Override specific agent models
"agents": {
"oracle": { "model": "openai/gpt-5.2" }, // Use GPT for debugging
"librarian": { "model": "zai-coding-plan/glm-4.7" }, // Cheap model for research
"explore": { "model": "opencode/gpt-5-nano" } // Free model for grep
},
// Override category models (used by delegate_task)
"categories": {
"quick": { "model": "opencode/gpt-5-nano" }, // Fast/cheap for trivial tasks
"visual-engineering": { "model": "google/gemini-3-pro" } // Gemini for UI
}
}
```
**Find available models:** Run `opencode models` to see all models in your environment.
## Config File Locations
Config file locations (priority order):
@@ -42,7 +75,7 @@ When both `oh-my-opencode.jsonc` and `oh-my-opencode.json` files exist, `.jsonc`
"model": "openai/gpt-5.2" // GPT for strategic reasoning
},
"explore": {
"model": "opencode/grok-code" // Free & fast for exploration
"model": "opencode/gpt-5-nano" // Free & fast for exploration
},
},
}
@@ -50,7 +83,7 @@ When both `oh-my-opencode.jsonc` and `oh-my-opencode.json` files exist, `.jsonc`
## Google Auth
**Recommended**: For Google Gemini authentication, install the [`opencode-antigravity-auth`](https://github.com/NoeFabris/opencode-antigravity-auth) plugin. It provides multi-account load balancing, more models (including Claude via Antigravity), and active maintenance. See [Installation > Google Gemini](../README.md#google-gemini-antigravity-oauth).
**Recommended**: For Google Gemini authentication, install the [`opencode-antigravity-auth`](https://github.com/NoeFabris/opencode-antigravity-auth) plugin (`@latest`). It provides multi-account load balancing, variant-based thinking levels, dual quota system (Antigravity + Gemini CLI), and active maintenance. See [Installation > Google Gemini](docs/guide/installation.md#google-gemini-antigravity-oauth).
## Agents
@@ -127,7 +160,7 @@ Available agents: `oracle`, `librarian`, `explore`, `multimodal-looker`
Oh My OpenCode includes built-in skills that provide additional capabilities:
- **playwright**: Browser automation with Playwright MCP. Use for web scraping, testing, screenshots, and browser interactions.
- **git-master**: Git expert for atomic commits, rebase/squash, and history search (blame, bisect, log -S). STRONGLY RECOMMENDED: Use with `delegate_task(category='quick', skills=['git-master'], ...)` to save context.
- **git-master**: Git expert for atomic commits, rebase/squash, and history search (blame, bisect, log -S). STRONGLY RECOMMENDED: Use with `delegate_task(category='quick', load_skills=['git-master'], ...)` to save context.
Disable built-in skills via `disabled_skills` in `~/.config/opencode/oh-my-opencode.json` or `.opencode/oh-my-opencode.json`:
@@ -272,7 +305,7 @@ Categories enable domain-specific task delegation via the `delegate_task` tool.
| Category | Model | Description |
| ---------------- | ----------------------------- | ---------------------------------------------------------------------------- |
| `visual` | `google/gemini-3-pro-preview` | Frontend, UI/UX, design-focused tasks. High creativity (temp 0.7). |
| `visual` | `google/gemini-3-pro` | Frontend, UI/UX, design-focused tasks. High creativity (temp 0.7). |
| `business-logic` | `openai/gpt-5.2` | Backend logic, architecture, strategic reasoning. Low creativity (temp 0.1). |
**Usage:**
@@ -299,7 +332,7 @@ Add custom categories in `oh-my-opencode.json`:
"prompt_append": "Focus on data analysis, ML pipelines, and statistical methods."
},
"visual": {
"model": "google/gemini-3-pro-preview",
"model": "google/gemini-3-pro",
"prompt_append": "Use shadcn/ui components and Tailwind CSS."
}
}
@@ -370,9 +403,9 @@ Each agent has a defined provider priority chain. The system tries providers in
|-------|-------------------|-------------------------|
| **Sisyphus** | `claude-opus-4-5` | anthropic → github-copilot → opencode → antigravity → google |
| **oracle** | `gpt-5.2` | openai → anthropic → google → github-copilot → opencode |
| **librarian** | `glm-4.7-free` | opencode → github-copilot → anthropic |
| **explore** | `grok-code` | opencode → anthropic → github-copilot |
| **multimodal-looker** | `gemini-3-pro-preview` | google → openai → anthropic → github-copilot → opencode |
| **librarian** | `big-pickle` | opencode → github-copilot → anthropic |
| **explore** | `gpt-5-nano` | anthropic → opencode |
| **multimodal-looker** | `gemini-3-flash` | google → openai → zai-coding-plan → anthropic → opencode |
| **Prometheus (Planner)** | `claude-opus-4-5` | anthropic → github-copilot → opencode → antigravity → google |
| **Metis (Plan Consultant)** | `claude-sonnet-4-5` | anthropic → github-copilot → opencode → antigravity → google |
| **Momus (Plan Reviewer)** | `claude-opus-4-5` | anthropic → github-copilot → opencode → antigravity → google |
@@ -384,13 +417,13 @@ Categories follow the same resolution logic:
| Category | Model (no prefix) | Provider Priority Chain |
|----------|-------------------|-------------------------|
| **visual-engineering** | `gemini-3-pro-preview` | google → openai → anthropic → github-copilot → opencode |
| **visual-engineering** | `gemini-3-pro` | google → openai → anthropic → github-copilot → opencode |
| **ultrabrain** | `gpt-5.2-codex` | openai → anthropic → google → github-copilot → opencode |
| **artistry** | `gemini-3-pro-preview` | google → openai → anthropic → github-copilot → opencode |
| **artistry** | `gemini-3-pro` | google → openai → anthropic → github-copilot → opencode |
| **quick** | `claude-haiku-4-5` | anthropic → github-copilot → opencode → antigravity → google |
| **unspecified-low** | `claude-sonnet-4-5` | anthropic → github-copilot → opencode → antigravity → google |
| **unspecified-high** | `claude-opus-4-5` | anthropic → github-copilot → opencode → antigravity → google |
| **writing** | `gemini-3-flash-preview` | google → openai → anthropic → github-copilot → opencode |
| **writing** | `gemini-3-flash` | google → openai → anthropic → github-copilot → opencode |
### Checking Your Configuration

View File

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

View File

@@ -132,7 +132,7 @@ First, add the opencode-antigravity-auth plugin:
{
"plugin": [
"oh-my-opencode",
"opencode-antigravity-auth@1.2.8"
"opencode-antigravity-auth@latest"
]
}
```
@@ -140,7 +140,7 @@ First, add the opencode-antigravity-auth plugin:
##### Model Configuration
You'll also need full model settings in `opencode.json`.
Read the [opencode-antigravity-auth documentation](https://github.com/NoeFabris/opencode-antigravity-auth), copy provider/models config from the README, and merge carefully to avoid breaking the user's existing setup.
Read the [opencode-antigravity-auth documentation](https://github.com/NoeFabris/opencode-antigravity-auth), copy the full model configuration from the README, and merge carefully to avoid breaking the user's existing setup. The plugin now uses a **variant system** — models like `antigravity-gemini-3-pro` support `low`/`high` variants instead of separate `-low`/`-high` model entries.
##### oh-my-opencode Agent Model Override
@@ -154,7 +154,17 @@ The `opencode-antigravity-auth` plugin uses different model names than the built
}
```
**Available model names**: `google/antigravity-gemini-3-pro-high`, `google/antigravity-gemini-3-pro-low`, `google/antigravity-gemini-3-flash`, `google/antigravity-claude-sonnet-4-5`, `google/antigravity-claude-sonnet-4-5-thinking-low`, `google/antigravity-claude-sonnet-4-5-thinking-medium`, `google/antigravity-claude-sonnet-4-5-thinking-high`, `google/antigravity-claude-opus-4-5-thinking-low`, `google/antigravity-claude-opus-4-5-thinking-medium`, `google/antigravity-claude-opus-4-5-thinking-high`, `google/gemini-3-pro-preview`, `google/gemini-3-flash-preview`, `google/gemini-2.5-pro`, `google/gemini-2.5-flash`
**Available models (Antigravity quota)**:
- `google/antigravity-gemini-3-pro` — variants: `low`, `high`
- `google/antigravity-gemini-3-flash` — variants: `minimal`, `low`, `medium`, `high`
- `google/antigravity-claude-sonnet-4-5` — no variants
- `google/antigravity-claude-sonnet-4-5-thinking` — variants: `low`, `max`
- `google/antigravity-claude-opus-4-5-thinking` — variants: `low`, `max`
**Available models (Gemini CLI quota)**:
- `google/gemini-2.5-flash`, `google/gemini-2.5-pro`, `google/gemini-3-flash-preview`, `google/gemini-3-pro-preview`
> **Note**: Legacy tier-suffixed names like `google/antigravity-gemini-3-pro-high` still work but variants are recommended. Use `--variant=high` with the base model name instead.
Then authenticate:
@@ -183,7 +193,7 @@ When GitHub Copilot is the best available provider, oh-my-opencode uses these mo
| ------------- | -------------------------------- |
| **Sisyphus** | `github-copilot/claude-opus-4.5` |
| **Oracle** | `github-copilot/gpt-5.2` |
| **Explore** | `github-copilot/grok-code-fast-1`|
| **Explore** | `opencode/gpt-5-nano` |
| **Librarian** | `zai-coding-plan/glm-4.7` (if Z.ai available) or fallback |
GitHub Copilot acts as a proxy provider, routing requests to underlying models based on your subscription.
@@ -203,7 +213,7 @@ If Z.ai is the only provider available, all agents will use GLM models:
#### OpenCode Zen
OpenCode Zen provides access to `opencode/` prefixed models including `opencode/claude-opus-4-5`, `opencode/gpt-5.2`, `opencode/grok-code`, and `opencode/glm-4.7-free`.
OpenCode Zen provides access to `opencode/` prefixed models including `opencode/claude-opus-4-5`, `opencode/gpt-5.2`, `opencode/gpt-5-nano`, and `opencode/big-pickle`.
When OpenCode Zen is the best available provider (no native or Copilot), these models are used:
@@ -211,8 +221,8 @@ When OpenCode Zen is the best available provider (no native or Copilot), these m
| ------------- | -------------------------------- |
| **Sisyphus** | `opencode/claude-opus-4-5` |
| **Oracle** | `opencode/gpt-5.2` |
| **Explore** | `opencode/grok-code` |
| **Librarian** | `opencode/glm-4.7-free` |
| **Explore** | `opencode/gpt-5-nano` |
| **Librarian** | `opencode/big-pickle` |
##### Setup

View File

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

View File

@@ -1,6 +1,6 @@
# Understanding the Orchestration System
Oh My OpenCode's orchestration system transforms a simple AI agent into a coordinated development team. This document explains how the Prometheus → Orchestrator → Junior workflow creates high-quality, reliable code output.
Oh My OpenCode's orchestration system transforms a simple AI agent into a coordinated development team. This document explains how the Prometheus → Atlas → Junior workflow creates high-quality, reliable code output.
---
@@ -29,7 +29,7 @@ flowchart TB
end
subgraph Execution["Execution Layer (Orchestrator)"]
Orchestrator["⚡ Orchestrator-Sisyphus<br/>(Conductor)<br/>Claude Opus 4.5"]
Orchestrator["⚡ Atlas<br/>(Conductor)<br/>Claude Opus 4.5"]
end
subgraph Workers["Worker Layer (Specialized Agents)"]
@@ -152,7 +152,7 @@ If REJECTED, Prometheus fixes issues and resubmits. **No maximum retry limit.**
---
## Layer 2: Execution (Orchestrator-Sisyphus)
## Layer 2: Execution (Atlas)
### The Conductor Mindset
@@ -160,7 +160,7 @@ The Orchestrator is like an orchestra conductor: **it doesn't play instruments,
```mermaid
flowchart LR
subgraph Orchestrator["Orchestrator-Sisyphus"]
subgraph Orchestrator["Atlas"]
Read["1. Read Plan"]
Analyze["2. Analyze Tasks"]
Wisdom["3. Accumulate Wisdom"]
@@ -326,13 +326,13 @@ Skills prepend specialized instructions to subagent prompts:
// Category + Skill combination
delegate_task(
category="visual-engineering",
skills=["frontend-ui-ux"], // Adds UI/UX expertise
load_skills=["frontend-ui-ux"], // Adds UI/UX expertise
prompt="..."
)
delegate_task(
category="general",
skills=["playwright"], // Adds browser automation expertise
load_skills=["playwright"], // Adds browser automation expertise
prompt="..."
)
```
@@ -341,8 +341,8 @@ delegate_task(
| Before | After |
|--------|-------|
| Hardcoded: `frontend-ui-ux-engineer` (Gemini 3 Pro) | `category="visual-engineering" + skills=["frontend-ui-ux"]` |
| One-size-fits-all | `category="visual-engineering" + skills=["unity-master"]` |
| Hardcoded: `frontend-ui-ux-engineer` (Gemini 3 Pro) | `category="visual-engineering" + load_skills=["frontend-ui-ux"]` |
| One-size-fits-all | `category="visual-engineering" + load_skills=["unity-master"]` |
| Model bias | Category-based: model abstraction eliminates bias |
---
@@ -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/
@@ -365,7 +365,7 @@ sequenceDiagram
Note over Orchestrator: Prompt Structure:<br/>1. TASK (exact checkbox)<br/>2. EXPECTED OUTCOME<br/>3. REQUIRED SKILLS<br/>4. REQUIRED TOOLS<br/>5. MUST DO<br/>6. MUST NOT DO<br/>7. CONTEXT + Wisdom
Orchestrator->>Junior: delegate_task(category, skills, prompt)
Orchestrator->>Junior: delegate_task(category, load_skills, prompt)
Junior->>Junior: Create todos, execute
Junior->>Junior: Verify (lsp_diagnostics, tests)
@@ -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.1",
"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.1",
"oh-my-opencode-darwin-x64": "3.0.1",
"oh-my-opencode-linux-arm64": "3.0.1",
"oh-my-opencode-linux-arm64-musl": "3.0.1",
"oh-my-opencode-linux-x64": "3.0.1",
"oh-my-opencode-linux-x64-musl": "3.0.1",
"oh-my-opencode-windows-x64": "3.0.1"
},
"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.1",
"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.1",
"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.1",
"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.1",
"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.1",
"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.1",
"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.1",
"description": "Platform-specific binary for oh-my-opencode (windows-x64)",
"license": "MIT",
"repository": {

View File

@@ -711,6 +711,110 @@
"created_at": "2026-01-22T12:39:26Z",
"repoId": 1108837393,
"pullRequestNo": 989
},
{
"name": "l3aro",
"id": 25253808,
"comment_id": 3786383804,
"created_at": "2026-01-22T19:52:42Z",
"repoId": 1108837393,
"pullRequestNo": 999
},
{
"name": "Ssoon-m",
"id": 89559826,
"comment_id": 3788539617,
"created_at": "2026-01-23T06:31:24Z",
"repoId": 1108837393,
"pullRequestNo": 1014
},
{
"name": "veetase",
"id": 2784250,
"comment_id": 3789028002,
"created_at": "2026-01-23T08:27:02Z",
"repoId": 1108837393,
"pullRequestNo": 985
},
{
"name": "RouHim",
"id": 3582050,
"comment_id": 3791988227,
"created_at": "2026-01-23T19:32:01Z",
"repoId": 1108837393,
"pullRequestNo": 1031
},
{
"name": "gongxh0901",
"id": 15622561,
"comment_id": 3793478620,
"created_at": "2026-01-24T02:15:02Z",
"repoId": 1108837393,
"pullRequestNo": 1037
},
{
"name": "gongxh0901",
"id": 15622561,
"comment_id": 3793521632,
"created_at": "2026-01-24T02:23:34Z",
"repoId": 1108837393,
"pullRequestNo": 1037
},
{
"name": "AndersHsueh",
"id": 121805544,
"comment_id": 3793787614,
"created_at": "2026-01-24T04:41:46Z",
"repoId": 1108837393,
"pullRequestNo": 1042
},
{
"name": "AamiRobin",
"id": 22963668,
"comment_id": 3794632200,
"created_at": "2026-01-24T13:28:22Z",
"repoId": 1108837393,
"pullRequestNo": 1067
},
{
"name": "ThanhNguyxn",
"id": 74597207,
"comment_id": 3795232176,
"created_at": "2026-01-24T17:41:53Z",
"repoId": 1108837393,
"pullRequestNo": 1075
},
{
"name": "sadnow",
"id": 87896100,
"comment_id": 3795495342,
"created_at": "2026-01-24T20:49:29Z",
"repoId": 1108837393,
"pullRequestNo": 1080
},
{
"name": "jsl9208",
"id": 4048787,
"comment_id": 3795582626,
"created_at": "2026-01-24T21:41:24Z",
"repoId": 1108837393,
"pullRequestNo": 1082
},
{
"name": "potb",
"id": 10779093,
"comment_id": 3795856573,
"created_at": "2026-01-25T02:38:16Z",
"repoId": 1108837393,
"pullRequestNo": 1083
},
{
"name": "kvokka",
"id": 15954013,
"comment_id": 3795884358,
"created_at": "2026-01-25T03:13:52Z",
"repoId": 1108837393,
"pullRequestNo": 1084
}
]
}

View File

@@ -239,7 +239,7 @@ Ask yourself:
I will use delegate_task with:
- **Category**: [selected-category-name]
- **Why this category**: [how category description matches task domain]
- **Skills**: [list of selected skills]
- **load_skills**: [list of selected skills]
- **Skill evaluation**:
- [skill-1]: INCLUDED because [reason based on skill description]
- [skill-2]: OMITTED because [reason why skill domain doesn't apply]
@@ -256,7 +256,7 @@ I will use delegate_task with:
I will use delegate_task with:
- **Category**: [category-name]
- **Why this category**: Category description says "[quote description]" which matches this task's requirements
- **Skills**: ["skill-a", "skill-b"]
- **load_skills**: ["skill-a", "skill-b"]
- **Skill evaluation**:
- skill-a: INCLUDED - description says "[quote]" which applies to this task
- skill-b: INCLUDED - description says "[quote]" which is needed here
@@ -265,7 +265,7 @@ I will use delegate_task with:
delegate_task(
category="[category-name]",
skills=["skill-a", "skill-b"],
load_skills=["skill-a", "skill-b"],
prompt="..."
)
```
@@ -276,12 +276,12 @@ delegate_task(
I will use delegate_task with:
- **Agent**: [agent-name]
- **Reason**: This requires [agent's specialty] based on agent description
- **Skills**: [] (agents have built-in expertise)
- **load_skills**: [] (agents have built-in expertise)
- **Expected Outcome**: [what agent should return]
delegate_task(
subagent_type="[agent-name]",
skills=[],
load_skills=[],
prompt="..."
)
```
@@ -292,13 +292,13 @@ delegate_task(
I will use delegate_task with:
- **Agent**: explore
- **Reason**: Need to find all authentication implementations across the codebase - this is contextual grep
- **Skills**: []
- **load_skills**: []
- **Expected Outcome**: List of files containing auth patterns
delegate_task(
subagent_type="explore",
run_in_background=true,
skills=[],
load_skills=[],
prompt="Find all authentication implementations in the codebase"
)
```
@@ -306,7 +306,7 @@ delegate_task(
**WRONG: No Skill Evaluation**
```
delegate_task(category="...", skills=[], prompt="...") // Where's the justification?
delegate_task(category="...", load_skills=[], prompt="...") // Where's the justification?
```
**WRONG: Vague Category Selection**
@@ -329,11 +329,11 @@ I'll use this category because it seems right.
```typescript
// CORRECT: Always background, always parallel
// Contextual Grep (internal)
delegate_task(subagent_type="explore", run_in_background=true, skills=[], prompt="Find auth implementations in our codebase...")
delegate_task(subagent_type="explore", run_in_background=true, skills=[], prompt="Find error handling patterns here...")
delegate_task(subagent_type="explore", run_in_background=true, load_skills=[], prompt="Find auth implementations in our codebase...")
delegate_task(subagent_type="explore", run_in_background=true, load_skills=[], prompt="Find error handling patterns here...")
// Reference Grep (external)
delegate_task(subagent_type="librarian", run_in_background=true, skills=[], prompt="Find JWT best practices in official docs...")
delegate_task(subagent_type="librarian", run_in_background=true, skills=[], prompt="Find how production apps handle auth in Express...")
delegate_task(subagent_type="librarian", run_in_background=true, load_skills=[], prompt="Find JWT best practices in official docs...")
delegate_task(subagent_type="librarian", run_in_background=true, load_skills=[], prompt="Find how production apps handle auth in Express...")
// Continue working immediately. Collect with background_output when needed.
// WRONG: Sequential or blocking
@@ -416,7 +416,7 @@ Skills inject specialized instructions into the subagent. Read the description t
For EVERY skill listed above, ask yourself:
> "Does this skill's expertise domain overlap with my task?"
- If YES → INCLUDE in `skills=[...]`
- If YES → INCLUDE in `load_skills=[...]`
- If NO → You MUST justify why (see below)
**STEP 3: Justify Omissions**
@@ -444,14 +444,14 @@ SKILL EVALUATION for "[skill-name]":
```typescript
delegate_task(
category="[selected-category]",
skills=["skill-1", "skill-2"], // Include ALL relevant skills
load_skills=["skill-1", "skill-2"], // Include ALL relevant skills
prompt="..."
)
```
**ANTI-PATTERN (will produce poor results):**
```typescript
delegate_task(category="...", skills=[], prompt="...") // Empty skills without justification
delegate_task(category="...", load_skills=[], prompt="...") // Empty load_skills without justification
```
### Delegation Table:
@@ -724,7 +724,7 @@ If the user's approach seems problematic:
| **Error Handling** | Empty catch blocks `catch(e) {}` |
| **Testing** | Deleting failing tests to "pass" |
| **Search** | Firing agents for single-line typos or obvious syntax errors |
| **Delegation** | Using `skills=[]` without justifying why no skills apply |
| **Delegation** | Using `load_skills=[]` without justifying why no skills apply |
| **Debugging** | Shotgun debugging, random changes |
## Soft Guidelines

View File

@@ -8,17 +8,17 @@
```
agents/
├── atlas.ts # Master Orchestrator (1383 lines)
├── sisyphus.ts # Main prompt (615 lines)
├── sisyphus-junior.ts # Delegated task executor
├── dynamic-agent-prompt-builder.ts # Dynamic prompt generation
├── atlas.ts # Master Orchestrator (572 lines)
├── sisyphus.ts # Main prompt (450 lines)
├── sisyphus-junior.ts # Delegated task executor (135 lines)
├── dynamic-agent-prompt-builder.ts # Dynamic prompt generation (359 lines)
├── oracle.ts # Strategic advisor (GPT-5.2)
├── librarian.ts # Multi-repo research (GLM-4.7-free)
├── librarian.ts # Multi-repo research (326 lines)
├── explore.ts # Fast grep (Grok Code)
├── multimodal-looker.ts # Media analyzer (Gemini 3 Flash)
├── prometheus-prompt.ts # Planning (1196 lines)
├── metis.ts # Plan consultant
├── momus.ts # Plan reviewer
├── metis.ts # Plan consultant (315 lines)
├── momus.ts # Plan reviewer (444 lines)
├── types.ts # AgentModelConfig, AgentPromptMetadata
├── utils.ts # createBuiltinAgents(), resolveModelWithFallback()
└── index.ts # builtinAgents export
@@ -31,8 +31,8 @@ agents/
| Sisyphus | anthropic/claude-opus-4-5 | 0.1 | Primary orchestrator |
| Atlas | anthropic/claude-opus-4-5 | 0.1 | Master orchestrator |
| oracle | openai/gpt-5.2 | 0.1 | Consultation, debugging |
| librarian | opencode/glm-4.7-free | 0.1 | Docs, GitHub search |
| explore | opencode/grok-code | 0.1 | Fast contextual grep |
| librarian | opencode/big-pickle | 0.1 | Docs, GitHub search |
| explore | opencode/gpt-5-nano | 0.1 | Fast contextual grep |
| multimodal-looker | google/gemini-3-flash | 0.1 | PDF/image analysis |
| Prometheus | anthropic/claude-opus-4-5 | 0.1 | Strategic planning |
| Metis | anthropic/claude-sonnet-4-5 | 0.3 | Pre-planning analysis |

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -144,11 +144,11 @@ ${librarianSection}
\`\`\`typescript
// CORRECT: Always background, always parallel
// Contextual Grep (internal)
delegate_task(subagent_type="explore", run_in_background=true, skills=[], prompt="Find auth implementations in our codebase...")
delegate_task(subagent_type="explore", run_in_background=true, skills=[], prompt="Find error handling patterns here...")
delegate_task(subagent_type="explore", run_in_background=true, load_skills=[], prompt="Find auth implementations in our codebase...")
delegate_task(subagent_type="explore", run_in_background=true, load_skills=[], prompt="Find error handling patterns here...")
// Reference Grep (external)
delegate_task(subagent_type="librarian", run_in_background=true, skills=[], prompt="Find JWT best practices in official docs...")
delegate_task(subagent_type="librarian", run_in_background=true, skills=[], prompt="Find how production apps handle auth in Express...")
delegate_task(subagent_type="librarian", run_in_background=true, load_skills=[], prompt="Find JWT best practices in official docs...")
delegate_task(subagent_type="librarian", run_in_background=true, load_skills=[], prompt="Find how production apps handle auth in Express...")
// Continue working immediately. Collect with background_output when needed.
// WRONG: Sequential or blocking
@@ -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 continue when:**
| Scenario | Action |
|----------|--------|
| Task failed/incomplete | \`session_id="{session_id}", prompt="Fix: {specific error}"\` |
| Follow-up question on result | \`session_id="{session_id}", prompt="Also: {question}"\` |
| Multi-turn with same agent | \`session_id="{session_id}"\` - NEVER start fresh |
| Verification failed | \`session_id="{session_id}", prompt="Failed verification: {error}. Fix."\` |
**Why session_id 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(session_id="ses_abc123", prompt="Fix: Type error on line 42")
\`\`\`
**After EVERY delegation, STORE the session_id for potential continuation.**
### Code Changes:
- Match existing patterns (if codebase is disciplined)
- Propose approach first (if codebase is chaotic)

View File

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

View File

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

View File

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

View File

@@ -10,8 +10,9 @@ CLI entry: `bunx oh-my-opencode`. Interactive installer, doctor diagnostics. Com
cli/
├── index.ts # Commander.js entry
├── install.ts # Interactive TUI (520 lines)
├── config-manager.ts # JSONC parsing (641 lines)
├── config-manager.ts # JSONC parsing (664 lines)
├── types.ts # InstallArgs, InstallConfig
├── model-fallback.ts # Model fallback configuration
├── doctor/
│ ├── index.ts # Doctor entry
│ ├── runner.ts # Check orchestration
@@ -25,6 +26,7 @@ cli/
│ ├── dependencies.ts # AST-Grep, Comment Checker
│ ├── lsp.ts # LSP connectivity
│ ├── mcp.ts # MCP validation
│ ├── model-resolution.ts # Model resolution check
│ └── gh.ts # GitHub CLI
├── run/
│ └── index.ts # Session launcher

File diff suppressed because it is too large Load Diff

View File

@@ -170,7 +170,7 @@ describe("fetchNpmDistTags", () => {
})
describe("config-manager ANTIGRAVITY_PROVIDER_CONFIG", () => {
test("Gemini models include full spec (limit + modalities)", () => {
test("all models include full spec (limit + modalities + Antigravity label)", () => {
const google = (ANTIGRAVITY_PROVIDER_CONFIG as any).google
expect(google).toBeTruthy()
@@ -178,9 +178,11 @@ describe("config-manager ANTIGRAVITY_PROVIDER_CONFIG", () => {
expect(models).toBeTruthy()
const required = [
"antigravity-gemini-3-pro-high",
"antigravity-gemini-3-pro-low",
"antigravity-gemini-3-pro",
"antigravity-gemini-3-flash",
"antigravity-claude-sonnet-4-5",
"antigravity-claude-sonnet-4-5-thinking",
"antigravity-claude-opus-4-5-thinking",
]
for (const key of required) {
@@ -198,6 +200,43 @@ describe("config-manager ANTIGRAVITY_PROVIDER_CONFIG", () => {
expect(Array.isArray(model.modalities.output)).toBe(true)
}
})
test("Gemini models have variant definitions", () => {
// #given the antigravity provider config
const models = (ANTIGRAVITY_PROVIDER_CONFIG as any).google.models as Record<string, any>
// #when checking Gemini Pro variants
const pro = models["antigravity-gemini-3-pro"]
// #then should have low and high variants
expect(pro.variants).toBeTruthy()
expect(pro.variants.low).toBeTruthy()
expect(pro.variants.high).toBeTruthy()
// #when checking Gemini Flash variants
const flash = models["antigravity-gemini-3-flash"]
// #then should have minimal, low, medium, high variants
expect(flash.variants).toBeTruthy()
expect(flash.variants.minimal).toBeTruthy()
expect(flash.variants.low).toBeTruthy()
expect(flash.variants.medium).toBeTruthy()
expect(flash.variants.high).toBeTruthy()
})
test("Claude thinking models have variant definitions", () => {
// #given the antigravity provider config
const models = (ANTIGRAVITY_PROVIDER_CONFIG as any).google.models as Record<string, any>
// #when checking Claude thinking variants
const sonnetThinking = models["antigravity-claude-sonnet-4-5-thinking"]
const opusThinking = models["antigravity-claude-opus-4-5-thinking"]
// #then both should have low and max variants
for (const model of [sonnetThinking, opusThinking]) {
expect(model.variants).toBeTruthy()
expect(model.variants.low).toBeTruthy()
expect(model.variants.max).toBeTruthy()
}
})
})
describe("generateOmoConfig - model fallback system", () => {
@@ -219,7 +258,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 +277,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 +296,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 +316,7 @@ describe("generateOmoConfig - model fallback system", () => {
// #then should use ultimate fallback for all agents
expect(result.$schema).toBe("https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json")
expect((result.agents as Record<string, { model: string }>).Sisyphus.model).toBe("opencode/glm-4.7-free")
expect((result.agents as Record<string, { model: string }>).sisyphus.model).toBe("opencode/big-pickle")
})
test("uses zai-coding-plan/glm-4.7 for librarian when Z.ai available", () => {
@@ -298,7 +337,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 +356,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 +382,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 +397,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

@@ -497,38 +497,61 @@ export async function runBunInstallWithDetails(): Promise<BunInstallResult> {
*
* IMPORTANT: Model names MUST use `antigravity-` prefix for stability.
*
* The opencode-antigravity-auth plugin supports two naming conventions:
* - `antigravity-gemini-3-pro-high` (RECOMMENDED, explicit Antigravity quota routing)
* - `gemini-3-pro-high` (LEGACY, backward compatible but may break in future)
* Since opencode-antigravity-auth v1.3.0, models use a variant system:
* - `antigravity-gemini-3-pro` with variants: low, high
* - `antigravity-gemini-3-flash` with variants: minimal, low, medium, high
*
* Legacy names rely on Gemini CLI using `-preview` suffix for disambiguation.
* If Google removes `-preview`, legacy names may route to wrong quota.
* Legacy tier-suffixed names (e.g., `antigravity-gemini-3-pro-high`) still work
* but variants are the recommended approach.
*
* @see https://github.com/NoeFabris/opencode-antigravity-auth#migration-guide-v127
* @see https://github.com/NoeFabris/opencode-antigravity-auth#models
*/
export const ANTIGRAVITY_PROVIDER_CONFIG = {
google: {
name: "Google",
models: {
"antigravity-gemini-3-pro-high": {
name: "Gemini 3 Pro High (Antigravity)",
thinking: true,
attachment: true,
limit: { context: 1048576, output: 65535 },
modalities: { input: ["text", "image", "pdf"], output: ["text"] },
},
"antigravity-gemini-3-pro-low": {
name: "Gemini 3 Pro Low (Antigravity)",
thinking: true,
attachment: true,
"antigravity-gemini-3-pro": {
name: "Gemini 3 Pro (Antigravity)",
limit: { context: 1048576, output: 65535 },
modalities: { input: ["text", "image", "pdf"], output: ["text"] },
variants: {
low: { thinkingLevel: "low" },
high: { thinkingLevel: "high" },
},
},
"antigravity-gemini-3-flash": {
name: "Gemini 3 Flash (Antigravity)",
attachment: true,
limit: { context: 1048576, output: 65536 },
modalities: { input: ["text", "image", "pdf"], output: ["text"] },
variants: {
minimal: { thinkingLevel: "minimal" },
low: { thinkingLevel: "low" },
medium: { thinkingLevel: "medium" },
high: { thinkingLevel: "high" },
},
},
"antigravity-claude-sonnet-4-5": {
name: "Claude Sonnet 4.5 (Antigravity)",
limit: { context: 200000, output: 64000 },
modalities: { input: ["text", "image", "pdf"], output: ["text"] },
},
"antigravity-claude-sonnet-4-5-thinking": {
name: "Claude Sonnet 4.5 Thinking (Antigravity)",
limit: { context: 200000, output: 64000 },
modalities: { input: ["text", "image", "pdf"], output: ["text"] },
variants: {
low: { thinkingConfig: { thinkingBudget: 8192 } },
max: { thinkingConfig: { thinkingBudget: 32768 } },
},
},
"antigravity-claude-opus-4-5-thinking": {
name: "Claude Opus 4.5 Thinking (Antigravity)",
limit: { context: 200000, output: 64000 },
modalities: { input: ["text", "image", "pdf"], output: ["text"] },
variants: {
low: { thinkingConfig: { thinkingBudget: 8192 } },
max: { thinkingConfig: { thinkingBudget: 32768 } },
},
},
},
},

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,12 +12,14 @@ features/
│ ├── manager.ts # Launch → poll → complete
│ ├── concurrency.ts # Per-provider limits
│ └── types.ts # BackgroundTask, LaunchInput
├── skill-mcp-manager/ # MCP client lifecycle
├── skill-mcp-manager/ # MCP client lifecycle (520 lines)
│ ├── manager.ts # Lazy loading, cleanup
│ └── types.ts # SkillMcpConfig
├── builtin-skills/ # Playwright, git-master, frontend-ui-ux
│ └── skills.ts # 1203 lines
├── builtin-commands/ # ralph-loop, refactor, init-deep
├── builtin-commands/ # ralph-loop, refactor, init-deep, start-work, remove-deadcode
│ ├── commands.ts # Command registry
│ └── templates/ # Command templates (4 files)
├── claude-code-agent-loader/ # ~/.claude/agents/*.md
├── claude-code-command-loader/ # ~/.claude/commands/*.md
├── claude-code-mcp-loader/ # .mcp.json
@@ -26,7 +28,8 @@ features/
├── opencode-skill-loader/ # Skills from 6 directories
├── context-injector/ # AGENTS.md/README.md injection
├── boulder-state/ # Todo state persistence
── hook-message-injector/ # Message injection
── hook-message-injector/ # Message injection
└── task-toast-manager/ # Background task notifications
```
## LOADER PRIORITY

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>
@@ -81,7 +81,7 @@ export function loadBuiltinCommands(
for (const [name, definition] of Object.entries(BUILTIN_COMMAND_DEFINITIONS)) {
if (!disabled.has(name as BuiltinCommandName)) {
const { argumentHint: _argumentHint, ...openCodeCompatible } = definition
commands[name] = openCodeCompatible as CommandDefinition
commands[name] = { ...openCodeCompatible, name } as CommandDefinition
}
}

View File

@@ -17,7 +17,7 @@ export const RALPH_LOOP_TEMPLATE = `You are starting a Ralph Loop - a self-refer
## Exit Conditions
1. **Completion**: Output \`<promise>DONE</promise>\` (or custom promise text) when fully complete
1. **Completion**: Output your completion promise tag when fully complete
2. **Max Iterations**: Loop stops automatically at limit
3. **Cancel**: User runs \`/cancel-ralph\` command

View File

@@ -1,6 +1,6 @@
---
name: git-master
description: "MUST USE for ANY git operations. Atomic commits, rebase/squash, history search (blame, bisect, log -S). STRONGLY RECOMMENDED: Use with delegate_task(category='quick', skills=['git-master'], ...) to save context. Triggers: 'commit', 'rebase', 'squash', 'who wrote', 'when was X added', 'find the commit that'."
description: "MUST USE for ANY git operations. Atomic commits, rebase/squash, history search (blame, bisect, log -S). STRONGLY RECOMMENDED: Use with delegate_task(category='quick', load_skills=['git-master'], ...) to save context. Triggers: 'commit', 'rebase', 'squash', 'who wrote', 'when was X added', 'find the commit that'."
---
# Git Master Agent

View File

@@ -95,7 +95,7 @@ Interpret creatively and make unexpected choices that feel genuinely designed fo
const gitMasterSkill: BuiltinSkill = {
name: "git-master",
description:
"MUST USE for ANY git operations. Atomic commits, rebase/squash, history search (blame, bisect, log -S). STRONGLY RECOMMENDED: Use with delegate_task(category='quick', skills=['git-master'], ...) to save context. Triggers: 'commit', 'rebase', 'squash', 'who wrote', 'when was X added', 'find the commit that'.",
"MUST USE for ANY git operations. Atomic commits, rebase/squash, history search (blame, bisect, log -S). STRONGLY RECOMMENDED: Use with delegate_task(category='quick', load_skills=['git-master'], ...) to save context. Triggers: 'commit', 'rebase', 'squash', 'who wrote', 'when was X added', 'find the commit that'.",
template: `# Git Master Agent
You are a Git expert combining three specializations:

View File

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

View File

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

View File

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

View File

@@ -8,9 +8,9 @@
```
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
├── todo-continuation-enforcer.ts # Force TODO completion (489 lines)
├── ralph-loop/ # Self-referential dev loop
├── claude-code-hooks/ # settings.json compat layer - see AGENTS.md
├── comment-checker/ # Prevents AI slop
@@ -28,7 +28,15 @@ hooks/
├── prometheus-md-only/ # Planner read-only mode
├── agent-usage-reminder/ # Specialized agent hints
├── auto-update-checker/ # Plugin update check
── tool-output-truncator.ts # Prevents context bloat
── tool-output-truncator.ts # Prevents context bloat
├── compaction-context-injector/ # Injects context on compaction
├── delegate-task-retry/ # Retries failed delegations
├── interactive-bash-session/ # Tmux session management
├── non-interactive-env/ # Non-TTY environment handling
├── start-work/ # Sisyphus work session starter
├── task-resume-info/ # Resume info for cancelled tasks
├── question-label-truncator/ # Auto-truncates question labels >30 chars
└── index.ts # Hook aggregation + registration
```
## HOOK EVENTS

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 = {
@@ -141,7 +141,7 @@ describe("atlas hook", () => {
// #then - standalone verification reminder appended
expect(output.output).toContain("Task completed successfully")
expect(output.output).toContain("MANDATORY:")
expect(output.output).toContain("delegate_task(resume=")
expect(output.output).toContain("delegate_task(session_id=")
cleanupMessageStorage(sessionID)
})
@@ -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")
@@ -180,7 +180,7 @@ describe("atlas hook", () => {
expect(output.output).toContain("SUBAGENT WORK COMPLETED")
expect(output.output).toContain("test-plan")
expect(output.output).toContain("LIE")
expect(output.output).toContain("delegate_task(resume=")
expect(output.output).toContain("delegate_task(session_id=")
cleanupMessageStorage(sessionID)
})
@@ -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")
@@ -332,10 +332,10 @@ describe("atlas hook", () => {
cleanupMessageStorage(sessionID)
})
test("should include resume and checkbox instructions in reminder", async () => {
test("should include session_id 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")
@@ -361,8 +361,8 @@ describe("atlas hook", () => {
output
)
// #then - should include resume instructions and verification
expect(output.output).toContain("delegate_task(resume=")
// #then - should include session_id instructions and verification
expect(output.output).toContain("delegate_task(session_id=")
expect(output.output).toContain("[x]")
expect(output.output).toContain("MANDATORY:")

View File

@@ -179,13 +179,13 @@ If you were NOT given **exactly ONE atomic task**, you MUST:
`
function buildVerificationReminder(sessionId: string): string {
return `${VERIFICATION_REMINDER}
return `${VERIFICATION_REMINDER}
---
**If ANY verification fails, use this immediately:**
\`\`\`
delegate_task(resume="${sessionId}", prompt="fix: [describe the specific failure]")
delegate_task(session_id="${sessionId}", prompt="fix: [describe the specific failure]")
\`\`\``
}
@@ -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 }],
},
@@ -709,8 +711,8 @@ export function createAtlasHook(
return
}
const outputStr = output.output && typeof output.output === "string" ? output.output : ""
const isBackgroundLaunch = outputStr.includes("Background task launched") || outputStr.includes("Background task resumed")
const outputStr = output.output && typeof output.output === "string" ? output.output : ""
const isBackgroundLaunch = outputStr.includes("Background task launched") || outputStr.includes("Background task continued")
if (isBackgroundLaunch) {
return

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

@@ -1,87 +0,0 @@
import type { BackgroundManager } from "../../features/background-agent"
interface CompactingInput {
sessionID: string
}
interface CompactingOutput {
context: string[]
prompt?: string
}
/**
* Background agent compaction hook - preserves task state during context compaction.
*
* When OpenCode compacts session context to save tokens, this hook injects
* information about running and recently completed background tasks so the
* agent doesn't lose awareness of delegated work.
*/
export function createBackgroundCompactionHook(manager: BackgroundManager) {
return {
"experimental.session.compacting": async (
input: CompactingInput,
output: CompactingOutput
): Promise<void> => {
const { sessionID } = input
// Get running tasks for this session
const running = manager.getRunningTasks()
.filter(t => t.parentSessionID === sessionID)
.map(t => ({
id: t.id,
agent: t.agent,
description: t.description,
startedAt: t.startedAt,
}))
// Get recently completed tasks (still in memory within 5-min retention)
const completed = manager.getCompletedTasks()
.filter(t => t.parentSessionID === sessionID)
.slice(-10) // Last 10 completed
.map(t => ({
id: t.id,
agent: t.agent,
description: t.description,
status: t.status,
}))
// Early exit if nothing to preserve
if (running.length === 0 && completed.length === 0) return
const sections: string[] = ["<background-tasks>"]
// Running tasks section
if (running.length > 0) {
sections.push("## Running Background Tasks")
sections.push("")
for (const t of running) {
const elapsed = t.startedAt
? Math.floor((Date.now() - t.startedAt.getTime()) / 1000)
: 0
sections.push(`- **\`${t.id}\`** (${t.agent}): ${t.description} [${elapsed}s elapsed]`)
}
sections.push("")
sections.push("> **Note:** You WILL be notified when tasks complete.")
sections.push("> Do NOT poll - continue productive work.")
sections.push("")
}
// Completed tasks section
if (completed.length > 0) {
sections.push("## Recently Completed Tasks")
sections.push("")
for (const t of completed) {
const statusLabel = t.status === "completed" ? "[DONE]" : t.status === "error" ? "[ERROR]" : "[PENDING]"
sections.push(`- ${statusLabel} **\`${t.id}\`**: ${t.description}`)
}
sections.push("")
}
sections.push("## Retrieval")
sections.push('Use `background_output(task_id="<id>")` to retrieve task results.')
sections.push("</background-tasks>")
output.context.push(sections.join("\n"))
}
}
}

View File

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

View File

@@ -169,10 +169,10 @@ TELL THE USER WHAT AGENTS YOU WILL LEVERAGE NOW TO SATISFY USER'S REQUEST.
## AGENTS / **CATEGORY + SKILLS** UTILIZATION PRINCIPLES (by capability, not by name)
- **Codebase Exploration**: Spawn exploration agents using BACKGROUND TASKS for file patterns, internal implementations, project structure
- **Documentation & References**: Use librarian-type agents via BACKGROUND TASKS for API references, examples, external library docs
- **Planning & Strategy**: NEVER plan yourself - ALWAYS spawn a dedicated planning agent for work breakdown
- MUST USE PLAN AGENT. MUST USE PLAN AGENT. MUST USE PLAN AGENT.
- ALWAYS ASK PLAN AGENT TO WHAT CATEGORY + SKILLS / AGENTS TO LEVERAGE.
- IF IMPLEMENT TASK, MUST ADD TODO NOW: "CONSULT WITH PLAN AGENT WITH CATEGORY + SKILLS"
- **Planning & Strategy**: NEVER plan yourself - ALWAYS spawn the Plan agent for work breakdown
- MUST invoke: \`delegate_task(subagent_type="plan", prompt="<gathered context + user request>")\`
- In your prompt to the Plan agent, ASK it to recommend which CATEGORY + SKILLS / AGENTS to leverage for implementation.
- IF IMPLEMENT TASK, MUST ADD TODO NOW: "Consult Plan agent via delegate_task(subagent_type='plan') for work breakdown with category + skills recommendations"
- **High-IQ Reasoning**: Leverage specialized agents for architecture decisions, code review, strategic planning
- **SPECIAL TASKS COVERED WITH CATEGORY + LOAD_SKILLS**: Delegate to specialized agents with category+skills for design and implementation, as following guide:
- CATEGORY + SKILL GUIDE
@@ -192,7 +192,7 @@ TELL THE USER WHAT AGENTS YOU WILL LEVERAGE NOW TO SATISFY USER'S REQUEST.
## WORKFLOW
1. Analyze the request and identify required capabilities
2. Spawn exploration/librarian agents via delegate_task(background=true) in PARALLEL (10+ if needed)
3. Always Use Plan agent with gathered context to create detailed work breakdown
3. Spawn Plan agent: \`delegate_task(subagent_type="plan", prompt="<context + request>")\` to create detailed work breakdown
4. Execute with continuous verification against original requirements
## VERIFICATION GUARANTEE (NON-NEGOTIABLE)
@@ -266,9 +266,9 @@ Write these criteria explicitly. Share with user if scope is non-trivial.
THE USER ASKED FOR X. DELIVER EXACTLY X. NOT A SUBSET. NOT A DEMO. NOT A STARTING POINT.
1. EXPLORES + LIBRARIANS
2. GATHER -> PLAN AGENT SPAWN
3. WORK BY DELEGATING TO ANOTHER AGENTS
1. EXPLORES + LIBRARIANS (background)
2. GATHER -> delegate_task(subagent_type="plan", prompt="<context + request>")
3. WORK BY DELEGATING TO CATEGORY + SKILLS AGENTS
NOW.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -459,7 +459,7 @@ describe("ralph-loop", () => {
})
hook.startLoop("session-123", "Build something", { completionPromise: "COMPLETE" })
writeFileSync(transcriptPath, JSON.stringify({ content: "Task done <promise>COMPLETE</promise>" }))
writeFileSync(transcriptPath, JSON.stringify({ type: "tool_result", tool_name: "write", tool_output: { output: "Task done <promise>COMPLETE</promise>" } }) + "\n")
// #when - session goes idle (transcriptPath now derived from sessionID via getTranscriptPath)
await hook.event({
@@ -703,10 +703,105 @@ describe("ralph-loop", () => {
expect(promptCalls[0].text).toContain("2/50")
})
test("should NOT detect completion from user message in transcript (issue #622)", async () => {
// #given - transcript contains user message with template text that includes completion promise
// This reproduces the bug where the RALPH_LOOP_TEMPLATE instructional text
// containing `<promise>DONE</promise>` is recorded as a user message and
// falsely triggers completion detection
const transcriptPath = join(TEST_DIR, "transcript.jsonl")
const templateText = `You are starting a Ralph Loop...
Output <promise>DONE</promise> when fully complete`
const userEntry = JSON.stringify({
type: "user",
timestamp: new Date().toISOString(),
content: templateText,
})
writeFileSync(transcriptPath, userEntry + "\n")
const hook = createRalphLoopHook(createMockPluginInput(), {
getTranscriptPath: () => transcriptPath,
})
hook.startLoop("session-123", "Build something", { completionPromise: "DONE" })
// #when - session goes idle
await hook.event({
event: {
type: "session.idle",
properties: { sessionID: "session-123" },
},
})
// #then - loop should CONTINUE (user message completion promise is instructional, not actual)
expect(promptCalls.length).toBe(1)
expect(hook.getState()?.iteration).toBe(2)
})
test("should NOT detect completion from continuation prompt in transcript (issue #622)", async () => {
// #given - transcript contains continuation prompt (also a user message) with completion promise
const transcriptPath = join(TEST_DIR, "transcript.jsonl")
const continuationText = `RALPH LOOP 2/100
When FULLY complete, output: <promise>DONE</promise>
Original task: Build something`
const userEntry = JSON.stringify({
type: "user",
timestamp: new Date().toISOString(),
content: continuationText,
})
writeFileSync(transcriptPath, userEntry + "\n")
const hook = createRalphLoopHook(createMockPluginInput(), {
getTranscriptPath: () => transcriptPath,
})
hook.startLoop("session-123", "Build something", { completionPromise: "DONE" })
// #when - session goes idle
await hook.event({
event: {
type: "session.idle",
properties: { sessionID: "session-123" },
},
})
// #then - loop should CONTINUE (continuation prompt text is not actual completion)
expect(promptCalls.length).toBe(1)
expect(hook.getState()?.iteration).toBe(2)
})
test("should detect completion from tool_result entry in transcript", async () => {
// #given - transcript contains a tool_result with completion promise
const transcriptPath = join(TEST_DIR, "transcript.jsonl")
const toolResultEntry = JSON.stringify({
type: "tool_result",
timestamp: new Date().toISOString(),
tool_name: "write",
tool_input: {},
tool_output: { output: "Task complete! <promise>DONE</promise>" },
})
writeFileSync(transcriptPath, toolResultEntry + "\n")
const hook = createRalphLoopHook(createMockPluginInput(), {
getTranscriptPath: () => transcriptPath,
})
hook.startLoop("session-123", "Build something", { completionPromise: "DONE" })
// #when - session goes idle
await hook.event({
event: {
type: "session.idle",
properties: { sessionID: "session-123" },
},
})
// #then - loop should complete (tool_result contains actual completion output)
expect(promptCalls.length).toBe(0)
expect(toastCalls.some((t) => t.title === "Ralph Loop Complete!")).toBe(true)
expect(hook.getState()).toBeNull()
})
test("should check transcript BEFORE API to optimize performance", async () => {
// #given - transcript has completion promise
const transcriptPath = join(TEST_DIR, "transcript.jsonl")
writeFileSync(transcriptPath, JSON.stringify({ content: "<promise>DONE</promise>" }))
writeFileSync(transcriptPath, JSON.stringify({ type: "tool_result", tool_name: "write", tool_output: { output: "<promise>DONE</promise>" } }) + "\n")
mockSessionMessages = [
{ info: { role: "assistant" }, parts: [{ type: "text", text: "No promise here" }] },
]
@@ -736,7 +831,7 @@ describe("ralph-loop", () => {
const hook = createRalphLoopHook(createMockPluginInput(), {
getTranscriptPath: () => transcriptPath,
})
writeFileSync(transcriptPath, JSON.stringify({ content: "<promise>DONE</promise>" }))
writeFileSync(transcriptPath, JSON.stringify({ type: "tool_result", tool_name: "write", tool_output: { output: "<promise>DONE</promise>" } }) + "\n")
hook.startLoop("test-id", "Build API", { ultrawork: true })
// #when - idle event triggered
@@ -754,7 +849,7 @@ describe("ralph-loop", () => {
const hook = createRalphLoopHook(createMockPluginInput(), {
getTranscriptPath: () => transcriptPath,
})
writeFileSync(transcriptPath, JSON.stringify({ content: "<promise>DONE</promise>" }))
writeFileSync(transcriptPath, JSON.stringify({ type: "tool_result", tool_name: "write", tool_output: { output: "<promise>DONE</promise>" } }) + "\n")
hook.startLoop("test-id", "Build API")
// #when - idle event triggered

View File

@@ -100,7 +100,18 @@ export function createRalphLoopHook(
const content = readFileSync(transcriptPath, "utf-8")
const pattern = new RegExp(`<promise>\\s*${escapeRegex(promise)}\\s*</promise>`, "is")
return pattern.test(content)
const lines = content.split("\n").filter(l => l.trim())
for (const line of lines) {
try {
const entry = JSON.parse(line)
if (entry.type === "user") continue
if (pattern.test(line)) return true
} catch {
continue
}
}
return false
} catch {
return false
}

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

@@ -16,21 +16,21 @@ function extractSessionId(output: string): string | null {
}
export function createTaskResumeInfoHook() {
const toolExecuteAfter = async (
input: { tool: string; sessionID: string; callID: string },
output: { title: string; output: string; metadata: unknown }
) => {
if (!TARGET_TOOLS.includes(input.tool)) return
if (output.output.startsWith("Error:") || output.output.startsWith("Failed")) return
if (output.output.includes("\nto resume:")) return
const toolExecuteAfter = async (
input: { tool: string; sessionID: string; callID: string },
output: { title: string; output: string; metadata: unknown }
) => {
if (!TARGET_TOOLS.includes(input.tool)) return
if (output.output.startsWith("Error:") || output.output.startsWith("Failed")) return
if (output.output.includes("\nto continue:")) return
const sessionId = extractSessionId(output.output)
if (!sessionId) return
const sessionId = extractSessionId(output.output)
if (!sessionId) return
output.output = output.output.trimEnd() + `\n\nto resume: delegate_task(resume="${sessionId}", prompt="...")`
}
output.output = output.output.trimEnd() + `\n\nto continue: delegate_task(session_id="${sessionId}", prompt="...")`
}
return {
"tool.execute.after": toolExecuteAfter,
}
return {
"tool.execute.after": toolExecuteAfter,
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,7 @@
## OVERVIEW
50 cross-cutting utilities: path resolution, token truncation, config parsing, model resolution.
34 cross-cutting utilities: path resolution, token truncation, config parsing, model resolution, agent display names.
## STRUCTURE
@@ -19,12 +19,27 @@ shared/
├── migration.ts # Legacy config migration
├── opencode-version.ts # Version comparison
├── external-plugin-detector.ts # OAuth spoofing detection
├── env-expander.ts # ${VAR} expansion
├── model-requirements.ts # Agent/Category requirements
├── model-availability.ts # Models fetch + fuzzy match
├── model-resolver.ts # 3-step resolution
├── model-sanitizer.ts # Model ID normalization
├── shell-env.ts # Cross-platform shell
├── prompt-parts-helper.ts # Prompt manipulation
├── agent-display-names.ts # Agent display name mapping
├── agent-tool-restrictions.ts # Tool restriction helpers
├── agent-variant.ts # Agent variant detection
├── command-executor.ts # Subprocess execution
├── config-errors.ts # Config error types
├── deep-merge.ts # Deep object merge
├── file-reference-resolver.ts # File path resolution
├── file-utils.ts # File utilities
├── hook-disabled.ts # Hook enable/disable check
├── pattern-matcher.ts # Glob pattern matching
├── session-cursor.ts # Session cursor tracking
├── snake-case.ts # String case conversion
├── system-directive.ts # System prompt helpers
├── tool-name.ts # Tool name constants
├── zip-extractor.ts # ZIP file extraction
├── index.ts # Barrel export
└── *.test.ts # Colocated tests
```
@@ -40,6 +55,7 @@ shared/
| Resolve paths | `getOpenCodeConfigDir()` |
| Compare versions | `isOpenCodeVersionAtLeast("1.1.0")` |
| Resolve model | `resolveModelWithFallback()` |
| Agent display name | `getAgentDisplayName(agentName)` |
## PATTERNS

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,43 +0,0 @@
import * as path from "path"
import * as os from "os"
import * as fs from "fs"
/**
* Returns the user-level config directory based on the OS.
* @deprecated Use getOpenCodeConfigDir() from opencode-config-dir.ts instead.
*/
export function getUserConfigDir(): string {
if (process.platform === "win32") {
const crossPlatformDir = path.join(os.homedir(), ".config")
const crossPlatformConfigPath = path.join(crossPlatformDir, "opencode", "oh-my-opencode.json")
const appdataDir = process.env.APPDATA || path.join(os.homedir(), "AppData", "Roaming")
const appdataConfigPath = path.join(appdataDir, "opencode", "oh-my-opencode.json")
if (fs.existsSync(crossPlatformConfigPath)) {
return crossPlatformDir
}
if (fs.existsSync(appdataConfigPath)) {
return appdataDir
}
return crossPlatformDir
}
return process.env.XDG_CONFIG_HOME || path.join(os.homedir(), ".config")
}
/**
* Returns the full path to the user-level oh-my-opencode config file.
*/
export function getUserConfigPath(): string {
return path.join(getUserConfigDir(), "opencode", "oh-my-opencode.json")
}
/**
* Returns the full path to the project-level oh-my-opencode config file.
*/
export function getProjectConfigPath(directory: string): string {
return path.join(directory, ".opencode", "oh-my-opencode.json")
}

View File

@@ -10,7 +10,6 @@ export * from "./hook-disabled"
export * from "./deep-merge"
export * from "./file-utils"
export * from "./dynamic-truncator"
export * from "./config-path"
export * from "./data-path"
export * from "./config-errors"
export * from "./claude-config-dir"

View File

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

View File

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

View File

@@ -1,26 +1,43 @@
import { describe, it, expect, beforeEach } from "bun:test"
import { describe, it, expect, beforeEach, afterEach } from "bun:test"
import { mkdtempSync, writeFileSync, rmSync } from "fs"
import { tmpdir } from "os"
import { join } from "path"
import { fetchAvailableModels, fuzzyMatchModel, __resetModelCache } from "./model-availability"
describe("fetchAvailableModels", () => {
let mockClient: any
let tempDir: string
let originalXdgCache: string | undefined
beforeEach(() => {
__resetModelCache()
tempDir = mkdtempSync(join(tmpdir(), "opencode-test-"))
originalXdgCache = process.env.XDG_CACHE_HOME
process.env.XDG_CACHE_HOME = tempDir
})
it("#given API returns list of models #when fetchAvailableModels called #then returns Set of model IDs", async () => {
const mockModels = [
{ id: "openai/gpt-5.2", name: "GPT-5.2" },
{ id: "anthropic/claude-opus-4-5", name: "Claude Opus 4.5" },
{ id: "google/gemini-3-pro", name: "Gemini 3 Pro" },
]
mockClient = {
model: {
list: async () => mockModels,
},
afterEach(() => {
if (originalXdgCache !== undefined) {
process.env.XDG_CACHE_HOME = originalXdgCache
} else {
delete process.env.XDG_CACHE_HOME
}
rmSync(tempDir, { recursive: true, force: true })
})
const result = await fetchAvailableModels(mockClient)
function writeModelsCache(data: Record<string, any>) {
const cacheDir = join(tempDir, "opencode")
require("fs").mkdirSync(cacheDir, { recursive: true })
writeFileSync(join(cacheDir, "models.json"), JSON.stringify(data))
}
it("#given cache file with models #when fetchAvailableModels called #then returns Set of model IDs", async () => {
writeModelsCache({
openai: { id: "openai", models: { "gpt-5.2": { id: "gpt-5.2" } } },
anthropic: { id: "anthropic", models: { "claude-opus-4-5": { id: "claude-opus-4-5" } } },
google: { id: "google", models: { "gemini-3-pro": { id: "gemini-3-pro" } } },
})
const result = await fetchAvailableModels()
expect(result).toBeInstanceOf(Set)
expect(result.size).toBe(3)
@@ -29,77 +46,50 @@ describe("fetchAvailableModels", () => {
expect(result.has("google/gemini-3-pro")).toBe(true)
})
it("#given API fails #when fetchAvailableModels called #then returns empty Set without throwing", async () => {
mockClient = {
model: {
list: async () => {
throw new Error("API connection failed")
},
},
}
const result = await fetchAvailableModels(mockClient)
it("#given cache file not found #when fetchAvailableModels called #then returns empty Set", async () => {
const result = await fetchAvailableModels()
expect(result).toBeInstanceOf(Set)
expect(result.size).toBe(0)
})
it("#given API called twice #when second call made #then uses cached result without re-fetching", async () => {
let callCount = 0
const mockModels = [
{ id: "openai/gpt-5.2", name: "GPT-5.2" },
{ id: "anthropic/claude-opus-4-5", name: "Claude Opus 4.5" },
]
mockClient = {
model: {
list: async () => {
callCount++
return mockModels
},
},
}
it("#given cache read twice #when second call made #then uses cached result", async () => {
writeModelsCache({
openai: { id: "openai", models: { "gpt-5.2": { id: "gpt-5.2" } } },
anthropic: { id: "anthropic", models: { "claude-opus-4-5": { id: "claude-opus-4-5" } } },
})
const result1 = await fetchAvailableModels(mockClient)
const result2 = await fetchAvailableModels(mockClient)
const result1 = await fetchAvailableModels()
const result2 = await fetchAvailableModels()
expect(callCount).toBe(1)
expect(result1).toEqual(result2)
expect(result1.has("openai/gpt-5.2")).toBe(true)
})
it("#given empty model list from API #when fetchAvailableModels called #then returns empty Set", async () => {
mockClient = {
model: {
list: async () => [],
},
}
it("#given empty providers in cache #when fetchAvailableModels called #then returns empty Set", async () => {
writeModelsCache({})
const result = await fetchAvailableModels(mockClient)
const result = await fetchAvailableModels()
expect(result).toBeInstanceOf(Set)
expect(result.size).toBe(0)
})
it("#given API returns models with various formats #when fetchAvailableModels called #then extracts all IDs correctly", async () => {
const mockModels = [
{ id: "openai/gpt-5.2-codex", name: "GPT-5.2 Codex" },
{ id: "anthropic/claude-sonnet-4-5", name: "Claude Sonnet 4.5" },
{ id: "google/gemini-3-flash", name: "Gemini 3 Flash" },
{ id: "opencode/grok-code", name: "Grok Code" },
]
mockClient = {
model: {
list: async () => mockModels,
},
}
it("#given cache file with various providers #when fetchAvailableModels called #then extracts all IDs correctly", async () => {
writeModelsCache({
openai: { id: "openai", models: { "gpt-5.2-codex": { id: "gpt-5.2-codex" } } },
anthropic: { id: "anthropic", models: { "claude-sonnet-4-5": { id: "claude-sonnet-4-5" } } },
google: { id: "google", models: { "gemini-3-flash": { id: "gemini-3-flash" } } },
opencode: { id: "opencode", models: { "gpt-5-nano": { id: "gpt-5-nano" } } },
})
const result = await fetchAvailableModels(mockClient)
const result = await fetchAvailableModels()
expect(result.size).toBe(4)
expect(result.has("openai/gpt-5.2-codex")).toBe(true)
expect(result.has("anthropic/claude-sonnet-4-5")).toBe(true)
expect(result.has("google/gemini-3-flash")).toBe(true)
expect(result.has("opencode/grok-code")).toBe(true)
expect(result.has("opencode/gpt-5-nano")).toBe(true)
})
})

View File

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

View File

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

View File

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

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