Compare commits

...

99 Commits

Author SHA1 Message Date
YeonGyu-Kim
4a14bd6d68 feat(cli): auto-configure Athena councils 2026-03-26 12:59:44 +09:00
YeonGyu-Kim
1c125ec3ef feat(tools): add switch agent background workflow 2026-03-26 12:59:36 +09:00
YeonGyu-Kim
647f691fe2 feat(agents): add Athena council foundation 2026-03-26 12:59:22 +09:00
YeonGyu-Kim
a391f44420 Merge pull request #2842 from code-yeongyu/fix/opencode-skill-override-gaps
fix: align path discovery with upstream opencode
2026-03-26 11:54:08 +09:00
YeonGyu-Kim
94b4a4f850 fix(slashcommand): deduplicate opencode command aliases
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-26 11:36:59 +09:00
YeonGyu-Kim
9fde370838 fix(commands): preserve nearest opencode command precedence
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-26 11:36:59 +09:00
YeonGyu-Kim
b6ee7f09b1 fix(slashcommand): discover ancestor opencode commands
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-26 11:22:00 +09:00
YeonGyu-Kim
28bcab066e fix(commands): load opencode command dirs from aliases
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-26 11:22:00 +09:00
YeonGyu-Kim
b5cb50b561 fix(skills): discover ancestor project skill directories
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-26 11:22:00 +09:00
YeonGyu-Kim
8242500856 fix(skills): expand tilde config source paths
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-26 11:22:00 +09:00
YeonGyu-Kim
6d688ac0ae fix(shared): support opencode directory aliases
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-26 11:22:00 +09:00
YeonGyu-Kim
da3e80464d fix(shared): add ancestor project discovery helpers
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-26 11:22:00 +09:00
YeonGyu-Kim
23df6bd255 Merge pull request #2841 from code-yeongyu/fix/model-fallback-test-isolation
fix(tests): resolve 5 cross-file test isolation failures
2026-03-26 09:31:09 +09:00
YeonGyu-Kim
7895361f42 fix(tests): resolve 5 cross-file test isolation failures
- model-fallback hook: mock selectFallbackProvider and add _resetForTesting()
  to test-setup.ts to clear module-level state between files
- fallback-retry-handler: add afterAll(mock.restore) and use mockReturnValueOnce
  to prevent connected-providers mock leaking to subsequent test files
- opencode-config-dir: use win32.join for Windows APPDATA path construction
  so tests pass on macOS (path.join uses POSIX semantics regardless of
  process.platform override)
- system-loaded-version: use resolveSymlink from file-utils instead of
  realpathSync to handle macOS /var -> /private/var symlink consistently

All 4456 tests pass (0 failures) on full bun test suite.
2026-03-26 09:30:34 +09:00
YeonGyu-Kim
90919bf359 Merge pull request #2664 from kilhyeonjun/fix/anthropic-1m-ga-context-limit
fix(shared): respect cached model context limits for Anthropic providers post-GA
2026-03-26 08:55:04 +09:00
YeonGyu-Kim
32f2c688e7 Merge pull request #2707 from MoerAI/fix/windows-symlink-config
fix(windows): resolve symlinked config paths and plugin name parsing (fixes #2271)
2026-03-26 08:54:45 +09:00
YeonGyu-Kim
e6d0484e57 Merge pull request #2710 from MoerAI/fix/rate-limit-hang
fix(runtime-fallback): detect bare 429 rate-limit signals (fixes #2677)
2026-03-26 08:53:41 +09:00
YeonGyu-Kim
abd62472cf Merge pull request #2752 from MoerAI/fix/quota-error-fallback-detection
fix(runtime-fallback): detect prettified quota errors without HTTP status codes (fixes #2747)
2026-03-26 08:50:58 +09:00
YeonGyu-Kim
b1e099130a Merge pull request #2756 from MoerAI/fix/plugin-display-name
fix(plugin): display friendly name in configuration UI instead of file path (fixes #2644)
2026-03-26 08:50:29 +09:00
YeonGyu-Kim
09fb364bfb Merge pull request #2833 from kuitos/feat/agent-order-support
feat(agent-priority): inject order field for deterministic agent Tab cycling
2026-03-26 08:49:58 +09:00
YeonGyu-Kim
d1ff8b1e3f Merge pull request #2727 from octo-patch/feature/upgrade-minimax-m2.7
feat: upgrade MiniMax from M2.5 to M2.7 and expand to more agents/categories
2026-03-26 08:49:11 +09:00
YeonGyu-Kim
6e42b553cc Merge origin/dev into feature/upgrade-minimax-m2.7 (resolve conflicts) 2026-03-26 08:48:53 +09:00
YeonGyu-Kim
02ab83f4d4 Merge pull request #2834 from RaviTharuma/feat/model-capabilities-canonical-guardrails
fix(model-capabilities): harden canonical alias guardrails
2026-03-26 08:46:43 +09:00
github-actions[bot]
ce1bffbc4d @ventsislav-georgiev has signed the CLA in code-yeongyu/oh-my-openagent#2840 2026-03-25 23:11:43 +00:00
github-actions[bot]
4d4680be3c @clansty has signed the CLA in code-yeongyu/oh-my-openagent#2839 2026-03-25 21:33:49 +00:00
Ravi Tharuma
ce877ec0d8 test(atlas): avoid shared barrel mock pollution 2026-03-25 22:27:26 +01:00
Ravi Tharuma
ec20a82b4e fix(model-capabilities): align gemini aliases and alias lookup 2026-03-25 22:19:51 +01:00
Ravi Tharuma
5043cc21ac fix(model-capabilities): harden canonical alias guardrails 2026-03-25 22:11:45 +01:00
github-actions[bot]
8df3a2876a @anas-asghar4831 has signed the CLA in code-yeongyu/oh-my-openagent#2837 2026-03-25 18:48:32 +00:00
YeonGyu-Kim
087e33d086 Merge pull request #2832 from RaviTharuma/fix/todo-sync-priority-default
test(todo-sync): match required priority fallback
2026-03-26 01:30:50 +09:00
Ravi Tharuma
46c6e1dcf6 test(todo-sync): match required priority fallback 2026-03-25 16:38:21 +01:00
kuitos
5befb60229 feat(agent-priority): inject order field for deterministic agent Tab cycling
Inject an explicit `order` field (1-4) into the four core agents
(Sisyphus, Hephaestus, Prometheus, Atlas) via reorderAgentsByPriority().
This pre-empts OpenCode's alphabetical agent sorting so the intended
Tab cycle order is preserved once OpenCode merges order field support
(anomalyco/opencode#19127).

Refs anomalyco/opencode#7372
2026-03-25 23:35:40 +08:00
Ravi Tharuma
55df2179b8 fix(todo-sync): preserve missing task priority 2026-03-25 16:26:23 +01:00
YeonGyu-Kim
76420b36ab Merge pull request #2829 from RaviTharuma/fix/model-capabilities-review-followup
fix(model-capabilities): harden runtime capability handling
2026-03-26 00:25:07 +09:00
Ravi Tharuma
a15f6076bc feat(model-capabilities): add maintenance guardrails 2026-03-25 16:14:19 +01:00
Ravi Tharuma
7c0289d7bc fix(model-capabilities): honor root thinking flags 2026-03-25 15:41:12 +01:00
YeonGyu-Kim
5e9231e251 Merge pull request #2828 from code-yeongyu/fix/content-based-thinking-gating-v2
fix(thinking-block-validator): replace model-name gating with content-based history detection
2026-03-25 23:26:52 +09:00
YeonGyu-Kim
f04cc0fa9c fix(thinking-block-validator): replace model-name gating with content-based history detection
Replace isExtendedThinkingModel() model-name check with hasSignedThinkingBlocksInHistory()
which scans message history for real Anthropic-signed thinking blocks.

Content-based gating is more robust than model-name checks — works correctly
with custom model IDs, proxied models, and new model releases without code changes.

- Add isSignedThinkingPart() that matches type thinking/redacted_thinking with valid signature
- Skip synthetic parts (injected by previous hook runs)
- GPT reasoning blocks (type=reasoning, no signature) correctly excluded
- Add comprehensive tests: signed injection, redacted_thinking, reasoning negative case, synthetic skip

Inspired by PR #2653 content-based approach, combined with redacted_thinking support from 0732cb85.

Ultraworked with Sisyphus
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-25 23:23:46 +09:00
Ravi Tharuma
613ef8eee8 fix(model-capabilities): harden runtime capability handling 2026-03-25 15:09:25 +01:00
YeonGyu-Kim
99b398063c Merge pull request #2826 from RaviTharuma/feat/model-capabilities-models-dev
feat(model-capabilities): add models.dev snapshot and runtime capability refresh
2026-03-25 23:08:17 +09:00
Ravi Tharuma
2af9324400 feat: add models.dev-backed model capabilities 2026-03-25 14:47:46 +01:00
YeonGyu-Kim
7a52639a1b Merge pull request #2673 from sanoyphilippe/fix/oauth-discovery-root-fallback
fix(mcp-oauth): fall back to root well-known URL for non-root resource paths (fixes #2675)
2026-03-25 21:48:13 +09:00
YeonGyu-Kim
5df54bced4 Merge pull request #2725 from cphoward/fix/spawn-budget-lifetime-semantics-clean
fix(background-agent): decrement spawn budget on task completion, cancellation, error, and interrupt
2026-03-25 21:46:51 +09:00
YeonGyu-Kim
cd04e6a19e Merge pull request #2751 from sjawhar/fix/atlas-subagent-agent-guard
fix(atlas): restore agent mismatch guard for subagent boulder continuation
2026-03-25 21:46:37 +09:00
YeonGyu-Kim
e974b151c1 Merge pull request #2701 from tonymfer/fix/lsp-initialization-options
fix(lsp): wrap initialization config in initializationOptions field
2026-03-25 21:46:16 +09:00
YeonGyu-Kim
6f213a0ac9 Merge pull request #2686 from sjawhar/fix/look-at-respect-configured-model
fix(look-at): respect configured multimodal-looker model instead of overriding via dynamic fallback
2026-03-25 21:46:11 +09:00
YeonGyu-Kim
71004e88d3 Merge pull request #2583 from Jrakru/fix/start-work-atlas-handoff
fix: preserve Atlas handoff metadata on /start-work
2026-03-25 21:46:06 +09:00
YeonGyu-Kim
5898d36321 Merge pull request #2575 from apple-ouyang/fix/issue-2571-subagent-safeguards
fix(delegate-task): add subagent turn limit and model routing transparency
2026-03-25 21:46:01 +09:00
YeonGyu-Kim
90aa3e4489 Merge pull request #2589 from MoerAI/fix/plan-agent-continuation-loop
fix(todo-continuation-enforcer): add plan agent to DEFAULT_SKIP_AGENTS (fixes #2526)
2026-03-25 21:45:58 +09:00
YeonGyu-Kim
2268ba45f9 Merge pull request #2262 from Stranmor/feat/prompt-file-uri-support
feat: support file:// URIs in agent prompt field
2026-03-25 21:45:53 +09:00
YeonGyu-Kim
aca9342722 Merge pull request #2345 from DarkFunct/fix/todo-sync-priority-null
fix(todo-sync): provide default priority to prevent SQLite NOT NULL violation
2026-03-25 21:45:48 +09:00
YeonGyu-Kim
a3519c3a14 Merge pull request #2544 from djdembeck/fix/quick-anti-loop-v2
fix(agents): add termination criteria to Sisyphus-Junior default
2026-03-25 21:45:43 +09:00
YeonGyu-Kim
e610d88558 Merge pull request #2594 from MoerAI/fix/subagent-fallback-model-v2
fix(agent-registration): always attempt fallback when model resolution fails (fixes #2427, supersedes #2517)
2026-03-25 21:45:40 +09:00
YeonGyu-Kim
ed09bf5462 Merge pull request #2674 from RaviTharuma/fix/dedup-delegated-model-config
refactor: deduplicate DelegatedModelConfig into shared module
2026-03-25 21:43:31 +09:00
YeonGyu-Kim
1d48518b41 Merge pull request #2643 from RaviTharuma/feat/model-settings-compatibility-resolver
feat(settings): add model settings compatibility resolver
2026-03-25 21:43:28 +09:00
YeonGyu-Kim
d6d4cece9d Merge pull request #2622 from RaviTharuma/feat/object-style-fallback-models
feat(config): object-style fallback_models with per-model settings
2026-03-25 21:43:22 +09:00
Ravi Tharuma
9d930656da test(restack): drop stale compatibility expectations 2026-03-25 11:14:04 +01:00
Ravi Tharuma
f86b8b3336 fix(review): align model compatibility and prompt param helpers 2026-03-25 11:14:04 +01:00
Ravi Tharuma
1f5d7702ff refactor(delegate-task): deduplicate DelegatedModelConfig + registry refactor
- Move DelegatedModelConfig to src/shared/model-resolution-types.ts
- Re-export from delegate-task/types.ts (preserving import paths)
- Replace background-agent/types.ts local duplicate with shared import
- Consolidate model-settings-compatibility.ts registry patterns
2026-03-25 11:14:04 +01:00
Ravi Tharuma
1e70f64001 chore(schema): refresh generated fallback model schema 2026-03-25 11:13:53 +01:00
Ravi Tharuma
d4f962b55d feat(model-settings-compat): add variant/reasoningEffort compatibility resolver
- Registry-based model family detection (provider-agnostic)
- Variant and reasoningEffort ladder downgrade logic
- Three-tier resolution: metadata override → family heuristic → unknown drop
- Comprehensive test suite covering all model families
2026-03-25 11:13:53 +01:00
Ravi Tharuma
fb085538eb test(background-agent): restore spawner createTask import 2026-03-25 11:13:28 +01:00
Ravi Tharuma
e5c5438a44 fix(delegate-task): gate fallback settings to real fallback matches 2026-03-25 11:04:49 +01:00
Ravi Tharuma
a77a16c494 feat(config): support object-style fallback_models with per-model settings
Add support for object-style entries in fallback_models arrays, enabling
per-model configuration of variant, reasoningEffort, temperature, top_p,
maxTokens, and thinking settings.

- Zod schema for FallbackModelObject with full validation
- normalizeFallbackModels() and flattenToFallbackModelStrings() utilities
- Provider-agnostic model resolution pipeline with fallback chain
- Session prompt params state management
- Fallback chain construction with prefix-match lookup
- Integration across delegate-task, background-agent, and plugin layers
2026-03-25 11:04:49 +01:00
YeonGyu-Kim
7761e48dca Merge pull request #2592 from MoerAI/fix/gemini-quota-fallback
fix(runtime-fallback): detect Gemini quota errors in session.status retry events (fixes #2454)
2026-03-25 18:14:21 +09:00
MoerAI
d7a1945b27 fix(plugin-loader): preserve scoped npm package names in plugin key parsing
Scoped packages like @scope/pkg were truncated to just 'pkg' because
basename() strips the scope prefix. Fix:
- Detect scoped packages (starting with @) and find version separator
  after the scope slash, not at the leading @
- Return full scoped name (@scope/pkg) instead of calling basename
- Add regression test for scoped package name preservation
2026-03-25 17:10:07 +09:00
MoerAI
44fb114370 fix(runtime-fallback): rename misleading test to match actual behavior
The test name claimed it exercised RETRYABLE_ERROR_PATTERNS directly,
but classifyErrorType actually matches 'payment required' via the
quota_exceeded path first. Rename to 'detects payment required errors
as retryable' to accurately describe end-to-end behavior.
2026-03-25 16:58:49 +09:00
YeonGyu-Kim
bf804b0626 fix(shared): restrict cached Anthropic 1M context to GA 4.6 models only 2026-03-25 14:29:59 +09:00
YeonGyu-Kim
c4aa380855 Merge pull request #2734 from ndaemy/fix/remove-duplicate-ultrawork-separator
fix(keyword-detector): remove duplicate separator from ultrawork templates
2026-03-25 13:22:41 +09:00
YeonGyu-Kim
993bd51eac Merge pull request #2524 from Gujiassh/fix/session-todo-filename-match
fix(session-manager): match todo filenames exactly
2026-03-25 13:22:39 +09:00
YeonGyu-Kim
732743960f Merge pull request #2533 from Gujiassh/fix/background-task-metadata-id
fix(delegate-task): report the real background task id
2026-03-25 13:22:37 +09:00
YeonGyu-Kim
bff573488c Merge pull request #2443 from tc9011/fix/github-copilot-model-version
fix: github copilot model version for Sisyphus agent
2026-03-25 13:22:34 +09:00
MoerAI
f16d55ad95 fix: add errorName-based quota detection and strengthen test coverage 2026-03-23 15:19:09 +09:00
Philippe Oscar Sanoy
3c49bf3a8c Merge branch 'code-yeongyu:dev' into fix/oauth-discovery-root-fallback 2026-03-23 09:45:54 +08:00
MoerAI
29a7bc2d31 fix(plugin): display friendly name in configuration UI instead of file path (fixes #2644) 2026-03-23 10:41:37 +09:00
MoerAI
62d2704009 fix(runtime-fallback): detect prettified quota errors without HTTP status codes (fixes #2747) 2026-03-23 10:34:22 +09:00
Sami Jawhar
db32bad004 fix(look-at): respect configured multimodal-looker model instead of overriding via dynamic fallback 2026-03-23 01:12:24 +00:00
Sami Jawhar
5777bf9894 fix(atlas): restore agent mismatch guard for subagent boulder continuation (#18681) 2026-03-23 01:04:36 +00:00
ndaemy
07ea8debdc fix(keyword-detector): remove duplicate separator from ultrawork templates 2026-03-21 19:09:51 +09:00
PR Bot
0d52519293 feat: upgrade MiniMax from M2.5 to M2.7 and expand to more agents/categories
- Upgrade minimax-m2.5 → minimax-m2.7 (latest model) across all agents and categories
- Replace minimax-m2.5-free with minimax-m2.7-highspeed (optimized speed variant)
- Expand MiniMax fallback coverage to atlas, sisyphus-junior, writing, and unspecified-low
- Add isMiniMaxModel() detection function in types.ts for model family detection
- Update all tests (58 passing) and documentation
2026-03-21 01:29:53 +08:00
Casey Howard
031503bb8c test(background-agent): add regression tests for spawn budget decrement on task completion
Tests prove rootDescendantCounts is never decremented on task completion,
cancellation, or error — making maxDescendants a lifetime quota instead of
a concurrent-active cap. All 4 tests fail (RED phase) before the fix.

Refs: code-yeongyu/oh-my-openagent#2700
2026-03-20 12:52:06 -04:00
Casey Howard
5986583641 fix(background-agent): decrement spawn budget on task completion, cancellation, error, and interrupt
rootDescendantCounts was incremented on every spawn but never decremented
when tasks reached terminal states (completed, cancelled, error, interrupt,
stale-pruned). This made maxDescendants=50 a session-lifetime quota instead
of its intended semantics as a concurrent-active agent cap.

Fix: add unregisterRootDescendant() in five terminal-state handlers:
- tryCompleteTask(): task completes successfully
- cancelTask(): running task cancelled (wasRunning guard prevents
  double-decrement for pending tasks already handled by
  rollbackPreStartDescendantReservation)
- session.error handler: task errors
- promptAsync catch (startTask): task interrupted on launch
- promptAsync catch (resume): task interrupted on resume
- onTaskPruned callback: stale task pruned (wasPending guard)

Fixes: code-yeongyu/oh-my-openagent#2700
2026-03-20 12:51:21 -04:00
MoerAI
3773e370ec fix(runtime-fallback): detect bare 429 rate-limit signals (fixes #2677) 2026-03-20 11:00:00 +09:00
MoerAI
23a30e86f2 fix(windows): resolve symlinked config paths for plugin detection (fixes #2271) 2026-03-20 10:44:19 +09:00
Tony Park
04637ff0f1 fix(lsp): wrap initialization config in initializationOptions field
The LSP `initialize` request expects custom server options in the
`initializationOptions` field, but the code was spreading
`this.server.initialization` directly into the root params object.
This caused LSP servers that depend on `initializationOptions`
(like ets-language-server, pyright, etc.) to not receive their
configuration.

Closes #2665

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-20 02:11:54 +09:00
sanoyphilippe
0d96e0d3bc Fix OAuth discovery for servers with non-root resource paths
When the resource URL has a sub-path (e.g. https://mcp.sentry.dev/mcp),
the RFC 8414 path-suffixed well-known URL may not exist. Fall back to
the root well-known URL before giving up.

This matches OpenCode core's behavior and fixes authentication for
servers like Sentry that serve OAuth metadata only at the root path.
2026-03-18 16:45:54 +08:00
kilhyeonjun
719a58270b fix(shared): respect cached model context limits for Anthropic providers post-GA
After Anthropic's 1M context GA (2026-03-13), the beta header is no
longer sent. The existing detection relied solely on the beta header
to set anthropicContext1MEnabled, causing all Anthropic models to
fall back to the 200K default despite models.dev reporting 1M.

Update resolveActualContextLimit to check per-model cached limits
from provider config (populated from models.dev data) when the
explicit 1M flag is not set. Priority order:
1. Explicit 1M mode (beta header or env var) - all Anthropic models
2. Per-model cached limit from provider config
3. Default 200K fallback

This preserves the #2460 fix (explicit 1M flag always wins over
cached values) while allowing GA models to use their correct limits.

Fixes premature context warnings at 140K and unnecessary compaction
at 156K for opus-4-6 and sonnet-4-6 users without env var workaround.
2026-03-18 12:21:08 +09:00
MoerAI
7e3c36ee03 ci: retrigger CI 2026-03-16 11:08:14 +09:00
MoerAI
11d942f3a2 fix(runtime-fallback): detect Gemini quota errors in session.status retry events
When Gemini returns a quota exhausted error, OpenCode auto-retries and
fires session.status with type='retry'. The extractAutoRetrySignal
function requires BOTH 'retrying in' text AND a quota pattern to match,
but some providers (like Gemini) include only the error text in the
retry message without the 'retrying in' phrase.

Since status.type='retry' already confirms this is a retry event, the
fix adds a fallback check: if extractAutoRetrySignal fails, check the
message directly against RETRYABLE_ERROR_PATTERNS. This ensures quota
errors like 'exhausted your capacity' trigger the fallback chain even
when the retry message format differs from expected.

Fixes #2454
2026-03-16 11:08:14 +09:00
MoerAI
2b6b08345a fix(todo-continuation-enforcer): add plan agent to DEFAULT_SKIP_AGENTS to prevent infinite loop
The todo-continuation-enforcer injects continuation prompts when
sessions go idle with pending todos. When Plan Mode agents (which are
read-only) create todo items, the continuation prompt contradicts
Plan Mode's STRICTLY FORBIDDEN directive, causing an infinite loop
where the agent acknowledges the conflict then goes idle, triggering
another injection.

Adding 'plan' to DEFAULT_SKIP_AGENTS prevents continuation injection
into Plan Mode sessions, matching the same exclusion pattern already
used for prometheus and compaction agents.

Fixes #2526
2026-03-16 11:07:28 +09:00
MoerAI
abdd39da00 fix(agent-registration): always attempt fallback when model resolution fails
Removes both the isFirstRunNoCache and override?.model guards from
the fallback logic in collectPendingBuiltinAgents(). Previously, when
a user configured a model like minimax/MiniMax-M2.5 that wasn't in
availableModels, the agent was silently excluded and --agent Librarian
would crash with 'undefined is not an object'.

Now: if applyModelResolution() fails for ANY reason (cache state,
unavailable model, config merge issue), getFirstFallbackModel() is
always attempted. A log warning is emitted when a user-configured
model couldn't be resolved, making the previously silent failure
visible.

Supersedes #2517
Fixes #2427
2026-03-16 11:06:00 +09:00
Jean Philippe Wan
711aac0f0a fix: preserve atlas handoff on start-work 2026-03-15 19:04:20 -04:00
Ouyang Xingyuan
f2b26e5346 fix(delegate-task): add subagent turn limit and model routing transparency
原因:
- subagent 无最大步数限制,陷入 tool-call 死循环时可无限运行,造成巨额 API 费用
- category 路由将 subagent 静默切换到与父 session 不同的模型,用户完全无感知

改动:
- sync-session-poller: 新增 maxAssistantTurns 参数(默认 300),每检测到新 assistant 消息
  计数一次,超限后调用 abortSyncSession 并返回明确错误信息
- sync-task: task 完成时在返回字符串中显示实际使用的模型;若与父 session 模型不同,
  加 ⚠️ 警告提示用户发生了静默路由

影响:
- 现有行为不变,maxAssistantTurns 为可选参数,默认值 300 远高于正常任务所需轮次
- 修复 #2571:用户一个下午因 Sisyphus-Junior 死循环 + 静默路由到 Gemini 3.1 Pro
  烧掉 $350+,且 OpenCode 显示费用仅为实际的一半
2026-03-15 12:05:42 +08:00
djdembeck
a7a7799b44 fix(agents): add termination criteria to Sisyphus-Junior default 2026-03-12 16:09:51 -05:00
Gujiassh
1e0823a0fc fix(delegate-task): report the real background task id
Keep background task metadata aligned with the background_output contract so callers do not pass a session id where the task manager expects a background task id.

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-13 01:25:13 +09:00
Gujiassh
edfa411684 fix(session-manager): match todo filenames exactly
Stop sibling session IDs from colliding in stable JSON storage by requiring an exact todo filename match instead of a substring filter.

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-12 19:58:57 +09:00
tc9011
6d8bc95fa6 fix: github copilot model version for Sisyphus agent 2026-03-11 10:34:25 +08:00
韩澍
229c6b0cdb fix(todo-sync): provide default priority to prevent SQLite NOT NULL violation
extractPriority() returns undefined when task metadata has no priority
field, but OpenCode's TodoTable requires priority as NOT NULL. This
causes a silent SQLiteError that prevents all Task→Todo syncing.

Add ?? "medium" fallback so todos always have a valid priority.
2026-03-06 23:28:58 +08:00
Stranmor
3eb97110c6 feat: support file:// URIs in agent prompt field 2026-03-03 03:32:07 +03:00
215 changed files with 51639 additions and 701 deletions

View File

@@ -0,0 +1,46 @@
name: Refresh Model Capabilities
on:
schedule:
- cron: "17 4 * * 1"
workflow_dispatch:
permissions:
contents: write
pull-requests: write
jobs:
refresh:
runs-on: ubuntu-latest
if: github.repository == 'code-yeongyu/oh-my-openagent'
steps:
- uses: actions/checkout@v4
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install dependencies
run: bun install
env:
BUN_INSTALL_ALLOW_SCRIPTS: "@ast-grep/napi"
- name: Refresh bundled model capabilities snapshot
run: bun run build:model-capabilities
- name: Validate capability guardrails
run: bun run test:model-capabilities
- name: Create refresh pull request
uses: peter-evans/create-pull-request@v7
with:
commit-message: "chore: refresh model capabilities snapshot"
title: "chore: refresh model capabilities snapshot"
body: |
Automated refresh of `src/generated/model-capabilities.generated.json` from `https://models.dev/api.json`.
This keeps the bundled capability snapshot aligned with upstream model metadata without relying on manual refreshes.
branch: automation/refresh-model-capabilities
delete-branch: true
labels: |
maintenance

File diff suppressed because it is too large Load Diff

View File

@@ -92,10 +92,10 @@ These agents do grep, search, and retrieval. They intentionally use the fastest,
| Agent | Role | Fallback Chain | Notes |
| --------------------- | ------------------ | ---------------------------------------------- | ----------------------------------------------------- |
| **Explore** | Fast codebase grep | Grok Code Fast → opencode-go/minimax-m2.5 → MiniMax Free → Haiku → GPT-5-Nano | Speed is everything. Fire 10 in parallel. |
| **Librarian** | Docs/code search | opencode-go/minimax-m2.5 → MiniMax Free → Haiku → GPT-5-Nano | Doc retrieval doesn't need deep reasoning. |
| **Multimodal Looker** | Vision/screenshots | GPT-5.4 → opencode-go/kimi-k2.5 → GLM-4.6v → GPT-5-Nano | Uses the first available multimodal-capable fallback. |
| **Sisyphus-Junior** | Category executor | Claude Sonnet → opencode-go/kimi-k2.5 → GPT-5.4 → Big Pickle | Handles delegated category tasks. Sonnet-tier default. |
| **Explore** | Fast codebase grep | Grok Code Fast → opencode-go/minimax-m2.7-highspeed → MiniMax M2.7 → Haiku → GPT-5-Nano | Speed is everything. Fire 10 in parallel. |
| **Librarian** | Docs/code search | opencode-go/minimax-m2.7 → MiniMax M2.7-highspeed → Haiku → GPT-5-Nano | Doc retrieval doesn't need deep reasoning. |
| **Multimodal Looker** | Vision/screenshots | GPT-5.4 → opencode-go/kimi-k2.5 → GLM-4.6v → GPT-5-Nano | Uses the first available multimodal-capable fallback. |
| **Sisyphus-Junior** | Category executor | Claude Sonnet → opencode-go/kimi-k2.5 → GPT-5.4 → MiniMax M2.7 → Big Pickle | Handles delegated category tasks. Sonnet-tier default. |
---
@@ -131,7 +131,8 @@ Principle-driven, explicit reasoning, deep technical capability. Best for agents
| **Gemini 3.1 Pro** | Excels at visual/frontend tasks. Different reasoning style. Default for `visual-engineering` and `artistry`. |
| **Gemini 3 Flash** | Fast. Good for doc search and light tasks. |
| **Grok Code Fast 1** | Blazing fast code grep. Default for Explore agent. |
| **MiniMax M2.5** | Fast and smart. Good for utility tasks and search/retrieval. |
| **MiniMax M2.7** | Fast and smart. Good for utility tasks and search/retrieval. Upgraded from M2.5 with better reasoning. |
| **MiniMax M2.7 Highspeed** | Ultra-fast variant. Optimized for latency-sensitive tasks like codebase grep. |
### OpenCode Go
@@ -143,11 +144,11 @@ A premium subscription tier ($10/month) that provides reliable access to Chinese
| ------------------------ | --------------------------------------------------------------------- |
| **opencode-go/kimi-k2.5** | Vision-capable, Claude-like reasoning. Used by Sisyphus, Atlas, Sisyphus-Junior, Multimodal Looker. |
| **opencode-go/glm-5** | Text-only orchestration model. Used by Oracle, Prometheus, Metis, Momus. |
| **opencode-go/minimax-m2.5** | Ultra-cheap, fast responses. Used by Librarian, Explore for utility work. |
| **opencode-go/minimax-m2.7** | Ultra-cheap, fast responses. Used by Librarian, Explore, Atlas, Sisyphus-Junior for utility work. |
**When It Gets Used:**
OpenCode Go models appear in fallback chains as intermediate options. They bridge the gap between premium Claude access and free-tier alternatives. The system tries OpenCode Go models before falling back to free tiers (MiniMax Free, Big Pickle) or GPT alternatives.
OpenCode Go models appear in fallback chains as intermediate options. They bridge the gap between premium Claude access and free-tier alternatives. The system tries OpenCode Go models before falling back to free tiers (MiniMax M2.7-highspeed, Big Pickle) or GPT alternatives.
**Go-Only Scenarios:**
@@ -155,7 +156,7 @@ Some model identifiers like `k2p5` (paid Kimi K2.5) and `glm-5` may only be avai
### About Free-Tier Fallbacks
You may see model names like `kimi-k2.5-free`, `minimax-m2.5-free`, or `big-pickle` (GLM 4.6) in the source code or logs. These are free-tier versions of the same model families, served through the OpenCode Zen provider. They exist as lower-priority entries in fallback chains.
You may see model names like `kimi-k2.5-free`, `minimax-m2.7-highspeed`, or `big-pickle` (GLM 4.6) in the source code or logs. These are free-tier or speed-optimized versions of the same model families. They exist as lower-priority entries in fallback chains.
You don't need to configure them. The system includes them so it degrades gracefully when you don't have every paid subscription. If you have the paid version, the paid version is always preferred.
@@ -171,7 +172,7 @@ When agents delegate work, they don't pick a model name — they pick a **catego
| `ultrabrain` | Maximum reasoning needed | GPT-5.4 → Gemini 3.1 Pro → Claude Opus → opencode-go/glm-5 |
| `deep` | Deep coding, complex logic | GPT-5.3 Codex → Claude Opus → Gemini 3.1 Pro |
| `artistry` | Creative, novel approaches | Gemini 3.1 Pro → Claude Opus → GPT-5.4 |
| `quick` | Simple, fast tasks | GPT-5.4 Mini → Claude Haiku → Gemini Flash → opencode-go/minimax-m2.5 → GPT-5-Nano |
| `quick` | Simple, fast tasks | GPT-5.4 Mini → Claude Haiku → Gemini Flash → opencode-go/minimax-m2.7 → GPT-5-Nano |
| `unspecified-high` | General complex work | Claude Opus → GPT-5.4 → GLM 5 → K2P5 → opencode-go/glm-5 → Kimi K2.5 |
| `unspecified-low` | General standard work | Claude Sonnet → GPT-5.3 Codex → opencode-go/kimi-k2.5 → Gemini Flash |
| `writing` | Text, docs, prose | Gemini Flash → opencode-go/kimi-k2.5 → Claude Sonnet |

View File

@@ -69,7 +69,7 @@ Ask the user these questions to determine CLI options:
- If **no**`--zai-coding-plan=no` (default)
7. **Do you have an OpenCode Go subscription?**
- OpenCode Go is a $10/month subscription providing access to GLM-5, Kimi K2.5, and MiniMax M2.5 models
- OpenCode Go is a $10/month subscription providing access to GLM-5, Kimi K2.5, and MiniMax M2.7 models
- If **yes**`--opencode-go=yes`
- If **no**`--opencode-go=no` (default)
@@ -205,7 +205,7 @@ When GitHub Copilot is the best available provider, oh-my-openagent uses these m
| Agent | Model |
| ------------- | --------------------------------- |
| **Sisyphus** | `github-copilot/claude-opus-4-6` |
| **Sisyphus** | `github-copilot/claude-opus-4.6` |
| **Oracle** | `github-copilot/gpt-5.4` |
| **Explore** | `github-copilot/grok-code-fast-1` |
| **Librarian** | `github-copilot/gemini-3-flash` |
@@ -227,7 +227,7 @@ If Z.ai is your main provider, the most important fallbacks are:
#### OpenCode Zen
OpenCode Zen provides access to `opencode/` prefixed models including `opencode/claude-opus-4-6`, `opencode/gpt-5.4`, `opencode/gpt-5.3-codex`, `opencode/gpt-5-nano`, `opencode/glm-5`, `opencode/big-pickle`, and `opencode/minimax-m2.5-free`.
OpenCode Zen provides access to `opencode/` prefixed models including `opencode/claude-opus-4-6`, `opencode/gpt-5.4`, `opencode/gpt-5.3-codex`, `opencode/gpt-5-nano`, `opencode/glm-5`, `opencode/big-pickle`, and `opencode/minimax-m2.7-highspeed`.
When OpenCode Zen is the best available provider (no native or Copilot), these models are used:
@@ -236,7 +236,7 @@ When OpenCode Zen is the best available provider (no native or Copilot), these m
| **Sisyphus** | `opencode/claude-opus-4-6` |
| **Oracle** | `opencode/gpt-5.4` |
| **Explore** | `opencode/gpt-5-nano` |
| **Librarian** | `opencode/minimax-m2.5-free` / `opencode/big-pickle` |
| **Librarian** | `opencode/minimax-m2.7-highspeed` / `opencode/big-pickle` |
##### Setup
@@ -296,8 +296,8 @@ Not all models behave the same way. Understanding which models are "similar" hel
| --------------------- | -------------------------------- | ----------------------------------------------------------- |
| **Gemini 3.1 Pro** | google, github-copilot, opencode | Excels at visual/frontend tasks. Different reasoning style. |
| **Gemini 3 Flash** | google, github-copilot, opencode | Fast, good for doc search and light tasks. |
| **MiniMax M2.5** | venice | Fast and smart. Good for utility tasks. |
| **MiniMax M2.5 Free** | opencode | Free-tier MiniMax. Fast for search/retrieval. |
| **MiniMax M2.7** | venice, opencode-go | Fast and smart. Good for utility tasks. Upgraded from M2.5. |
| **MiniMax M2.7 Highspeed** | opencode | Ultra-fast MiniMax variant. Optimized for latency. |
**Speed-Focused Models**:
@@ -305,7 +305,7 @@ Not all models behave the same way. Understanding which models are "similar" hel
| ----------------------- | ---------------------- | -------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| **Grok Code Fast 1** | github-copilot, venice | Very fast | Optimized for code grep/search. Default for Explore. |
| **Claude Haiku 4.5** | anthropic, opencode | Fast | Good balance of speed and intelligence. |
| **MiniMax M2.5 (Free)** | opencode, venice | Fast | Smart for its speed class. |
| **MiniMax M2.7 Highspeed** | opencode | Very fast | Ultra-fast MiniMax variant. Smart for its speed class. |
| **GPT-5.3-codex-spark** | openai | Extremely fast | Blazing fast but compacts so aggressively that oh-my-openagent's context management doesn't work well with it. Not recommended for omo agents. |
#### What Each Agent Does and Which Model It Got
@@ -344,8 +344,8 @@ These agents do search, grep, and retrieval. They intentionally use fast, cheap
| Agent | Role | Default Chain | Design Rationale |
| --------------------- | ------------------ | ---------------------------------------------------------------------- | -------------------------------------------------------------- |
| **Explore** | Fast codebase grep | MiniMax M2.5 Free → Grok Code Fast → MiniMax M2.5 → Haiku → GPT-5-Nano | Speed is everything. Grok is blazing fast for grep. |
| **Librarian** | Docs/code search | MiniMax M2.5 Free → Gemini Flash → Big Pickle | Entirely free-tier. Doc retrieval doesn't need deep reasoning. |
| **Explore** | Fast codebase grep | Grok Code Fast → MiniMax M2.7-highspeed → MiniMax M2.7 → Haiku → GPT-5-Nano | Speed is everything. Grok is blazing fast for grep. |
| **Librarian** | Docs/code search | MiniMax M2.7 → MiniMax M2.7-highspeed → Haiku → GPT-5-Nano | Doc retrieval doesn't need deep reasoning. MiniMax is fast. |
| **Multimodal Looker** | Vision/screenshots | Kimi K2.5 → Kimi Free → Gemini Flash → GPT-5.4 → GLM-4.6v | Kimi excels at multimodal understanding. |
#### Why Different Models Need Different Prompts

View File

@@ -221,7 +221,7 @@ You can override specific agents or categories in your config:
**Different-behavior models**:
- Gemini 3.1 Pro — excels at visual/frontend tasks
- MiniMax M2.5 — fast and smart for utility tasks
- MiniMax M2.7 / M2.7-highspeed — fast and smart for utility tasks
- Grok Code Fast 1 — optimized for code grep/search
See the [Agent-Model Matching Guide](./agent-model-matching.md) for complete details on which models work best for each agent, safe vs dangerous overrides, and provider priority chains.

View File

@@ -0,0 +1,33 @@
# Model Capabilities Maintenance
This project treats model capability resolution as a layered system:
1. runtime metadata from connected providers
2. `models.dev` bundled/runtime snapshot data
3. explicit compatibility aliases
4. heuristic fallback as the last resort
## Internal policy
- Built-in OmO agent/category requirement models must use canonical model IDs.
- Aliases exist only to preserve compatibility with historical OmO names or provider-specific decorations.
- New decorated names like `-high`, `-low`, or `-thinking` should not be added to built-in requirements when a canonical model ID plus structured settings can express the same thing.
- If a provider or config input still uses an alias, normalize it at the edge and continue internally with the canonical ID.
## When adding an alias
- Add the alias rule to `src/shared/model-capability-aliases.ts`.
- Include a rationale for why the alias exists.
- Add or update tests so the alias is covered explicitly.
- Ensure the alias canonical target exists in the bundled `models.dev` snapshot.
## Guardrails
`bun run test:model-capabilities` enforces the following invariants:
- exact alias targets must exist in the bundled snapshot
- exact alias keys must not silently become canonical `models.dev` IDs
- pattern aliases must not rewrite canonical snapshot IDs
- built-in requirement models must stay canonical and snapshot-backed
The scheduled `refresh-model-capabilities` workflow runs these guardrails before opening an automated snapshot refresh PR.

View File

@@ -270,8 +270,8 @@ Disable categories: `{ "disabled_categories": ["ultrabrain"] }`
| **Sisyphus** | `claude-opus-4-6` | `claude-opus-4-6``glm-5``big-pickle` |
| **Hephaestus** | `gpt-5.3-codex` | `gpt-5.3-codex``gpt-5.4` (GitHub Copilot fallback) |
| **oracle** | `gpt-5.4` | `gpt-5.4``gemini-3.1-pro``claude-opus-4-6` |
| **librarian** | `gemini-3-flash` | `gemini-3-flash``minimax-m2.5-free``big-pickle` |
| **explore** | `grok-code-fast-1` | `grok-code-fast-1``minimax-m2.5-free``claude-haiku-4-5``gpt-5-nano` |
| **librarian** | `minimax-m2.7` | `minimax-m2.7``minimax-m2.7-highspeed``claude-haiku-4-5``gpt-5-nano` |
| **explore** | `grok-code-fast-1` | `grok-code-fast-1``minimax-m2.7-highspeed``minimax-m2.7``claude-haiku-4-5``gpt-5-nano` |
| **multimodal-looker** | `gpt-5.3-codex` | `gpt-5.3-codex``k2p5``gemini-3-flash``glm-4.6v``gpt-5-nano` |
| **Prometheus** | `claude-opus-4-6` | `claude-opus-4-6``gpt-5.4``gemini-3.1-pro` |
| **Metis** | `claude-opus-4-6` | `claude-opus-4-6``gpt-5.4``gemini-3.1-pro` |
@@ -286,10 +286,10 @@ Disable categories: `{ "disabled_categories": ["ultrabrain"] }`
| **ultrabrain** | `gpt-5.4` | `gpt-5.4``gemini-3.1-pro``claude-opus-4-6` |
| **deep** | `gpt-5.3-codex` | `gpt-5.3-codex``claude-opus-4-6``gemini-3.1-pro` |
| **artistry** | `gemini-3.1-pro` | `gemini-3.1-pro``claude-opus-4-6``gpt-5.4` |
| **quick** | `gpt-5.4-mini` | `gpt-5.4-mini``claude-haiku-4-5``gemini-3-flash``minimax-m2.5``gpt-5-nano` |
| **unspecified-low** | `claude-sonnet-4-6` | `claude-sonnet-4-6``gpt-5.3-codex``gemini-3-flash` |
| **quick** | `gpt-5.4-mini` | `gpt-5.4-mini``claude-haiku-4-5``gemini-3-flash``minimax-m2.7``gpt-5-nano` |
| **unspecified-low** | `claude-sonnet-4-6` | `claude-sonnet-4-6``gpt-5.3-codex``gemini-3-flash` `minimax-m2.7` |
| **unspecified-high** | `claude-opus-4-6` | `claude-opus-4-6``gpt-5.4 (high)``glm-5``k2p5``kimi-k2.5` |
| **writing** | `gemini-3-flash` | `gemini-3-flash``claude-sonnet-4-6` |
| **writing** | `gemini-3-flash` | `gemini-3-flash``claude-sonnet-4-6` `minimax-m2.7` |
Run `bunx oh-my-openagent doctor --verbose` to see effective model resolution for your config.

View File

@@ -11,8 +11,8 @@ Oh-My-OpenAgent provides 11 specialized AI agents. Each has distinct expertise,
| **Sisyphus** | `claude-opus-4-6` | The default orchestrator. Plans, delegates, and executes complex tasks using specialized subagents with aggressive parallel execution. Todo-driven workflow with extended thinking (32k budget). Fallback: `glm-5``big-pickle`. |
| **Hephaestus** | `gpt-5.3-codex` | The Legitimate Craftsman. Autonomous deep worker inspired by AmpCode's deep mode. Goal-oriented execution with thorough research before action. Explores codebase patterns, completes tasks end-to-end without premature stopping. Named after the Greek god of forge and craftsmanship. Fallback: `gpt-5.4` on GitHub Copilot. Requires a GPT-capable provider. |
| **Oracle** | `gpt-5.4` | Architecture decisions, code review, debugging. Read-only consultation with stellar logical reasoning and deep analysis. Inspired by AmpCode. Fallback: `gemini-3.1-pro``claude-opus-4-6`. |
| **Librarian** | `gemini-3-flash` | Multi-repo analysis, documentation lookup, OSS implementation examples. Deep codebase understanding with evidence-based answers. Fallback: `minimax-m2.5-free``big-pickle`. |
| **Explore** | `grok-code-fast-1` | Fast codebase exploration and contextual grep. Fallback: `minimax-m2.5-free``claude-haiku-4-5``gpt-5-nano`. |
| **Librarian** | `minimax-m2.7` | Multi-repo analysis, documentation lookup, OSS implementation examples. Deep codebase understanding with evidence-based answers. Fallback: `minimax-m2.7-highspeed``claude-haiku-4-5``gpt-5-nano`. |
| **Explore** | `grok-code-fast-1` | Fast codebase exploration and contextual grep. Fallback: `minimax-m2.7-highspeed``minimax-m2.7``claude-haiku-4-5``gpt-5-nano`. |
| **Multimodal-Looker** | `gpt-5.3-codex` | Visual content specialist. Analyzes PDFs, images, diagrams to extract information. Fallback: `k2p5``gemini-3-flash``glm-4.6v``gpt-5-nano`. |
### Planning Agents

View File

@@ -0,0 +1,86 @@
# Model Settings Compatibility Resolver Implementation Plan
> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Centralize compatibility handling for `variant` and `reasoningEffort` so an already-selected model receives the best valid settings for that exact model.
**Architecture:** Introduce a pure shared resolver in `src/shared/` that computes compatible settings and records downgrades/removals. Integrate it first in `chat.params`, then keep Claude-specific effort logic as a thin layer rather than a special-case policy owner.
**Tech Stack:** TypeScript, Bun test, existing shared model normalization/utilities, OpenCode plugin `chat.params` path.
---
### Task 1: Create the pure compatibility resolver
**Files:**
- Create: `src/shared/model-settings-compatibility.ts`
- Create: `src/shared/model-settings-compatibility.test.ts`
- Modify: `src/shared/index.ts`
- [ ] **Step 1: Write failing tests for exact keep behavior**
- [ ] **Step 2: Write failing tests for downgrade behavior (`max` -> `high`, `xhigh` -> `high` where needed)**
- [ ] **Step 3: Write failing tests for unsupported-value removal**
- [ ] **Step 4: Write failing tests for model-family distinctions (Opus vs Sonnet/Haiku, GPT-family variants)**
- [ ] **Step 5: Implement the pure resolver with explicit capability ladders**
- [ ] **Step 6: Export the resolver from `src/shared/index.ts`**
- [ ] **Step 7: Run `bun test src/shared/model-settings-compatibility.test.ts`**
- [ ] **Step 8: Commit**
### Task 2: Integrate resolver into chat.params
**Files:**
- Modify: `src/plugin/chat-params.ts`
- Modify: `src/plugin/chat-params.test.ts`
- [ ] **Step 1: Write failing tests showing `chat.params` applies resolver output to runtime settings**
- [ ] **Step 2: Ensure tests cover both `variant` and `reasoningEffort` decisions**
- [ ] **Step 3: Update `chat-params.ts` to call the shared resolver before hook-specific adjustments**
- [ ] **Step 4: Preserve existing prompt-param-store merging behavior**
- [ ] **Step 5: Run `bun test src/plugin/chat-params.test.ts`**
- [ ] **Step 6: Commit**
### Task 3: Re-scope anthropic-effort around the resolver
**Files:**
- Modify: `src/hooks/anthropic-effort/hook.ts`
- Modify: `src/hooks/anthropic-effort/index.test.ts`
- [ ] **Step 1: Write failing tests that codify the intended remaining Anthropic-specific behavior after centralization**
- [ ] **Step 2: Reduce `anthropic-effort` to Claude/Anthropic-specific effort injection where still needed**
- [ ] **Step 3: Remove duplicated compatibility policy from the hook if the shared resolver now owns it**
- [ ] **Step 4: Run `bun test src/hooks/anthropic-effort/index.test.ts`**
- [ ] **Step 5: Commit**
### Task 4: Add integration/regression coverage across real request paths
**Files:**
- Modify: `src/plugin/chat-params.test.ts`
- Modify: `src/hooks/anthropic-effort/index.test.ts`
- Add tests only where needed in nearby suites
- [ ] **Step 1: Add regression test for non-Opus Claude with `variant=max` resolving to compatible settings without ad hoc path-only logic**
- [ ] **Step 2: Add regression test for GPT-style `reasoningEffort` compatibility**
- [ ] **Step 3: Add regression test showing supported values remain unchanged**
- [ ] **Step 4: Run the focused test set**
- [ ] **Step 5: Commit**
### Task 5: Verify full quality bar
**Files:**
- No intended code changes
- [ ] **Step 1: Run `bun run typecheck`**
- [ ] **Step 2: Run a focused suite for the touched files**
- [ ] **Step 3: If clean, run `bun test`**
- [ ] **Step 4: Review diff for accidental scope creep**
- [ ] **Step 5: Commit any final cleanup**
### Task 6: Prepare PR metadata
**Files:**
- No repo file change required unless docs are updated further
- [ ] **Step 1: Write a human summary explaining this is settings compatibility, not model fallback**
- [ ] **Step 2: Document scope: Phase 1 covers `variant` and `reasoningEffort` only**
- [ ] **Step 3: Document explicit non-goals: no model switching, no automatic upscaling in Phase 1**
- [ ] **Step 4: Request review**

View File

@@ -0,0 +1,164 @@
# Model Settings Compatibility Resolver Design
## Goal
Introduce a central resolver that takes an already-selected model and a set of desired model settings, then returns the best compatible configuration for that exact model.
This is explicitly separate from model fallback.
## Problem
Today, logic for `variant` and `reasoningEffort` compatibility is scattered across multiple places:
- `hooks/anthropic-effort`
- `plugin/chat-params`
- agent/category/fallback config layers
- delegate/background prompt plumbing
That creates inconsistent behavior:
- some paths clamp unsupported levels
- some paths pass them through unchanged
- some paths silently drop them
- some paths use model-family-specific assumptions that do not generalize
The result is brittle request behavior even when the chosen model itself is valid.
## Scope
Phase 1 covers only:
- `variant`
- `reasoningEffort`
Out of scope for Phase 1:
- model fallback itself
- `thinking`
- `maxTokens`
- `temperature`
- `top_p`
- automatic upward remapping of settings
## Desired behavior
Given a fixed model and desired settings:
1. If a desired value is supported, keep it.
2. If not supported, downgrade to the nearest lower compatible value.
3. If no compatible value exists, drop the field.
4. Do not switch models.
5. Do not automatically upgrade settings in Phase 1.
## Architecture
Add a central module:
- `src/shared/model-settings-compatibility.ts`
Core API:
```ts
type DesiredModelSettings = {
variant?: string
reasoningEffort?: string
}
type ModelSettingsCompatibilityInput = {
providerID: string
modelID: string
desired: DesiredModelSettings
}
type ModelSettingsCompatibilityChange = {
field: "variant" | "reasoningEffort"
from: string
to?: string
reason: string
}
type ModelSettingsCompatibilityResult = {
variant?: string
reasoningEffort?: string
changes: ModelSettingsCompatibilityChange[]
}
```
## Compatibility model
Phase 1 should be **metadata-first where the platform exposes reliable capability data**, and only fall back to family-based rules when that metadata is absent.
### Variant compatibility
Preferred source of truth:
- OpenCode/provider model metadata (`variants`)
Fallback when metadata is unavailable:
- family-based ladders
Examples of fallback ladders:
- Claude Opus family: `low`, `medium`, `high`, `max`
- Claude Sonnet/Haiku family: `low`, `medium`, `high`
- OpenAI GPT family: conservative family fallback only when metadata is missing
- Unknown family: drop unsupported values conservatively
### Reasoning effort compatibility
Current Phase 1 source of truth:
- conservative model/provider family heuristics
Reason:
- the currently available OpenCode SDK/provider metadata exposes model `variants`, but does not expose an equivalent per-model capability list for `reasoningEffort` levels
Examples:
- GPT/OpenAI-style models: `low`, `medium`, `high`, `xhigh` where supported by family heuristics
- Claude family via current OpenCode path: treat `reasoningEffort` as unsupported in Phase 1 and remove it
The resolver should remain pure model/settings logic only. Transport restrictions remain the responsibility of the request-building path.
## Separation of concerns
This design intentionally separates:
- model selection (`resolveModel...`, fallback chains)
- settings compatibility (this resolver)
- request transport compatibility (`chat.params`, prompt body constraints)
That keeps responsibilities clear:
- choose model first
- normalize settings second
- build request third
## First integration point
Phase 1 should first integrate into `chat.params`.
Why:
- it is already the centralized path for request-time tuning
- it can influence provider-facing options without leaking unsupported fields into prompt payload bodies
- it avoids trying to patch every prompt constructor at once
## Rollout plan
### Phase 1
- add resolver module and tests
- integrate into `chat.params`
- migrate `anthropic-effort` to either use the resolver or become a thin Claude-specific supplement around it
### Phase 2
- expand to `thinking`, `maxTokens`, `temperature`, `top_p`
- formalize request-path capability tables if needed
### Phase 3
- centralize all variant/reasoning normalization away from scattered hooks and ad hoc callers
## Risks
- Overfitting family rules to current model naming conventions
- Accidentally changing request semantics on paths that currently rely on implicit behavior
- Mixing provider transport limitations with model capability logic
## Mitigations
- Keep resolver pure and narrowly scoped in Phase 1
- Add explicit regression tests for keep/downgrade/drop decisions
- Integrate at one central point first (`chat.params`)
- Preserve existing behavior where desired values are already valid
## Recommendation
Proceed with the central resolver as a new, isolated implementation in a dedicated branch/worktree.
This is the clean long-term path and is more reviewable than continuing to add special-case clamps in hooks.

View File

@@ -25,10 +25,12 @@
"build:all": "bun run build && bun run build:binaries",
"build:binaries": "bun run script/build-binaries.ts",
"build:schema": "bun run script/build-schema.ts",
"build:model-capabilities": "bun run script/build-model-capabilities.ts",
"clean": "rm -rf dist",
"prepare": "bun run build",
"postinstall": "node postinstall.mjs",
"prepublishOnly": "bun run clean && bun run build",
"test:model-capabilities": "bun test src/shared/model-capability-aliases.test.ts src/shared/model-capability-guardrails.test.ts src/shared/model-capabilities.test.ts src/cli/doctor/checks/model-resolution.test.ts --bail",
"typecheck": "tsc --noEmit",
"test": "bun test"
},

View File

@@ -0,0 +1,13 @@
import { writeFileSync } from "fs"
import { resolve } from "path"
import {
fetchModelCapabilitiesSnapshot,
MODELS_DEV_SOURCE_URL,
} from "../src/shared/model-capabilities-cache"
const OUTPUT_PATH = resolve(import.meta.dir, "../src/generated/model-capabilities.generated.json")
console.log(`Fetching model capabilities snapshot from ${MODELS_DEV_SOURCE_URL}...`)
const snapshot = await fetchModelCapabilitiesSnapshot()
writeFileSync(OUTPUT_PATH, `${JSON.stringify(snapshot, null, 2)}\n`)
console.log(`Generated ${OUTPUT_PATH} with ${Object.keys(snapshot.models).length} models`)

View File

@@ -2303,6 +2303,30 @@
"created_at": "2026-03-23T04:28:20Z",
"repoId": 1108837393,
"pullRequestNo": 2758
},
{
"name": "anas-asghar4831",
"id": 110368394,
"comment_id": 4128950310,
"created_at": "2026-03-25T18:48:19Z",
"repoId": 1108837393,
"pullRequestNo": 2837
},
{
"name": "clansty",
"id": 18461360,
"comment_id": 4129934858,
"created_at": "2026-03-25T21:33:35Z",
"repoId": 1108837393,
"pullRequestNo": 2839
},
{
"name": "ventsislav-georgiev",
"id": 5616486,
"comment_id": 4130417794,
"created_at": "2026-03-25T23:11:32Z",
"repoId": 1108837393,
"pullRequestNo": 2840
}
]
}

View File

@@ -13,8 +13,8 @@ Agent factories following `createXXXAgent(model) → AgentConfig` pattern. Each
| **Sisyphus** | claude-opus-4-6 max | 0.1 | all | k2p5 → kimi-k2.5 → gpt-5.4 medium → glm-5 → big-pickle | Main orchestrator, plans + delegates |
| **Hephaestus** | gpt-5.3-codex medium | 0.1 | all | gpt-5.4 medium (copilot) | Autonomous deep worker |
| **Oracle** | gpt-5.4 high | 0.1 | subagent | gemini-3.1-pro high → claude-opus-4-6 max | Read-only consultation |
| **Librarian** | gemini-3-flash | 0.1 | subagent | minimax-m2.5-free → big-pickle | External docs/code search |
| **Explore** | grok-code-fast-1 | 0.1 | subagent | minimax-m2.5-free → claude-haiku-4-5 → gpt-5-nano | Contextual grep |
| **Librarian** | minimax-m2.7 | 0.1 | subagent | minimax-m2.7-highspeedclaude-haiku-4-5 → gpt-5-nano | External docs/code search |
| **Explore** | grok-code-fast-1 | 0.1 | subagent | minimax-m2.7-highspeed → minimax-m2.7 → claude-haiku-4-5 → gpt-5-nano | Contextual grep |
| **Multimodal-Looker** | gpt-5.3-codex medium | 0.1 | subagent | k2p5 → gemini-3-flash → glm-4.6v → gpt-5-nano | PDF/image analysis |
| **Metis** | claude-opus-4-6 max | **0.3** | subagent | gpt-5.4 high → gemini-3.1-pro high | Pre-planning consultant |
| **Momus** | gpt-5.4 xhigh | 0.1 | subagent | claude-opus-4-6 max → gemini-3.1-pro high | Plan reviewer |

16
src/agents/athena.ts Normal file
View File

@@ -0,0 +1,16 @@
import type { AgentConfig } from "@opencode-ai/sdk"
import type { AgentMode } from "./types"
import { buildAthenaPrompt, type AthenaPromptOptions } from "./athena/prompt"
const MODE: AgentMode = "primary"
export function createAthenaAgent(model: string, options?: AthenaPromptOptions): AgentConfig {
return {
description: "Primary council orchestrator for Athena workflows. (Athena - OhMyOpenCode)",
mode: MODE,
model,
temperature: 0.1,
prompt: buildAthenaPrompt(options),
}
}
createAthenaAgent.mode = MODE

View File

@@ -0,0 +1,36 @@
export const COUNCIL_MEMBER_RESPONSE_TAG = "COUNCIL_MEMBER_RESPONSE"
export type CouncilVerdict = "support" | "oppose" | "mixed" | "abstain"
export interface CouncilEvidenceItem {
source: string
detail: string
}
export interface CouncilMemberResponse {
member: string
verdict: CouncilVerdict
confidence: number
rationale: string
risks: string[]
evidence: CouncilEvidenceItem[]
proposed_actions: string[]
missing_information: string[]
}
export interface AthenaCouncilMember {
name: string
model: string
}
export interface ParsedCouncilMemberResponse {
ok: true
value: CouncilMemberResponse
source: "raw_json" | "tagged_json"
}
export interface CouncilResponseParseFailure {
ok: false
error: string
source: "raw_json" | "tagged_json" | "none"
}

View File

@@ -0,0 +1,24 @@
import type { AthenaCouncilMember } from "./council-contract"
function slugify(input: string): string {
return input
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
}
export function toCouncilMemberAgentName(memberName: string): string {
const slug = slugify(memberName)
return `council-member-${slug || "member"}`
}
export function buildCouncilRosterSection(members: AthenaCouncilMember[]): string {
if (members.length === 0) {
return "- No configured council roster. Use default subagent_type=\"council-member\"."
}
return members
.map((member) => `- ${member.name} | model=${member.model} | subagent_type=${toCouncilMemberAgentName(member.name)}`)
.join("\n")
}

View File

@@ -0,0 +1,38 @@
import { describe, expect, test } from "bun:test"
import { evaluateCouncilQuorum } from "./council-quorum"
describe("evaluateCouncilQuorum", () => {
test("#given partial failures with enough successful members #when evaluating #then quorum reached with graceful degradation", () => {
// given
const input = {
totalMembers: 5,
successfulMembers: 3,
failedMembers: 2,
}
// when
const result = evaluateCouncilQuorum(input)
// then
expect(result.required).toBe(3)
expect(result.reached).toBe(true)
expect(result.gracefulDegradation).toBe(true)
})
test("#given too many failures #when evaluating #then quorum is unreachable", () => {
// given
const input = {
totalMembers: 4,
successfulMembers: 1,
failedMembers: 3,
}
// when
const result = evaluateCouncilQuorum(input)
// then
expect(result.required).toBe(2)
expect(result.reached).toBe(false)
expect(result.canStillReach).toBe(false)
})
})

View File

@@ -0,0 +1,36 @@
export interface CouncilQuorumInput {
totalMembers: number
successfulMembers: number
failedMembers: number
requestedQuorum?: number
}
export interface CouncilQuorumResult {
required: number
reached: boolean
canStillReach: boolean
gracefulDegradation: boolean
}
function clampMinimumQuorum(totalMembers: number, requestedQuorum?: number): number {
if (requestedQuorum && requestedQuorum > 0) {
return Math.min(totalMembers, requestedQuorum)
}
return Math.max(1, Math.ceil(totalMembers / 2))
}
export function evaluateCouncilQuorum(input: CouncilQuorumInput): CouncilQuorumResult {
const required = clampMinimumQuorum(input.totalMembers, input.requestedQuorum)
const reached = input.successfulMembers >= required
const remainingPossible = input.totalMembers - input.failedMembers
const canStillReach = remainingPossible >= required
const gracefulDegradation = reached && input.failedMembers > 0
return {
required,
reached,
canStillReach,
gracefulDegradation,
}
}

View File

@@ -0,0 +1,71 @@
import { describe, expect, test } from "bun:test"
import { parseCouncilMemberResponse } from "./council-response-parser"
describe("parseCouncilMemberResponse", () => {
test("#given valid raw json #when parsing #then returns parsed council payload", () => {
// given
const raw = JSON.stringify({
member: "architect",
verdict: "support",
confidence: 0.9,
rationale: "Matches existing module boundaries",
risks: ["Regression in edge-case parser"],
evidence: [{ source: "src/agents/athena.ts", detail: "Current prompt is too generic" }],
proposed_actions: ["Add strict orchestration workflow"],
missing_information: ["Need runtime timeout budget"],
})
// when
const result = parseCouncilMemberResponse(raw)
// then
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.source).toBe("raw_json")
expect(result.value.member).toBe("architect")
expect(result.value.verdict).toBe("support")
})
test("#given tagged json payload #when parsing #then extracts from COUNCIL_MEMBER_RESPONSE tag", () => {
// given
const raw = [
"analysis intro",
"<COUNCIL_MEMBER_RESPONSE>",
JSON.stringify({
member: "skeptic",
verdict: "mixed",
confidence: 0.62,
rationale: "Quorum logic exists but retry handling is weak",
risks: ["Timeout blind spot"],
evidence: [{ source: "src/tools/background-task/create-background-wait.ts", detail: "No nudge semantics" }],
proposed_actions: ["Add stuck detection policy"],
missing_information: [],
}),
"</COUNCIL_MEMBER_RESPONSE>",
].join("\n")
// when
const result = parseCouncilMemberResponse(raw)
// then
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.source).toBe("tagged_json")
expect(result.value.member).toBe("skeptic")
expect(result.value.proposed_actions).toEqual(["Add stuck detection policy"])
})
test("#given malformed payload #when parsing #then returns structured parse failure", () => {
// given
const raw = "Council says: maybe this works"
// when
const result = parseCouncilMemberResponse(raw)
// then
expect(result.ok).toBe(false)
if (result.ok) return
expect(result.source).toBe("none")
expect(result.error.length).toBeGreaterThan(0)
})
})

View File

@@ -0,0 +1,159 @@
import {
COUNCIL_MEMBER_RESPONSE_TAG,
type CouncilMemberResponse,
type CouncilResponseParseFailure,
type ParsedCouncilMemberResponse,
} from "./council-contract"
type ParseResult = ParsedCouncilMemberResponse | CouncilResponseParseFailure
function normalizeJsonPayload(input: string): string {
const trimmed = input.trim()
if (!trimmed.startsWith("```") || !trimmed.endsWith("```")) {
return trimmed
}
const firstNewLine = trimmed.indexOf("\n")
if (firstNewLine < 0) {
return trimmed
}
return trimmed.slice(firstNewLine + 1, -3).trim()
}
function tryParseJsonObject(input: string): unknown {
const normalized = normalizeJsonPayload(input)
if (!normalized.startsWith("{")) {
return null
}
try {
return JSON.parse(normalized)
} catch {
return null
}
}
function extractTaggedPayload(raw: string): string | null {
const xmlLike = new RegExp(
`<${COUNCIL_MEMBER_RESPONSE_TAG}>([\\s\\S]*?)<\\/${COUNCIL_MEMBER_RESPONSE_TAG}>`,
"i",
)
const xmlMatch = raw.match(xmlLike)
if (xmlMatch?.[1]) {
return xmlMatch[1].trim()
}
const prefixed = new RegExp(`${COUNCIL_MEMBER_RESPONSE_TAG}\\s*:\\s*`, "i")
const prefixMatch = raw.match(prefixed)
if (!prefixMatch) {
return null
}
const matchIndex = prefixMatch.index
if (matchIndex === undefined) {
return null
}
const rest = raw.slice(matchIndex + prefixMatch[0].length)
const firstBrace = rest.indexOf("{")
if (firstBrace < 0) {
return null
}
return rest.slice(firstBrace).trim()
}
function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((item) => typeof item === "string")
}
function isEvidenceArray(value: unknown): value is CouncilMemberResponse["evidence"] {
return Array.isArray(value)
&& value.every(
(item) =>
typeof item === "object"
&& item !== null
&& typeof (item as { source?: unknown }).source === "string"
&& typeof (item as { detail?: unknown }).detail === "string",
)
}
function validateCouncilMemberResponse(payload: unknown): CouncilMemberResponse | null {
if (typeof payload !== "object" || payload === null) {
return null
}
const candidate = payload as Record<string, unknown>
const verdict = candidate.verdict
const confidence = candidate.confidence
if (
typeof candidate.member !== "string"
|| (verdict !== "support" && verdict !== "oppose" && verdict !== "mixed" && verdict !== "abstain")
|| typeof confidence !== "number"
|| confidence < 0
|| confidence > 1
|| typeof candidate.rationale !== "string"
|| !isStringArray(candidate.risks)
|| !isEvidenceArray(candidate.evidence)
|| !isStringArray(candidate.proposed_actions)
|| !isStringArray(candidate.missing_information)
) {
return null
}
return {
member: candidate.member,
verdict,
confidence,
rationale: candidate.rationale,
risks: candidate.risks,
evidence: candidate.evidence,
proposed_actions: candidate.proposed_actions,
missing_information: candidate.missing_information,
}
}
function parseValidated(payload: unknown, source: ParsedCouncilMemberResponse["source"]): ParseResult {
const validated = validateCouncilMemberResponse(payload)
if (!validated) {
return {
ok: false,
error: "Council member response does not match required contract",
source,
}
}
return {
ok: true,
value: validated,
source,
}
}
export function parseCouncilMemberResponse(raw: string): ParseResult {
const directJson = tryParseJsonObject(raw)
if (directJson) {
return parseValidated(directJson, "raw_json")
}
const taggedPayload = extractTaggedPayload(raw)
if (taggedPayload) {
const taggedJson = tryParseJsonObject(taggedPayload)
if (taggedJson) {
return parseValidated(taggedJson, "tagged_json")
}
return {
ok: false,
error: "Tagged council response found, but JSON payload is invalid",
source: "tagged_json",
}
}
return {
ok: false,
error: "No parseable council response payload found",
source: "none",
}
}

View File

@@ -0,0 +1,50 @@
import { describe, expect, test } from "bun:test"
import { decideCouncilRecoveryAction } from "./council-retry"
describe("decideCouncilRecoveryAction", () => {
test("#given running member with stale progress and nudge budget #when deciding #then nudge", () => {
// given
const now = 10_000
const decision = decideCouncilRecoveryAction(
{
status: "running",
attempts: 1,
nudges: 0,
startedAt: 1_000,
lastProgressAt: 1_000,
},
{
maxAttempts: 2,
maxNudges: 1,
stuckAfterMs: 2_000,
},
now,
)
// then
expect(decision.action).toBe("nudge")
})
test("#given stuck member after nudge with retry budget #when deciding #then retry", () => {
// given
const now = 20_000
const decision = decideCouncilRecoveryAction(
{
status: "running",
attempts: 1,
nudges: 1,
startedAt: 1_000,
lastProgressAt: 1_000,
},
{
maxAttempts: 3,
maxNudges: 1,
stuckAfterMs: 5_000,
},
now,
)
// then
expect(decision.action).toBe("retry")
})
})

View File

@@ -0,0 +1,68 @@
export type CouncilMemberTaskStatus =
| "pending"
| "running"
| "completed"
| "failed"
| "cancelled"
| "timed_out"
export interface CouncilMemberTaskState {
status: CouncilMemberTaskStatus
attempts: number
nudges: number
startedAt: number
lastProgressAt: number
}
export interface CouncilRetryPolicy {
maxAttempts: number
maxNudges: number
stuckAfterMs: number
}
export type CouncilRecoveryAction = "wait" | "nudge" | "retry" | "give_up"
export interface CouncilRecoveryDecision {
action: CouncilRecoveryAction
reason: string
}
export function isCouncilMemberStuck(
now: number,
lastProgressAt: number,
stuckAfterMs: number,
): boolean {
return now - lastProgressAt >= stuckAfterMs
}
export function decideCouncilRecoveryAction(
state: CouncilMemberTaskState,
policy: CouncilRetryPolicy,
now: number,
): CouncilRecoveryDecision {
if (state.status === "completed" || state.status === "cancelled") {
return { action: "give_up", reason: "Task already reached terminal status" }
}
if (state.status === "failed" || state.status === "timed_out") {
if (state.attempts < policy.maxAttempts) {
return { action: "retry", reason: "Terminal failure with retries remaining" }
}
return { action: "give_up", reason: "Terminal failure and retry budget exhausted" }
}
const stuck = isCouncilMemberStuck(now, state.lastProgressAt, policy.stuckAfterMs)
if (!stuck) {
return { action: "wait", reason: "Task is still making progress" }
}
if (state.nudges < policy.maxNudges) {
return { action: "nudge", reason: "Task appears stuck and nudge budget remains" }
}
if (state.attempts < policy.maxAttempts) {
return { action: "retry", reason: "Task stuck after nudges, retrying with fresh run" }
}
return { action: "give_up", reason: "Task stuck and all recovery budgets exhausted" }
}

View File

@@ -0,0 +1,43 @@
import { describe, expect, test } from "bun:test"
import { synthesizeCouncilOutcome } from "./council-synthesis"
import type { CouncilMemberResponse } from "./council-contract"
function response(overrides: Partial<CouncilMemberResponse>): CouncilMemberResponse {
return {
member: "member-a",
verdict: "support",
confidence: 0.8,
rationale: "default rationale",
risks: [],
evidence: [{ source: "file.ts", detail: "detail" }],
proposed_actions: ["Ship with tests"],
missing_information: [],
...overrides,
}
}
describe("synthesizeCouncilOutcome", () => {
test("#given majority support with one failure #when synthesizing #then reports agreement and graceful degradation", () => {
// given
const responses = [
response({ member: "architect", verdict: "support", proposed_actions: ["Ship with tests"] }),
response({ member: "skeptic", verdict: "support", proposed_actions: ["Ship with tests"] }),
response({ member: "critic", verdict: "oppose", risks: ["Parser drift"] }),
]
// when
const result = synthesizeCouncilOutcome({
responses,
failedMembers: ["perf"],
quorumReached: true,
})
// then
expect(result.majorityVerdict).toBe("support")
expect(result.agreementMembers).toEqual(["architect", "skeptic"])
expect(result.disagreementMembers).toContain("critic")
expect(result.disagreementMembers).toContain("perf")
expect(result.commonActions).toEqual(["Ship with tests"])
expect(result.gracefulDegradation).toBe(true)
})
})

View File

@@ -0,0 +1,141 @@
import type { CouncilMemberResponse, CouncilVerdict } from "./council-contract"
export interface CouncilSynthesisInput {
responses: CouncilMemberResponse[]
failedMembers: string[]
quorumReached: boolean
}
export interface CouncilSynthesisResult {
majorityVerdict: CouncilVerdict
consensusLevel: "unanimous" | "strong" | "split" | "fragmented"
agreementMembers: string[]
disagreementMembers: string[]
commonActions: string[]
contestedRisks: string[]
unresolvedQuestions: string[]
gracefulDegradation: boolean
}
function normalizeKey(value: string): string {
return value.trim().toLowerCase()
}
function getMajorityVerdict(responses: CouncilMemberResponse[]): CouncilVerdict {
const counts = new Map<CouncilVerdict, number>()
for (const response of responses) {
counts.set(response.verdict, (counts.get(response.verdict) ?? 0) + 1)
}
const orderedVerdicts: CouncilVerdict[] = ["support", "mixed", "oppose", "abstain"]
let winner: CouncilVerdict = "abstain"
let winnerCount = -1
for (const verdict of orderedVerdicts) {
const count = counts.get(verdict) ?? 0
if (count > winnerCount) {
winner = verdict
winnerCount = count
}
}
return winner
}
function deriveConsensusLevel(agreementCount: number, totalCount: number): CouncilSynthesisResult["consensusLevel"] {
if (totalCount === 0) {
return "fragmented"
}
if (agreementCount === totalCount) {
return "unanimous"
}
const ratio = agreementCount / totalCount
if (ratio >= 0.75) {
return "strong"
}
if (ratio >= 0.5) {
return "split"
}
return "fragmented"
}
function collectCommonActions(responses: CouncilMemberResponse[]): string[] {
const counts = new Map<string, { text: string; count: number }>()
for (const response of responses) {
for (const action of response.proposed_actions) {
const key = normalizeKey(action)
const existing = counts.get(key)
if (!existing) {
counts.set(key, { text: action, count: 1 })
continue
}
existing.count += 1
}
}
const threshold = Math.max(2, Math.ceil(responses.length / 2))
return [...counts.values()]
.filter((item) => item.count >= threshold)
.map((item) => item.text)
}
function collectContestedRisks(responses: CouncilMemberResponse[]): string[] {
const counts = new Map<string, { text: string; count: number }>()
for (const response of responses) {
for (const risk of response.risks) {
const key = normalizeKey(risk)
const existing = counts.get(key)
if (!existing) {
counts.set(key, { text: risk, count: 1 })
continue
}
existing.count += 1
}
}
return [...counts.values()]
.filter((item) => item.count === 1)
.map((item) => item.text)
}
function collectUnresolvedQuestions(responses: CouncilMemberResponse[]): string[] {
const seen = new Set<string>()
const questions: string[] = []
for (const response of responses) {
for (const question of response.missing_information) {
const key = normalizeKey(question)
if (seen.has(key)) {
continue
}
seen.add(key)
questions.push(question)
}
}
return questions
}
export function synthesizeCouncilOutcome(input: CouncilSynthesisInput): CouncilSynthesisResult {
const majorityVerdict = getMajorityVerdict(input.responses)
const agreementMembers = input.responses
.filter((response) => response.verdict === majorityVerdict)
.map((response) => response.member)
const disagreementMembers = input.responses
.filter((response) => response.verdict !== majorityVerdict)
.map((response) => response.member)
.concat(input.failedMembers)
return {
majorityVerdict,
consensusLevel: deriveConsensusLevel(agreementMembers.length, input.responses.length),
agreementMembers,
disagreementMembers,
commonActions: collectCommonActions(input.responses),
contestedRisks: collectContestedRisks(input.responses),
unresolvedQuestions: collectUnresolvedQuestions(input.responses),
gracefulDegradation: input.quorumReached && input.failedMembers.length > 0,
}
}

View File

@@ -0,0 +1,68 @@
import type { AthenaCouncilMember } from "./council-contract"
import { COUNCIL_MEMBER_RESPONSE_TAG } from "./council-contract"
import { buildCouncilRosterSection } from "./council-members"
export interface AthenaPromptOptions {
members?: AthenaCouncilMember[]
}
export function buildAthenaPrompt(options: AthenaPromptOptions = {}): string {
const roster = buildCouncilRosterSection(options.members ?? [])
return `You are Athena, a primary council orchestrator agent.
Operate as a strict multi-model council coordinator.
Core workflow:
1) Receive user request and define a concise decision question for the council.
2) Fan out council-member tasks in parallel with task(..., run_in_background=true).
3) Collect with background_wait first, then background_output for completed IDs.
4) Parse each member output as strict JSON contract; fallback to ${COUNCIL_MEMBER_RESPONSE_TAG} tag extraction.
5) Apply quorum, retries, and graceful degradation.
6) Synthesize agreement vs disagreement explicitly, then provide final recommendation.
Council roster:
${roster}
Execution protocol:
- Always run council fan-out in parallel. Never sequentially wait on one member before launching others.
- Use subagent_type="council-member" if no named roster is configured.
- For named roster entries, use that exact subagent_type so each member runs on its assigned model.
- Keep prompts evidence-oriented and read-only. Members must inspect code, tests, logs, and config references.
- Never ask members to edit files, delegate, or switch agents.
Member response contract (required):
- Preferred: raw JSON only.
- Fallback allowed: wrap JSON in <${COUNCIL_MEMBER_RESPONSE_TAG}>...</${COUNCIL_MEMBER_RESPONSE_TAG}>.
- Required JSON keys:
{
"member": string,
"verdict": "support" | "oppose" | "mixed" | "abstain",
"confidence": number (0..1),
"rationale": string,
"risks": string[],
"evidence": [{ "source": string, "detail": string }],
"proposed_actions": string[],
"missing_information": string[]
}
Failure and stuck handling:
- Track per-member attempts, nudges, and progress timestamps.
- Detect stuck tasks when no progress appears within expected interval.
- First recovery action for stuck: nudge through continuation prompt.
- If still stuck or failed: retry with a fresh background task, bounded by retry limit.
- If a member remains failed after retry budget, mark as failed and continue.
Quorum and degradation:
- Default quorum: ceil(total_members / 2), minimum 1.
- If quorum reached, continue synthesis even when some members failed.
- If quorum cannot be reached after retries, report partial findings and explicit uncertainty.
Synthesis output requirements:
- Separate "agreement" and "disagreement" sections.
- Name which members support the majority view and which dissent or failed.
- Call out unresolved questions and evidence gaps.
- End with one executable recommendation and a confidence statement.
Do not expose internal operational noise. Report concise structured findings.`
}

View File

@@ -12,6 +12,8 @@ import { createMetisAgent, metisPromptMetadata } from "./metis"
import { createAtlasAgent, atlasPromptMetadata } from "./atlas"
import { createMomusAgent, momusPromptMetadata } from "./momus"
import { createHephaestusAgent } from "./hephaestus"
import { createAthenaAgent } from "./athena"
import { createCouncilMemberAgent } from "./council-member"
import { createSisyphusJuniorAgentWithOverrides } from "./sisyphus-junior"
import type { AvailableCategory } from "./dynamic-agent-prompt-builder"
import {
@@ -33,6 +35,7 @@ type AgentSource = AgentFactory | AgentConfig
const agentSources: Record<BuiltinAgentName, AgentSource> = {
sisyphus: createSisyphusAgent,
hephaestus: createHephaestusAgent,
athena: createAthenaAgent,
oracle: createOracleAgent,
librarian: createLibrarianAgent,
explore: createExploreAgent,
@@ -43,6 +46,7 @@ const agentSources: Record<BuiltinAgentName, AgentSource> = {
// because it needs OrchestratorContext, not just a model string
atlas: createAtlasAgent as AgentFactory,
"sisyphus-junior": createSisyphusJuniorAgentWithOverrides as unknown as AgentFactory,
"council-member": createCouncilMemberAgent,
}
/**

View File

@@ -44,6 +44,10 @@ export function mergeAgentConfig(
const { prompt_append, ...rest } = migratedOverride
const merged = deepMerge(base, rest as Partial<AgentConfig>)
if (merged.prompt && typeof merged.prompt === 'string' && merged.prompt.startsWith('file://')) {
merged.prompt = resolvePromptAppend(merged.prompt, directory)
}
if (prompt_append && merged.prompt) {
merged.prompt = merged.prompt + "\n" + resolvePromptAppend(prompt_append, directory)
}

View File

@@ -8,6 +8,7 @@ import { buildAgent, isFactory } from "../agent-builder"
import { applyOverrides } from "./agent-overrides"
import { applyEnvironmentContext } from "./environment-context"
import { applyModelResolution, getFirstFallbackModel } from "./model-resolution"
import { log } from "../../shared/logger"
export function collectPendingBuiltinAgents(input: {
agentSources: Record<BuiltinAgentName, import("../agent-builder").AgentSource>
@@ -38,7 +39,6 @@ export function collectPendingBuiltinAgents(input: {
browserProvider,
uiSelectedModel,
availableModels,
isFirstRunNoCache,
disabledSkills,
disableOmoEnv = false,
} = input
@@ -55,8 +55,9 @@ export function collectPendingBuiltinAgents(input: {
if (agentName === "sisyphus-junior") continue
if (disabledAgents.some((name) => name.toLowerCase() === agentName.toLowerCase())) continue
const override = agentOverrides[agentName]
?? Object.entries(agentOverrides).find(([key]) => key.toLowerCase() === agentName.toLowerCase())?.[1]
const override = Object.entries(agentOverrides).find(
([key]) => key.toLowerCase() === agentName.toLowerCase(),
)?.[1]
const requirement = AGENT_MODEL_REQUIREMENTS[agentName]
// Check if agent requires a specific model
@@ -75,7 +76,13 @@ export function collectPendingBuiltinAgents(input: {
availableModels,
systemDefaultModel,
})
if (!resolution && isFirstRunNoCache && !override?.model) {
if (!resolution) {
if (override?.model) {
log("[agent-registration] User-configured model could not be resolved, falling back", {
agent: agentName,
configuredModel: override.model,
})
}
resolution = getFirstFallbackModel(requirement)
}
if (!resolution) continue

View File

@@ -0,0 +1,51 @@
import type { AgentConfig } from "@opencode-ai/sdk"
import type { AgentMode } from "./types"
import { createAgentToolRestrictions } from "../shared/permission-compat"
import { COUNCIL_MEMBER_RESPONSE_TAG } from "./athena/council-contract"
const MODE: AgentMode = "subagent"
const councilMemberRestrictions = createAgentToolRestrictions([
"write",
"edit",
"apply_patch",
"task",
"task_*",
"teammate",
"call_omo_agent",
"switch_agent",
])
export function createCouncilMemberAgent(model: string): AgentConfig {
return {
description: "Internal hidden council member used by Athena. Read-only analysis only.",
mode: MODE,
model,
temperature: 0.1,
hidden: true,
...councilMemberRestrictions,
prompt: `You are an internal council-member for Athena.
You are strictly read-only and evidence-oriented.
You must not modify files, delegate, or switch agents.
You must cite concrete evidence from files, tests, logs, or tool output.
Output contract:
- Preferred output: raw JSON only.
- Fallback output: wrap JSON with <${COUNCIL_MEMBER_RESPONSE_TAG}>...</${COUNCIL_MEMBER_RESPONSE_TAG}>.
- Required JSON schema:
{
"member": string,
"verdict": "support" | "oppose" | "mixed" | "abstain",
"confidence": number (0..1),
"rationale": string,
"risks": string[],
"evidence": [{ "source": string, "detail": string }],
"proposed_actions": string[],
"missing_information": string[]
}
Do not include markdown explanations outside the contract unless Athena asks for it explicitly.`,
}
}
createCouncilMemberAgent.mode = MODE

View File

@@ -35,6 +35,11 @@ Task NOT complete without:
- ${verificationText}
</Verification>
<Termination>
STOP after first successful verification. Do NOT re-verify.
Maximum status checks: 2. Then stop regardless.
</Termination>
<Style>
- Start immediately. No acknowledgments.
- Match user's communication style.

View File

@@ -1,5 +1,5 @@
import { describe, test, expect } from "bun:test";
import { isGptModel, isGeminiModel, isGpt5_4Model } from "./types";
import { isGptModel, isGeminiModel, isGpt5_4Model, isMiniMaxModel } from "./types";
describe("isGpt5_4Model", () => {
test("detects gpt-5.4 models", () => {
@@ -79,6 +79,28 @@ describe("isGptModel", () => {
});
});
describe("isMiniMaxModel", () => {
test("detects minimax models with provider prefix", () => {
expect(isMiniMaxModel("opencode-go/minimax-m2.7")).toBe(true);
expect(isMiniMaxModel("opencode/minimax-m2.7-highspeed")).toBe(true);
expect(isMiniMaxModel("opencode-go/minimax-m2.5")).toBe(true);
expect(isMiniMaxModel("opencode/minimax-m2.5-free")).toBe(true);
});
test("detects minimax models without provider prefix", () => {
expect(isMiniMaxModel("minimax-m2.7")).toBe(true);
expect(isMiniMaxModel("minimax-m2.7-highspeed")).toBe(true);
expect(isMiniMaxModel("minimax-m2.5")).toBe(true);
});
test("does not match non-minimax models", () => {
expect(isMiniMaxModel("openai/gpt-5.4")).toBe(false);
expect(isMiniMaxModel("anthropic/claude-opus-4-6")).toBe(false);
expect(isMiniMaxModel("google/gemini-3.1-pro")).toBe(false);
expect(isMiniMaxModel("opencode-go/kimi-k2.5")).toBe(false);
});
});
describe("isGeminiModel", () => {
test("#given google provider models #then returns true", () => {
expect(isGeminiModel("google/gemini-3.1-pro")).toBe(true);

View File

@@ -91,6 +91,11 @@ export function isGpt5_3CodexModel(model: string): boolean {
const GEMINI_PROVIDERS = ["google/", "google-vertex/"];
export function isMiniMaxModel(model: string): boolean {
const modelName = extractModelName(model).toLowerCase();
return modelName.includes("minimax");
}
export function isGeminiModel(model: string): boolean {
if (GEMINI_PROVIDERS.some((prefix) => model.startsWith(prefix))) return true;
@@ -107,6 +112,7 @@ export function isGeminiModel(model: string): boolean {
export type BuiltinAgentName =
| "sisyphus"
| "hephaestus"
| "athena"
| "oracle"
| "librarian"
| "explore"
@@ -114,16 +120,17 @@ export type BuiltinAgentName =
| "metis"
| "momus"
| "atlas"
| "sisyphus-junior";
| "sisyphus-junior"
| "council-member";
export type OverridableAgentName = "build" | BuiltinAgentName;
export type OverridableAgentName = "build" | Exclude<BuiltinAgentName, "council-member">;
export type AgentName = BuiltinAgentName;
export type AgentOverrideConfig = Partial<AgentConfig> & {
prompt_append?: string;
variant?: string;
fallback_models?: string | string[];
fallback_models?: string | (string | import("../config/schema/fallback-models").FallbackModelObject)[];
};
export type AgentOverrides = Partial<

View File

@@ -11,6 +11,32 @@ import * as shared from "../shared"
const TEST_DEFAULT_MODEL = "anthropic/claude-opus-4-6"
describe("createBuiltinAgents with model overrides", () => {
test("registers athena as builtin primary agent", async () => {
// #given
// #when
const agents = await createBuiltinAgents([], {}, undefined, TEST_DEFAULT_MODEL)
// #then
expect(agents.athena).toBeDefined()
expect(agents.athena.mode).toBe("primary")
})
test("registers council-member as hidden internal subagent", async () => {
// #given
// #when
const agents = await createBuiltinAgents([], {}, undefined, TEST_DEFAULT_MODEL)
// #then
expect(agents["council-member"]).toBeDefined()
expect(agents["council-member"].mode).toBe("subagent")
expect((agents["council-member"] as AgentConfig & { hidden?: boolean }).hidden).toBe(true)
expect(agents.sisyphus.prompt).not.toContain("council-member")
expect(agents.hephaestus.prompt).not.toContain("council-member")
expect(agents.atlas.prompt).not.toContain("council-member")
})
test("Sisyphus with default model has thinking config when all models available", async () => {
// #given
const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue(

View File

@@ -4,9 +4,15 @@ exports[`generateModelConfig no providers available returns ULTIMATE_FALLBACK fo
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
"agents": {
"athena": {
"model": "opencode/gpt-5-nano",
},
"atlas": {
"model": "opencode/gpt-5-nano",
},
"council-member": {
"model": "opencode/gpt-5-nano",
},
"explore": {
"model": "opencode/gpt-5-nano",
},
@@ -68,9 +74,15 @@ exports[`generateModelConfig single native provider uses Claude models when only
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
"agents": {
"athena": {
"model": "anthropic/claude-sonnet-4-6",
},
"atlas": {
"model": "anthropic/claude-sonnet-4-6",
},
"council-member": {
"model": "anthropic/claude-sonnet-4-6",
},
"explore": {
"model": "anthropic/claude-haiku-4-5",
},
@@ -130,9 +142,15 @@ exports[`generateModelConfig single native provider uses Claude models with isMa
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
"agents": {
"athena": {
"model": "anthropic/claude-sonnet-4-6",
},
"atlas": {
"model": "anthropic/claude-sonnet-4-6",
},
"council-member": {
"model": "anthropic/claude-sonnet-4-6",
},
"explore": {
"model": "anthropic/claude-haiku-4-5",
},
@@ -193,10 +211,18 @@ exports[`generateModelConfig single native provider uses OpenAI models when only
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
"agents": {
"athena": {
"model": "openai/gpt-5.4",
"variant": "medium",
},
"atlas": {
"model": "openai/gpt-5.4",
"variant": "medium",
},
"council-member": {
"model": "openai/gpt-5.4",
"variant": "medium",
},
"explore": {
"model": "openai/gpt-5.4",
"variant": "medium",
@@ -278,10 +304,18 @@ exports[`generateModelConfig single native provider uses OpenAI models with isMa
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
"agents": {
"athena": {
"model": "openai/gpt-5.4",
"variant": "medium",
},
"atlas": {
"model": "openai/gpt-5.4",
"variant": "medium",
},
"council-member": {
"model": "openai/gpt-5.4",
"variant": "medium",
},
"explore": {
"model": "openai/gpt-5.4",
"variant": "medium",
@@ -363,9 +397,15 @@ exports[`generateModelConfig single native provider uses Gemini models when only
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
"agents": {
"athena": {
"model": "opencode/gpt-5-nano",
},
"atlas": {
"model": "opencode/gpt-5-nano",
},
"council-member": {
"model": "opencode/gpt-5-nano",
},
"explore": {
"model": "opencode/gpt-5-nano",
},
@@ -423,9 +463,15 @@ exports[`generateModelConfig single native provider uses Gemini models with isMa
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
"agents": {
"athena": {
"model": "opencode/gpt-5-nano",
},
"atlas": {
"model": "opencode/gpt-5-nano",
},
"council-member": {
"model": "opencode/gpt-5-nano",
},
"explore": {
"model": "opencode/gpt-5-nano",
},
@@ -483,9 +529,16 @@ exports[`generateModelConfig all native providers uses preferred models from fal
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
"agents": {
"athena": {
"model": "anthropic/claude-sonnet-4-6",
},
"atlas": {
"model": "anthropic/claude-sonnet-4-6",
},
"council-member": {
"model": "openai/gpt-5.4",
"variant": "medium",
},
"explore": {
"model": "anthropic/claude-haiku-4-5",
},
@@ -558,9 +611,16 @@ exports[`generateModelConfig all native providers uses preferred models with isM
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
"agents": {
"athena": {
"model": "anthropic/claude-sonnet-4-6",
},
"atlas": {
"model": "anthropic/claude-sonnet-4-6",
},
"council-member": {
"model": "openai/gpt-5.4",
"variant": "medium",
},
"explore": {
"model": "anthropic/claude-haiku-4-5",
},
@@ -634,9 +694,16 @@ exports[`generateModelConfig fallback providers uses OpenCode Zen models when on
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
"agents": {
"athena": {
"model": "opencode/claude-sonnet-4-6",
},
"atlas": {
"model": "opencode/claude-sonnet-4-6",
},
"council-member": {
"model": "opencode/gpt-5.4",
"variant": "medium",
},
"explore": {
"model": "opencode/claude-haiku-4-5",
},
@@ -709,9 +776,16 @@ exports[`generateModelConfig fallback providers uses OpenCode Zen models with is
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
"agents": {
"athena": {
"model": "opencode/claude-sonnet-4-6",
},
"atlas": {
"model": "opencode/claude-sonnet-4-6",
},
"council-member": {
"model": "opencode/gpt-5.4",
"variant": "medium",
},
"explore": {
"model": "opencode/claude-haiku-4-5",
},
@@ -785,9 +859,16 @@ exports[`generateModelConfig fallback providers uses GitHub Copilot models when
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
"agents": {
"athena": {
"model": "github-copilot/claude-sonnet-4.6",
},
"atlas": {
"model": "github-copilot/claude-sonnet-4.6",
},
"council-member": {
"model": "github-copilot/gpt-5.4",
"variant": "medium",
},
"explore": {
"model": "github-copilot/gpt-5-mini",
},
@@ -855,9 +936,16 @@ exports[`generateModelConfig fallback providers uses GitHub Copilot models with
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
"agents": {
"athena": {
"model": "github-copilot/claude-sonnet-4.6",
},
"atlas": {
"model": "github-copilot/claude-sonnet-4.6",
},
"council-member": {
"model": "github-copilot/gpt-5.4",
"variant": "medium",
},
"explore": {
"model": "github-copilot/gpt-5-mini",
},
@@ -926,9 +1014,15 @@ exports[`generateModelConfig fallback providers uses ZAI model for librarian whe
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
"agents": {
"athena": {
"model": "opencode/gpt-5-nano",
},
"atlas": {
"model": "opencode/gpt-5-nano",
},
"council-member": {
"model": "opencode/gpt-5-nano",
},
"explore": {
"model": "opencode/gpt-5-nano",
},
@@ -984,9 +1078,15 @@ exports[`generateModelConfig fallback providers uses ZAI model for librarian wit
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
"agents": {
"athena": {
"model": "opencode/gpt-5-nano",
},
"atlas": {
"model": "opencode/gpt-5-nano",
},
"council-member": {
"model": "opencode/gpt-5-nano",
},
"explore": {
"model": "opencode/gpt-5-nano",
},
@@ -1042,9 +1142,16 @@ exports[`generateModelConfig mixed provider scenarios uses Claude + OpenCode Zen
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
"agents": {
"athena": {
"model": "anthropic/claude-sonnet-4-6",
},
"atlas": {
"model": "anthropic/claude-sonnet-4-6",
},
"council-member": {
"model": "opencode/gpt-5.4",
"variant": "medium",
},
"explore": {
"model": "anthropic/claude-haiku-4-5",
},
@@ -1117,9 +1224,16 @@ exports[`generateModelConfig mixed provider scenarios uses OpenAI + Copilot comb
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
"agents": {
"athena": {
"model": "github-copilot/claude-sonnet-4.6",
},
"atlas": {
"model": "github-copilot/claude-sonnet-4.6",
},
"council-member": {
"model": "openai/gpt-5.4",
"variant": "medium",
},
"explore": {
"model": "github-copilot/gpt-5-mini",
},
@@ -1192,9 +1306,15 @@ exports[`generateModelConfig mixed provider scenarios uses Claude + ZAI combinat
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
"agents": {
"athena": {
"model": "anthropic/claude-sonnet-4-6",
},
"atlas": {
"model": "anthropic/claude-sonnet-4-6",
},
"council-member": {
"model": "anthropic/claude-sonnet-4-6",
},
"explore": {
"model": "anthropic/claude-haiku-4-5",
},
@@ -1256,9 +1376,15 @@ exports[`generateModelConfig mixed provider scenarios uses Gemini + Claude combi
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
"agents": {
"athena": {
"model": "anthropic/claude-sonnet-4-6",
},
"atlas": {
"model": "anthropic/claude-sonnet-4-6",
},
"council-member": {
"model": "anthropic/claude-sonnet-4-6",
},
"explore": {
"model": "anthropic/claude-haiku-4-5",
},
@@ -1322,9 +1448,16 @@ exports[`generateModelConfig mixed provider scenarios uses all fallback provider
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
"agents": {
"athena": {
"model": "github-copilot/claude-sonnet-4.6",
},
"atlas": {
"model": "github-copilot/claude-sonnet-4.6",
},
"council-member": {
"model": "github-copilot/gpt-5.4",
"variant": "medium",
},
"explore": {
"model": "opencode/claude-haiku-4-5",
},
@@ -1400,9 +1533,16 @@ exports[`generateModelConfig mixed provider scenarios uses all providers togethe
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
"agents": {
"athena": {
"model": "anthropic/claude-sonnet-4-6",
},
"atlas": {
"model": "anthropic/claude-sonnet-4-6",
},
"council-member": {
"model": "openai/gpt-5.4",
"variant": "medium",
},
"explore": {
"model": "anthropic/claude-haiku-4-5",
},
@@ -1478,9 +1618,16 @@ exports[`generateModelConfig mixed provider scenarios uses all providers with is
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-openagent/dev/assets/oh-my-opencode.schema.json",
"agents": {
"athena": {
"model": "anthropic/claude-sonnet-4-6",
},
"atlas": {
"model": "anthropic/claude-sonnet-4-6",
},
"council-member": {
"model": "openai/gpt-5.4",
"variant": "medium",
},
"explore": {
"model": "anthropic/claude-haiku-4-5",
},

View File

@@ -34,6 +34,7 @@ describe("runCliInstaller", () => {
hasOpencodeZen: false,
hasZaiCodingPlan: false,
hasKimiForCoding: false,
hasOpencodeGo: false,
}),
spyOn(configManager, "isOpenCodeInstalled").mockResolvedValue(true),
spyOn(configManager, "getOpenCodeVersion").mockResolvedValue("1.0.200"),
@@ -56,6 +57,7 @@ describe("runCliInstaller", () => {
opencodeZen: "no",
zaiCodingPlan: "no",
kimiForCoding: "no",
opencodeGo: "no",
}
//#when

View File

@@ -3,6 +3,7 @@ import { install } from "./install"
import { run } from "./run"
import { getLocalVersion } from "./get-local-version"
import { doctor } from "./doctor"
import { refreshModelCapabilities } from "./refresh-model-capabilities"
import { createMcpOAuthCommand } from "./mcp-oauth"
import type { InstallArgs } from "./types"
import type { RunOptions } from "./run"
@@ -176,6 +177,21 @@ Examples:
process.exit(exitCode)
})
program
.command("refresh-model-capabilities")
.description("Refresh the cached models.dev-based model capabilities snapshot")
.option("-d, --directory <path>", "Working directory to read oh-my-opencode config from")
.option("--source-url <url>", "Override the models.dev source URL")
.option("--json", "Output refresh summary as JSON")
.action(async (options) => {
const exitCode = await refreshModelCapabilities({
directory: options.directory,
sourceUrl: options.sourceUrl,
json: options.json ?? false,
})
process.exit(exitCode)
})
program
.command("version")
.description("Show version information")

View File

@@ -0,0 +1,129 @@
import { transformModelForProvider } from "../../shared/provider-model-id-transform"
import { toProviderAvailability } from "../provider-availability"
import type { InstallConfig } from "../types"
export interface AthenaMemberTemplate {
provider: string
model: string
name: string
isAvailable: (config: InstallConfig) => boolean
}
export interface AthenaCouncilMember {
name: string
model: string
}
export interface AthenaConfig {
model?: string
members: AthenaCouncilMember[]
}
const ATHENA_MEMBER_TEMPLATES: AthenaMemberTemplate[] = [
{
provider: "openai",
model: "gpt-5.4",
name: "OpenAI Strategist",
isAvailable: (config) => config.hasOpenAI,
},
{
provider: "anthropic",
model: "claude-sonnet-4-6",
name: "Claude Strategist",
isAvailable: (config) => config.hasClaude,
},
{
provider: "google",
model: "gemini-3.1-pro",
name: "Gemini Strategist",
isAvailable: (config) => config.hasGemini,
},
{
provider: "github-copilot",
model: "gpt-5.4",
name: "Copilot Strategist",
isAvailable: (config) => config.hasCopilot,
},
{
provider: "opencode",
model: "gpt-5.4",
name: "OpenCode Strategist",
isAvailable: (config) => config.hasOpencodeZen,
},
{
provider: "zai-coding-plan",
model: "glm-4.7",
name: "Z.ai Strategist",
isAvailable: (config) => config.hasZaiCodingPlan,
},
{
provider: "kimi-for-coding",
model: "k2p5",
name: "Kimi Strategist",
isAvailable: (config) => config.hasKimiForCoding,
},
{
provider: "opencode-go",
model: "glm-5",
name: "OpenCode Go Strategist",
isAvailable: (config) => config.hasOpencodeGo,
},
]
function toProviderModel(provider: string, model: string): string {
const transformedModel = transformModelForProvider(provider, model)
return `${provider}/${transformedModel}`
}
function createUniqueMemberName(baseName: string, usedNames: Set<string>): string {
if (!usedNames.has(baseName.toLowerCase())) {
usedNames.add(baseName.toLowerCase())
return baseName
}
let suffix = 2
let candidate = `${baseName} ${suffix}`
while (usedNames.has(candidate.toLowerCase())) {
suffix += 1
candidate = `${baseName} ${suffix}`
}
usedNames.add(candidate.toLowerCase())
return candidate
}
export function createAthenaCouncilMembersFromTemplates(
templates: AthenaMemberTemplate[]
): AthenaCouncilMember[] {
const members: AthenaCouncilMember[] = []
const usedNames = new Set<string>()
for (const template of templates) {
members.push({
name: createUniqueMemberName(template.name, usedNames),
model: toProviderModel(template.provider, template.model),
})
}
return members
}
export function generateAthenaConfig(config: InstallConfig): AthenaConfig | undefined {
const selectedTemplates = ATHENA_MEMBER_TEMPLATES.filter((template) => template.isAvailable(config))
if (selectedTemplates.length === 0) {
return undefined
}
const members = createAthenaCouncilMembersFromTemplates(selectedTemplates)
const availability = toProviderAvailability(config)
const preferredCoordinator =
(availability.native.openai && members.find((member) => member.model.startsWith("openai/"))) ||
(availability.native.claude && members.find((member) => member.model.startsWith("anthropic/"))) ||
members[0]
return {
model: preferredCoordinator.model,
members,
}
}

View File

@@ -0,0 +1,102 @@
import { describe, expect, it } from "bun:test"
import type { InstallConfig } from "../types"
import {
createAthenaCouncilMembersFromTemplates,
generateAthenaConfig,
type AthenaMemberTemplate,
} from "./generate-athena-config"
import { generateOmoConfig } from "./generate-omo-config"
import { transformModelForProvider } from "../../shared/provider-model-id-transform"
function createInstallConfig(overrides: Partial<InstallConfig> = {}): InstallConfig {
return {
hasClaude: false,
isMax20: false,
hasOpenAI: false,
hasGemini: false,
hasCopilot: false,
hasOpencodeZen: false,
hasZaiCodingPlan: false,
hasKimiForCoding: false,
hasOpencodeGo: false,
...overrides,
}
}
describe("generateOmoConfig athena council", () => {
it("creates athena council members from enabled providers", () => {
// given
const installConfig = createInstallConfig({ hasOpenAI: true, hasClaude: true, hasGemini: true })
// when
const generated = generateOmoConfig(installConfig)
const athena = generated.athena as { model?: string; members?: Array<{ name: string; model: string }> }
const googleModel = `google/${transformModelForProvider("google", "gemini-3.1-pro")}`
// then
expect(athena.model).toBe("openai/gpt-5.4")
expect(athena.members).toHaveLength(3)
expect(athena.members?.map((member) => member.model)).toEqual([
"openai/gpt-5.4",
"anthropic/claude-sonnet-4-6",
googleModel,
])
})
it("does not create athena config when no providers are enabled", () => {
// given
const installConfig = createInstallConfig()
// when
const generated = generateOmoConfig(installConfig)
// then
expect(generated.athena).toBeUndefined()
})
})
describe("generateAthenaConfig", () => {
it("uses anthropic as coordinator when openai is unavailable", () => {
// given
const installConfig = createInstallConfig({ hasClaude: true, hasCopilot: true })
// when
const athena = generateAthenaConfig(installConfig)
// then
expect(athena?.model).toBe("anthropic/claude-sonnet-4-6")
expect(athena?.members?.map((member) => member.model)).toEqual([
"anthropic/claude-sonnet-4-6",
"github-copilot/gpt-5.4",
])
})
})
describe("createAthenaCouncilMembersFromTemplates", () => {
it("adds numeric suffixes when template names collide case-insensitively", () => {
// given
const templates: AthenaMemberTemplate[] = [
{
provider: "openai",
model: "gpt-5.4",
name: "Strategist",
isAvailable: () => true,
},
{
provider: "anthropic",
model: "claude-sonnet-4-6",
name: "strategist",
isAvailable: () => true,
},
]
// when
const members = createAthenaCouncilMembersFromTemplates(templates)
// then
expect(members).toEqual([
{ name: "Strategist", model: "openai/gpt-5.4" },
{ name: "strategist 2", model: "anthropic/claude-sonnet-4-6" },
])
})
})

View File

@@ -1,6 +1,17 @@
import type { InstallConfig } from "../types"
import { generateModelConfig } from "../model-fallback"
import { generateAthenaConfig } from "./generate-athena-config"
export function generateOmoConfig(installConfig: InstallConfig): Record<string, unknown> {
return generateModelConfig(installConfig)
const generatedConfig = generateModelConfig(installConfig)
const athenaConfig = generateAthenaConfig(installConfig)
if (!athenaConfig) {
return generatedConfig
}
return {
...generatedConfig,
athena: athenaConfig,
}
}

View File

@@ -18,6 +18,7 @@ const installConfig: InstallConfig = {
hasOpencodeZen: false,
hasZaiCodingPlan: false,
hasKimiForCoding: false,
hasOpencodeGo: false,
}
function getRecord(value: unknown): Record<string, unknown> {

View File

@@ -4,6 +4,10 @@ import { getOpenCodeCacheDir } from "../../../shared"
import type { AvailableModelsInfo, ModelResolutionInfo, OmoConfig } from "./model-resolution-types"
import { formatModelWithVariant, getCategoryEffectiveVariant, getEffectiveVariant } from "./model-resolution-variant"
function formatCapabilityResolutionLabel(mode: string | undefined): string {
return mode ?? "unknown"
}
export function buildModelResolutionDetails(options: {
info: ModelResolutionInfo
available: AvailableModelsInfo
@@ -37,7 +41,7 @@ export function buildModelResolutionDetails(options: {
agent.effectiveModel,
getEffectiveVariant(agent.name, agent.requirement, options.config)
)
details.push(` ${marker} ${agent.name}: ${display}`)
details.push(` ${marker} ${agent.name}: ${display} [capabilities: ${formatCapabilityResolutionLabel(agent.capabilityDiagnostics?.resolutionMode)}]`)
}
details.push("")
details.push("Categories:")
@@ -47,7 +51,7 @@ export function buildModelResolutionDetails(options: {
category.effectiveModel,
getCategoryEffectiveVariant(category.name, category.requirement, options.config)
)
details.push(` ${marker} ${category.name}: ${display}`)
details.push(` ${marker} ${category.name}: ${display} [capabilities: ${formatCapabilityResolutionLabel(category.capabilityDiagnostics?.resolutionMode)}]`)
}
details.push("")
details.push("● = user override, ○ = provider fallback")

View File

@@ -1,3 +1,4 @@
import type { ModelCapabilitiesDiagnostics } from "../../../shared/model-capabilities"
import type { ModelRequirement } from "../../../shared/model-requirements"
export interface AgentResolutionInfo {
@@ -7,6 +8,7 @@ export interface AgentResolutionInfo {
userVariant?: string
effectiveModel: string
effectiveResolution: string
capabilityDiagnostics?: ModelCapabilitiesDiagnostics
}
export interface CategoryResolutionInfo {
@@ -16,6 +18,7 @@ export interface CategoryResolutionInfo {
userVariant?: string
effectiveModel: string
effectiveResolution: string
capabilityDiagnostics?: ModelCapabilitiesDiagnostics
}
export interface ModelResolutionInfo {

View File

@@ -129,6 +129,19 @@ describe("model-resolution check", () => {
expect(visual!.userOverride).toBe("google/gemini-3-flash-preview")
expect(visual!.userVariant).toBe("high")
})
it("attaches snapshot-backed capability diagnostics for built-in models", async () => {
const { getModelResolutionInfoWithOverrides } = await import("./model-resolution")
const info = getModelResolutionInfoWithOverrides({})
const sisyphus = info.agents.find((a) => a.name === "sisyphus")
expect(sisyphus).toBeDefined()
expect(sisyphus!.capabilityDiagnostics).toMatchObject({
resolutionMode: "snapshot-backed",
snapshot: { source: "bundled-snapshot" },
})
})
})
describe("checkModelResolution", () => {
@@ -162,6 +175,23 @@ describe("model-resolution check", () => {
expect(result.details!.some((d) => d.includes("Categories:"))).toBe(true)
// Should have legend
expect(result.details!.some((d) => d.includes("user override"))).toBe(true)
expect(result.details!.some((d) => d.includes("capabilities: snapshot-backed"))).toBe(true)
})
it("collects warnings when configured models rely on compatibility fallback", async () => {
const { collectCapabilityResolutionIssues, getModelResolutionInfoWithOverrides } = await import("./model-resolution")
const info = getModelResolutionInfoWithOverrides({
agents: {
oracle: { model: "custom/unknown-llm" },
},
})
const issues = collectCapabilityResolutionIssues(info)
expect(issues).toHaveLength(1)
expect(issues[0]?.title).toContain("compatibility fallback")
expect(issues[0]?.description).toContain("oracle=custom/unknown-llm")
})
})

View File

@@ -1,4 +1,5 @@
import { AGENT_MODEL_REQUIREMENTS, CATEGORY_MODEL_REQUIREMENTS } from "../../../shared/model-requirements"
import { getModelCapabilities } from "../../../shared/model-capabilities"
import { CHECK_IDS, CHECK_NAMES } from "../constants"
import type { CheckResult, DoctorIssue } from "../types"
import { loadAvailableModelsFromCache } from "./model-resolution-cache"
@@ -7,16 +8,36 @@ import { buildModelResolutionDetails } from "./model-resolution-details"
import { buildEffectiveResolution, getEffectiveModel } from "./model-resolution-effective-model"
import type { AgentResolutionInfo, CategoryResolutionInfo, ModelResolutionInfo, OmoConfig } from "./model-resolution-types"
export function getModelResolutionInfo(): ModelResolutionInfo {
const agents: AgentResolutionInfo[] = Object.entries(AGENT_MODEL_REQUIREMENTS).map(([name, requirement]) => ({
name,
requirement,
effectiveModel: getEffectiveModel(requirement),
effectiveResolution: buildEffectiveResolution(requirement),
}))
function parseProviderModel(value: string): { providerID: string; modelID: string } | null {
const slashIndex = value.indexOf("/")
if (slashIndex <= 0 || slashIndex === value.length - 1) {
return null
}
const categories: CategoryResolutionInfo[] = Object.entries(CATEGORY_MODEL_REQUIREMENTS).map(
([name, requirement]) => ({
return {
providerID: value.slice(0, slashIndex),
modelID: value.slice(slashIndex + 1),
}
}
function attachCapabilityDiagnostics<T extends AgentResolutionInfo | CategoryResolutionInfo>(entry: T): T {
const parsed = parseProviderModel(entry.effectiveModel)
if (!parsed) {
return entry
}
return {
...entry,
capabilityDiagnostics: getModelCapabilities({
providerID: parsed.providerID,
modelID: parsed.modelID,
}).diagnostics,
}
}
export function getModelResolutionInfo(): ModelResolutionInfo {
const agents: AgentResolutionInfo[] = Object.entries(AGENT_MODEL_REQUIREMENTS).map(([name, requirement]) =>
attachCapabilityDiagnostics({
name,
requirement,
effectiveModel: getEffectiveModel(requirement),
@@ -24,6 +45,16 @@ export function getModelResolutionInfo(): ModelResolutionInfo {
})
)
const categories: CategoryResolutionInfo[] = Object.entries(CATEGORY_MODEL_REQUIREMENTS).map(
([name, requirement]) =>
attachCapabilityDiagnostics({
name,
requirement,
effectiveModel: getEffectiveModel(requirement),
effectiveResolution: buildEffectiveResolution(requirement),
})
)
return { agents, categories }
}
@@ -31,34 +62,60 @@ export function getModelResolutionInfoWithOverrides(config: OmoConfig): ModelRes
const agents: AgentResolutionInfo[] = Object.entries(AGENT_MODEL_REQUIREMENTS).map(([name, requirement]) => {
const userOverride = config.agents?.[name]?.model
const userVariant = config.agents?.[name]?.variant
return {
return attachCapabilityDiagnostics({
name,
requirement,
userOverride,
userVariant,
effectiveModel: getEffectiveModel(requirement, userOverride),
effectiveResolution: buildEffectiveResolution(requirement, userOverride),
}
})
})
const categories: CategoryResolutionInfo[] = Object.entries(CATEGORY_MODEL_REQUIREMENTS).map(
([name, requirement]) => {
const userOverride = config.categories?.[name]?.model
const userVariant = config.categories?.[name]?.variant
return {
return attachCapabilityDiagnostics({
name,
requirement,
userOverride,
userVariant,
effectiveModel: getEffectiveModel(requirement, userOverride),
effectiveResolution: buildEffectiveResolution(requirement, userOverride),
}
})
}
)
return { agents, categories }
}
export function collectCapabilityResolutionIssues(info: ModelResolutionInfo): DoctorIssue[] {
const issues: DoctorIssue[] = []
const allEntries = [...info.agents, ...info.categories]
const fallbackEntries = allEntries.filter((entry) => {
const mode = entry.capabilityDiagnostics?.resolutionMode
return mode === "alias-backed" || mode === "heuristic-backed" || mode === "unknown"
})
if (fallbackEntries.length === 0) {
return issues
}
const summary = fallbackEntries
.map((entry) => `${entry.name}=${entry.effectiveModel} (${entry.capabilityDiagnostics?.resolutionMode ?? "unknown"})`)
.join(", ")
issues.push({
title: "Configured models rely on compatibility fallback",
description: summary,
severity: "warning",
affects: fallbackEntries.map((entry) => entry.name),
})
return issues
}
export async function checkModels(): Promise<CheckResult> {
const config = loadOmoConfig() ?? {}
const info = getModelResolutionInfoWithOverrides(config)
@@ -75,6 +132,8 @@ export async function checkModels(): Promise<CheckResult> {
})
}
issues.push(...collectCapabilityResolutionIssues(info))
const overrideCount =
info.agents.filter((agent) => Boolean(agent.userOverride)).length +
info.categories.filter((category) => Boolean(category.userOverride)).length

View File

@@ -1,9 +1,10 @@
import { afterEach, describe, expect, it } from "bun:test"
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { dirname, join } from "node:path"
import { PACKAGE_NAME } from "../constants"
import { resolveSymlink } from "../../../shared/file-utils"
const systemLoadedVersionModulePath = "./system-loaded-version?system-loaded-version-test"
@@ -104,6 +105,31 @@ describe("system loaded version", () => {
expect(loadedVersion.expectedVersion).toBe("2.3.4")
expect(loadedVersion.loadedVersion).toBe("2.3.4")
})
it("resolves symlinked config directories before selecting install path", () => {
//#given
const realConfigDir = createTemporaryDirectory("omo-real-config-")
const symlinkBaseDir = createTemporaryDirectory("omo-symlink-base-")
const symlinkConfigDir = join(symlinkBaseDir, "config-link")
symlinkSync(realConfigDir, symlinkConfigDir, process.platform === "win32" ? "junction" : "dir")
process.env.OPENCODE_CONFIG_DIR = symlinkConfigDir
writeJson(join(realConfigDir, "package.json"), {
dependencies: { [PACKAGE_NAME]: "4.5.6" },
})
writeJson(join(realConfigDir, "node_modules", PACKAGE_NAME, "package.json"), {
version: "4.5.6",
})
//#when
const loadedVersion = getLoadedPluginVersion()
//#then
expect(loadedVersion.cacheDir).toBe(resolveSymlink(symlinkConfigDir))
expect(loadedVersion.expectedVersion).toBe("4.5.6")
expect(loadedVersion.loadedVersion).toBe("4.5.6")
})
})
describe("getSuggestedInstallTag", () => {

View File

@@ -1,7 +1,7 @@
import { existsSync, readFileSync } from "node:fs"
import { homedir } from "node:os"
import { join } from "node:path"
import { resolveSymlink } from "../../../shared/file-utils"
import { getLatestVersion } from "../../../hooks/auto-update-checker/checker"
import { extractChannel } from "../../../hooks/auto-update-checker"
import { PACKAGE_NAME } from "../constants"
@@ -36,6 +36,11 @@ function resolveOpenCodeCacheDir(): string {
return platformDefault
}
function resolveExistingDir(dirPath: string): string {
if (!existsSync(dirPath)) return dirPath
return resolveSymlink(dirPath)
}
function readPackageJson(filePath: string): PackageJsonShape | null {
if (!existsSync(filePath)) return null
@@ -55,12 +60,13 @@ function normalizeVersion(value: string | undefined): string | null {
export function getLoadedPluginVersion(): LoadedVersionInfo {
const configPaths = getOpenCodeConfigPaths({ binary: "opencode" })
const cacheDir = resolveOpenCodeCacheDir()
const configDir = resolveExistingDir(configPaths.configDir)
const cacheDir = resolveExistingDir(resolveOpenCodeCacheDir())
const candidates = [
{
cacheDir: configPaths.configDir,
cachePackagePath: configPaths.packageJson,
installedPackagePath: join(configPaths.configDir, "node_modules", PACKAGE_NAME, "package.json"),
cacheDir: configDir,
cachePackagePath: join(configDir, "package.json"),
installedPackagePath: join(configDir, "node_modules", PACKAGE_NAME, "package.json"),
},
{
cacheDir,

View File

@@ -55,7 +55,7 @@ export function generateModelConfig(config: InstallConfig): GeneratedOmoConfig {
for (const [role, req] of Object.entries(CLI_AGENT_MODEL_REQUIREMENTS)) {
if (role === "librarian") {
if (avail.opencodeGo) {
agents[role] = { model: "opencode-go/minimax-m2.5" }
agents[role] = { model: "opencode-go/minimax-m2.7" }
} else if (avail.zai) {
agents[role] = { model: ZAI_MODEL }
}
@@ -68,7 +68,7 @@ export function generateModelConfig(config: InstallConfig): GeneratedOmoConfig {
} else if (avail.opencodeZen) {
agents[role] = { model: "opencode/claude-haiku-4-5" }
} else if (avail.opencodeGo) {
agents[role] = { model: "opencode-go/minimax-m2.5" }
agents[role] = { model: "opencode-go/minimax-m2.7" }
} else if (avail.copilot) {
agents[role] = { model: "github-copilot/gpt-5-mini" }
} else {

View File

@@ -53,8 +53,8 @@ describe("generateModelConfig OpenAI-only model catalog", () => {
const result = generateModelConfig(config)
// #then
expect(result.agents?.explore).toEqual({ model: "opencode-go/minimax-m2.5" })
expect(result.agents?.librarian).toEqual({ model: "opencode-go/minimax-m2.5" })
expect(result.agents?.explore).toEqual({ model: "opencode-go/minimax-m2.7" })
expect(result.agents?.librarian).toEqual({ model: "opencode-go/minimax-m2.7" })
expect(result.categories?.quick).toEqual({ model: "openai/gpt-5.4-mini" })
})
})

View File

@@ -0,0 +1,114 @@
import { describe, expect, it, mock } from "bun:test"
import { refreshModelCapabilities } from "./refresh-model-capabilities"
describe("refreshModelCapabilities", () => {
it("uses config source_url when CLI override is absent", async () => {
const loadConfig = mock(() => ({
model_capabilities: {
source_url: "https://mirror.example/api.json",
},
}))
const refreshCache = mock(async () => ({
generatedAt: "2026-03-25T00:00:00.000Z",
sourceUrl: "https://mirror.example/api.json",
models: {
"gpt-5.4": { id: "gpt-5.4" },
},
}))
let stdout = ""
const exitCode = await refreshModelCapabilities(
{ directory: "/repo", json: false },
{
loadConfig,
refreshCache,
stdout: {
write: (chunk: string) => {
stdout += chunk
return true
},
} as never,
stderr: {
write: () => true,
} as never,
},
)
expect(exitCode).toBe(0)
expect(loadConfig).toHaveBeenCalledWith("/repo", null)
expect(refreshCache).toHaveBeenCalledWith({
sourceUrl: "https://mirror.example/api.json",
})
expect(stdout).toContain("Refreshed model capabilities cache (1 models)")
})
it("CLI sourceUrl overrides config and supports json output", async () => {
const refreshCache = mock(async () => ({
generatedAt: "2026-03-25T00:00:00.000Z",
sourceUrl: "https://override.example/api.json",
models: {
"gpt-5.4": { id: "gpt-5.4" },
"claude-opus-4-6": { id: "claude-opus-4-6" },
},
}))
let stdout = ""
const exitCode = await refreshModelCapabilities(
{
directory: "/repo",
json: true,
sourceUrl: "https://override.example/api.json",
},
{
loadConfig: () => ({}),
refreshCache,
stdout: {
write: (chunk: string) => {
stdout += chunk
return true
},
} as never,
stderr: {
write: () => true,
} as never,
},
)
expect(exitCode).toBe(0)
expect(refreshCache).toHaveBeenCalledWith({
sourceUrl: "https://override.example/api.json",
})
expect(JSON.parse(stdout)).toEqual({
sourceUrl: "https://override.example/api.json",
generatedAt: "2026-03-25T00:00:00.000Z",
modelCount: 2,
})
})
it("returns exit code 1 when refresh fails", async () => {
let stderr = ""
const exitCode = await refreshModelCapabilities(
{ directory: "/repo" },
{
loadConfig: () => ({}),
refreshCache: async () => {
throw new Error("boom")
},
stdout: {
write: () => true,
} as never,
stderr: {
write: (chunk: string) => {
stderr += chunk
return true
},
} as never,
},
)
expect(exitCode).toBe(1)
expect(stderr).toContain("Failed to refresh model capabilities cache")
})
})

View File

@@ -0,0 +1,51 @@
import { loadPluginConfig } from "../plugin-config"
import { refreshModelCapabilitiesCache } from "../shared/model-capabilities-cache"
export type RefreshModelCapabilitiesOptions = {
directory?: string
json?: boolean
sourceUrl?: string
}
type RefreshModelCapabilitiesDeps = {
loadConfig?: typeof loadPluginConfig
refreshCache?: typeof refreshModelCapabilitiesCache
stdout?: Pick<typeof process.stdout, "write">
stderr?: Pick<typeof process.stderr, "write">
}
export async function refreshModelCapabilities(
options: RefreshModelCapabilitiesOptions,
deps: RefreshModelCapabilitiesDeps = {},
): Promise<number> {
const directory = options.directory ?? process.cwd()
const loadConfig = deps.loadConfig ?? loadPluginConfig
const refreshCache = deps.refreshCache ?? refreshModelCapabilitiesCache
const stdout = deps.stdout ?? process.stdout
const stderr = deps.stderr ?? process.stderr
try {
const config = loadConfig(directory, null)
const sourceUrl = options.sourceUrl ?? config.model_capabilities?.source_url
const snapshot = await refreshCache({ sourceUrl })
const summary = {
sourceUrl: snapshot.sourceUrl,
generatedAt: snapshot.generatedAt,
modelCount: Object.keys(snapshot.models).length,
}
if (options.json) {
stdout.write(`${JSON.stringify(summary, null, 2)}\n`)
} else {
stdout.write(
`Refreshed model capabilities cache (${summary.modelCount} models) from ${summary.sourceUrl}\n`,
)
}
return 0
} catch (error) {
stderr.write(`Failed to refresh model capabilities cache: ${String(error)}\n`)
return 1
}
}

View File

@@ -4,6 +4,7 @@ export {
export type {
OhMyOpenCodeConfig,
AthenaConfig,
AgentOverrideConfig,
AgentOverrides,
McpName,
@@ -19,5 +20,6 @@ export type {
SisyphusConfig,
SisyphusTasksConfig,
RuntimeFallbackConfig,
ModelCapabilitiesConfig,
FallbackModels,
} from "./schema"

View File

@@ -147,6 +147,37 @@ describe("disabled_mcps schema", () => {
})
})
describe("OhMyOpenCodeConfigSchema - model_capabilities", () => {
test("accepts valid model capabilities config", () => {
const input = {
model_capabilities: {
enabled: true,
auto_refresh_on_start: true,
refresh_timeout_ms: 5000,
source_url: "https://models.dev/api.json",
},
}
const result = OhMyOpenCodeConfigSchema.safeParse(input)
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.model_capabilities).toEqual(input.model_capabilities)
}
})
test("rejects invalid model capabilities config", () => {
const result = OhMyOpenCodeConfigSchema.safeParse({
model_capabilities: {
refresh_timeout_ms: -1,
source_url: "not-a-url",
},
})
expect(result.success).toBe(false)
})
})
describe("AgentOverrideConfigSchema", () => {
describe("category field", () => {
test("accepts category as optional string", () => {
@@ -371,6 +402,26 @@ describe("CategoryConfigSchema", () => {
}
})
test("accepts reasoningEffort values none and minimal", () => {
// given
const noneConfig = { reasoningEffort: "none" }
const minimalConfig = { reasoningEffort: "minimal" }
// when
const noneResult = CategoryConfigSchema.safeParse(noneConfig)
const minimalResult = CategoryConfigSchema.safeParse(minimalConfig)
// then
expect(noneResult.success).toBe(true)
expect(minimalResult.success).toBe(true)
if (noneResult.success) {
expect(noneResult.data.reasoningEffort).toBe("none")
}
if (minimalResult.success) {
expect(minimalResult.data.reasoningEffort).toBe("minimal")
}
})
test("rejects non-string variant", () => {
// given
const config = { model: "openai/gpt-5.4", variant: 123 }

View File

@@ -1,5 +1,6 @@
export * from "./schema/agent-names"
export * from "./schema/agent-overrides"
export * from "./schema/athena-config"
export * from "./schema/babysitting"
export * from "./schema/background-task"
export * from "./schema/browser-automation"
@@ -13,6 +14,7 @@ export * from "./schema/fallback-models"
export * from "./schema/git-env-prefix"
export * from "./schema/git-master"
export * from "./schema/hooks"
export * from "./schema/model-capabilities"
export * from "./schema/notification"
export * from "./schema/oh-my-opencode-config"
export * from "./schema/ralph-loop"

View File

@@ -3,6 +3,7 @@ import { z } from "zod"
export const BuiltinAgentNameSchema = z.enum([
"sisyphus",
"hephaestus",
"athena",
"prometheus",
"oracle",
"librarian",
@@ -12,6 +13,7 @@ export const BuiltinAgentNameSchema = z.enum([
"momus",
"atlas",
"sisyphus-junior",
"council-member",
])
export const BuiltinSkillNameSchema = z.enum([
@@ -27,6 +29,7 @@ export const OverridableAgentNameSchema = z.enum([
"plan",
"sisyphus",
"hephaestus",
"athena",
"sisyphus-junior",
"OpenCode-Builder",
"prometheus",

View File

@@ -35,7 +35,7 @@ export const AgentOverrideConfigSchema = z.object({
})
.optional(),
/** Reasoning effort level (OpenAI). Overrides category and default settings. */
reasoningEffort: z.enum(["low", "medium", "high", "xhigh"]).optional(),
reasoningEffort: z.enum(["none", "minimal", "low", "medium", "high", "xhigh"]).optional(),
/** Text verbosity level. */
textVerbosity: z.enum(["low", "medium", "high"]).optional(),
/** Provider-specific options. Passed directly to OpenCode SDK. */
@@ -62,6 +62,7 @@ export const AgentOverridesSchema = z.object({
hephaestus: AgentOverrideConfigSchema.extend({
allow_non_gpt_model: z.boolean().optional(),
}).optional(),
athena: AgentOverrideConfigSchema.optional(),
"sisyphus-junior": AgentOverrideConfigSchema.optional(),
"OpenCode-Builder": AgentOverrideConfigSchema.optional(),
prometheus: AgentOverrideConfigSchema.optional(),

View File

@@ -0,0 +1,82 @@
import { describe, expect, test } from "bun:test"
import { AthenaConfigSchema } from "./athena-config"
import { OhMyOpenCodeConfigSchema } from "./oh-my-opencode-config"
describe("AthenaConfigSchema", () => {
test("accepts athena config with required members", () => {
// given
const config = {
model: "openai/gpt-5.4",
members: [
{ name: "Socrates", model: "openai/gpt-5.4" },
{ name: "Plato", model: "anthropic/claude-sonnet-4-6" },
],
}
// when
const result = AthenaConfigSchema.safeParse(config)
// then
expect(result.success).toBe(true)
})
test("rejects athena config when members are missing", () => {
// given
const config = {
model: "openai/gpt-5.4",
}
// when
const result = AthenaConfigSchema.safeParse(config)
// then
expect(result.success).toBe(false)
})
test("rejects case-insensitive duplicate member names", () => {
// given
const config = {
members: [
{ name: "Socrates", model: "openai/gpt-5.4" },
{ name: "socrates", model: "anthropic/claude-sonnet-4-6" },
],
}
// when
const result = AthenaConfigSchema.safeParse(config)
// then
expect(result.success).toBe(false)
})
test("rejects member model without provider prefix", () => {
// given
const config = {
members: [{ name: "Socrates", model: "gpt-5.4" }],
}
// when
const result = AthenaConfigSchema.safeParse(config)
// then
expect(result.success).toBe(false)
})
})
describe("OhMyOpenCodeConfigSchema athena field", () => {
test("accepts athena config at root", () => {
// given
const config = {
athena: {
model: "openai/gpt-5.4",
members: [{ name: "Socrates", model: "openai/gpt-5.4" }],
},
}
// when
const result = OhMyOpenCodeConfigSchema.safeParse(config)
// then
expect(result.success).toBe(true)
})
})

View File

@@ -0,0 +1,39 @@
import { z } from "zod"
const PROVIDER_MODEL_PATTERN = /^[^/\s]+\/[^/\s]+$/
const ProviderModelSchema = z
.string()
.regex(PROVIDER_MODEL_PATTERN, "Model must use provider/model format")
const AthenaCouncilMemberSchema = z.object({
name: z.string().trim().min(1),
model: ProviderModelSchema,
})
export const AthenaConfigSchema = z
.object({
model: ProviderModelSchema.optional(),
members: z.array(AthenaCouncilMemberSchema).min(1),
})
.superRefine((value, ctx) => {
const seen = new Map<string, number>()
for (const [index, member] of value.members.entries()) {
const normalizedName = member.name.trim().toLowerCase()
const existingIndex = seen.get(normalizedName)
if (existingIndex !== undefined) {
ctx.addIssue({
code: "custom",
path: ["members", index, "name"],
message: `Duplicate member name '${member.name}' (case-insensitive). First seen at members[${existingIndex}]`,
})
continue
}
seen.set(normalizedName, index)
}
})
export type AthenaConfig = z.infer<typeof AthenaConfigSchema>

View File

@@ -16,7 +16,7 @@ export const CategoryConfigSchema = z.object({
budgetTokens: z.number().optional(),
})
.optional(),
reasoningEffort: z.enum(["low", "medium", "high", "xhigh"]).optional(),
reasoningEffort: z.enum(["none", "minimal", "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

@@ -1,5 +1,25 @@
import { z } from "zod"
export const FallbackModelsSchema = z.union([z.string(), z.array(z.string())])
export const FallbackModelObjectSchema = z.object({
model: z.string(),
variant: z.string().optional(),
reasoningEffort: z.enum(["none", "minimal", "low", "medium", "high", "xhigh"]).optional(),
temperature: z.number().min(0).max(2).optional(),
top_p: z.number().min(0).max(1).optional(),
maxTokens: z.number().optional(),
thinking: z
.object({
type: z.enum(["enabled", "disabled"]),
budgetTokens: z.number().optional(),
})
.optional(),
})
export type FallbackModelObject = z.infer<typeof FallbackModelObjectSchema>
export const FallbackModelsSchema = z.union([
z.string(),
z.array(z.union([z.string(), FallbackModelObjectSchema])),
])
export type FallbackModels = z.infer<typeof FallbackModelsSchema>

View File

@@ -41,6 +41,7 @@ export const HookNameSchema = z.enum([
"no-hephaestus-non-gpt",
"start-work",
"atlas",
"agent-switch",
"unstable-agent-babysitter",
"task-resume-info",
"stop-continuation-guard",

View File

@@ -0,0 +1,10 @@
import { z } from "zod"
export const ModelCapabilitiesConfigSchema = z.object({
enabled: z.boolean().optional(),
auto_refresh_on_start: z.boolean().optional(),
refresh_timeout_ms: z.number().int().positive().optional(),
source_url: z.string().url().optional(),
})
export type ModelCapabilitiesConfig = z.infer<typeof ModelCapabilitiesConfigSchema>

View File

@@ -2,6 +2,7 @@ import { z } from "zod"
import { AnyMcpNameSchema } from "../../mcp/types"
import { BuiltinSkillNameSchema } from "./agent-names"
import { AgentOverridesSchema } from "./agent-overrides"
import { AthenaConfigSchema } from "./athena-config"
import { BabysittingConfigSchema } from "./babysitting"
import { BackgroundTaskConfigSchema } from "./background-task"
import { BrowserAutomationConfigSchema } from "./browser-automation"
@@ -13,6 +14,7 @@ import { ExperimentalConfigSchema } from "./experimental"
import { GitMasterConfigSchema } from "./git-master"
import { NotificationConfigSchema } from "./notification"
import { OpenClawConfigSchema } from "./openclaw"
import { ModelCapabilitiesConfigSchema } from "./model-capabilities"
import { RalphLoopConfigSchema } from "./ralph-loop"
import { RuntimeFallbackConfigSchema } from "./runtime-fallback"
import { SkillsConfigSchema } from "./skills"
@@ -40,6 +42,7 @@ export const OhMyOpenCodeConfigSchema = z.object({
/** Enable model fallback on API errors (default: false). Set to true to enable automatic model switching when model errors occur. */
model_fallback: z.boolean().optional(),
agents: AgentOverridesSchema.optional(),
athena: AthenaConfigSchema.optional(),
categories: CategoriesConfigSchema.optional(),
claude_code: ClaudeCodeConfigSchema.optional(),
sisyphus_agent: SisyphusAgentConfigSchema.optional(),
@@ -56,6 +59,7 @@ export const OhMyOpenCodeConfigSchema = z.object({
runtime_fallback: z.union([z.boolean(), RuntimeFallbackConfigSchema]).optional(),
background_task: BackgroundTaskConfigSchema.optional(),
notification: NotificationConfigSchema.optional(),
model_capabilities: ModelCapabilitiesConfigSchema.optional(),
openclaw: OpenClawConfigSchema.optional(),
babysitting: BabysittingConfigSchema.optional(),
git_master: GitMasterConfigSchema.optional(),

View File

@@ -1,4 +1,4 @@
import { describe, test, expect, mock, beforeEach } from "bun:test"
import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test"
mock.module("../../shared", () => ({
log: mock(() => {}),
@@ -82,6 +82,10 @@ function createDefaultArgs(taskOverrides: Partial<BackgroundTask> = {}) {
}
describe("tryFallbackRetry", () => {
afterAll(() => {
mock.restore()
})
beforeEach(() => {
;(shouldRetryError as any).mockImplementation(() => true)
;(selectFallbackProvider as any).mockImplementation((providers: string[]) => providers[0])
@@ -274,8 +278,8 @@ describe("tryFallbackRetry", () => {
describe("#given disconnected fallback providers with connected preferred provider", () => {
test("keeps fallback entry and selects connected preferred provider", () => {
;(readProviderModelsCache as any).mockReturnValue({ connected: ["provider-a"] })
;(selectFallbackProvider as any).mockImplementation(
;(readProviderModelsCache as any).mockReturnValueOnce({ connected: ["provider-a"] })
;(selectFallbackProvider as any).mockImplementationOnce(
(_providers: string[], preferredProviderID?: string) => preferredProviderID ?? "provider-b",
)

View File

@@ -1,5 +1,6 @@
declare const require: (name: string) => any
const { describe, test, expect, beforeEach, afterEach, spyOn } = require("bun:test")
import { getSessionPromptParams, clearSessionPromptParams } from "../../shared/session-prompt-params-state"
import { tmpdir } from "node:os"
import type { PluginInput } from "@opencode-ai/plugin"
import type { BackgroundTask, ResumeInput } from "./types"
@@ -1636,6 +1637,9 @@ describe("BackgroundManager.resume model persistence", () => {
})
afterEach(() => {
clearSessionPromptParams("session-1")
clearSessionPromptParams("session-advanced")
clearSessionPromptParams("session-2")
manager.shutdown()
})
@@ -1671,6 +1675,60 @@ describe("BackgroundManager.resume model persistence", () => {
expect(promptCalls[0].body.agent).toBe("explore")
})
test("should preserve promoted per-model settings when resuming a task", async () => {
// given - task resumed after fallback promotion
const taskWithAdvancedModel: BackgroundTask = {
id: "task-with-advanced-model",
sessionID: "session-advanced",
parentSessionID: "parent-session",
parentMessageID: "msg-1",
description: "task with advanced model settings",
prompt: "original prompt",
agent: "explore",
status: "completed",
startedAt: new Date(),
completedAt: new Date(),
model: {
providerID: "openai",
modelID: "gpt-5.4-preview",
variant: "minimal",
reasoningEffort: "high",
temperature: 0.25,
top_p: 0.55,
maxTokens: 8192,
thinking: { type: "disabled" },
},
concurrencyGroup: "explore",
}
getTaskMap(manager).set(taskWithAdvancedModel.id, taskWithAdvancedModel)
// when
await manager.resume({
sessionId: "session-advanced",
prompt: "continue the work",
parentSessionID: "parent-session-2",
parentMessageID: "msg-2",
})
// then
expect(promptCalls).toHaveLength(1)
expect(promptCalls[0].body.model).toEqual({
providerID: "openai",
modelID: "gpt-5.4-preview",
})
expect(promptCalls[0].body.variant).toBe("minimal")
expect(promptCalls[0].body.options).toBeUndefined()
expect(getSessionPromptParams("session-advanced")).toEqual({
temperature: 0.25,
topP: 0.55,
options: {
reasoningEffort: "high",
thinking: { type: "disabled" },
maxTokens: 8192,
},
})
})
test("should NOT pass model when task has no model (backward compatibility)", async () => {
// given - task without model (default behavior)
const taskWithoutModel: BackgroundTask = {
@@ -2426,6 +2484,133 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
expect(abortCalls).toEqual([createdSessionID])
expect(getConcurrencyManager(manager).getCount("test-agent")).toBe(0)
})
test("should release descendant quota when task completes", async () => {
manager.shutdown()
manager = new BackgroundManager(
{
client: createMockClientWithSessionChain({
"session-root": { directory: "/test/dir" },
}),
directory: tmpdir(),
} as unknown as PluginInput,
{ maxDescendants: 1 },
)
stubNotifyParentSession(manager)
const input = {
description: "Test task",
prompt: "Do something",
agent: "test-agent",
parentSessionID: "session-root",
parentMessageID: "parent-message",
}
const task = await manager.launch(input)
const internalTask = getTaskMap(manager).get(task.id)!
internalTask.status = "running"
internalTask.sessionID = "child-session-complete"
internalTask.rootSessionID = "session-root"
// Complete via internal method (session.status events go through the poller, not handleEvent)
await tryCompleteTaskForTest(manager, internalTask)
await expect(manager.launch(input)).resolves.toBeDefined()
})
test("should release descendant quota when running task is cancelled", async () => {
manager.shutdown()
manager = new BackgroundManager(
{
client: createMockClientWithSessionChain({
"session-root": { directory: "/test/dir" },
}),
directory: tmpdir(),
} as unknown as PluginInput,
{ maxDescendants: 1 },
)
const input = {
description: "Test task",
prompt: "Do something",
agent: "test-agent",
parentSessionID: "session-root",
parentMessageID: "parent-message",
}
const task = await manager.launch(input)
const internalTask = getTaskMap(manager).get(task.id)!
internalTask.status = "running"
internalTask.sessionID = "child-session-cancel"
await manager.cancelTask(task.id)
await expect(manager.launch(input)).resolves.toBeDefined()
})
test("should release descendant quota when task errors", async () => {
manager.shutdown()
manager = new BackgroundManager(
{
client: createMockClientWithSessionChain({
"session-root": { directory: "/test/dir" },
}),
directory: tmpdir(),
} as unknown as PluginInput,
{ maxDescendants: 1 },
)
const input = {
description: "Test task",
prompt: "Do something",
agent: "test-agent",
parentSessionID: "session-root",
parentMessageID: "parent-message",
}
const task = await manager.launch(input)
const internalTask = getTaskMap(manager).get(task.id)!
internalTask.status = "running"
internalTask.sessionID = "child-session-error"
manager.handleEvent({
type: "session.error",
properties: { sessionID: internalTask.sessionID, info: { id: internalTask.sessionID } },
})
await new Promise((resolve) => setTimeout(resolve, 100))
await expect(manager.launch(input)).resolves.toBeDefined()
})
test("should not double-decrement quota when pending task is cancelled", async () => {
manager.shutdown()
manager = new BackgroundManager(
{
client: createMockClientWithSessionChain({
"session-root": { directory: "/test/dir" },
}),
directory: tmpdir(),
} as unknown as PluginInput,
{ maxDescendants: 2 },
)
const input = {
description: "Test task",
prompt: "Do something",
agent: "test-agent",
parentSessionID: "session-root",
parentMessageID: "parent-message",
}
const task1 = await manager.launch(input)
const task2 = await manager.launch(input)
await manager.cancelTask(task1.id)
await manager.cancelTask(task2.id)
await expect(manager.launch(input)).resolves.toBeDefined()
await expect(manager.launch(input)).resolves.toBeDefined()
})
})
describe("pending task can be cancelled", () => {

View File

@@ -15,6 +15,7 @@ import {
resolveInheritedPromptTools,
createInternalAgentTextPart,
} from "../../shared"
import { applySessionPromptParams } from "../../shared/session-prompt-params-helpers"
import { setSessionTools } from "../../shared/session-tools-store"
import { SessionCategoryRegistry } from "../../shared/session-category-registry"
import { ConcurrencyManager } from "./concurrency"
@@ -504,14 +505,20 @@ export class BackgroundManager {
})
// Fire-and-forget prompt via promptAsync (no response body needed)
// Include model if caller provided one (e.g., from Sisyphus category configs)
// IMPORTANT: variant must be a top-level field in the body, NOT nested inside model
// OpenCode's PromptInput schema expects: { model: { providerID, modelID }, variant: "max" }
// OpenCode prompt payload accepts model provider/model IDs and top-level variant only.
// Temperature/topP and provider-specific options are applied through chat.params.
const launchModel = input.model
? { providerID: input.model.providerID, modelID: input.model.modelID }
? {
providerID: input.model.providerID,
modelID: input.model.modelID,
}
: undefined
const launchVariant = input.model?.variant
if (input.model) {
applySessionPromptParams(sessionID, input.model)
}
promptWithModelSuggestionRetry(this.client, {
path: { id: sessionID },
body: {
@@ -543,6 +550,9 @@ export class BackgroundManager {
existingTask.error = errorMessage
}
existingTask.completedAt = new Date()
if (existingTask.rootSessionID) {
this.unregisterRootDescendant(existingTask.rootSessionID)
}
if (existingTask.concurrencyKey) {
this.concurrencyManager.release(existingTask.concurrencyKey)
existingTask.concurrencyKey = undefined
@@ -782,13 +792,19 @@ export class BackgroundManager {
})
// Fire-and-forget prompt via promptAsync (no response body needed)
// Include model if task has one (preserved from original launch with category config)
// variant must be top-level in body, not nested inside model (OpenCode PromptInput schema)
// Resume uses the same PromptInput contract as launch: model IDs plus top-level variant.
const resumeModel = existingTask.model
? { providerID: existingTask.model.providerID, modelID: existingTask.model.modelID }
? {
providerID: existingTask.model.providerID,
modelID: existingTask.model.modelID,
}
: undefined
const resumeVariant = existingTask.model?.variant
if (existingTask.model) {
applySessionPromptParams(existingTask.sessionID!, existingTask.model)
}
this.client.session.promptAsync({
path: { id: existingTask.sessionID },
body: {
@@ -813,6 +829,9 @@ export class BackgroundManager {
const errorMessage = error instanceof Error ? error.message : String(error)
existingTask.error = errorMessage
existingTask.completedAt = new Date()
if (existingTask.rootSessionID) {
this.unregisterRootDescendant(existingTask.rootSessionID)
}
// Release concurrency on error to prevent slot leaks
if (existingTask.concurrencyKey) {
@@ -1009,6 +1028,9 @@ export class BackgroundManager {
task.status = "error"
task.error = errorMsg
task.completedAt = new Date()
if (task.rootSessionID) {
this.unregisterRootDescendant(task.rootSessionID)
}
this.taskHistory.record(task.parentSessionID, { id: task.id, sessionID: task.sessionID, agent: task.agent, description: task.description, status: "error", category: task.category, startedAt: task.startedAt, completedAt: task.completedAt })
if (task.concurrencyKey) {
@@ -1341,8 +1363,12 @@ export class BackgroundManager {
log("[background-agent] Cancelled pending task:", { taskId, key })
}
const wasRunning = task.status === "running"
task.status = "cancelled"
task.completedAt = new Date()
if (wasRunning && task.rootSessionID) {
this.unregisterRootDescendant(task.rootSessionID)
}
if (reason) {
task.error = reason
}
@@ -1463,6 +1489,10 @@ export class BackgroundManager {
task.completedAt = new Date()
this.taskHistory.record(task.parentSessionID, { id: task.id, sessionID: task.sessionID, agent: task.agent, description: task.description, status: "completed", category: task.category, startedAt: task.startedAt, completedAt: task.completedAt })
if (task.rootSessionID) {
this.unregisterRootDescendant(task.rootSessionID)
}
removeTaskToastTracking(task.id)
// Release concurrency BEFORE any async operations to prevent slot leaks
@@ -1701,6 +1731,9 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
task.status = "error"
task.error = errorMessage
task.completedAt = new Date()
if (!wasPending && task.rootSessionID) {
this.unregisterRootDescendant(task.rootSessionID)
}
this.taskHistory.record(task.parentSessionID, { id: task.id, sessionID: task.sessionID, agent: task.agent, description: task.description, status: "error", category: task.category, startedAt: task.startedAt, completedAt: task.completedAt })
if (task.concurrencyKey) {
this.concurrencyManager.release(task.concurrencyKey)

View File

@@ -1,68 +1,96 @@
import { describe, test, expect } from "bun:test"
import { describe, test, expect, mock, afterEach } from "bun:test"
import { createTask, startTask } from "./spawner"
import type { BackgroundTask } from "./types"
import {
clearSessionPromptParams,
getSessionPromptParams,
} from "../../shared/session-prompt-params-state"
describe("background-agent spawner.startTask", () => {
test("applies explicit child session permission rules when creating child session", async () => {
describe("background-agent spawner fallback model promotion", () => {
afterEach(() => {
clearSessionPromptParams("session-123")
})
test("passes promoted fallback model settings through supported prompt channels", async () => {
//#given
const createCalls: any[] = []
const parentPermission = [
{ permission: "question", action: "allow" as const, pattern: "*" },
{ permission: "plan_enter", action: "deny" as const, pattern: "*" },
]
let promptArgs: any
const client = {
session: {
get: async () => ({ data: { directory: "/parent/dir", permission: parentPermission } }),
create: async (args?: any) => {
createCalls.push(args)
return { data: { id: "ses_child" } }
},
promptAsync: async () => ({}),
get: mock(async () => ({ data: { directory: "/tmp/test" } })),
create: mock(async () => ({ data: { id: "session-123" } })),
promptAsync: mock(async (input: any) => {
promptArgs = input
return { data: {} }
}),
},
}
} as any
const task = createTask({
const concurrencyManager = {
release: mock(() => {}),
} as any
const onTaskError = mock(() => {})
const task: BackgroundTask = {
id: "bg_test123",
status: "pending",
queuedAt: new Date(),
description: "Test task",
prompt: "Do work",
agent: "explore",
parentSessionID: "ses_parent",
parentMessageID: "msg_parent",
})
const item = {
task,
input: {
description: task.description,
prompt: task.prompt,
agent: task.agent,
parentSessionID: task.parentSessionID,
parentMessageID: task.parentMessageID,
parentModel: task.parentModel,
parentAgent: task.parentAgent,
model: task.model,
sessionPermission: [
{ permission: "question", action: "deny", pattern: "*" },
],
prompt: "Do the thing",
agent: "oracle",
parentSessionID: "parent-1",
parentMessageID: "message-1",
model: {
providerID: "openai",
modelID: "gpt-5.4",
variant: "low",
reasoningEffort: "high",
temperature: 0.4,
top_p: 0.7,
maxTokens: 4096,
thinking: { type: "disabled" },
},
}
const ctx = {
client,
directory: "/fallback",
concurrencyManager: { release: () => {} },
tmuxEnabled: false,
onTaskError: () => {},
const input = {
description: "Test task",
prompt: "Do the thing",
agent: "oracle",
parentSessionID: "parent-1",
parentMessageID: "message-1",
model: task.model,
}
//#when
await startTask(item as any, ctx as any)
await startTask(
{ task, input },
{
client,
directory: "/tmp/test",
concurrencyManager,
tmuxEnabled: false,
onTaskError,
},
)
await new Promise((resolve) => setTimeout(resolve, 0))
//#then
expect(createCalls).toHaveLength(1)
expect(createCalls[0]?.body?.permission).toEqual([
{ permission: "question", action: "deny", pattern: "*" },
])
expect(promptArgs.body.model).toEqual({
providerID: "openai",
modelID: "gpt-5.4",
})
expect(promptArgs.body.variant).toBe("low")
expect(promptArgs.body.options).toBeUndefined()
expect(getSessionPromptParams("session-123")).toEqual({
temperature: 0.4,
topP: 0.7,
options: {
reasoningEffort: "high",
thinking: { type: "disabled" },
maxTokens: 4096,
},
})
})
test("keeps agent when explicit model is configured", async () => {

View File

@@ -2,6 +2,7 @@ import type { BackgroundTask, LaunchInput, ResumeInput } from "./types"
import type { OpencodeClient, OnSubagentSessionCreated, QueueItem } from "./constants"
import { TMUX_CALLBACK_DELAY_MS } from "./constants"
import { log, getAgentToolRestrictions, promptWithModelSuggestionRetry, createInternalAgentTextPart } from "../../shared"
import { applySessionPromptParams } from "../../shared/session-prompt-params-helpers"
import { subagentSessions } from "../claude-code-session-state"
import { getTaskToastManager } from "../task-toast-manager"
import { isInsideTmux } from "../../shared/tmux"
@@ -128,10 +129,15 @@ export async function startTask(
})
const launchModel = input.model
? { providerID: input.model.providerID, modelID: input.model.modelID }
? {
providerID: input.model.providerID,
modelID: input.model.modelID,
}
: undefined
const launchVariant = input.model?.variant
applySessionPromptParams(sessionID, input.model)
promptWithModelSuggestionRetry(client, {
path: { id: sessionID },
body: {
@@ -213,10 +219,15 @@ export async function resumeTask(
})
const resumeModel = task.model
? { providerID: task.model.providerID, modelID: task.model.modelID }
? {
providerID: task.model.providerID,
modelID: task.model.modelID,
}
: undefined
const resumeVariant = task.model?.variant
applySessionPromptParams(task.sessionID, task.model)
client.session.promptAsync({
path: { id: task.sessionID },
body: {

View File

@@ -1,4 +1,5 @@
import type { FallbackEntry } from "../../shared/model-requirements"
import type { DelegatedModelConfig } from "../../shared/model-resolution-types"
import type { SessionPermissionRule } from "../../shared/question-denied-session-permission"
export type BackgroundTaskStatus =
@@ -43,7 +44,7 @@ export interface BackgroundTask {
error?: string
progress?: TaskProgress
parentModel?: { providerID: string; modelID: string }
model?: { providerID: string; modelID: string; variant?: string }
model?: DelegatedModelConfig
/** Fallback chain for runtime retry on model errors */
fallbackChain?: FallbackEntry[]
/** Number of fallback retry attempts made */
@@ -76,7 +77,7 @@ export interface LaunchInput {
parentModel?: { providerID: string; modelID: string }
parentAgent?: string
parentTools?: Record<string, boolean>
model?: { providerID: string; modelID: string; variant?: string }
model?: DelegatedModelConfig
/** Fallback chain for runtime retry on model errors */
fallbackChain?: FallbackEntry[]
isUnstableAgent?: boolean

View File

@@ -0,0 +1,101 @@
import { afterEach, beforeEach, describe, expect, it } from "bun:test"
import { mkdirSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { loadOpencodeGlobalCommands, loadOpencodeProjectCommands } from "./loader"
const TEST_DIR = join(tmpdir(), `claude-code-command-loader-${Date.now()}`)
function writeCommand(directory: string, name: string, description: string): void {
mkdirSync(directory, { recursive: true })
writeFileSync(
join(directory, `${name}.md`),
`---\ndescription: ${description}\n---\nRun ${name}.\n`,
)
}
describe("claude-code command loader", () => {
let originalOpencodeConfigDir: string | undefined
beforeEach(() => {
mkdirSync(TEST_DIR, { recursive: true })
originalOpencodeConfigDir = process.env.OPENCODE_CONFIG_DIR
})
afterEach(() => {
if (originalOpencodeConfigDir === undefined) {
delete process.env.OPENCODE_CONFIG_DIR
} else {
process.env.OPENCODE_CONFIG_DIR = originalOpencodeConfigDir
}
rmSync(TEST_DIR, { recursive: true, force: true })
})
it("#given a parent .opencode/commands directory #when loadOpencodeProjectCommands is called from child directory #then it loads the ancestor command", async () => {
// given
const projectDir = join(TEST_DIR, "project")
const childDir = join(projectDir, "apps", "desktop")
writeCommand(join(projectDir, ".opencode", "commands"), "ancestor", "Ancestor command")
// when
const commands = await loadOpencodeProjectCommands(childDir)
// then
expect(commands.ancestor?.description).toBe("(opencode-project) Ancestor command")
})
it("#given a .opencode/command directory #when loadOpencodeProjectCommands is called #then it loads the singular alias directory", async () => {
// given
writeCommand(join(TEST_DIR, ".opencode", "command"), "singular", "Singular command")
// when
const commands = await loadOpencodeProjectCommands(TEST_DIR)
// then
expect(commands.singular?.description).toBe("(opencode-project) Singular command")
})
it("#given duplicate project command names across ancestors #when loadOpencodeProjectCommands is called #then the nearest directory wins", async () => {
// given
const projectRoot = join(TEST_DIR, "project")
const childDir = join(projectRoot, "apps", "desktop")
const ancestorDir = join(TEST_DIR, ".opencode", "commands")
const projectDir = join(projectRoot, ".opencode", "commands")
writeCommand(ancestorDir, "duplicate", "Ancestor command")
writeCommand(projectDir, "duplicate", "Nearest command")
// when
const commands = await loadOpencodeProjectCommands(childDir)
// then
expect(commands.duplicate?.description).toBe("(opencode-project) Nearest command")
})
it("#given a global .opencode/commands directory #when loadOpencodeGlobalCommands is called #then it loads the plural alias directory", async () => {
// given
const opencodeConfigDir = join(TEST_DIR, "opencode-config")
process.env.OPENCODE_CONFIG_DIR = opencodeConfigDir
writeCommand(join(opencodeConfigDir, "commands"), "global-plural", "Global plural command")
// when
const commands = await loadOpencodeGlobalCommands()
// then
expect(commands["global-plural"]?.description).toBe("(opencode) Global plural command")
})
it("#given duplicate global command names across profile and parent dirs #when loadOpencodeGlobalCommands is called #then the profile dir wins", async () => {
// given
const opencodeRootDir = join(TEST_DIR, "opencode-root")
const profileConfigDir = join(opencodeRootDir, "profiles", "codex")
process.env.OPENCODE_CONFIG_DIR = profileConfigDir
writeCommand(join(opencodeRootDir, "commands"), "duplicate-global", "Parent global command")
writeCommand(join(profileConfigDir, "commands"), "duplicate-global", "Profile global command")
// when
const commands = await loadOpencodeGlobalCommands()
// then
expect(commands["duplicate-global"]?.description).toBe("(opencode) Profile global command")
})
})

View File

@@ -3,7 +3,12 @@ import { join, basename } from "path"
import { parseFrontmatter } from "../../shared/frontmatter"
import { sanitizeModelField } from "../../shared/model-sanitizer"
import { isMarkdownFile } from "../../shared/file-utils"
import { getClaudeConfigDir, getOpenCodeConfigDir } from "../../shared"
import {
findProjectOpencodeCommandDirs,
getClaudeConfigDir,
getOpenCodeCommandDirs,
getOpenCodeConfigDir,
} from "../../shared"
import { log } from "../../shared/logger"
import type { CommandScope, CommandDefinition, CommandFrontmatter, LoadedCommand } from "./types"
@@ -99,9 +104,25 @@ $ARGUMENTS
return commands
}
function deduplicateLoadedCommandsByName(commands: LoadedCommand[]): LoadedCommand[] {
const seen = new Set<string>()
const deduplicatedCommands: LoadedCommand[] = []
for (const command of commands) {
if (seen.has(command.name)) {
continue
}
seen.add(command.name)
deduplicatedCommands.push(command)
}
return deduplicatedCommands
}
function commandsToRecord(commands: LoadedCommand[]): Record<string, CommandDefinition> {
const result: Record<string, CommandDefinition> = {}
for (const cmd of commands) {
for (const cmd of deduplicateLoadedCommandsByName(commands)) {
const { name: _name, argumentHint: _argumentHint, ...openCodeCompatible } = cmd.definition
result[cmd.name] = openCodeCompatible as CommandDefinition
}
@@ -121,16 +142,21 @@ export async function loadProjectCommands(directory?: string): Promise<Record<st
}
export async function loadOpencodeGlobalCommands(): Promise<Record<string, CommandDefinition>> {
const configDir = getOpenCodeConfigDir({ binary: "opencode" })
const opencodeCommandsDir = join(configDir, "command")
const commands = await loadCommandsFromDir(opencodeCommandsDir, "opencode")
return commandsToRecord(commands)
const opencodeCommandDirs = getOpenCodeCommandDirs({ binary: "opencode" })
const allCommands = await Promise.all(
opencodeCommandDirs.map((commandsDir) => loadCommandsFromDir(commandsDir, "opencode")),
)
return commandsToRecord(allCommands.flat())
}
export async function loadOpencodeProjectCommands(directory?: string): Promise<Record<string, CommandDefinition>> {
const opencodeProjectDir = join(directory ?? process.cwd(), ".opencode", "command")
const commands = await loadCommandsFromDir(opencodeProjectDir, "opencode-project")
return commandsToRecord(commands)
const opencodeProjectDirs = findProjectOpencodeCommandDirs(directory ?? process.cwd())
const allCommands = await Promise.all(
opencodeProjectDirs.map((commandsDir) =>
loadCommandsFromDir(commandsDir, "opencode-project"),
),
)
return commandsToRecord(allCommands.flat())
}
export async function loadAllCommands(directory?: string): Promise<Record<string, CommandDefinition>> {

View File

@@ -0,0 +1,104 @@
import { afterEach, beforeEach, describe, expect, it } from "bun:test"
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { discoverInstalledPlugins } from "./discovery"
const originalClaudePluginsHome = process.env.CLAUDE_PLUGINS_HOME
const temporaryDirectories: string[] = []
function createTemporaryDirectory(prefix: string): string {
const directory = mkdtempSync(join(tmpdir(), prefix))
temporaryDirectories.push(directory)
return directory
}
describe("discoverInstalledPlugins", () => {
beforeEach(() => {
const pluginsHome = createTemporaryDirectory("omo-claude-plugins-")
process.env.CLAUDE_PLUGINS_HOME = pluginsHome
})
afterEach(() => {
if (originalClaudePluginsHome === undefined) {
delete process.env.CLAUDE_PLUGINS_HOME
} else {
process.env.CLAUDE_PLUGINS_HOME = originalClaudePluginsHome
}
for (const directory of temporaryDirectories.splice(0)) {
rmSync(directory, { recursive: true, force: true })
}
})
it("preserves scoped package name from npm plugin keys", () => {
//#given
const pluginsHome = process.env.CLAUDE_PLUGINS_HOME as string
const installPath = join(createTemporaryDirectory("omo-plugin-install-"), "@myorg", "my-plugin")
mkdirSync(installPath, { recursive: true })
const databasePath = join(pluginsHome, "installed_plugins.json")
writeFileSync(
databasePath,
JSON.stringify({
version: 2,
plugins: {
"@myorg/my-plugin@1.0.0": [
{
scope: "user",
installPath,
version: "1.0.0",
installedAt: "2026-03-25T00:00:00Z",
lastUpdated: "2026-03-25T00:00:00Z",
},
],
},
}),
"utf-8",
)
//#when
const discovered = discoverInstalledPlugins()
//#then
expect(discovered.errors).toHaveLength(0)
expect(discovered.plugins).toHaveLength(1)
expect(discovered.plugins[0]?.name).toBe("@myorg/my-plugin")
})
it("derives package name from file URL plugin keys", () => {
//#given
const pluginsHome = process.env.CLAUDE_PLUGINS_HOME as string
const installPath = join(createTemporaryDirectory("omo-plugin-install-"), "oh-my-opencode")
mkdirSync(installPath, { recursive: true })
const databasePath = join(pluginsHome, "installed_plugins.json")
writeFileSync(
databasePath,
JSON.stringify({
version: 2,
plugins: {
"file:///D:/configs/user-configs/.config/opencode/node_modules/oh-my-opencode@latest": [
{
scope: "user",
installPath,
version: "3.10.0",
installedAt: "2026-03-20T00:00:00Z",
lastUpdated: "2026-03-20T00:00:00Z",
},
],
},
}),
"utf-8",
)
//#when
const discovered = discoverInstalledPlugins()
//#then
expect(discovered.errors).toHaveLength(0)
expect(discovered.plugins).toHaveLength(1)
expect(discovered.plugins[0]?.name).toBe("oh-my-opencode")
})
})

View File

@@ -1,6 +1,7 @@
import { existsSync, readFileSync } from "fs"
import { homedir } from "os"
import { join } from "path"
import { basename, join } from "path"
import { fileURLToPath } from "url"
import { log } from "../../shared/logger"
import type {
InstalledPluginsDatabase,
@@ -79,8 +80,34 @@ function loadPluginManifest(installPath: string): PluginManifest | null {
}
function derivePluginNameFromKey(pluginKey: string): string {
const atIndex = pluginKey.indexOf("@")
return atIndex > 0 ? pluginKey.substring(0, atIndex) : pluginKey
const keyWithoutSource = pluginKey.startsWith("npm:") ? pluginKey.slice(4) : pluginKey
let versionSeparator: number
if (keyWithoutSource.startsWith("@")) {
const scopeEnd = keyWithoutSource.indexOf("/")
versionSeparator = scopeEnd > 0 ? keyWithoutSource.indexOf("@", scopeEnd) : -1
} else {
versionSeparator = keyWithoutSource.lastIndexOf("@")
}
const keyWithoutVersion = versionSeparator > 0 ? keyWithoutSource.slice(0, versionSeparator) : keyWithoutSource
if (keyWithoutVersion.startsWith("file://")) {
try {
return basename(fileURLToPath(keyWithoutVersion))
} catch {
return basename(keyWithoutVersion)
}
}
if (keyWithoutVersion.startsWith("@") && keyWithoutVersion.includes("/")) {
return keyWithoutVersion
}
if (keyWithoutVersion.includes("/") || keyWithoutVersion.includes("\\")) {
return basename(keyWithoutVersion)
}
return keyWithoutVersion
}
function isPluginEnabled(

View File

@@ -1 +1,2 @@
export * from "./state"
export * from "./switch-agent-state"

View File

@@ -1,3 +1,5 @@
import { resetPendingSessionAgentSwitchesForTesting } from "./switch-agent-state"
export const subagentSessions = new Set<string>()
export const syncSubagentSessions = new Set<string>()
@@ -17,6 +19,7 @@ export function _resetForTesting(): void {
subagentSessions.clear()
syncSubagentSessions.clear()
sessionAgentMap.clear()
resetPendingSessionAgentSwitchesForTesting()
}
const sessionAgentMap = new Map<string, string>()

View File

@@ -0,0 +1,38 @@
import { describe, expect, test, beforeEach } from "bun:test"
import {
clearPendingSessionAgentSwitch,
consumePendingSessionAgentSwitch,
getPendingSessionAgentSwitch,
resetPendingSessionAgentSwitchesForTesting,
setPendingSessionAgentSwitch,
} from "./switch-agent-state"
describe("switch-agent-state", () => {
beforeEach(() => {
resetPendingSessionAgentSwitchesForTesting()
})
test("#given pending switch #when consuming #then consumes once and clears", () => {
// given
setPendingSessionAgentSwitch("ses-1", "explore")
// when
const first = consumePendingSessionAgentSwitch("ses-1")
const second = consumePendingSessionAgentSwitch("ses-1")
// then
expect(first?.agent).toBe("explore")
expect(second).toBeUndefined()
})
test("#given pending switch #when clearing #then state is removed", () => {
// given
setPendingSessionAgentSwitch("ses-1", "librarian")
// when
clearPendingSessionAgentSwitch("ses-1")
// then
expect(getPendingSessionAgentSwitch("ses-1")).toBeUndefined()
})
})

View File

@@ -0,0 +1,37 @@
type PendingAgentSwitch = {
agent: string
requestedAt: Date
}
const pendingAgentSwitchBySession = new Map<string, PendingAgentSwitch>()
export function setPendingSessionAgentSwitch(sessionID: string, agent: string): PendingAgentSwitch {
const pendingSwitch: PendingAgentSwitch = {
agent,
requestedAt: new Date(),
}
pendingAgentSwitchBySession.set(sessionID, pendingSwitch)
return pendingSwitch
}
export function getPendingSessionAgentSwitch(sessionID: string): PendingAgentSwitch | undefined {
return pendingAgentSwitchBySession.get(sessionID)
}
export function consumePendingSessionAgentSwitch(sessionID: string): PendingAgentSwitch | undefined {
const pendingSwitch = pendingAgentSwitchBySession.get(sessionID)
if (!pendingSwitch) {
return undefined
}
pendingAgentSwitchBySession.delete(sessionID)
return pendingSwitch
}
export function clearPendingSessionAgentSwitch(sessionID: string): void {
pendingAgentSwitchBySession.delete(sessionID)
}
export function resetPendingSessionAgentSwitchesForTesting(): void {
pendingAgentSwitchBySession.clear()
}

View File

@@ -90,6 +90,69 @@ describe("discoverOAuthServerMetadata", () => {
})
})
test("falls back to root well-known URL when resource has a sub-path", () => {
// given — resource URL has a /mcp path (e.g. https://mcp.sentry.dev/mcp)
const resource = "https://mcp.example.com/mcp"
const prmUrl = new URL("/.well-known/oauth-protected-resource", resource).toString()
const pathSuffixedAsUrl = "https://mcp.example.com/.well-known/oauth-authorization-server/mcp"
const rootAsUrl = "https://mcp.example.com/.well-known/oauth-authorization-server"
const calls: string[] = []
const fetchMock = async (input: string | URL) => {
const url = typeof input === "string" ? input : input.toString()
calls.push(url)
if (url === prmUrl) {
return new Response("not found", { status: 404 })
}
if (url === pathSuffixedAsUrl) {
return new Response("not found", { status: 404 })
}
if (url === rootAsUrl) {
return new Response(
JSON.stringify({
authorization_endpoint: "https://mcp.example.com/oauth/authorize",
token_endpoint: "https://mcp.example.com/oauth/token",
registration_endpoint: "https://mcp.example.com/oauth/register",
}),
{ status: 200 }
)
}
return new Response("not found", { status: 404 })
}
Object.defineProperty(globalThis, "fetch", { value: fetchMock, configurable: true })
// when
return discoverOAuthServerMetadata(resource).then((result) => {
// then
expect(result).toEqual({
authorizationEndpoint: "https://mcp.example.com/oauth/authorize",
tokenEndpoint: "https://mcp.example.com/oauth/token",
registrationEndpoint: "https://mcp.example.com/oauth/register",
resource,
})
expect(calls).toEqual([prmUrl, pathSuffixedAsUrl, rootAsUrl])
})
})
test("throws when PRM, path-suffixed AS, and root AS all return 404", () => {
// given
const resource = "https://mcp.example.com/mcp"
const prmUrl = new URL("/.well-known/oauth-protected-resource", resource).toString()
const fetchMock = async (input: string | URL) => {
const url = typeof input === "string" ? input : input.toString()
if (url === prmUrl || url.includes(".well-known/oauth-authorization-server")) {
return new Response("not found", { status: 404 })
}
return new Response("not found", { status: 404 })
}
Object.defineProperty(globalThis, "fetch", { value: fetchMock, configurable: true })
// when
const result = discoverOAuthServerMetadata(resource)
// then
return expect(result).rejects.toThrow("OAuth authorization server metadata not found")
})
test("throws when both PRM and AS discovery return 404", () => {
// given
const resource = "https://mcp.example.com"

View File

@@ -36,28 +36,16 @@ async function fetchMetadata(url: string): Promise<{ ok: true; json: Record<stri
return { ok: true, json }
}
async function fetchAuthorizationServerMetadata(issuer: string, resource: string): Promise<OAuthServerMetadata> {
const issuerUrl = parseHttpsUrl(issuer, "Authorization server URL")
const issuerPath = issuerUrl.pathname.replace(/\/+$/, "")
const metadataUrl = new URL(`/.well-known/oauth-authorization-server${issuerPath}`, issuerUrl).toString()
const metadata = await fetchMetadata(metadataUrl)
if (!metadata.ok) {
if (metadata.status === 404) {
throw new Error("OAuth authorization server metadata not found")
}
throw new Error(`OAuth authorization server metadata fetch failed (${metadata.status})`)
}
function parseMetadataFields(json: Record<string, unknown>, resource: string): OAuthServerMetadata {
const authorizationEndpoint = parseHttpsUrl(
readStringField(metadata.json, "authorization_endpoint"),
readStringField(json, "authorization_endpoint"),
"authorization_endpoint"
).toString()
const tokenEndpoint = parseHttpsUrl(
readStringField(metadata.json, "token_endpoint"),
readStringField(json, "token_endpoint"),
"token_endpoint"
).toString()
const registrationEndpointValue = metadata.json.registration_endpoint
const registrationEndpointValue = json.registration_endpoint
const registrationEndpoint =
typeof registrationEndpointValue === "string" && registrationEndpointValue.length > 0
? parseHttpsUrl(registrationEndpointValue, "registration_endpoint").toString()
@@ -71,6 +59,29 @@ async function fetchAuthorizationServerMetadata(issuer: string, resource: string
}
}
async function fetchAuthorizationServerMetadata(issuer: string, resource: string): Promise<OAuthServerMetadata> {
const issuerUrl = parseHttpsUrl(issuer, "Authorization server URL")
const issuerPath = issuerUrl.pathname.replace(/\/+$/, "")
const metadataUrl = new URL(`/.well-known/oauth-authorization-server${issuerPath}`, issuerUrl).toString()
const metadata = await fetchMetadata(metadataUrl)
if (!metadata.ok) {
if (metadata.status === 404 && issuerPath !== "") {
const rootMetadataUrl = new URL("/.well-known/oauth-authorization-server", issuerUrl).toString()
const rootMetadata = await fetchMetadata(rootMetadataUrl)
if (rootMetadata.ok) {
return parseMetadataFields(rootMetadata.json, resource)
}
}
if (metadata.status === 404) {
throw new Error("OAuth authorization server metadata not found")
}
throw new Error(`OAuth authorization server metadata fetch failed (${metadata.status})`)
}
return parseMetadataFields(metadata.json, resource)
}
function parseAuthorizationServers(metadata: Record<string, unknown>): string[] {
const servers = metadata.authorization_servers
if (!Array.isArray(servers)) return []

View File

@@ -1,7 +1,7 @@
import { afterEach, beforeEach, describe, expect, it } from "bun:test"
import { mkdirSync, rmSync, writeFileSync } from "fs"
import { join } from "path"
import { tmpdir } from "os"
import { homedir, tmpdir } from "os"
import { SkillsConfigSchema } from "../../config/schema/skills"
import { discoverConfigSourceSkills, normalizePathForGlob } from "./config-source-discovery"
@@ -69,6 +69,28 @@ describe("config source discovery", () => {
expect(names).not.toContain("skip/skipped-skill")
})
it("loads skills from ~/ sources path", async () => {
// given
const homeSkillsDir = join(homedir(), `.omo-config-source-${Date.now()}`)
writeSkill(join(homeSkillsDir, "tilde-skill"), "tilde-skill", "Loaded from tilde path")
const config = SkillsConfigSchema.parse({
sources: [{ path: `~/${homeSkillsDir.split(homedir())[1]?.replace(/^\//, "")}`, recursive: true }],
})
try {
// when
const skills = await discoverConfigSourceSkills({
config,
configDir: join(TEST_DIR, "config"),
})
// then
expect(skills.some((skill) => skill.name === "tilde-skill")).toBe(true)
} finally {
rmSync(homeSkillsDir, { recursive: true, force: true })
}
})
it("normalizes windows separators before glob matching", () => {
// given
const windowsPath = "keep\\nested\\SKILL.md"

View File

@@ -1,4 +1,5 @@
import { promises as fs } from "fs"
import { homedir } from "os"
import { dirname, extname, isAbsolute, join, relative } from "path"
import picomatch from "picomatch"
import type { SkillsConfig } from "../../config/schema"
@@ -15,6 +16,14 @@ function isHttpUrl(path: string): boolean {
}
function toAbsolutePath(path: string, configDir: string): string {
if (path === "~") {
return homedir()
}
if (path.startsWith("~/")) {
return join(homedir(), path.slice(2))
}
if (isAbsolute(path)) {
return path
}

View File

@@ -615,5 +615,92 @@ Skill body.
expect(skill).toBeDefined()
expect(skill?.scope).toBe("project")
})
it("#given a skill in ancestor .agents/skills/ #when discoverProjectAgentsSkills is called from child directory #then it discovers the ancestor skill", async () => {
// given
const skillContent = `---
name: ancestor-agent-skill
description: A skill from ancestor .agents/skills directory
---
Skill body.
`
const projectDir = join(TEST_DIR, "project")
const childDir = join(projectDir, "apps", "worker")
const agentsProjectSkillsDir = join(projectDir, ".agents", "skills")
const skillDir = join(agentsProjectSkillsDir, "ancestor-agent-skill")
mkdirSync(childDir, { recursive: true })
mkdirSync(skillDir, { recursive: true })
writeFileSync(join(skillDir, "SKILL.md"), skillContent)
// when
const { discoverProjectAgentsSkills } = await import("./loader")
const skills = await discoverProjectAgentsSkills(childDir)
const skill = skills.find((candidate) => candidate.name === "ancestor-agent-skill")
// then
expect(skill).toBeDefined()
expect(skill?.scope).toBe("project")
})
})
describe("opencode project skill discovery", () => {
it("#given a skill in ancestor .opencode/skills/ #when discoverOpencodeProjectSkills is called from child directory #then it discovers the ancestor skill", async () => {
// given
const skillContent = `---
name: ancestor-opencode-skill
description: A skill from ancestor .opencode/skills directory
---
Skill body.
`
const projectDir = join(TEST_DIR, "project")
const childDir = join(projectDir, "packages", "cli")
const skillsDir = join(projectDir, ".opencode", "skills", "ancestor-opencode-skill")
mkdirSync(childDir, { recursive: true })
mkdirSync(skillsDir, { recursive: true })
writeFileSync(join(skillsDir, "SKILL.md"), skillContent)
// when
const { discoverOpencodeProjectSkills } = await import("./loader")
const skills = await discoverOpencodeProjectSkills(childDir)
const skill = skills.find((candidate) => candidate.name === "ancestor-opencode-skill")
// then
expect(skill).toBeDefined()
expect(skill?.scope).toBe("opencode-project")
})
it("#given a skill in .opencode/skill/ #when discoverOpencodeProjectSkills is called #then it discovers the singular alias directory", async () => {
// given
const skillContent = `---
name: singular-opencode-skill
description: A skill from .opencode/skill directory
---
Skill body.
`
const singularSkillDir = join(
TEST_DIR,
".opencode",
"skill",
"singular-opencode-skill",
)
mkdirSync(singularSkillDir, { recursive: true })
writeFileSync(join(singularSkillDir, "SKILL.md"), skillContent)
// when
const { discoverOpencodeProjectSkills } = await import("./loader")
const originalCwd = process.cwd()
process.chdir(TEST_DIR)
try {
const skills = await discoverOpencodeProjectSkills()
const skill = skills.find((candidate) => candidate.name === "singular-opencode-skill")
// then
expect(skill).toBeDefined()
expect(skill?.scope).toBe("opencode-project")
} finally {
process.chdir(originalCwd)
}
})
})
})

View File

@@ -3,6 +3,11 @@ import { homedir } from "os"
import { getClaudeConfigDir } from "../../shared/claude-config-dir"
import { getOpenCodeConfigDir } from "../../shared/opencode-config-dir"
import { getOpenCodeSkillDirs } from "../../shared/opencode-command-dirs"
import {
findProjectAgentsSkillDirs,
findProjectClaudeSkillDirs,
findProjectOpencodeSkillDirs,
} from "../../shared/project-discovery-dirs"
import type { CommandDefinition } from "../claude-code-command-loader/types"
import type { LoadedSkill } from "./types"
import { skillsToCommandDefinitionRecord } from "./skill-definition-record"
@@ -16,9 +21,11 @@ export async function loadUserSkills(): Promise<Record<string, CommandDefinition
}
export async function loadProjectSkills(directory?: string): Promise<Record<string, CommandDefinition>> {
const projectSkillsDir = join(directory ?? process.cwd(), ".claude", "skills")
const skills = await loadSkillsFromDir({ skillsDir: projectSkillsDir, scope: "project" })
return skillsToCommandDefinitionRecord(skills)
const projectSkillDirs = findProjectClaudeSkillDirs(directory ?? process.cwd())
const allSkills = await Promise.all(
projectSkillDirs.map((skillsDir) => loadSkillsFromDir({ skillsDir, scope: "project" })),
)
return skillsToCommandDefinitionRecord(deduplicateSkillsByName(allSkills.flat()))
}
export async function loadOpencodeGlobalSkills(): Promise<Record<string, CommandDefinition>> {
@@ -30,9 +37,15 @@ export async function loadOpencodeGlobalSkills(): Promise<Record<string, Command
}
export async function loadOpencodeProjectSkills(directory?: string): Promise<Record<string, CommandDefinition>> {
const opencodeProjectDir = join(directory ?? process.cwd(), ".opencode", "skills")
const skills = await loadSkillsFromDir({ skillsDir: opencodeProjectDir, scope: "opencode-project" })
return skillsToCommandDefinitionRecord(skills)
const opencodeProjectSkillDirs = findProjectOpencodeSkillDirs(
directory ?? process.cwd(),
)
const allSkills = await Promise.all(
opencodeProjectSkillDirs.map((skillsDir) =>
loadSkillsFromDir({ skillsDir, scope: "opencode-project" }),
),
)
return skillsToCommandDefinitionRecord(deduplicateSkillsByName(allSkills.flat()))
}
export interface DiscoverSkillsOptions {
@@ -104,8 +117,11 @@ export async function discoverUserClaudeSkills(): Promise<LoadedSkill[]> {
}
export async function discoverProjectClaudeSkills(directory?: string): Promise<LoadedSkill[]> {
const projectSkillsDir = join(directory ?? process.cwd(), ".claude", "skills")
return loadSkillsFromDir({ skillsDir: projectSkillsDir, scope: "project" })
const projectSkillDirs = findProjectClaudeSkillDirs(directory ?? process.cwd())
const allSkills = await Promise.all(
projectSkillDirs.map((skillsDir) => loadSkillsFromDir({ skillsDir, scope: "project" })),
)
return deduplicateSkillsByName(allSkills.flat())
}
export async function discoverOpencodeGlobalSkills(): Promise<LoadedSkill[]> {
@@ -117,13 +133,23 @@ export async function discoverOpencodeGlobalSkills(): Promise<LoadedSkill[]> {
}
export async function discoverOpencodeProjectSkills(directory?: string): Promise<LoadedSkill[]> {
const opencodeProjectDir = join(directory ?? process.cwd(), ".opencode", "skills")
return loadSkillsFromDir({ skillsDir: opencodeProjectDir, scope: "opencode-project" })
const opencodeProjectSkillDirs = findProjectOpencodeSkillDirs(
directory ?? process.cwd(),
)
const allSkills = await Promise.all(
opencodeProjectSkillDirs.map((skillsDir) =>
loadSkillsFromDir({ skillsDir, scope: "opencode-project" }),
),
)
return deduplicateSkillsByName(allSkills.flat())
}
export async function discoverProjectAgentsSkills(directory?: string): Promise<LoadedSkill[]> {
const agentsProjectDir = join(directory ?? process.cwd(), ".agents", "skills")
return loadSkillsFromDir({ skillsDir: agentsProjectDir, scope: "project" })
const agentsProjectSkillDirs = findProjectAgentsSkillDirs(directory ?? process.cwd())
const allSkills = await Promise.all(
agentsProjectSkillDirs.map((skillsDir) => loadSkillsFromDir({ skillsDir, scope: "project" })),
)
return deduplicateSkillsByName(allSkills.flat())
}
export async function discoverGlobalAgentsSkills(): Promise<LoadedSkill[]> {

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
import { log, normalizeModelID } from "../../shared"
const OPUS_PATTERN = /claude-opus/i
const OPUS_PATTERN = /claude-.*opus/i
function isClaudeProvider(providerID: string, modelID: string): boolean {
if (["anthropic", "google-vertex-anthropic", "opencode"].includes(providerID)) return true

View File

@@ -45,75 +45,31 @@ function createMockParams(overrides: {
}
describe("createAnthropicEffortHook", () => {
describe("opus 4-6 with variant max", () => {
it("should inject effort max for anthropic opus-4-6 with variant max", async () => {
//#given anthropic opus-4-6 model with variant max
describe("opus family with variant max", () => {
it("injects effort max for anthropic opus-4-6", async () => {
const hook = createAnthropicEffortHook()
const { input, output } = createMockParams({})
//#when chat.params hook is called
await hook["chat.params"](input, output)
//#then effort should be injected into options
expect(output.options.effort).toBe("max")
})
it("should inject effort max for github-copilot claude-opus-4-6", async () => {
//#given github-copilot provider with claude-opus-4-6
it("injects effort max for another opus family model such as opus-4-5", async () => {
const hook = createAnthropicEffortHook()
const { input, output } = createMockParams({
providerID: "github-copilot",
modelID: "claude-opus-4-6",
})
const { input, output } = createMockParams({ modelID: "claude-opus-4-5" })
//#when chat.params hook is called
await hook["chat.params"](input, output)
//#then effort should be injected (github-copilot resolves to anthropic)
expect(output.options.effort).toBe("max")
})
it("should inject effort max for opencode provider with claude-opus-4-6", async () => {
//#given opencode provider with claude-opus-4-6
it("injects effort max for dotted opus ids", async () => {
const hook = createAnthropicEffortHook()
const { input, output } = createMockParams({
providerID: "opencode",
modelID: "claude-opus-4-6",
})
const { input, output } = createMockParams({ modelID: "claude-opus-4.6" })
//#when chat.params hook is called
await hook["chat.params"](input, output)
//#then effort should be injected
expect(output.options.effort).toBe("max")
})
it("should inject effort max for google-vertex-anthropic provider", async () => {
//#given google-vertex-anthropic provider with claude-opus-4-6
const hook = createAnthropicEffortHook()
const { input, output } = createMockParams({
providerID: "google-vertex-anthropic",
modelID: "claude-opus-4-6",
})
//#when chat.params hook is called
await hook["chat.params"](input, output)
//#then effort should be injected
expect(output.options.effort).toBe("max")
})
it("should handle normalized model ID with dots (opus-4.6)", async () => {
//#given model ID with dots instead of hyphens
const hook = createAnthropicEffortHook()
const { input, output } = createMockParams({
modelID: "claude-opus-4.6",
})
//#when chat.params hook is called
await hook["chat.params"](input, output)
//#then should normalize and inject effort
expect(output.options.effort).toBe("max")
})
@@ -133,39 +89,30 @@ describe("createAnthropicEffortHook", () => {
})
})
describe("conditions NOT met - should skip", () => {
it("should NOT inject effort when variant is not max", async () => {
//#given opus-4-6 with variant high (not max)
describe("skip conditions", () => {
it("does nothing when variant is not max", async () => {
const hook = createAnthropicEffortHook()
const { input, output } = createMockParams({ variant: "high" })
//#when chat.params hook is called
await hook["chat.params"](input, output)
//#then effort should NOT be injected
expect(output.options.effort).toBeUndefined()
})
it("should NOT inject effort when variant is undefined", async () => {
//#given opus-4-6 with no variant
it("does nothing when variant is undefined", async () => {
const hook = createAnthropicEffortHook()
const { input, output } = createMockParams({ variant: undefined })
//#when chat.params hook is called
await hook["chat.params"](input, output)
//#then effort should NOT be injected
expect(output.options.effort).toBeUndefined()
})
it("should clamp effort to high for non-opus claude model with variant max", async () => {
//#given claude-sonnet-4-6 (not opus) with variant max
const hook = createAnthropicEffortHook()
const { input, output } = createMockParams({
modelID: "claude-sonnet-4-6",
})
const { input, output } = createMockParams({ modelID: "claude-sonnet-4-6" })
//#when chat.params hook is called
await hook["chat.params"](input, output)
//#then effort should be clamped to high (not max)
@@ -173,74 +120,24 @@ describe("createAnthropicEffortHook", () => {
expect(input.message.variant).toBe("high")
})
it("should NOT inject effort for non-anthropic provider with non-claude model", async () => {
//#given openai provider with gpt model
it("does nothing for non-claude providers/models", async () => {
const hook = createAnthropicEffortHook()
const { input, output } = createMockParams({
providerID: "openai",
modelID: "gpt-5.4",
})
const { input, output } = createMockParams({ providerID: "openai", modelID: "gpt-5.4" })
//#when chat.params hook is called
await hook["chat.params"](input, output)
//#then effort should NOT be injected
expect(output.options.effort).toBeUndefined()
})
it("should NOT throw when model.modelID is undefined", async () => {
//#given model with undefined modelID (runtime edge case)
const hook = createAnthropicEffortHook()
const input = {
sessionID: "test-session",
agent: { name: "sisyphus" },
model: { providerID: "anthropic", modelID: undefined as unknown as string },
provider: { id: "anthropic" },
message: { variant: "max" as const },
}
const output = { temperature: 0.1, options: {} }
//#when chat.params hook is called with undefined modelID
await hook["chat.params"](input, output)
//#then should gracefully skip without throwing
expect(output.options.effort).toBeUndefined()
})
})
describe("preserves existing options", () => {
it("should NOT overwrite existing effort if already set", async () => {
//#given options already have effort set
describe("existing options", () => {
it("does not overwrite existing effort", async () => {
const hook = createAnthropicEffortHook()
const { input, output } = createMockParams({
existingOptions: { effort: "high" },
})
const { input, output } = createMockParams({ existingOptions: { effort: "high" } })
//#when chat.params hook is called
await hook["chat.params"](input, output)
//#then existing effort should be preserved
expect(output.options.effort).toBe("high")
})
it("should preserve other existing options when injecting effort", async () => {
//#given options with existing thinking config
const hook = createAnthropicEffortHook()
const { input, output } = createMockParams({
existingOptions: {
thinking: { type: "enabled", budgetTokens: 31999 },
},
})
//#when chat.params hook is called
await hook["chat.params"](input, output)
//#then effort should be added without affecting thinking
expect(output.options.effort).toBe("max")
expect(output.options.thinking).toEqual({
type: "enabled",
budgetTokens: 31999,
})
})
})
})

View File

@@ -6,7 +6,7 @@ import { tmpdir } from "node:os"
import { join } from "node:path"
import { clearBoulderState, readBoulderState, writeBoulderState } from "../../features/boulder-state"
import type { BoulderState } from "../../features/boulder-state"
import { _resetForTesting, subagentSessions } from "../../features/claude-code-session-state"
import { _resetForTesting, setSessionAgent, subagentSessions } from "../../features/claude-code-session-state"
const { createAtlasHook } = await import("./index")
@@ -16,7 +16,7 @@ describe("atlas hook idle-event session lineage", () => {
let testDirectory = ""
let promptCalls: Array<unknown> = []
function writeIncompleteBoulder(): void {
function writeIncompleteBoulder(overrides: Partial<BoulderState> = {}): void {
const planPath = join(testDirectory, "test-plan.md")
writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [ ] Task 2")
@@ -25,6 +25,7 @@ describe("atlas hook idle-event session lineage", () => {
started_at: "2026-01-02T10:00:00Z",
session_ids: [MAIN_SESSION_ID],
plan_name: "test-plan",
...overrides,
}
writeBoulderState(testDirectory, state)
@@ -103,6 +104,7 @@ describe("atlas hook idle-event session lineage", () => {
writeIncompleteBoulder()
subagentSessions.add(subagentSessionID)
setSessionAgent(subagentSessionID, "atlas")
const hook = createHook({
[subagentSessionID]: intermediateParentSessionID,
@@ -119,4 +121,63 @@ describe("atlas hook idle-event session lineage", () => {
assert.equal(readBoulderState(testDirectory)?.session_ids.includes(subagentSessionID), true)
assert.equal(promptCalls.length, 1)
})
it("does not inject continuation for boulder-lineage subagent with non-matching agent", async () => {
const subagentSessionID = "subagent-session-agent-mismatch"
writeIncompleteBoulder({ agent: "atlas" })
subagentSessions.add(subagentSessionID)
setSessionAgent(subagentSessionID, "sisyphus-junior")
const hook = createHook({
[subagentSessionID]: MAIN_SESSION_ID,
})
await hook.handler({
event: {
type: "session.idle",
properties: { sessionID: subagentSessionID },
},
})
assert.equal(readBoulderState(testDirectory)?.session_ids.includes(subagentSessionID), true)
assert.equal(promptCalls.length, 0)
})
it("injects continuation for boulder-lineage subagent with matching agent", async () => {
const subagentSessionID = "subagent-session-agent-match"
writeIncompleteBoulder({ agent: "atlas" })
subagentSessions.add(subagentSessionID)
setSessionAgent(subagentSessionID, "atlas")
const hook = createHook({
[subagentSessionID]: MAIN_SESSION_ID,
})
await hook.handler({
event: {
type: "session.idle",
properties: { sessionID: subagentSessionID },
},
})
assert.equal(promptCalls.length, 1)
})
it("injects continuation for explicitly tracked boulder session regardless of agent", async () => {
writeIncompleteBoulder({ agent: "atlas" })
setSessionAgent(MAIN_SESSION_ID, "hephaestus")
const hook = createHook()
await hook.handler({
event: {
type: "session.idle",
properties: { sessionID: MAIN_SESSION_ID },
},
})
assert.equal(promptCalls.length, 1)
})
})

View File

@@ -5,6 +5,8 @@ import {
readBoulderState,
readCurrentTopLevelTask,
} from "../../features/boulder-state"
import { getSessionAgent, subagentSessions } from "../../features/claude-code-session-state"
import { getAgentConfigKey } from "../../shared/agent-display-names"
import { log } from "../../shared/logger"
import { injectBoulderContinuation } from "./boulder-continuation-injector"
import { HOOK_NAME } from "./hook-name"
@@ -136,6 +138,23 @@ export async function handleAtlasSessionIdle(input: {
})
}
if (subagentSessions.has(sessionID)) {
const sessionAgent = getSessionAgent(sessionID)
const agentKey = getAgentConfigKey(sessionAgent ?? "")
const requiredAgentKey = getAgentConfigKey(boulderState.agent ?? "atlas")
const agentMatches =
agentKey === requiredAgentKey ||
(requiredAgentKey === getAgentConfigKey("atlas") && agentKey === getAgentConfigKey("sisyphus"))
if (!agentMatches) {
log(`[${HOOK_NAME}] Skipped: subagent agent does not match boulder agent`, {
sessionID,
agent: sessionAgent ?? "unknown",
requiredAgent: boulderState.agent ?? "atlas",
})
return
}
}
const sessionState = getState(sessionID)
const now = Date.now()

View File

@@ -1282,6 +1282,7 @@ session_id: ses_untrusted_999
}
writeBoulderState(TEST_DIR, state)
subagentSessions.add(subagentSessionID)
updateSessionAgent(subagentSessionID, "atlas")
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)

View File

@@ -1,8 +1,14 @@
const { describe, expect, mock, test } = require("bun:test")
mock.module("../../shared", () => ({
mock.module("../../shared/opencode-message-dir", () => ({
getMessageDir: () => null,
}))
mock.module("../../shared/opencode-storage-detection", () => ({
isSqliteBackend: () => true,
}))
mock.module("../../shared/normalize-sdk-response", () => ({
normalizeSDKResponse: <TData>(response: { data?: TData }, fallback: TData): TData => response.data ?? fallback,
}))

View File

@@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"
const mockShowConfigErrorsIfAny = mock(async () => {})
const mockShowModelCacheWarningIfNeeded = mock(async () => {})
const mockUpdateAndShowConnectedProvidersCacheStatus = mock(async () => {})
const mockRefreshModelCapabilitiesOnStartup = mock(async () => {})
const mockShowLocalDevToast = mock(async () => {})
const mockShowVersionToast = mock(async () => {})
const mockRunBackgroundUpdateCheck = mock(async () => {})
@@ -22,6 +23,10 @@ mock.module("./hook/connected-providers-status", () => ({
mockUpdateAndShowConnectedProvidersCacheStatus,
}))
mock.module("./hook/model-capabilities-status", () => ({
refreshModelCapabilitiesOnStartup: mockRefreshModelCapabilitiesOnStartup,
}))
mock.module("./hook/startup-toasts", () => ({
showLocalDevToast: mockShowLocalDevToast,
showVersionToast: mockShowVersionToast,
@@ -78,6 +83,7 @@ beforeEach(() => {
mockShowConfigErrorsIfAny.mockClear()
mockShowModelCacheWarningIfNeeded.mockClear()
mockUpdateAndShowConnectedProvidersCacheStatus.mockClear()
mockRefreshModelCapabilitiesOnStartup.mockClear()
mockShowLocalDevToast.mockClear()
mockShowVersionToast.mockClear()
mockRunBackgroundUpdateCheck.mockClear()
@@ -112,6 +118,7 @@ describe("createAutoUpdateCheckerHook", () => {
expect(mockShowConfigErrorsIfAny).not.toHaveBeenCalled()
expect(mockShowModelCacheWarningIfNeeded).not.toHaveBeenCalled()
expect(mockUpdateAndShowConnectedProvidersCacheStatus).not.toHaveBeenCalled()
expect(mockRefreshModelCapabilitiesOnStartup).not.toHaveBeenCalled()
expect(mockShowLocalDevToast).not.toHaveBeenCalled()
expect(mockShowVersionToast).not.toHaveBeenCalled()
expect(mockRunBackgroundUpdateCheck).not.toHaveBeenCalled()
@@ -129,6 +136,7 @@ describe("createAutoUpdateCheckerHook", () => {
//#then - startup checks, toast, and background check run
expect(mockShowConfigErrorsIfAny).toHaveBeenCalledTimes(1)
expect(mockUpdateAndShowConnectedProvidersCacheStatus).toHaveBeenCalledTimes(1)
expect(mockRefreshModelCapabilitiesOnStartup).toHaveBeenCalledTimes(1)
expect(mockShowModelCacheWarningIfNeeded).toHaveBeenCalledTimes(1)
expect(mockShowVersionToast).toHaveBeenCalledTimes(1)
expect(mockRunBackgroundUpdateCheck).toHaveBeenCalledTimes(1)
@@ -146,6 +154,7 @@ describe("createAutoUpdateCheckerHook", () => {
//#then - no startup actions run
expect(mockShowConfigErrorsIfAny).not.toHaveBeenCalled()
expect(mockUpdateAndShowConnectedProvidersCacheStatus).not.toHaveBeenCalled()
expect(mockRefreshModelCapabilitiesOnStartup).not.toHaveBeenCalled()
expect(mockShowModelCacheWarningIfNeeded).not.toHaveBeenCalled()
expect(mockShowLocalDevToast).not.toHaveBeenCalled()
expect(mockShowVersionToast).not.toHaveBeenCalled()
@@ -165,6 +174,7 @@ describe("createAutoUpdateCheckerHook", () => {
//#then - side effects execute only once
expect(mockShowConfigErrorsIfAny).toHaveBeenCalledTimes(1)
expect(mockUpdateAndShowConnectedProvidersCacheStatus).toHaveBeenCalledTimes(1)
expect(mockRefreshModelCapabilitiesOnStartup).toHaveBeenCalledTimes(1)
expect(mockShowModelCacheWarningIfNeeded).toHaveBeenCalledTimes(1)
expect(mockShowVersionToast).toHaveBeenCalledTimes(1)
expect(mockRunBackgroundUpdateCheck).toHaveBeenCalledTimes(1)
@@ -183,6 +193,7 @@ describe("createAutoUpdateCheckerHook", () => {
//#then - local dev toast is shown and background check is skipped
expect(mockShowConfigErrorsIfAny).toHaveBeenCalledTimes(1)
expect(mockUpdateAndShowConnectedProvidersCacheStatus).toHaveBeenCalledTimes(1)
expect(mockRefreshModelCapabilitiesOnStartup).toHaveBeenCalledTimes(1)
expect(mockShowModelCacheWarningIfNeeded).toHaveBeenCalledTimes(1)
expect(mockShowLocalDevToast).toHaveBeenCalledTimes(1)
expect(mockShowVersionToast).not.toHaveBeenCalled()
@@ -205,6 +216,7 @@ describe("createAutoUpdateCheckerHook", () => {
//#then - no startup actions run
expect(mockShowConfigErrorsIfAny).not.toHaveBeenCalled()
expect(mockUpdateAndShowConnectedProvidersCacheStatus).not.toHaveBeenCalled()
expect(mockRefreshModelCapabilitiesOnStartup).not.toHaveBeenCalled()
expect(mockShowModelCacheWarningIfNeeded).not.toHaveBeenCalled()
expect(mockShowLocalDevToast).not.toHaveBeenCalled()
expect(mockShowVersionToast).not.toHaveBeenCalled()

View File

@@ -5,11 +5,17 @@ import type { AutoUpdateCheckerOptions } from "./types"
import { runBackgroundUpdateCheck } from "./hook/background-update-check"
import { showConfigErrorsIfAny } from "./hook/config-errors-toast"
import { updateAndShowConnectedProvidersCacheStatus } from "./hook/connected-providers-status"
import { refreshModelCapabilitiesOnStartup } from "./hook/model-capabilities-status"
import { showModelCacheWarningIfNeeded } from "./hook/model-cache-warning"
import { showLocalDevToast, showVersionToast } from "./hook/startup-toasts"
export function createAutoUpdateCheckerHook(ctx: PluginInput, options: AutoUpdateCheckerOptions = {}) {
const { showStartupToast = true, isSisyphusEnabled = false, autoUpdate = true } = options
const {
showStartupToast = true,
isSisyphusEnabled = false,
autoUpdate = true,
modelCapabilities,
} = options
const isCliRunMode = process.env.OPENCODE_CLI_RUN_MODE === "true"
const getToastMessage = (isUpdate: boolean, latestVersion?: string): string => {
@@ -43,6 +49,7 @@ export function createAutoUpdateCheckerHook(ctx: PluginInput, options: AutoUpdat
await showConfigErrorsIfAny(ctx)
await updateAndShowConnectedProvidersCacheStatus(ctx)
await refreshModelCapabilitiesOnStartup(modelCapabilities)
await showModelCacheWarningIfNeeded(ctx)
if (localDevVersion) {

View File

@@ -0,0 +1,37 @@
import type { ModelCapabilitiesConfig } from "../../../config/schema/model-capabilities"
import { refreshModelCapabilitiesCache } from "../../../shared/model-capabilities-cache"
import { log } from "../../../shared/logger"
const DEFAULT_REFRESH_TIMEOUT_MS = 5000
export async function refreshModelCapabilitiesOnStartup(
config: ModelCapabilitiesConfig | undefined,
): Promise<void> {
if (config?.enabled === false) {
return
}
if (config?.auto_refresh_on_start === false) {
return
}
const timeoutMs = config?.refresh_timeout_ms ?? DEFAULT_REFRESH_TIMEOUT_MS
let timeoutId: ReturnType<typeof setTimeout> | undefined
try {
await Promise.race([
refreshModelCapabilitiesCache({
sourceUrl: config?.source_url,
}),
new Promise<never>((_, reject) => {
timeoutId = setTimeout(() => reject(new Error("Model capabilities refresh timed out")), timeoutMs)
}),
])
} catch (error) {
log("[auto-update-checker] Model capabilities refresh failed", { error: String(error) })
} finally {
if (timeoutId) {
clearTimeout(timeoutId)
}
}
}

View File

@@ -1,3 +1,5 @@
import type { ModelCapabilitiesConfig } from "../../config/schema/model-capabilities"
export interface NpmDistTags {
latest: string
[key: string]: string
@@ -26,4 +28,5 @@ export interface AutoUpdateCheckerOptions {
showStartupToast?: boolean
isSisyphusEnabled?: boolean
autoUpdate?: boolean
modelCapabilities?: ModelCapabilitiesConfig
}

View File

@@ -135,9 +135,96 @@ describe("context-window-monitor modelContextLimitsCache", () => {
})
})
describe("#given Anthropic provider with cached context limit and 1M mode disabled", () => {
describe("#when cached usage exceeds the Anthropic default limit", () => {
it("#then should ignore the cached limit and append the reminder from the default Anthropic limit", async () => {
describe("#given Anthropic 4.6 provider with cached context limit and 1M mode disabled", () => {
describe("#when cached usage is below threshold of cached limit", () => {
it("#then should respect the cached limit and skip the reminder", async () => {
// given
const modelContextLimitsCache = new Map<string, number>()
modelContextLimitsCache.set("anthropic/claude-sonnet-4-6", 500000)
const hook = createContextWindowMonitorHook({} as never, {
anthropicContext1MEnabled: false,
modelContextLimitsCache,
})
const sessionID = "ses_anthropic_cached_limit_respected"
await hook.event({
event: {
type: "message.updated",
properties: {
info: {
role: "assistant",
sessionID,
providerID: "anthropic",
modelID: "claude-sonnet-4-6",
finish: true,
tokens: {
input: 150000,
output: 0,
reasoning: 0,
cache: { read: 10000, write: 0 },
},
},
},
},
})
// when
const output = createOutput()
await hook["tool.execute.after"]({ tool: "bash", sessionID, callID: "call_1" }, output)
// then — 160K/500K = 32%, well below 70% threshold
expect(output.output).toBe("original")
})
})
describe("#when cached usage exceeds threshold of cached limit", () => {
it("#then should use the cached limit for the reminder", async () => {
// given
const modelContextLimitsCache = new Map<string, number>()
modelContextLimitsCache.set("anthropic/claude-sonnet-4-6", 500000)
const hook = createContextWindowMonitorHook({} as never, {
anthropicContext1MEnabled: false,
modelContextLimitsCache,
})
const sessionID = "ses_anthropic_cached_limit_exceeded"
await hook.event({
event: {
type: "message.updated",
properties: {
info: {
role: "assistant",
sessionID,
providerID: "anthropic",
modelID: "claude-sonnet-4-6",
finish: true,
tokens: {
input: 350000,
output: 0,
reasoning: 0,
cache: { read: 10000, write: 0 },
},
},
},
},
})
// when
const output = createOutput()
await hook["tool.execute.after"]({ tool: "bash", sessionID, callID: "call_1" }, output)
// then — 360K/500K = 72%, above 70% threshold, uses cached 500K limit
expect(output.output).toContain("context remaining")
expect(output.output).toContain("500,000-token context window")
})
})
})
describe("#given older Anthropic provider with cached context limit and 1M mode disabled", () => {
describe("#when cached usage would only exceed the incorrect cached limit", () => {
it("#then should ignore the cached limit and use the 200K default", async () => {
// given
const modelContextLimitsCache = new Map<string, number>()
modelContextLimitsCache.set("anthropic/claude-sonnet-4-5", 500000)
@@ -146,7 +233,7 @@ describe("context-window-monitor modelContextLimitsCache", () => {
anthropicContext1MEnabled: false,
modelContextLimitsCache,
})
const sessionID = "ses_anthropic_default_overrides_cached_limit"
const sessionID = "ses_anthropic_older_model_ignores_cached_limit"
await hook.event({
event: {
@@ -176,8 +263,6 @@ describe("context-window-monitor modelContextLimitsCache", () => {
// then
expect(output.output).toContain("context remaining")
expect(output.output).toContain("200,000-token context window")
expect(output.output).not.toContain("500,000-token context window")
expect(output.output).not.toContain("1,000,000-token context window")
})
})
})

View File

@@ -53,3 +53,4 @@ export { createJsonErrorRecoveryHook, JSON_ERROR_TOOL_EXCLUDE_LIST, JSON_ERROR_P
export { createReadImageResizerHook } from "./read-image-resizer"
export { createTodoDescriptionOverrideHook } from "./todo-description-override"
export { createWebFetchRedirectGuardHook } from "./webfetch-redirect-guard"
export { createSwitchAgentHook } from "./switch-agent"

View File

@@ -293,8 +293,6 @@ NOW.
</ultrawork-mode>
---
`
export function getDefaultUltraworkMessage(): string {

View File

@@ -283,8 +283,6 @@ NOW.
</ultrawork-mode>
---
`
export function getGeminiUltraworkMessage(): string {

View File

@@ -166,8 +166,6 @@ A task is complete when:
</ultrawork-mode>
---
`;
export function getGptUltraworkMessage(): string {

View File

@@ -136,7 +136,5 @@ ${ULTRAWORK_PLANNER_SECTION}
</ultrawork-mode>
---
`
}

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