Compare commits

..

90 Commits

Author SHA1 Message Date
github-actions[bot]
c77c9ceb53 release: v3.1.9 2026-01-30 14:15:54 +00:00
YeonGyu-Kim
8c2625cfb0 🏆 test: optimize test suite with FakeTimers and race condition fixes (#1284)
* fix: exclude prompt/permission from plan agent config

plan agent should only inherit model settings from prometheus,
not the prompt or permission. This ensures plan agent uses
OpenCode's default behavior while only overriding the model.

* test(todo-continuation-enforcer): use FakeTimers for 15x faster tests

- Add custom FakeTimers implementation (~100 lines)
- Replace all real setTimeout waits with fakeTimers.advanceBy()
- Test time: 104.6s → 7.01s

* test(callback-server): fix race conditions with Promise.all and Bun.fetch

- Use Bun.fetch.bind(Bun) to avoid globalThis.fetch mock interference
- Use Promise.all pattern for concurrent fetch/waitForCallback
- Add Bun.sleep(10) in afterEach for port release

* test(concurrency): replace placeholder assertions with getCount checks

Replace 6 meaningless expect(true).toBe(true) assertions with
actual getCount() verifications for test quality improvement

* refactor(config-handler): simplify planDemoteConfig creation

Remove unnecessary IIFE and destructuring, use direct spread instead

* test(executor): use FakeTimeouts for faster tests

- Add custom FakeTimeouts implementation
- Replace setTimeout waits with fakeTimeouts.advanceBy()
- Test time reduced from ~26s to ~6.8s

* test: fix gemini model mock for artistry unstable mode

* test: fix model list mock payload shape

* test: mock provider models for artistry category

---------

Co-authored-by: justsisyphus <justsisyphus@users.noreply.github.com>
2026-01-30 22:10:52 +09:00
github-actions[bot]
3ced20d1ab @kunal70006 has signed the CLA in code-yeongyu/oh-my-opencode#1282 2026-01-30 09:56:07 +00:00
github-actions[bot]
fb02cc9e95 @Zacks-Zhang has signed the CLA in code-yeongyu/oh-my-opencode#1280 2026-01-30 08:51:59 +00:00
justsisyphus
80ee52fe3b fix: improve model resolution with client API fallback and explicit model passing
- fetchAvailableModels now falls back to client.model.list() when cache is empty
- provider-models cache empty → models.json → client API (3-tier fallback)
- look-at tool explicitly passes registered agent's model to session.prompt
- Ensures multimodal-looker uses correctly resolved model (e.g., gemini-3-flash-preview)
- Add comprehensive tests for fuzzy matching and fallback scenarios
2026-01-30 16:57:21 +09:00
github-actions[bot]
2f7e188cb5 @Hisir0909 has signed the CLA in code-yeongyu/oh-my-opencode#1275 2026-01-30 07:33:44 +00:00
justsisyphus
f8be01c6dd test: update Atlas fallback test and misc code improvements
- Update Atlas fallback test to expect k2p5 as primary (kimi-for-coding)

- Minor improvements to connected-providers-cache and utils

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-01-30 16:19:02 +09:00
justsisyphus
0dbec08923 feat(cli): add kimi-for-coding provider to model fallback
- Add kimiForCoding field to ProviderAvailability interface

- Add kimi-for-coding provider mapping in isProviderAvailable

- Include kimi-for-coding in Sisyphus fallback chain for non-max plan

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-01-30 16:19:02 +09:00
justsisyphus
691fa8b815 refactor(sisyphus-junior): extract MODE constant and add export
- Add AgentMode type import and MODE constant

- Export mode on createSisyphusJuniorAgentWithOverrides function

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-01-30 16:19:02 +09:00
justsisyphus
a73d806d4e docs: update explore agent model and category descriptions
- Change explore agent from Grok Code to Claude Haiku 4.5

- Update deep category description for clarity

- Fix Momus fallback chain order

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-01-30 16:19:02 +09:00
justsisyphus
a424f81cd5 docs: update Sisyphus fallback chain across all documentation
Update Sisyphus fallback chain to include gpt-5.2-codex and gemini-3-pro

Files: AGENTS.md, README*.md, src/agents/AGENTS.md

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-01-30 16:19:02 +09:00
justsisyphus
1187a02020 fix: Atlas respects fallbackChain, always refresh provider-models cache
- Remove uiSelectedModel from Atlas model resolution (use k2p5 as primary)
- Always overwrite provider-models.json on session start to prevent stale cache
2026-01-30 16:19:02 +09:00
Junho Yeo
3074434887 fix: use correct gh api command for starring repo (#1274)
`gh repo star` is not a valid GitHub CLI command.
Use `gh api --silent --method PUT /user/starred/OWNER/REPO` instead.
2026-01-30 15:58:56 +09:00
justsisyphus
6bb2854162 Merge branch 'omo-avail' into dev 2026-01-30 15:28:20 +09:00
justsisyphus
e08904a27a feat: add artistry category to ultrawork-mode specialist delegation
- Add oracle vs artistry distinction in MANDATORY CERTAINTY PROTOCOL
- Update WHEN IN DOUBT examples with both delegation options
- Add artistry to IF YOU ENCOUNTER A BLOCKER section
- Add 'Hard problem (non-conventional)' row to AGENTS UTILIZATION table
- Update analyze-mode message with artistry specialist option

Oracle: conventional problems (architecture, debugging, complex logic)
Artistry: non-conventional problems (different approach needed)
2026-01-30 15:19:38 +09:00
justsisyphus
0188d69233 test: add requiresModel and isModelAvailable tests 2026-01-30 15:11:32 +09:00
justsisyphus
2c74f608f0 feat(delegate-task, agents): check requiresModel for conditional activation 2026-01-30 15:11:27 +09:00
justsisyphus
baefd16b3f feat(shared): add requiresModel field and isModelAvailable helper 2026-01-30 15:11:19 +09:00
justsisyphus
b1b4578906 feat: add opencode/kimi-k2.5-free fallback and prioritize kimi for atlas 2026-01-30 15:10:38 +09:00
justsisyphus
9d20a5b11c feat: add kimi-for-coding provider to installer and fix model ID to k2p5 2026-01-30 15:08:26 +09:00
justsisyphus
d2d8d1a782 feat: add kimi-k2.5 to agent fallback chains and update model catalog
- sisyphus: opus → kimi-k2.5 → glm-4.7 → gpt-5.2-codex → gemini-3-pro
- atlas: sonnet-4-5 → kimi-k2.5 → gpt-5.2 → gemini-3-pro
- prometheus/metis: opus → kimi-k2.5 → gpt-5.2 → gemini-3-pro
- multimodal-looker: gemini-flash → gpt-5.2 → glm-4.6v → kimi-k2.5 → haiku → gpt-5-nano
- visual-engineering: remove gpt-5.2 from chain
- ultrabrain: reorder to gpt-5.2-codex → gemini-3-pro → opus
- Add cross-provider fuzzy match for model resolution
- Update all documentation (AGENTS.md, features.md, configurations.md, category-skill-guide.md)
2026-01-30 14:53:50 +09:00
justsisyphus
10bdb6c694 chore: update artistry category description for creative problem-solving 2026-01-30 14:53:50 +09:00
justsisyphus
5f243e2d3a chore: add glm-4.7 to visual-engineering fallback chain 2026-01-30 14:53:50 +09:00
justsisyphus
82a47ff928 chore: add code style requirements to ultrabrain prompt
- MUST search existing codebase for patterns before writing code
- MUST match project's existing conventions
- MUST write readable, human-friendly code
2026-01-30 14:53:50 +09:00
justsisyphus
c06f38693e refactor: revamp ultrabrain category with deep work mindset
- Add variant: max to ultrabrain's gemini-3-pro fallback entry
- Rename STRATEGIC_CATEGORY_PROMPT_APPEND to ULTRABRAIN_CATEGORY_PROMPT_APPEND
- Keep original strategic advisor prompt content (no micromanagement instructions)
- Update description: use only for genuinely hard tasks, give clear goals only
- Update tests to match renamed constant
2026-01-30 14:53:50 +09:00
justsisyphus
6e9cb7ecd8 chore: add variant max to momus opus-4-5 fallback entry 2026-01-30 14:53:50 +09:00
justsisyphus
b731399edf chore: prioritize gemini-3-pro over opus in oracle fallback chain
- Move gemini-3-pro above claude-opus-4-5 in oracle's fallbackChain
- Add variant: "max" to gemini-3-pro entry
2026-01-30 14:53:50 +09:00
github-actions[bot]
0a28f6a790 @gabriel-ecegi has signed the CLA in code-yeongyu/oh-my-opencode#1271 2026-01-30 05:13:19 +00:00
justsisyphus
4e529b74e0 revert: remove unnecessary NODE_AUTH_TOKEN from publish.yml (OIDC works) 2026-01-30 13:54:46 +09:00
justsisyphus
90eec0a369 fix: add NODE_AUTH_TOKEN env to main publish workflow 2026-01-30 13:50:55 +09:00
justsisyphus
3b5d18e6bf fix(agents): exclude subagents from UI model selection override
Subagents (explore, librarian, oracle, etc.) now use their own fallback
chain instead of inheriting the UI-selected model. This fixes the issue
where explore agent was incorrectly using Opus instead of Haiku.

- Add AgentMode type and static mode property to AgentFactory
- Each agent declares its own mode via factory.mode = MODE pattern
- createBuiltinAgents() checks source.mode before passing uiSelectedModel
2026-01-30 13:49:40 +09:00
justsisyphus
67aeb9cb8c chore: replace big-pickle model with glm-4.7-free 2026-01-30 13:44:04 +09:00
justsisyphus
b1c1f02172 fix: add NODE_AUTH_TOKEN env to publish step 2026-01-30 13:36:20 +09:00
justsisyphus
2b39d119cd fix: restore registry-url for npm auth with new granular token 2026-01-30 13:21:35 +09:00
justsisyphus
afa2ece847 fix: remove registry-url from setup-node to enable OIDC auth 2026-01-30 13:11:44 +09:00
justsisyphus
390c25197f fix: manually create .npmrc without token for OIDC
setup-node with registry-url injects NODE_AUTH_TOKEN secret which is revoked.
Create .npmrc manually with empty _authToken to force OIDC authentication.
2026-01-30 12:57:15 +09:00
justsisyphus
9e07b143df fix: match main workflow's OIDC setup exactly
Main workflow works with registry-url + NPM_CONFIG_PROVENANCE.
Removed all extra env vars and debugging - simplify to match working pattern.
2026-01-30 12:52:57 +09:00
justsisyphus
ad95880198 fix(start-work): restore atlas agent and proper model fallback chain
- Restore agent: 'atlas' in start-work command (removed by PR #1201)
- Fix model-resolver to properly iterate through fallback chain providers
- Remove broken parent model inheritance that bypassed fallback logic
- Add model-suggestion-retry for runtime API failures (cherry-pick 800846c1)

Fixes #1200
2026-01-30 12:52:46 +09:00
justsisyphus
86088d3a6e fix: remove registry-url to enable npm OIDC auto-detection
- Remove registry-url from setup-node (was injecting NODE_AUTH_TOKEN)
- Add npm version check and auto-upgrade for OIDC support (11.5.1+)
- Add explicit --registry flag to npm publish
- Remove empty NODE_AUTH_TOKEN/NPM_CONFIG_USERCONFIG env vars that were breaking OIDC
2026-01-30 12:47:15 +09:00
justsisyphus
ae8a6c5eb8 refactor: replace console.log/warn/error with file-based log() for silent logging
Replace all console output with shared logger to write to
/tmp/oh-my-opencode.log instead of stdout/stderr.

Files changed:
- index.ts: console.warn → log()
- hook-message-injector/injector.ts: console.warn → log()
- lsp/client.ts: console.error → log()
- ast-grep/downloader.ts: console.log/error → log()
- session-recovery/index.ts: console.error → log()
- comment-checker/downloader.ts: console.log/error → log()

CLI tools (install.ts, doctor, etc.) retain console output for UX.
2026-01-30 12:45:37 +09:00
justsisyphus
db538c7e6b fix(ci): override env vars to disable token auth, force OIDC 2026-01-30 12:41:00 +09:00
justsisyphus
dfed2abd3e fix(ci): also remove NPM_CONFIG_USERCONFIG .npmrc and unset tokens for OIDC 2026-01-30 12:37:12 +09:00
justsisyphus
300a3fdc14 fix(ci): remove .npmrc to enable pure OIDC auth for npm publish 2026-01-30 12:33:51 +09:00
justsisyphus
c993cf007f fix(ci): remove registry-url to use pure OIDC auth for npm publish 2026-01-30 12:29:33 +09:00
justsisyphus
3d7de0a050 fix(publish-platform): use 7z on Windows, simplify skip logic 2026-01-30 12:25:30 +09:00
justsisyphus
8e19ffdce4 ci(publish-platform): separate build/publish jobs with OIDC provenance
- Split into two jobs: build (compile binaries) and publish (npm publish)
- Build job uploads compressed artifacts (tar.gz/zip)
- Publish job downloads artifacts and uses OIDC Trusted Publishing
- Removes NODE_AUTH_TOKEN dependency, uses npm provenance instead
- Increased timeout for large binary uploads (40-120MB)
- Build parallelism increased to 7 (all platforms simultaneously)
- Fixes npm classic token deprecation issue

Benefits:
- Fresh OIDC token at publish time avoids timeout issues
- No token rotation needed (OIDC is ephemeral)
- Build failures isolated from publish failures
- Artifacts can be reused if publish fails
2026-01-30 12:21:24 +09:00
github-actions[bot]
456d9cea65 release: v3.1.8 2026-01-30 02:58:12 +00:00
justsisyphus
30f893b766 fix(cli/run): fix [undefine] tag and add text preview to verbose log
- Fix sessionTag showing '[undefine]' when sessionID is undefined
  - System events now display as '[system]' instead
- Fix message.updated expecting non-existent 'content' field
  - SDK's EventMessageUpdated only contains info metadata, not content
  - Content is streamed via message.part.updated events
- Add text preview to message.part.updated verbose logging
- Update MessageUpdatedProps type to match SDK structure
- Update tests to reflect actual SDK behavior
2026-01-30 11:45:58 +09:00
justsisyphus
c905e1cb7a fix(delegate-task): restore resolved.model to category userModel chain (#1227)
PR #1227 incorrectly removed resolved.model from the userModel chain,
assuming it was bypassing the fallback chain. However, resolved.model
contained the category's DEFAULT_CATEGORIES model (e.g., quick ->
claude-haiku-4-5), not the main session model.

Without resolved.model, when connectedProvidersCache is null and
availableModels is empty, category model resolution falls through to
systemDefaultModel (opus) instead of using the category's default.

This fix restores the original priority:
1. User category model override
2. Category default model (from resolved.model)
3. sisyphusJuniorModel
4. Fallback chain
5. System default
2026-01-30 11:45:19 +09:00
YeonGyu-Kim
d3e2b36e3d refactor(tmux-subagent): introduce dependency injection for testability (#1267)
Co-authored-by: justsisyphus <justsisyphus@users.noreply.github.com>
2026-01-30 10:59:54 +09:00
YeonGyu-Kim
5f0b6d49f5 fix(run): prevent premature exit on idle before meaningful work (#1263)
The run command's completion check had a race condition: when a session
transitions busy->idle before the LLM generates any output (empty
response or API delay), checkCompletionConditions() returns true because
0 incomplete todos + 0 busy children = complete. This caused the runner
to exit with 'All tasks completed' before any work was done.

Fix:
- Add hasReceivedMeaningfulWork flag to EventState
- Set flag on: assistant text content, tool execution, or message update
  with actual content (all scoped to main session only)
- Guard completion check in runner poll loop: skip if no meaningful work
  has been observed yet

This ensures the runner waits until the session has produced at least one
observable output before considering completion conditions.

Adds 6 new test cases covering the race condition scenarios.
2026-01-30 09:10:24 +09:00
github-actions[bot]
b45408dd9c @LeekJay has signed the CLA in code-yeongyu/oh-my-opencode#1254 2026-01-29 17:03:39 +00:00
github-actions[bot]
6c8527f29b release: v3.1.7 2026-01-29 12:39:22 +00:00
justsisyphus
cd4da93bf2 fix(test): migrate config-handler tests from mock.module to spyOn to prevent cross-file cache pollution 2026-01-29 21:35:14 +09:00
justsisyphus
71b2f1518a chore(agents): unify agent description format with OhMyOpenCode attribution 2026-01-29 21:27:04 +09:00
YeonGyu-Kim
dcda8769cc feat(mcp-oauth): add full OAuth 2.1 authentication for MCP servers (#1169)
* feat(mcp-oauth): add oauth field to ClaudeCodeMcpServer schema

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

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

* feat(mcp-oauth): add RFC 7591 Dynamic Client Registration

* feat(mcp-oauth): add RFC 9728 PRM + RFC 8414 AS discovery

* feat(mcp-oauth): add secure token storage with {host}/{resource} key format

* feat(mcp-oauth): add dynamic port OAuth callback server

* feat(mcp-oauth): add RFC 8707 Resource Indicators

* feat(mcp-oauth): implement full-spec McpOAuthProvider

* feat(mcp-oauth): add step-up authorization handler

* feat(mcp-oauth): integrate authProvider into SkillMcpManager

* feat(doctor): add MCP OAuth token status check

* feat(cli): add mcp oauth subcommand structure

* feat(cli): implement mcp oauth login command

* fix(mcp-oauth): address cubic review — security, correctness, and test issues

- Remove @ts-nocheck from provider.ts, storage.ts, provider.test.ts
- Fix server resource leak on missing code/state (close + reject)
- Fix command injection in openBrowser (spawn array args, cross-platform)
- Mock McpOAuthProvider in login.test.ts for deterministic CI
- Recreate auth provider with merged scopes in step-up flow
- Add listAllTokens() for global status listing
- Fix logout to accept --server-url for correct token deletion
- Support both quoted and unquoted WWW-Authenticate params (RFC 2617)
- Save/restore OPENCODE_CONFIG_DIR in storage.test.ts
- Fix index.test.ts: vitest → bun:test

* fix(mcp-oauth): use explorer instead of cmd /c start on Windows to prevent shell injection

* fix(mcp-oauth): address remaining cubic review issues

- Add 5-minute timeout to provider callback server to prevent indefinite hangs
- Persist client registration from token storage across process restarts
- Require --server-url for logout to match token storage key format
- Use listTokensByHost for server-specific status lookups
- Fix callback-server test to handle promise rejection ordering
- Fix provider test port expectations (8912 → 19877)
- Fix cli-guide.md duplicate Section 7 numbering
- Fix manager test for login-on-missing-tokens behavior

* fix(mcp-oauth): address final review issues

- P1: Redact token values in status.ts output to prevent credential leakage
- P2: Read OAuth error response body before throwing in token exchange
- Test: Fix mcp-oauth doctor test to use epoch seconds (not milliseconds)

---------

Co-authored-by: justsisyphus <justsisyphus@users.noreply.github.com>
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-01-29 19:48:36 +09:00
YeonGyu-Kim
a94fbadd57 Migrate LSP client to vscode-jsonrpc for improved stability (#1095)
* refactor(lsp): migrate to vscode-jsonrpc for improved stability

Replace custom JSON-RPC implementation with vscode-jsonrpc library.
Use MessageConnection with StreamMessageReader/Writer.
Implement Bun↔Node stream bridges for compatibility.
Preserve all existing functionality (warmup, cleanup, capabilities).
Net reduction of ~60 lines while improving protocol handling.

* fix(lsp): clear timeout on successful response to prevent unhandled rejections

---------

Co-authored-by: justsisyphus <justsisyphus@users.noreply.github.com>
2026-01-29 19:48:28 +09:00
YeonGyu-Kim
23b49c4a5c fix: expand override.category and explicit reasoningEffort priority (#1219) (#1235)
* fix: expand override.category and explicit reasoningEffort priority (#1219)

Two bugs fixed:

1. createBuiltinAgents(): override.category was never expanded into concrete
   config properties (model, variant, reasoningEffort, etc.). Added
   applyCategoryOverride() helper and applied it in the standard agent loop,
   Sisyphus path, and Atlas path.

2. Prometheus config-handler: reasoningEffort/textVerbosity/thinking from
   direct override now use explicit priority chains (direct > category)
   matching the existing variant pattern, instead of relying on spread
   ordering.

Priority order (highest to lowest):
  1. Direct override properties
  2. Override category properties
  3. Resolved variant from model fallback chain
  4. Factory base defaults

Closes #1219

* fix: use undefined check for thinking to allow explicit false
2026-01-29 19:46:34 +09:00
YeonGyu-Kim
b4973954e3 fix(background-agent): prevent zombie processes by aborting sessions on shutdown (#1240) (#1243)
- BackgroundManager.shutdown() now aborts all running child sessions via
  client.session.abort() before clearing state, preventing orphaned
  opencode processes when parent exits
- Add onShutdown callback to BackgroundManager constructor, used to
  trigger TmuxSessionManager.cleanup() on process exit signals
- Interactive bash session hook now aborts tracked subagent opencode
  sessions when killing tmux sessions (defense-in-depth)
- Add 4 tests verifying shutdown abort behavior and callback invocation

Closes #1240
2026-01-29 18:29:47 +09:00
github-actions[bot]
6d50fbe563 @Lynricsy has signed the CLA in code-yeongyu/oh-my-opencode#1241 2026-01-29 09:00:40 +00:00
YeonGyu-Kim
9850dd0f6e fix(test): align agent tests with connected-providers-cache fallback behavior (#1227)
Tests in utils.test.ts were written before bffa1ad introduced
connected-providers-cache fallback in resolveModelWithFallback.
Update assertions to match the new resolution path:
- Oracle resolves to openai/gpt-5.2 via cache (not systemDefault)
- Agents are created via cache fallback even without systemDefaultModel
2026-01-29 11:47:17 +09:00
YeonGyu-Kim
34aaef2219 fix(delegate-task): pass registered agent model explicitly for subagent_type (#1225)
When delegate_task uses subagent_type, extract the matched agent's model
object and pass it explicitly to session.prompt/manager.launch. This
ensures the model is always in the correct object format regardless of
how OpenCode handles string→object conversion for plugin-registered
agents.

Closes #1225
2026-01-29 11:27:07 +09:00
Mike
faca80caa9 fix(start-work): prevent overwriting session agent if already set; inherit parent model for subagent types (#1201)
* fix(start-work): prevent overwriting session agent if already set; inherit parent model for subagent types

* fix(model): include variant in StoredMessage model structure for better context propagation

* fix(injector): include variant in model structure for hook message injection
2026-01-29 09:30:37 +09:00
SUHO LEE
0c3fbd724b fix(model-resolver): respect UI model selection in agent initialization (#1158)
- Add uiSelectedModel parameter to resolveModelWithFallback()
- Update model resolution priority: UI Selection → Config Override → Fallback → System Default
- Pass config.model as uiSelectedModel in createBuiltinAgents()
- Fix ProviderModelNotFoundError when model is unset in config but selected in UI
2026-01-29 09:30:35 +09:00
Srijan Guchhait
c7455708f8 docs: Add missing configuration options to configurations.md (#1186)
- Add disabled_commands section with available commands
- Add comment_checker configuration (custom_prompt)
- Add notification configuration (force_enable)
- Add sisyphus tasks & swarm configuration sections
- Add staleTimeoutMs to background_task section
- Add dynamic_context_pruning to experimental section with full documentation
- Extend skills configuration with advanced options (sources, custom skills)
- Extend agents configuration with missing options (category, variant, maxTokens, thinking, reasoningEffort, textVerbosity, providerOptions)
- Extend categories configuration with missing options (description, is_unstable_agent)
- Extend LSP configuration with missing server options (env, initialization, disabled) and detailed examples
- Add missing hooks (auto-slash-command, sisyphus-junior-notepad, start-work) to hooks list
- Update available agents list to include all agents from schema

Co-authored-by: GitHub Actions <actions@github.com>
2026-01-29 09:30:32 +09:00
Peïo Thibault
bffa1ad43d fix(model-resolver): use connected providers cache when model cache is empty (#1227)
- Remove resolved.model from userModel in tools.ts (was bypassing fallback chain)
- Use connected providers cache in model-resolver when availableModels is empty
- Allows proper provider selection (e.g., github-copilot instead of google)
2026-01-29 09:30:19 +09:00
github-actions[bot]
6560dedd4c @mrdavidlaing has signed the CLA in code-yeongyu/oh-my-opencode#1226 2026-01-28 19:51:45 +00:00
sisyphus-dev-ai
b7e32a99f2 chore: changes by sisyphus-dev-ai 2026-01-28 16:51:21 +00:00
github-actions[bot]
a06e656565 release: v3.1.6 2026-01-28 16:15:27 +00:00
justsisyphus
30ed086c40 fix(delegate-task): use category default model when availableModels is empty 2026-01-29 01:11:42 +09:00
justsisyphus
7c15b06da7 fix(test): update tests to reflect new model-resolver behavior 2026-01-29 00:54:16 +09:00
justsisyphus
0e7ee2ac30 chore: remove noisy console.warn for AGENTS.md auto-disable 2026-01-29 00:46:16 +09:00
justsisyphus
ca93d2f0fe fix(model-resolver): skip fallback chain when model availability cannot be verified
When model cache is empty, the fallback chain resolution was blindly
trusting connected providers without verifying if the model actually
exists. This caused errors when a provider (e.g., opencode) was marked
as connected but didn't have the requested model (e.g., claude-haiku-4-5).

Now skips fallback chain entirely when model cache is unavailable and
falls through to system default, letting OpenCode handle the resolution.
2026-01-29 00:15:57 +09:00
YeonGyu-Kim
3ab4529bc7 fix(look-at): handle JSON parse errors from session.prompt gracefully (#1216)
When multimodal-looker agent returns empty/malformed response, the SDK
throws 'JSON Parse error: Unexpected EOF'. This commit adds try-catch
around session.prompt() to provide user-friendly error message with
troubleshooting guidance.

- Add error handling for JSON parse errors with detailed guidance
- Add error handling for generic prompt failures
- Add test cases for both error scenarios
2026-01-28 23:58:01 +09:00
github-actions[bot]
9d3e152b19 @KennyDizi has signed the CLA in code-yeongyu/oh-my-opencode#1214 2026-01-28 14:26:21 +00:00
github-actions[bot]
68c8f3dda7 release: v3.1.5 2026-01-28 14:15:42 +00:00
justsisyphus
03f6e72c9b refactor(ultrawork): replace prometheus with plan agent, add parallel task graph output
- Change all prometheus references to plan agent in ultrawork mode
- Add MANDATORY OUTPUT section to ULTRAWORK_PLANNER_SECTION:
  - Parallel Execution Waves structure
  - Dependency Matrix format
  - TODO List with category + skills + parallel group
  - Agent Dispatch Summary table
- Plan agent now outputs parallel task graphs for orchestrator execution
2026-01-28 23:09:51 +09:00
justsisyphus
4fd9f0fd04 refactor(agents): enforce zero user intervention in QA/acceptance criteria
- Prometheus: rename 'Manual QA' to 'Automated Verification Only'
- Prometheus: add explicit ZERO USER INTERVENTION principle
- Prometheus: replace placeholder examples with concrete executable commands
- Metis: add QA automation directives in output format
- Metis: strengthen CRITICAL RULES to forbid user-intervention criteria
2026-01-28 23:00:55 +09:00
github-actions[bot]
4413336724 @youming-ai has signed the CLA in code-yeongyu/oh-my-opencode#1203 2026-01-28 13:04:28 +00:00
Doyoon Kwon
895f366a11 docs: add Ollama streaming NDJSON issue guide and workaround (#1197)
* docs: add Ollama streaming NDJSON issue troubleshooting guide

- Document problem: JSON Parse error when using Ollama with stream: true
- Explain root cause: NDJSON vs single JSON object mismatch
- Provide 3 solutions: disable streaming, avoid tool agents, wait for SDK fix
- Include NDJSON parsing code example for SDK maintainers
- Add curl testing command for verification
- Link to issue #1124 and Ollama API docs

Fixes #1124

* docs: add Ollama provider configuration with streaming workaround

- Add Ollama Provider section to configurations.md
- Document stream: false requirement for Ollama
- Explain NDJSON vs single JSON mismatch
- Provide supported models table (qwen3-coder, ministral-3, lfm2.5-thinking)
- Add troubleshooting steps and curl test command
- Link to troubleshooting guide

feat: add NDJSON parser utility for Ollama streaming responses

- Create src/shared/ollama-ndjson-parser.ts
- Implement parseOllamaStreamResponse() for merging NDJSON lines
- Implement isNDJSONResponse() for format detection
- Add TypeScript interfaces for Ollama message structures
- Include JSDoc with usage examples
- Handle edge cases: malformed lines, stats aggregation

This utility can be contributed to Claude Code SDK for proper NDJSON support.

Related to #1124

* fix: use logger instead of console, remove trailing whitespace

- Replace console.warn with log() from shared/logger
- Remove trailing whitespace from troubleshooting guide
- Ensure TypeScript compatibility
2026-01-28 19:01:33 +09:00
YeonGyu-Kim
acc19fcd41 feat(hooks): auto-disable directory-agents-injector for OpenCode 1.1.37+ native support (#1204)
* feat(delegate-task): add prometheus self-delegation block and delegate_task permission

- Block prometheus from delegating to itself via delegate_task
- Grant delegate_task permission to prometheus when called as subagent
- Other subagents still have delegate_task disabled

* feat(version): add OPENCODE_NATIVE_AGENTS_INJECTION_VERSION constant

* docs: add deprecation notes for directory-agents-injector

* feat(hooks): auto-disable directory-agents-injector for OpenCode 1.1.37+

---------

Co-authored-by: justsisyphus <justsisyphus@users.noreply.github.com>
2026-01-28 18:46:51 +09:00
justsisyphus
68e0a32183 chore(issue-templates): add English language requirement checkbox 2026-01-28 18:24:15 +09:00
justsisyphus
dee89c1556 feat(delegate-task): add prometheus self-delegation block and delegate_task permission
- Block prometheus from delegating to itself via delegate_task
- Grant delegate_task permission to prometheus when called as subagent
- Other subagents still have delegate_task disabled
2026-01-28 18:24:15 +09:00
github-actions[bot]
315c75c51e @rooftop-Owl has signed the CLA in code-yeongyu/oh-my-opencode#1197 2026-01-28 08:47:09 +00:00
YeonGyu-Kim
3dd80889a5 fix(tools): add permission field to session.create() for consistency (#1192) (#1199)
- Add permission field to look_at and call_omo_agent session.create()
- Match pattern used in delegate_task and background-agent
- Add better error messages for Unauthorized failures
- Provide actionable guidance in error messages

This addresses potential session creation failures by ensuring
consistent session configuration across all tools that create
child sessions.
2026-01-28 17:35:25 +09:00
Sisyphus
8f6ed5b20f fix(hooks): add null guard for tool.execute.after output (#1054)
/review command and some Claude Code built-in commands trigger
tool.execute.after hooks with undefined output, causing crashes
when accessing output.metadata or output.output.

Fixes #1035

Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
2026-01-28 16:26:40 +09:00
TheEpTic
01500f1ebe Fix: prevent system-reminder tags from triggering mode keywords (#1155)
Automated system messages with <system-reminder> tags were incorrectly
triggering [search-mode], [analyze-mode], and other keyword modes when
they contained words like "search", "find", "explore", etc.

Changes:
- Add removeSystemReminders() to strip <system-reminder> content before keyword detection
- Add hasSystemReminder() utility function
- Update keyword-detector to clean text before pattern matching
- Add comprehensive test coverage for system-reminder filtering

Fixes issue where automated system notifications caused agents to
incorrectly enter MAXIMUM SEARCH EFFORT mode.

Co-authored-by: TheEpTic <git@eptic.me>
2026-01-28 16:26:37 +09:00
Thanh Nguyen
48f6c5e06d fix(skill): support YAML array format for allowed-tools field (#1163)
Fixes #1021

The allowed-tools field in skill frontmatter now supports both formats:
- Space-separated string: 'allowed-tools: Read Write Edit Bash'
- YAML array: 'allowed-tools: [Read, Write, Edit, Bash]'
- Multi-line YAML array format also works

Previously, skills using YAML array format would silently fail to parse,
causing them to not appear in the <available_skills> list.

Changes:
- Updated parseAllowedTools() in loader.ts, async-loader.ts, and merger.ts
  to handle both string and string[] types
- Updated SkillMetadata type to accept string | string[] for allowed-tools
- Added 4 test cases covering all allowed-tools formats
2026-01-28 16:26:34 +09:00
Moha Abdi
3e32afe646 fix(agent-variant): resolve variant based on current model, not static config (#1179) 2026-01-28 16:26:31 +09:00
Xiaoya Wang
d11c4a1f81 fix: guard JSON.parse(result.stdout) with || "{}" fallback in hook handlers (#1191)
Co-authored-by: wangxiaoya.2000 <wangxiaoya.2000@bytedance.com>
2026-01-28 16:26:28 +09:00
145 changed files with 8919 additions and 929 deletions

View File

@@ -14,6 +14,8 @@ body:
label: Prerequisites
description: Please confirm the following before submitting
options:
- label: I will write this issue in English (see our [Language Policy](https://github.com/code-yeongyu/oh-my-opencode/blob/dev/CONTRIBUTING.md#language-policy))
required: true
- label: I have searched existing issues to avoid duplicates
required: true
- label: I am using the latest version of oh-my-opencode

View File

@@ -14,6 +14,8 @@ body:
label: Prerequisites
description: Please confirm the following before submitting
options:
- label: I will write this issue in English (see our [Language Policy](https://github.com/code-yeongyu/oh-my-opencode/blob/dev/CONTRIBUTING.md#language-policy))
required: true
- label: I have searched existing issues and discussions to avoid duplicates
required: true
- label: This feature request is specific to oh-my-opencode (not OpenCode core)

View File

@@ -14,6 +14,8 @@ body:
label: Prerequisites
description: Please confirm the following before submitting
options:
- label: I will write this issue in English (see our [Language Policy](https://github.com/code-yeongyu/oh-my-opencode/blob/dev/CONTRIBUTING.md#language-policy))
required: true
- label: I have searched existing issues and discussions
required: true
- label: I have read the [documentation](https://github.com/code-yeongyu/oh-my-opencode#readme)

View File

@@ -28,16 +28,20 @@ permissions:
id-token: write
jobs:
publish-platform:
# Use windows-latest for Windows to avoid cross-compilation segfault (oven-sh/bun#18416)
# Fixes: #873, #844
# =============================================================================
# Job 1: Build binaries for all platforms
# - Windows builds on windows-latest (avoid bun cross-compile segfault)
# - All other platforms build on ubuntu-latest
# - Uploads compressed artifacts for the publish job
# =============================================================================
build:
runs-on: ${{ matrix.platform == 'windows-x64' && 'windows-latest' || 'ubuntu-latest' }}
defaults:
run:
shell: bash
strategy:
fail-fast: false
max-parallel: 2
max-parallel: 7
matrix:
platform: [darwin-arm64, darwin-x64, linux-x64, linux-arm64, linux-x64-musl, linux-arm64-musl, windows-x64]
steps:
@@ -47,11 +51,6 @@ jobs:
with:
bun-version: latest
- uses: actions/setup-node@v4
with:
node-version: "24"
registry-url: "https://registry.npmjs.org"
- name: Install dependencies
run: bun install
env:
@@ -63,15 +62,20 @@ jobs:
PKG_NAME="oh-my-opencode-${{ matrix.platform }}"
VERSION="${{ inputs.version }}"
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://registry.npmjs.org/${PKG_NAME}/${VERSION}")
# Convert platform name for output (replace - with _)
PLATFORM_KEY="${{ matrix.platform }}"
PLATFORM_KEY="${PLATFORM_KEY//-/_}"
if [ "$STATUS" = "200" ]; then
echo "skip=true" >> $GITHUB_OUTPUT
echo "skip_${PLATFORM_KEY}=true" >> $GITHUB_OUTPUT
echo "✓ ${PKG_NAME}@${VERSION} already published"
else
echo "skip=false" >> $GITHUB_OUTPUT
echo "skip_${PLATFORM_KEY}=false" >> $GITHUB_OUTPUT
echo "→ ${PKG_NAME}@${VERSION} needs publishing"
fi
- name: Update version
- name: Update version in package.json
if: steps.check.outputs.skip != 'true'
run: |
VERSION="${{ inputs.version }}"
@@ -99,15 +103,109 @@ jobs:
fi
bun build src/cli/index.ts --compile --minify --target=$TARGET --outfile=$OUTPUT
echo "Built binary:"
ls -lh "$OUTPUT"
- name: Compress binary
if: steps.check.outputs.skip != 'true'
run: |
PLATFORM="${{ matrix.platform }}"
cd packages/${PLATFORM}
if [ "$PLATFORM" = "windows-x64" ]; then
# Windows: use 7z (pre-installed on windows-latest)
7z a -tzip ../../binary-${PLATFORM}.zip bin/ package.json
else
# Unix: use tar.gz
tar -czvf ../../binary-${PLATFORM}.tar.gz bin/ package.json
fi
cd ../..
echo "Compressed artifact:"
ls -lh binary-${PLATFORM}.*
- name: Upload artifact
if: steps.check.outputs.skip != 'true'
uses: actions/upload-artifact@v4
with:
name: binary-${{ matrix.platform }}
path: |
binary-${{ matrix.platform }}.tar.gz
binary-${{ matrix.platform }}.zip
retention-days: 1
if-no-files-found: error
# =============================================================================
# Job 2: Publish all platforms using OIDC/Provenance
# - Runs on ubuntu-latest for ALL platforms (just downloading artifacts)
# - Uses npm Trusted Publishing (OIDC) - no NODE_AUTH_TOKEN needed
# - Fresh OIDC token at publish time avoids timeout issues
# =============================================================================
publish:
needs: build
runs-on: ubuntu-latest
strategy:
fail-fast: false
max-parallel: 2
matrix:
platform: [darwin-arm64, darwin-x64, linux-x64, linux-arm64, linux-x64-musl, linux-arm64-musl, windows-x64]
steps:
- name: Check if already published
id: check
run: |
PKG_NAME="oh-my-opencode-${{ matrix.platform }}"
VERSION="${{ inputs.version }}"
STATUS=$(curl -s -o /dev/null -w "%{http_code}" "https://registry.npmjs.org/${PKG_NAME}/${VERSION}")
if [ "$STATUS" = "200" ]; then
echo "skip=true" >> $GITHUB_OUTPUT
echo "✓ ${PKG_NAME}@${VERSION} already published, skipping"
else
echo "skip=false" >> $GITHUB_OUTPUT
echo "→ ${PKG_NAME}@${VERSION} will be published"
fi
- name: Download artifact
if: steps.check.outputs.skip != 'true'
uses: actions/download-artifact@v4
with:
name: binary-${{ matrix.platform }}
path: .
- name: Extract artifact
if: steps.check.outputs.skip != 'true'
run: |
PLATFORM="${{ matrix.platform }}"
mkdir -p packages/${PLATFORM}
if [ "$PLATFORM" = "windows-x64" ]; then
unzip binary-${PLATFORM}.zip -d packages/${PLATFORM}/
else
tar -xzvf binary-${PLATFORM}.tar.gz -C packages/${PLATFORM}/
fi
echo "Extracted contents:"
ls -la packages/${PLATFORM}/
ls -la packages/${PLATFORM}/bin/
- uses: actions/setup-node@v4
if: steps.check.outputs.skip != 'true'
with:
node-version: "24"
registry-url: "https://registry.npmjs.org"
- name: Publish ${{ matrix.platform }}
if: steps.check.outputs.skip != 'true'
run: |
cd packages/${{ matrix.platform }}
TAG_ARG=""
if [ -n "${{ inputs.dist_tag }}" ]; then
TAG_ARG="--tag ${{ inputs.dist_tag }}"
fi
npm publish --access public $TAG_ARG
npm publish --access public --provenance $TAG_ARG
env:
NPM_CONFIG_PROVENANCE: false
NODE_AUTH_TOKEN: ${{ secrets.NODE_AUTH_TOKEN }}
NPM_CONFIG_PROVENANCE: true
timeout-minutes: 15

1
.gitignore vendored
View File

@@ -33,3 +33,4 @@ yarn.lock
test-injection/
notepad.md
oauth-success.html
.188e87dbff6e7fd9-00000000.bun-build

View File

@@ -98,13 +98,13 @@ oh-my-opencode/
| Agent | Model | Purpose |
|-------|-------|---------|
| Sisyphus | anthropic/claude-opus-4-5 | Primary orchestrator |
| Atlas | anthropic/claude-opus-4-5 | Master orchestrator |
| Sisyphus | anthropic/claude-opus-4-5 | Primary orchestrator (fallback: kimi-k2.5 → glm-4.7 → gpt-5.2-codex → gemini-3-pro) |
| Atlas | anthropic/claude-sonnet-4-5 | Master orchestrator (fallback: kimi-k2.5 → gpt-5.2) |
| oracle | openai/gpt-5.2 | Consultation, debugging |
| librarian | opencode/big-pickle | Docs, GitHub search |
| explore | opencode/gpt-5-nano | Fast codebase grep |
| librarian | zai-coding-plan/glm-4.7 | Docs, GitHub search (fallback: glm-4.7-free) |
| explore | anthropic/claude-haiku-4-5 | Fast codebase grep (fallback: gpt-5-mini → gpt-5-nano) |
| multimodal-looker | google/gemini-3-flash | PDF/image analysis |
| Prometheus | anthropic/claude-opus-4-5 | Strategic planning |
| Prometheus | anthropic/claude-opus-4-5 | Strategic planning (fallback: kimi-k2.5 → gpt-5.2) |
## COMMANDS

View File

@@ -189,7 +189,7 @@ Windows から Linux に初めて乗り換えた時のこと、自分の思い
- Oracle: 設計、デバッグ (GPT 5.2 Medium)
- Frontend UI/UX Engineer: フロントエンド開発 (Gemini 3 Pro)
- Librarian: 公式ドキュメント、オープンソース実装、コードベース探索 (Claude Sonnet 4.5)
- Explore: 超高速コードベース探索 (Contextual Grep) (Grok Code)
- Explore: 超高速コードベース探索 (Contextual Grep) (Claude Haiku 4.5)
- Full LSP / AstGrep Support: 決定的にリファクタリングしましょう。
- Todo Continuation Enforcer: 途中で諦めたら、続行を強制します。これがシジフォスに岩を転がし続けさせる秘訣です。
- Comment Checker: AIが過剰なコメントを付けないようにします。シジフォスが生成したコードは、人間が書いたものと区別がつかないべきです。

View File

@@ -197,7 +197,7 @@ Hey please read this readme and tell me why it is different from other agent har
- Oracle: 디자인, 디버깅 (GPT 5.2 Medium)
- Frontend UI/UX Engineer: 프론트엔드 개발 (Gemini 3 Pro)
- Librarian: 공식 문서, 오픈 소스 구현, 코드베이스 탐색 (Claude Sonnet 4.5)
- Explore: 엄청나게 빠른 코드베이스 탐색 (Contextual Grep) (Grok Code)
- Explore: 엄청나게 빠른 코드베이스 탐색 (Contextual Grep) (Claude Haiku 4.5)
- 완전한 LSP / AstGrep 지원: 결정적으로 리팩토링합니다.
- TODO 연속 강제: 에이전트가 중간에 멈추면 계속하도록 강제합니다. **이것이 Sisyphus가 그 바위를 굴리게 하는 것입니다.**
- 주석 검사기: AI가 과도한 주석을 추가하는 것을 방지합니다. Sisyphus가 생성한 코드는 인간이 작성한 것과 구별할 수 없어야 합니다.

View File

@@ -196,7 +196,7 @@ Meet our main agent: Sisyphus (Opus 4.5 High). Below are the tools Sisyphus uses
- Oracle: Design, debugging (GPT 5.2 Medium)
- Frontend UI/UX Engineer: Frontend development (Gemini 3 Pro)
- Librarian: Official docs, open source implementations, codebase exploration (Claude Sonnet 4.5)
- Explore: Blazing fast codebase exploration (Contextual Grep) (Grok Code)
- Explore: Blazing fast codebase exploration (Contextual Grep) (Claude Haiku 4.5)
- Full LSP / AstGrep Support: Refactor decisively.
- Todo Continuation Enforcer: Forces the agent to continue if it quits halfway. **This is what keeps Sisyphus rolling that boulder.**
- Comment Checker: Prevents AI from adding excessive comments. Code generated by Sisyphus should be indistinguishable from human-written code.

View File

@@ -193,7 +193,7 @@
- Oracle设计、调试 (GPT 5.2 Medium)
- Frontend UI/UX Engineer前端开发 (Gemini 3 Pro)
- Librarian官方文档、开源实现、代码库探索 (Claude Sonnet 4.5)
- Explore极速代码库探索上下文感知 Grep(Grok Code)
- Explore极速代码库探索上下文感知 Grep(Claude Haiku 4.5)
- 完整 LSP / AstGrep 支持:果断重构。
- Todo 继续执行器:如果智能体中途退出,强制它继续。**这就是让 Sisyphus 继续推动巨石的关键。**
- 注释检查器:防止 AI 添加过多注释。Sisyphus 生成的代码应该与人类编写的代码无法区分。

View File

@@ -18,6 +18,7 @@
"jsonc-parser": "^3.3.1",
"picocolors": "^1.1.1",
"picomatch": "^4.0.2",
"vscode-jsonrpc": "^8.2.0",
"zod": "^4.1.8",
},
"devDependencies": {
@@ -27,13 +28,13 @@
"typescript": "^5.7.3",
},
"optionalDependencies": {
"oh-my-opencode-darwin-arm64": "3.1.2",
"oh-my-opencode-darwin-x64": "3.1.2",
"oh-my-opencode-linux-arm64": "3.1.2",
"oh-my-opencode-linux-arm64-musl": "3.1.2",
"oh-my-opencode-linux-x64": "3.1.2",
"oh-my-opencode-linux-x64-musl": "3.1.2",
"oh-my-opencode-windows-x64": "3.1.2",
"oh-my-opencode-darwin-arm64": "3.1.6",
"oh-my-opencode-darwin-x64": "3.1.6",
"oh-my-opencode-linux-arm64": "3.1.6",
"oh-my-opencode-linux-arm64-musl": "3.1.6",
"oh-my-opencode-linux-x64": "3.1.6",
"oh-my-opencode-linux-x64-musl": "3.1.6",
"oh-my-opencode-windows-x64": "3.1.6",
},
},
},
@@ -225,6 +226,20 @@
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
"oh-my-opencode-darwin-arm64": ["oh-my-opencode-darwin-arm64@3.1.6", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-KK+ptnkBigvDYbRtF/B5izEC4IoXDS8mAnRHWFBSCINhzQR2No6AtEcwijd6vKBPR+/r71ofq/8mTsIeb1PEVQ=="],
"oh-my-opencode-darwin-x64": ["oh-my-opencode-darwin-x64@3.1.6", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-UkPI/RUi7INarFasBUZ4Rous6RUQXsU2nr0V8KFJp+70END43D/96dDUwX+zmPtpDhD+DfWkejuwzqfkZJ2ZDQ=="],
"oh-my-opencode-linux-arm64": ["oh-my-opencode-linux-arm64@3.1.6", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-gvmvgh7WtTtcHiCbG7z43DOYfY/jrf2S6TX/jBMX2/e1AGkcLKwz30NjGhZxeK5SyzxRVypgfZZK1IuriRgbdA=="],
"oh-my-opencode-linux-arm64-musl": ["oh-my-opencode-linux-arm64-musl@3.1.6", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-j3R76pmQ4HGVGFJUMMCeF/1lO3Jg7xFdpcBUKCeFh42N1jMgn1aeyxkAaJYB9RwCF/p6+P8B6gVDLCEDu2mxjA=="],
"oh-my-opencode-linux-x64": ["oh-my-opencode-linux-x64@3.1.6", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-VDdo0tHCOr5nm7ajd652u798nPNOLRSTcPOnVh6vIPddkZ+ujRke+enOKOw9Pd5e+4AkthqHBwFXNm2VFgnEKg=="],
"oh-my-opencode-linux-x64-musl": ["oh-my-opencode-linux-x64-musl@3.1.6", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-hBG/dhsr8PZelUlYsPBruSLnelB9ocB7H92I+S9svTpDVo67rAmXOoR04twKQ9TeCO4ShOa6hhMhbQnuI8fgNw=="],
"oh-my-opencode-windows-x64": ["oh-my-opencode-windows-x64@3.1.6", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-c8Awp03p2DsbS0G589nzveRCeJPgJRJ0vQrha4ChRmmo31Qc5OSmJ5xuMaF8L4nM+/trbTgAQMFMtCMLgtC8IQ=="],
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
@@ -289,6 +304,8 @@
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
"vscode-jsonrpc": ["vscode-jsonrpc@8.2.1", "", {}, "sha512-kdjOSJ2lLIn7r1rtrMbbNCHjyMPfRnowdKjBQ+mGq6NAW5QY2bEZC/khaC5OR8svbbjvLEaIXkOq45e2X9BIbQ=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],

View File

@@ -23,6 +23,7 @@ A Category is an agent configuration preset optimized for specific domains.
|----------|---------------|-----------|
| `visual-engineering` | `google/gemini-3-pro` | Frontend, UI/UX, design, styling, animation |
| `ultrabrain` | `openai/gpt-5.2-codex` (xhigh) | Deep logical reasoning, complex architecture decisions requiring extensive analysis |
| `deep` | `openai/gpt-5.2-codex` (medium) | Goal-oriented autonomous problem-solving. Thorough research before action. For hairy problems requiring deep understanding. |
| `artistry` | `google/gemini-3-pro` (max) | Highly creative/artistic tasks, novel ideas |
| `quick` | `anthropic/claude-haiku-4-5` | Trivial tasks - single file changes, typo fixes, simple modifications |
| `unspecified-low` | `anthropic/claude-sonnet-4-5` | Tasks that don't fit other categories, low effort required |

View File

@@ -134,7 +134,41 @@ bunx oh-my-opencode run [prompt]
---
## 6. `auth` - Authentication Management
## 6. `mcp oauth` - MCP OAuth Management
Manages OAuth 2.1 authentication for remote MCP servers.
### Usage
```bash
# Login to an OAuth-protected MCP server
bunx oh-my-opencode mcp oauth login <server-name> --server-url https://api.example.com
# Login with explicit client ID and scopes
bunx oh-my-opencode mcp oauth login my-api --server-url https://api.example.com --client-id my-client --scopes "read,write"
# Remove stored OAuth tokens
bunx oh-my-opencode mcp oauth logout <server-name>
# Check OAuth token status
bunx oh-my-opencode mcp oauth status [server-name]
```
### Options
| Option | Description |
|--------|-------------|
| `--server-url <url>` | MCP server URL (required for login) |
| `--client-id <id>` | OAuth client ID (optional if server supports Dynamic Client Registration) |
| `--scopes <scopes>` | Comma-separated OAuth scopes |
### Token Storage
Tokens are stored in `~/.config/opencode/mcp-oauth.json` with `0600` permissions (owner read/write only). Key format: `{serverHost}/{resource}`.
---
## 7. `auth` - Authentication Management
Manages Google Antigravity OAuth authentication. Required for using Gemini models.
@@ -153,7 +187,7 @@ bunx oh-my-opencode auth status
---
## 7. Configuration Files
## 8. Configuration Files
The CLI searches for configuration files in the following locations (in priority order):
@@ -183,7 +217,7 @@ Configuration files support **JSONC (JSON with Comments)** format. You can use c
---
## 8. Troubleshooting
## 9. Troubleshooting
### "OpenCode version too old" Error
@@ -213,7 +247,7 @@ bunx oh-my-opencode doctor --category authentication
---
## 9. Non-Interactive Mode
## 10. Non-Interactive Mode
Use the `--no-tui` option for CI/CD environments.
@@ -227,7 +261,7 @@ bunx oh-my-opencode doctor --json > doctor-report.json
---
## 10. Developer Information
## 11. Developer Information
### CLI Structure

View File

@@ -85,6 +85,66 @@ When both `oh-my-opencode.jsonc` and `oh-my-opencode.json` files exist, `.jsonc`
**Recommended**: For Google Gemini authentication, install the [`opencode-antigravity-auth`](https://github.com/NoeFabris/opencode-antigravity-auth) plugin (`@latest`). It provides multi-account load balancing, variant-based thinking levels, dual quota system (Antigravity + Gemini CLI), and active maintenance. See [Installation > Google Gemini](docs/guide/installation.md#google-gemini-antigravity-oauth).
## Ollama Provider
**IMPORTANT**: When using Ollama as a provider, you **must** disable streaming to avoid JSON parsing errors.
### Required Configuration
```json
{
"agents": {
"explore": {
"model": "ollama/qwen3-coder",
"stream": false
}
}
}
```
### Why `stream: false` is Required
Ollama returns NDJSON (newline-delimited JSON) when streaming is enabled, but Claude Code SDK expects a single JSON object. This causes `JSON Parse error: Unexpected EOF` when agents attempt tool calls.
**Example of the problem**:
```json
// Ollama streaming response (NDJSON - multiple lines)
{"message":{"tool_calls":[...]}, "done":false}
{"message":{"content":""}, "done":true}
// Claude Code SDK expects (single JSON object)
{"message":{"tool_calls":[...], "content":""}, "done":true}
```
### Supported Models
Common Ollama models that work with oh-my-opencode:
| Model | Best For | Configuration |
|-------|----------|---------------|
| `ollama/qwen3-coder` | Code generation, build fixes | `{"model": "ollama/qwen3-coder", "stream": false}` |
| `ollama/ministral-3:14b` | Exploration, codebase search | `{"model": "ollama/ministral-3:14b", "stream": false}` |
| `ollama/lfm2.5-thinking` | Documentation, writing | `{"model": "ollama/lfm2.5-thinking", "stream": false}` |
### Troubleshooting
If you encounter `JSON Parse error: Unexpected EOF`:
1. **Verify `stream: false` is set** in your agent configuration
2. **Check Ollama is running**: `curl http://localhost:11434/api/tags`
3. **Test with curl**:
```bash
curl -s http://localhost:11434/api/chat \
-d '{"model": "qwen3-coder", "messages": [{"role": "user", "content": "Hello"}], "stream": false}'
```
4. **See detailed troubleshooting**: [docs/troubleshooting/ollama-streaming-issue.md](troubleshooting/ollama-streaming-issue.md)
### Future SDK Fix
The proper long-term fix requires Claude Code SDK to parse NDJSON responses correctly. Until then, use `stream: false` as a workaround.
**Tracking**: https://github.com/code-yeongyu/oh-my-opencode/issues/1124
## Agents
Override built-in agent settings:
@@ -103,7 +163,39 @@ Override built-in agent settings:
}
```
Each agent supports: `model`, `temperature`, `top_p`, `prompt`, `prompt_append`, `tools`, `disable`, `description`, `mode`, `color`, `permission`.
Each agent supports: `model`, `temperature`, `top_p`, `prompt`, `prompt_append`, `tools`, `disable`, `description`, `mode`, `color`, `permission`, `category`, `variant`, `maxTokens`, `thinking`, `reasoningEffort`, `textVerbosity`, `providerOptions`.
### Additional Agent Options
| Option | Type | Description |
| ------------------- | ------- | ----------------------------------------------------------------------------------------------- |
| `category` | string | Category name to inherit model and other settings from category defaults |
| `variant` | string | Model variant (e.g., `max`, `high`, `medium`, `low`, `xhigh`) |
| `maxTokens` | number | Maximum tokens for response. Passed directly to OpenCode SDK. |
| `thinking` | object | Extended thinking configuration for Anthropic models. See [Thinking Options](#thinking-options) below. |
| `reasoningEffort` | string | OpenAI reasoning effort level. Values: `low`, `medium`, `high`, `xhigh`. |
| `textVerbosity` | string | Text verbosity level. Values: `low`, `medium`, `high`. |
| `providerOptions` | object | Provider-specific options passed directly to OpenCode SDK. |
#### Thinking Options (Anthropic)
```json
{
"agents": {
"oracle": {
"thinking": {
"type": "enabled",
"budgetTokens": 200000
}
}
}
}
```
| Option | Type | Default | Description |
| ------------- | ------- | ------- | -------------------------------------------- |
| `type` | string | - | `enabled` or `disabled` |
| `budgetTokens`| number | - | Maximum budget tokens for extended thinking |
Use `prompt_append` to add extra instructions without replacing the default system prompt:
@@ -153,7 +245,7 @@ Or disable via `disabled_agents` in `~/.config/opencode/oh-my-opencode.json` or
}
```
Available agents: `oracle`, `librarian`, `explore`, `multimodal-looker`
Available agents: `sisyphus`, `prometheus`, `oracle`, `librarian`, `explore`, `multimodal-looker`, `metis`, `momus`, `atlas`
## Built-in Skills
@@ -172,6 +264,105 @@ Disable built-in skills via `disabled_skills` in `~/.config/opencode/oh-my-openc
Available built-in skills: `playwright`, `agent-browser`, `git-master`
## Skills Configuration
Configure advanced skills settings including custom skill sources, enabling/disabling specific skills, and defining custom skills.
```json
{
"skills": {
"sources": [
{ "path": "./custom-skills", "recursive": true },
"https://example.com/skill.yaml"
],
"enable": ["my-custom-skill"],
"disable": ["other-skill"],
"my-skill": {
"description": "Custom skill description",
"template": "Custom prompt template",
"from": "source-file.ts",
"model": "custom/model",
"agent": "custom-agent",
"subtask": true,
"argument-hint": "usage hint",
"license": "MIT",
"compatibility": ">= 3.0.0",
"metadata": {
"author": "Your Name"
},
"allowed-tools": ["tool1", "tool2"]
}
}
}
```
### Sources
Load skills from local directories or remote URLs:
```json
{
"skills": {
"sources": [
{ "path": "./custom-skills", "recursive": true },
{ "path": "./single-skill.yaml" },
"https://example.com/skill.yaml",
"https://raw.githubusercontent.com/user/repo/main/skills/*"
]
}
}
```
| Option | Default | Description |
| ----------- | ------- | ---------------------------------------------- |
| `path` | - | Local file/directory path or remote URL |
| `recursive` | `false` | Recursively load from directory |
| `glob` | - | Glob pattern for file selection |
### Enable/Disable Skills
```json
{
"skills": {
"enable": ["skill-1", "skill-2"],
"disable": ["disabled-skill"]
}
}
```
### Custom Skill Definition
Define custom skills directly in your config:
| Option | Default | Description |
| ---------------- | ------- | ------------------------------------------------------------------------------------ |
| `description` | - | Human-readable description of the skill |
| `template` | - | Custom prompt template for the skill |
| `from` | - | Source file to load template from |
| `model` | - | Override model for this skill |
| `agent` | - | Override agent for this skill |
| `subtask` | `false` | Whether to run as a subtask |
| `argument-hint` | - | Hint for how to use the skill |
| `license` | - | Skill license |
| `compatibility` | - | Required oh-my-opencode version compatibility |
| `metadata` | - | Additional metadata as key-value pairs |
| `allowed-tools` | - | Array of tools this skill is allowed to use |
**Example: Custom skill**
```json
{
"skills": {
"data-analyst": {
"description": "Specialized for data analysis tasks",
"template": "You are a data analyst. Focus on statistical analysis, visualization, and data interpretation.",
"model": "openai/gpt-5.2",
"allowed-tools": ["read", "bash", "lsp_diagnostics"]
}
}
}
```
## Browser Automation
Choose between two browser automation providers:
@@ -495,6 +686,7 @@ Configure concurrency limits for background agent tasks. This controls how many
{
"background_task": {
"defaultConcurrency": 5,
"staleTimeoutMs": 180000,
"providerConcurrency": {
"anthropic": 3,
"openai": 5,
@@ -511,6 +703,7 @@ Configure concurrency limits for background agent tasks. This controls how many
| Option | Default | Description |
| --------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------- |
| `defaultConcurrency` | - | Default maximum concurrent background tasks for all providers/models |
| `staleTimeoutMs` | `180000` | Stale timeout in milliseconds - interrupt tasks with no activity for this duration (minimum: 60000 = 1 minute) |
| `providerConcurrency` | - | Per-provider concurrency limits. Keys are provider names (e.g., `anthropic`, `openai`, `google`) |
| `modelConcurrency` | - | Per-model concurrency limits. Keys are full model names (e.g., `anthropic/claude-opus-4-5`). Overrides provider limits. |
@@ -632,7 +825,14 @@ Add your own categories or override built-in ones:
}
```
Each category supports: `model`, `temperature`, `top_p`, `maxTokens`, `thinking`, `reasoningEffort`, `textVerbosity`, `tools`, `prompt_append`, `variant`.
Each category supports: `model`, `temperature`, `top_p`, `maxTokens`, `thinking`, `reasoningEffort`, `textVerbosity`, `tools`, `prompt_append`, `variant`, `description`, `is_unstable_agent`.
### Additional Category Options
| Option | Type | Default | Description |
| ------------------ | ------- | ------- | --------------------------------------------------------------------------------------------------- |
| `description` | string | - | Human-readable description of the category's purpose. Shown in delegate_task prompt. |
| `is_unstable_agent`| boolean | `false` | Mark agent as unstable - forces background mode for monitoring. Auto-enabled for gemini models. |
## Model Resolution System
@@ -694,15 +894,15 @@ Each agent has a defined provider priority chain. The system tries providers in
| Agent | Model (no prefix) | Provider Priority Chain |
|-------|-------------------|-------------------------|
| **Sisyphus** | `claude-opus-4-5` | anthropic → github-copilot → opencode → antigravity → google |
| **oracle** | `gpt-5.2` | openai → anthropic → google → github-copilot → opencode |
| **librarian** | `big-pickle` | opencode → github-copilot → anthropic |
| **explore** | `gpt-5-nano` | anthropic → opencode |
| **multimodal-looker** | `gemini-3-flash` | google → openai → zai-coding-plan → anthropic → opencode |
| **Prometheus (Planner)** | `claude-opus-4-5` | anthropic → github-copilot → opencode → antigravity → google |
| **Metis (Plan Consultant)** | `claude-sonnet-4-5` | anthropic → github-copilot → opencode → antigravity → google |
| **Momus (Plan Reviewer)** | `claude-opus-4-5` | anthropic → github-copilot → opencode → antigravity → google |
| **Atlas** | `claude-sonnet-4-5` | anthropic → github-copilot → opencode → antigravity → google |
| **Sisyphus** | `claude-opus-4-5` | anthropic → kimi-for-coding → zai-coding-plan → openai → google |
| **oracle** | `gpt-5.2` | openai → google → anthropic |
| **librarian** | `glm-4.7` | zai-coding-plan → opencode → anthropic |
| **explore** | `claude-haiku-4-5` | anthropic → github-copilot → opencode |
| **multimodal-looker** | `gemini-3-flash` | google → openai → zai-coding-plan → kimi-for-coding → anthropic → opencode |
| **Prometheus (Planner)** | `claude-opus-4-5` | anthropic → kimi-for-coding → openai → google |
| **Metis (Plan Consultant)** | `claude-opus-4-5` | anthropic → kimi-for-coding → openai → google |
| **Momus (Plan Reviewer)** | `gpt-5.2` | openai → anthropic → google |
| **Atlas** | `claude-sonnet-4-5` | anthropic → kimi-for-coding → openai → google |
### Category Provider Chains
@@ -710,13 +910,14 @@ Categories follow the same resolution logic:
| Category | Model (no prefix) | Provider Priority Chain |
|----------|-------------------|-------------------------|
| **visual-engineering** | `gemini-3-pro` | google → openai → anthropic → github-copilot → opencode |
| **ultrabrain** | `gpt-5.2-codex` | openai → anthropic → google → github-copilot → opencode |
| **artistry** | `gemini-3-pro` | google → openai → anthropic → github-copilot → opencode |
| **quick** | `claude-haiku-4-5` | anthropic → github-copilot → opencode → antigravity → google |
| **unspecified-low** | `claude-sonnet-4-5` | anthropic → github-copilot → opencode → antigravity → google |
| **unspecified-high** | `claude-opus-4-5` | anthropic → github-copilot → opencode → antigravity → google |
| **writing** | `gemini-3-flash` | google → openai → anthropic → github-copilot → opencode |
| **visual-engineering** | `gemini-3-pro` | google → anthropic → zai-coding-plan |
| **ultrabrain** | `gpt-5.2-codex` | openai → google → anthropic |
| **deep** | `gpt-5.2-codex` | openai → anthropic → google |
| **artistry** | `gemini-3-pro` | google → anthropic → openai |
| **quick** | `claude-haiku-4-5` | anthropic → google → opencode |
| **unspecified-low** | `claude-sonnet-4-5` | anthropic → openai → google |
| **unspecified-high** | `claude-opus-4-5` | anthropic → openai → google |
| **writing** | `gemini-3-flash` | google → anthropic → zai-coding-plan → openai |
### Checking Your Configuration
@@ -766,10 +967,93 @@ Disable specific built-in hooks via `disabled_hooks` in `~/.config/opencode/oh-m
}
```
Available hooks: `todo-continuation-enforcer`, `context-window-monitor`, `session-recovery`, `session-notification`, `comment-checker`, `grep-output-truncator`, `tool-output-truncator`, `directory-agents-injector`, `directory-readme-injector`, `empty-task-response-detector`, `think-mode`, `anthropic-context-window-limit-recovery`, `rules-injector`, `background-notification`, `auto-update-checker`, `startup-toast`, `keyword-detector`, `agent-usage-reminder`, `non-interactive-env`, `interactive-bash-session`, `compaction-context-injector`, `thinking-block-validator`, `claude-code-hooks`, `ralph-loop`, `preemptive-compaction`
Available hooks: `todo-continuation-enforcer`, `context-window-monitor`, `session-recovery`, `session-notification`, `comment-checker`, `grep-output-truncator`, `tool-output-truncator`, `directory-agents-injector`, `directory-readme-injector`, `empty-task-response-detector`, `think-mode`, `anthropic-context-window-limit-recovery`, `rules-injector`, `background-notification`, `auto-update-checker`, `startup-toast`, `keyword-detector`, `agent-usage-reminder`, `non-interactive-env`, `interactive-bash-session`, `compaction-context-injector`, `thinking-block-validator`, `claude-code-hooks`, `ralph-loop`, `preemptive-compaction`, `auto-slash-command`, `sisyphus-junior-notepad`, `start-work`
**Note on `directory-agents-injector`**: This hook is **automatically disabled** when running on OpenCode 1.1.37+ because OpenCode now has native support for dynamically resolving AGENTS.md files from subdirectories (PR #10678). This prevents duplicate AGENTS.md injection. For older OpenCode versions, the hook remains active to provide the same functionality.
**Note on `auto-update-checker` and `startup-toast`**: The `startup-toast` hook is a sub-feature of `auto-update-checker`. To disable only the startup toast notification while keeping update checking enabled, add `"startup-toast"` to `disabled_hooks`. To disable all update checking features (including the toast), add `"auto-update-checker"` to `disabled_hooks`.
## Disabled Commands
Disable specific built-in commands via `disabled_commands` in `~/.config/opencode/oh-my-opencode.json` or `.opencode/oh-my-opencode.json`:
```json
{
"disabled_commands": ["init-deep", "start-work"]
}
```
Available commands: `init-deep`, `start-work`
## Comment Checker
Configure comment-checker hook behavior. The comment checker warns when excessive comments are added to code.
```json
{
"comment_checker": {
"custom_prompt": "Your custom warning message. Use {{comments}} placeholder for detected comments XML."
}
}
```
| Option | Default | Description |
| ------------- | ------- | -------------------------------------------------------------------------- |
| `custom_prompt` | - | Custom warning message to replace the default. Use `{{comments}}` placeholder. |
## Notification
Configure notification behavior for background task completion.
```json
{
"notification": {
"force_enable": true
}
}
```
| Option | Default | Description |
| -------------- | ------- | ---------------------------------------------------------------------------------------------- |
| `force_enable` | `false` | Force enable session-notification even if external notification plugins are detected. Default: `false`. |
## Sisyphus Tasks & Swarm
Configure Sisyphus Tasks and Swarm systems for advanced task management and multi-agent orchestration.
```json
{
"sisyphus": {
"tasks": {
"enabled": false,
"storage_path": ".sisyphus/tasks",
"claude_code_compat": false
},
"swarm": {
"enabled": false,
"storage_path": ".sisyphus/teams",
"ui_mode": "toast"
}
}
}
```
### Tasks Configuration
| Option | Default | Description |
| -------------------- | ------------------ | ------------------------------------------------------------------------- |
| `enabled` | `false` | Enable Sisyphus Tasks system |
| `storage_path` | `.sisyphus/tasks` | Storage path for tasks (relative to project root) |
| `claude_code_compat` | `false` | Enable Claude Code path compatibility mode |
### Swarm Configuration
| Option | Default | Description |
| -------------- | ------------------ | -------------------------------------------------------------- |
| `enabled` | `false` | Enable Sisyphus Swarm system for multi-agent orchestration |
| `storage_path` | `.sisyphus/teams` | Storage path for teams (relative to project root) |
| `ui_mode` | `toast` | UI mode: `toast` (notifications), `tmux` (panes), or `both` |
## MCPs
Exa, Context7 and grep.app MCP enabled by default.
@@ -811,6 +1095,38 @@ Add LSP servers via the `lsp` option in `~/.config/opencode/oh-my-opencode.json`
Each server supports: `command`, `extensions`, `priority`, `env`, `initialization`, `disabled`.
| Option | Type | Default | Description |
| -------------- | -------- | ------- | ---------------------------------------------------------------------- |
| `command` | array | - | Command to start the LSP server (executable + args) |
| `extensions` | array | - | File extensions this server handles (e.g., `[".ts", ".tsx"]`) |
| `priority` | number | - | Server priority when multiple servers match a file |
| `env` | object | - | Environment variables for the LSP server (key-value pairs) |
| `initialization`| object | - | Custom initialization options passed to the LSP server |
| `disabled` | boolean | `false` | Whether to disable this LSP server |
**Example with advanced options:**
```json
{
"lsp": {
"typescript-language-server": {
"command": ["typescript-language-server", "--stdio"],
"extensions": [".ts", ".tsx"],
"priority": 10,
"env": {
"NODE_OPTIONS": "--max-old-space-size=4096"
},
"initialization": {
"preferences": {
"includeInlayParameterNameHints": "all",
"includeInlayFunctionParameterTypeHints": true
}
}
}
}
}
```
## Experimental
Opt-in experimental features that may change or be removed in future versions. Use with caution.
@@ -820,7 +1136,29 @@ Opt-in experimental features that may change or be removed in future versions. U
"experimental": {
"truncate_all_tool_outputs": true,
"aggressive_truncation": true,
"auto_resume": true
"auto_resume": true,
"dynamic_context_pruning": {
"enabled": false,
"notification": "detailed",
"turn_protection": {
"enabled": true,
"turns": 3
},
"protected_tools": ["task", "todowrite", "lsp_rename"],
"strategies": {
"deduplication": {
"enabled": true
},
"supersede_writes": {
"enabled": true,
"aggressive": false
},
"purge_errors": {
"enabled": true,
"turns": 5
}
}
}
}
}
```
@@ -829,7 +1167,72 @@ Opt-in experimental features that may change or be removed in future versions. U
| --------------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `truncate_all_tool_outputs` | `false` | Truncates ALL tool outputs instead of just whitelisted tools (Grep, Glob, LSP, AST-grep). Tool output truncator is enabled by default - disable via `disabled_hooks`. |
| `aggressive_truncation` | `false` | When token limit is exceeded, aggressively truncates tool outputs to fit within limits. More aggressive than the default truncation behavior. Falls back to summarize/revert if insufficient. |
| `auto_resume` | `false` | Automatically resumes session after successful recovery from thinking block errors or thinking disabled violations. Extracts the last user message and continues. |
| `auto_resume` | `false` | Automatically resumes session after successful recovery from thinking block errors or thinking disabled violations. Extracts last user message and continues. |
| `dynamic_context_pruning` | See below | Dynamic context pruning configuration for managing context window usage automatically. See [Dynamic Context Pruning](#dynamic-context-pruning) below. |
### Dynamic Context Pruning
Dynamic context pruning automatically manages context window by intelligently pruning old tool outputs. This feature helps maintain performance in long sessions.
```json
{
"experimental": {
"dynamic_context_pruning": {
"enabled": false,
"notification": "detailed",
"turn_protection": {
"enabled": true,
"turns": 3
},
"protected_tools": ["task", "todowrite", "todoread", "lsp_rename", "session_read", "session_write", "session_search"],
"strategies": {
"deduplication": {
"enabled": true
},
"supersede_writes": {
"enabled": true,
"aggressive": false
},
"purge_errors": {
"enabled": true,
"turns": 5
}
}
}
}
}
```
| Option | Default | Description |
| ----------------- | ------- | ----------------------------------------------------------------------------------------- |
| `enabled` | `false` | Enable dynamic context pruning |
| `notification` | `detailed` | Notification level: `off`, `minimal`, or `detailed` |
| `turn_protection` | See below | Turn protection settings - prevent pruning recent tool outputs |
#### Turn Protection
| Option | Default | Description |
| --------- | ------- | ------------------------------------------------------------ |
| `enabled` | `true` | Enable turn protection |
| `turns` | `3` | Number of recent turns to protect from pruning (1-10) |
#### Protected Tools
Tools that should never be pruned (default):
```json
["task", "todowrite", "todoread", "lsp_rename", "session_read", "session_write", "session_search"]
```
#### Pruning Strategies
| Strategy | Option | Default | Description |
| ------------------- | ------------ | ------- | ---------------------------------------------------------------------------- |
| **deduplication** | `enabled` | `true` | Remove duplicate tool calls (same tool + same args) |
| **supersede_writes**| `enabled` | `true` | Prune write inputs when file subsequently read |
| | `aggressive` | `false` | Aggressive mode: prune any write if ANY subsequent read |
| **purge_errors** | `enabled` | `true` | Prune errored tool inputs after N turns |
| | `turns` | `5` | Number of turns before pruning errors (1-20) |
**Warning**: These features are experimental and may cause unexpected behavior. Enable only if you understand the implications.

View File

@@ -10,19 +10,19 @@ Oh-My-OpenCode provides 10 specialized AI agents. Each has distinct expertise, o
| Agent | Model | Purpose |
|-------|-------|---------|
| **Sisyphus** | `anthropic/claude-opus-4-5` | **The default orchestrator.** Plans, delegates, and executes complex tasks using specialized subagents with aggressive parallel execution. Todo-driven workflow with extended thinking (32k budget). |
| **Sisyphus** | `anthropic/claude-opus-4-5` | **The default orchestrator.** Plans, delegates, and executes complex tasks using specialized subagents with aggressive parallel execution. Todo-driven workflow with extended thinking (32k budget). Fallback: kimi-k2.5 → glm-4.7 → gpt-5.2-codex → gemini-3-pro. |
| **oracle** | `openai/gpt-5.2` | Architecture decisions, code review, debugging. Read-only consultation - stellar logical reasoning and deep analysis. Inspired by AmpCode. |
| **librarian** | `opencode/big-pickle` | Multi-repo analysis, documentation lookup, OSS implementation examples. Deep codebase understanding with evidence-based answers. Inspired by AmpCode. |
| **explore** | `opencode/gpt-5-nano` | Fast codebase exploration and contextual grep. Uses Gemini 3 Flash when Antigravity auth is configured, Haiku when Claude max20 is available, otherwise Grok. Inspired by Claude Code. |
| **multimodal-looker** | `google/gemini-3-flash` | Visual content specialist. Analyzes PDFs, images, diagrams to extract information. Saves tokens by having another agent process media. |
| **librarian** | `zai-coding-plan/glm-4.7` | Multi-repo analysis, documentation lookup, OSS implementation examples. Deep codebase understanding with evidence-based answers. Fallback: glm-4.7-free → claude-sonnet-4-5. |
| **explore** | `anthropic/claude-haiku-4-5` | Fast codebase exploration and contextual grep. Fallback: gpt-5-mini → gpt-5-nano. |
| **multimodal-looker** | `google/gemini-3-flash` | Visual content specialist. Analyzes PDFs, images, diagrams to extract information. Fallback: gpt-5.2 → glm-4.6v → kimi-k2.5 → claude-haiku-4-5 → gpt-5-nano. |
### Planning Agents
| Agent | Model | Purpose |
|-------|-------|---------|
| **Prometheus** | `anthropic/claude-opus-4-5` | Strategic planner with interview mode. Creates detailed work plans through iterative questioning. |
| **Metis** | `anthropic/claude-sonnet-4-5` | Plan consultant - pre-planning analysis. Identifies hidden intentions, ambiguities, and AI failure points. |
| **Momus** | `anthropic/claude-sonnet-4-5` | Plan reviewer - validates plans against clarity, verifiability, and completeness standards. |
| **Prometheus** | `anthropic/claude-opus-4-5` | Strategic planner with interview mode. Creates detailed work plans through iterative questioning. Fallback: kimi-k2.5 → gpt-5.2 → gemini-3-pro. |
| **Metis** | `anthropic/claude-opus-4-5` | Plan consultant - pre-planning analysis. Identifies hidden intentions, ambiguities, and AI failure points. Fallback: kimi-k2.5 → gpt-5.2 → gemini-3-pro. |
| **Momus** | `openai/gpt-5.2` | Plan reviewer - validates plans against clarity, verifiability, and completeness standards. Fallback: gpt-5.2 → claude-opus-4-5 → gemini-3-pro. |
### Invoking Agents
@@ -320,7 +320,7 @@ Hooks intercept and modify behavior at key points in the agent lifecycle.
| Hook | Event | Description |
|------|-------|-------------|
| **directory-agents-injector** | PostToolUse | Auto-injects AGENTS.md when reading files. Walks from file to project root, collecting all AGENTS.md files. |
| **directory-agents-injector** | PostToolUse | Auto-injects AGENTS.md when reading files. Walks from file to project root, collecting all AGENTS.md files. **Deprecated for OpenCode 1.1.37+** - Auto-disabled when native AGENTS.md injection is available. |
| **directory-readme-injector** | PostToolUse | Auto-injects README.md for directory context. |
| **rules-injector** | PostToolUse | Injects rules from `.claude/rules/` when conditions match. Supports globs and alwaysApply. |
| **compaction-context-injector** | Stop | Preserves critical context during session compaction. |
@@ -521,6 +521,37 @@ mcp:
The `skill_mcp` tool invokes these operations with full schema discovery.
#### OAuth-Enabled MCPs
Skills can define OAuth-protected remote MCP servers. OAuth 2.1 with full RFC compliance (RFC 9728, 8414, 8707, 7591) is supported:
```yaml
---
description: My API skill
mcp:
my-api:
url: https://api.example.com/mcp
oauth:
clientId: ${CLIENT_ID}
scopes: ["read", "write"]
---
```
When a skill MCP has `oauth` configured:
- **Auto-discovery**: Fetches `/.well-known/oauth-protected-resource` (RFC 9728), falls back to `/.well-known/oauth-authorization-server` (RFC 8414)
- **Dynamic Client Registration**: Auto-registers with servers supporting RFC 7591 (clientId becomes optional)
- **PKCE**: Mandatory for all flows
- **Resource Indicators**: Auto-generated from MCP URL per RFC 8707
- **Token Storage**: Persisted in `~/.config/opencode/mcp-oauth.json` (chmod 0600)
- **Auto-refresh**: Tokens refresh on 401; step-up authorization on 403 with `WWW-Authenticate`
- **Dynamic Port**: OAuth callback server uses an auto-discovered available port
Pre-authenticate via CLI:
```bash
bunx oh-my-opencode mcp oauth login <server-name> --server-url https://api.example.com
```
---
## Context Injection

View File

@@ -213,7 +213,7 @@ If Z.ai is the only provider available, all agents will use GLM models:
#### OpenCode Zen
OpenCode Zen provides access to `opencode/` prefixed models including `opencode/claude-opus-4-5`, `opencode/gpt-5.2`, `opencode/gpt-5-nano`, and `opencode/big-pickle`.
OpenCode Zen provides access to `opencode/` prefixed models including `opencode/claude-opus-4-5`, `opencode/gpt-5.2`, `opencode/gpt-5-nano`, and `opencode/glm-4.7-free`.
When OpenCode Zen is the best available provider (no native or Copilot), these models are used:
@@ -222,7 +222,7 @@ When OpenCode Zen is the best available provider (no native or Copilot), these m
| **Sisyphus** | `opencode/claude-opus-4-5` |
| **Oracle** | `opencode/gpt-5.2` |
| **Explore** | `opencode/gpt-5-nano` |
| **Librarian** | `opencode/big-pickle` |
| **Librarian** | `opencode/glm-4.7-free` |
##### Setup
@@ -278,7 +278,7 @@ After everything is set up and working, ask the user:
If the user agrees, run:
```bash
gh repo star code-yeongyu/oh-my-opencode
gh api --silent --method PUT /user/starred/code-yeongyu/oh-my-opencode >/dev/null 2>&1 || true
```
**Important**: Only run the command if the user explicitly says yes. Never run it automatically without consent.

View File

@@ -0,0 +1,126 @@
# Ollama Streaming Issue - JSON Parse Error
## Problem
When using Ollama as a provider with oh-my-opencode agents, you may encounter:
```
JSON Parse error: Unexpected EOF
```
This occurs when agents attempt tool calls (e.g., `explore` agent using `mcp_grep_search`).
## Root Cause
Ollama returns **NDJSON** (newline-delimited JSON) when `stream: true` is used in API requests:
```json
{"message":{"tool_calls":[{"function":{"name":"read","arguments":{"filePath":"README.md"}}}]}, "done":false}
{"message":{"content":""}, "done":true}
```
Claude Code SDK expects a single JSON object, not multiple NDJSON lines, causing the parse error.
### Why This Happens
- **Ollama API**: Returns streaming responses as NDJSON by design
- **Claude Code SDK**: Doesn't properly handle NDJSON responses for tool calls
- **oh-my-opencode**: Passes through the SDK's behavior (can't fix at this layer)
## Solutions
### Option 1: Disable Streaming (Recommended - Immediate Fix)
Configure your Ollama provider to use `stream: false`:
```json
{
"provider": "ollama",
"model": "qwen3-coder",
"stream": false
}
```
**Pros:**
- Works immediately
- No code changes needed
- Simple configuration
**Cons:**
- Slightly slower response time (no streaming)
- Less interactive feedback
### Option 2: Use Non-Tool Agents Only
If you need streaming, avoid agents that use tools:
-**Safe**: Simple text generation, non-tool tasks
-**Problematic**: Any agent with tool calls (explore, librarian, etc.)
### Option 3: Wait for SDK Fix (Long-term)
The proper fix requires Claude Code SDK to:
1. Detect NDJSON responses
2. Parse each line separately
3. Merge `tool_calls` from multiple lines
4. Return a single merged response
**Tracking**: https://github.com/code-yeongyu/oh-my-opencode/issues/1124
## Workaround Implementation
Until the SDK is fixed, here's how to implement NDJSON parsing (for SDK maintainers):
```typescript
async function parseOllamaStreamResponse(response: string): Promise<object> {
const lines = response.split('\n').filter(line => line.trim());
const mergedMessage = { tool_calls: [] };
for (const line of lines) {
try {
const json = JSON.parse(line);
if (json.message?.tool_calls) {
mergedMessage.tool_calls.push(...json.message.tool_calls);
}
if (json.message?.content) {
mergedMessage.content = json.message.content;
}
} catch (e) {
// Skip malformed lines
console.warn('Skipping malformed NDJSON line:', line);
}
}
return mergedMessage;
}
```
## Testing
To verify the fix works:
```bash
# Test with curl (should work with stream: false)
curl -s http://localhost:11434/api/chat \
-d '{
"model": "qwen3-coder",
"messages": [{"role": "user", "content": "Read file README.md"}],
"stream": false,
"tools": [{"type": "function", "function": {"name": "read", "description": "Read a file", "parameters": {"type": "object", "properties": {"filePath": {"type": "string"}}, "required": ["filePath"]}}}]
}'
```
## Related Issues
- **oh-my-opencode**: https://github.com/code-yeongyu/oh-my-opencode/issues/1124
- **Ollama API Docs**: https://github.com/ollama/ollama/blob/main/docs/api.md
## Getting Help
If you encounter this issue:
1. Check your Ollama provider configuration
2. Set `stream: false` as a workaround
3. Report any additional errors to the issue tracker
4. Provide your configuration (without secrets) for debugging

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode",
"version": "3.1.4",
"version": "3.1.9",
"description": "The Best AI Agent Harness - Batteries-Included OpenCode Plugin with Multi-Model Orchestration, Parallel Background Agents, and Crafted LSP/AST Tools",
"main": "dist/index.js",
"types": "dist/index.d.ts",
@@ -64,6 +64,7 @@
"jsonc-parser": "^3.3.1",
"picocolors": "^1.1.1",
"picomatch": "^4.0.2",
"vscode-jsonrpc": "^8.2.0",
"zod": "^4.1.8"
},
"devDependencies": {
@@ -73,13 +74,13 @@
"typescript": "^5.7.3"
},
"optionalDependencies": {
"oh-my-opencode-darwin-arm64": "3.1.4",
"oh-my-opencode-darwin-x64": "3.1.4",
"oh-my-opencode-linux-arm64": "3.1.4",
"oh-my-opencode-linux-arm64-musl": "3.1.4",
"oh-my-opencode-linux-x64": "3.1.4",
"oh-my-opencode-linux-x64-musl": "3.1.4",
"oh-my-opencode-windows-x64": "3.1.4"
"oh-my-opencode-darwin-arm64": "3.1.9",
"oh-my-opencode-darwin-x64": "3.1.9",
"oh-my-opencode-linux-arm64": "3.1.9",
"oh-my-opencode-linux-arm64-musl": "3.1.9",
"oh-my-opencode-linux-x64": "3.1.9",
"oh-my-opencode-linux-x64-musl": "3.1.9",
"oh-my-opencode-windows-x64": "3.1.9"
},
"trustedDependencies": [
"@ast-grep/cli",

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-darwin-arm64",
"version": "3.1.4",
"version": "3.1.9",
"description": "Platform-specific binary for oh-my-opencode (darwin-arm64)",
"license": "MIT",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-darwin-x64",
"version": "3.1.4",
"version": "3.1.9",
"description": "Platform-specific binary for oh-my-opencode (darwin-x64)",
"license": "MIT",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-linux-arm64-musl",
"version": "3.1.4",
"version": "3.1.9",
"description": "Platform-specific binary for oh-my-opencode (linux-arm64-musl)",
"license": "MIT",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-linux-arm64",
"version": "3.1.4",
"version": "3.1.9",
"description": "Platform-specific binary for oh-my-opencode (linux-arm64)",
"license": "MIT",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-linux-x64-musl",
"version": "3.1.4",
"version": "3.1.9",
"description": "Platform-specific binary for oh-my-opencode (linux-x64-musl)",
"license": "MIT",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-linux-x64",
"version": "3.1.4",
"version": "3.1.9",
"description": "Platform-specific binary for oh-my-opencode (linux-x64)",
"license": "MIT",
"repository": {

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode-windows-x64",
"version": "3.1.4",
"version": "3.1.9",
"description": "Platform-specific binary for oh-my-opencode (windows-x64)",
"license": "MIT",
"repository": {

View File

@@ -927,6 +927,86 @@
"created_at": "2026-01-28T01:02:02Z",
"repoId": 1108837393,
"pullRequestNo": 1188
},
{
"name": "rooftop-Owl",
"id": 254422872,
"comment_id": 3809867225,
"created_at": "2026-01-28T08:46:58Z",
"repoId": 1108837393,
"pullRequestNo": 1197
},
{
"name": "youming-ai",
"id": 173424537,
"comment_id": 3811195276,
"created_at": "2026-01-28T13:04:16Z",
"repoId": 1108837393,
"pullRequestNo": 1203
},
{
"name": "KennyDizi",
"id": 16578966,
"comment_id": 3811619818,
"created_at": "2026-01-28T14:26:10Z",
"repoId": 1108837393,
"pullRequestNo": 1214
},
{
"name": "mrdavidlaing",
"id": 227505,
"comment_id": 3813542625,
"created_at": "2026-01-28T19:51:34Z",
"repoId": 1108837393,
"pullRequestNo": 1226
},
{
"name": "Lynricsy",
"id": 62173814,
"comment_id": 3816370548,
"created_at": "2026-01-29T09:00:28Z",
"repoId": 1108837393,
"pullRequestNo": 1241
},
{
"name": "LeekJay",
"id": 39609783,
"comment_id": 3819009761,
"created_at": "2026-01-29T17:03:24Z",
"repoId": 1108837393,
"pullRequestNo": 1254
},
{
"name": "gabriel-ecegi",
"id": 35489017,
"comment_id": 3821842363,
"created_at": "2026-01-30T05:13:15Z",
"repoId": 1108837393,
"pullRequestNo": 1271
},
{
"name": "Hisir0909",
"id": 76634394,
"comment_id": 3822248445,
"created_at": "2026-01-30T07:20:09Z",
"repoId": 1108837393,
"pullRequestNo": 1275
},
{
"name": "Zacks-Zhang",
"id": 16462428,
"comment_id": 3822585754,
"created_at": "2026-01-30T08:51:49Z",
"repoId": 1108837393,
"pullRequestNo": 1280
},
{
"name": "kunal70006",
"id": 62700112,
"comment_id": 3822849937,
"created_at": "2026-01-30T09:55:57Z",
"repoId": 1108837393,
"pullRequestNo": 1282
}
]
}

View File

@@ -25,15 +25,15 @@ agents/
## AGENT MODELS
| Agent | Model | Temp | Purpose |
|-------|-------|------|---------|
| Sisyphus | anthropic/claude-opus-4-5 | 0.1 | Primary orchestrator |
| Atlas | anthropic/claude-opus-4-5 | 0.1 | Master orchestrator |
| Sisyphus | anthropic/claude-opus-4-5 | 0.1 | Primary orchestrator (fallback: kimi-k2.5 → glm-4.7 → gpt-5.2-codex → gemini-3-pro) |
| Atlas | anthropic/claude-sonnet-4-5 | 0.1 | Master orchestrator (fallback: kimi-k2.5 → gpt-5.2) |
| oracle | openai/gpt-5.2 | 0.1 | Consultation, debugging |
| librarian | opencode/big-pickle | 0.1 | Docs, GitHub search |
| explore | opencode/gpt-5-nano | 0.1 | Fast contextual grep |
| librarian | zai-coding-plan/glm-4.7 | 0.1 | Docs, GitHub search (fallback: glm-4.7-free) |
| explore | anthropic/claude-haiku-4-5 | 0.1 | Fast contextual grep (fallback: gpt-5-mini → gpt-5-nano) |
| multimodal-looker | google/gemini-3-flash | 0.1 | PDF/image analysis |
| Prometheus | anthropic/claude-opus-4-5 | 0.1 | Strategic planning |
| Metis | anthropic/claude-sonnet-4-5 | 0.3 | Pre-planning analysis |
| Momus | anthropic/claude-sonnet-4-5 | 0.1 | Plan validation |
| Prometheus | anthropic/claude-opus-4-5 | 0.1 | Strategic planning (fallback: kimi-k2.5 → gpt-5.2) |
| Metis | anthropic/claude-opus-4-5 | 0.3 | Pre-planning analysis (fallback: kimi-k2.5 → gpt-5.2) |
| Momus | openai/gpt-5.2 | 0.1 | Plan validation (fallback: claude-opus-4-5) |
| Sisyphus-Junior | anthropic/claude-sonnet-4-5 | 0.1 | Category-spawned executor |
## HOW TO ADD

View File

@@ -1,5 +1,7 @@
import type { AgentConfig } from "@opencode-ai/sdk"
import type { AgentPromptMetadata } from "./types"
import type { AgentMode, AgentPromptMetadata } from "./types"
const MODE: AgentMode = "primary"
import type { AvailableAgent, AvailableSkill, AvailableCategory } from "./dynamic-agent-prompt-builder"
import { buildCategorySkillsDelegationGuide } from "./dynamic-agent-prompt-builder"
import type { CategoryConfig } from "../config/schema"
@@ -529,8 +531,8 @@ export function createAtlasAgent(ctx: OrchestratorContext): AgentConfig {
])
return {
description:
"Orchestrates work via delegate_task() to complete ALL tasks in a todo list until fully done",
mode: "primary" as const,
"Orchestrates work via delegate_task() to complete ALL tasks in a todo list until fully done. (Atlas - OhMyOpenCode)",
mode: MODE,
...(ctx.model ? { model: ctx.model } : {}),
temperature: 0.1,
prompt: buildDynamicOrchestratorPrompt(ctx),
@@ -539,6 +541,7 @@ export function createAtlasAgent(ctx: OrchestratorContext): AgentConfig {
...restrictions,
} as AgentConfig
}
createAtlasAgent.mode = MODE
export const atlasPromptMetadata: AgentPromptMetadata = {
category: "advisor",

View File

@@ -1,7 +1,9 @@
import type { AgentConfig } from "@opencode-ai/sdk"
import type { AgentPromptMetadata } from "./types"
import type { AgentMode, AgentPromptMetadata } from "./types"
import { createAgentToolRestrictions } from "../shared/permission-compat"
const MODE: AgentMode = "subagent"
export const EXPLORE_PROMPT_METADATA: AgentPromptMetadata = {
category: "exploration",
cost: "FREE",
@@ -33,8 +35,8 @@ export function createExploreAgent(model: string): AgentConfig {
return {
description:
'Contextual grep for codebases. Answers "Where is X?", "Which file has Y?", "Find the code that does Z". Fire multiple in parallel for broad searches. Specify thoroughness: "quick" for basic, "medium" for moderate, "very thorough" for comprehensive analysis.',
mode: "subagent" as const,
'Contextual grep for codebases. Answers "Where is X?", "Which file has Y?", "Find the code that does Z". Fire multiple in parallel for broad searches. Specify thoroughness: "quick" for basic, "medium" for moderate, "very thorough" for comprehensive analysis. (Explore - OhMyOpenCode)',
mode: MODE,
model,
temperature: 0.1,
...restrictions,
@@ -119,4 +121,4 @@ Use the right tool for the job:
Flood with parallel calls. Cross-validate findings across multiple tools.`,
}
}
createExploreAgent.mode = MODE

View File

@@ -1,7 +1,9 @@
import type { AgentConfig } from "@opencode-ai/sdk"
import type { AgentPromptMetadata } from "./types"
import type { AgentMode, AgentPromptMetadata } from "./types"
import { createAgentToolRestrictions } from "../shared/permission-compat"
const MODE: AgentMode = "subagent"
export const LIBRARIAN_PROMPT_METADATA: AgentPromptMetadata = {
category: "exploration",
cost: "CHEAP",
@@ -30,8 +32,8 @@ export function createLibrarianAgent(model: string): AgentConfig {
return {
description:
"Specialized codebase understanding agent for multi-repository analysis, searching remote codebases, retrieving official documentation, and finding implementation examples using GitHub CLI, Context7, and Web Search. MUST BE USED when users ask to look up code in remote repositories, explain library internals, or find usage examples in open source.",
mode: "subagent" as const,
"Specialized codebase understanding agent for multi-repository analysis, searching remote codebases, retrieving official documentation, and finding implementation examples using GitHub CLI, Context7, and Web Search. MUST BE USED when users ask to look up code in remote repositories, explain library internals, or find usage examples in open source. (Librarian - OhMyOpenCode)",
mode: MODE,
model,
temperature: 0.1,
...restrictions,
@@ -323,4 +325,4 @@ grep_app_searchGitHub(query: "useQuery")
`,
}
}
createLibrarianAgent.mode = MODE

View File

@@ -1,7 +1,9 @@
import type { AgentConfig } from "@opencode-ai/sdk"
import type { AgentPromptMetadata } from "./types"
import type { AgentMode, AgentPromptMetadata } from "./types"
import { createAgentToolRestrictions } from "../shared/permission-compat"
const MODE: AgentMode = "subagent"
/**
* Metis - Plan Consultant Agent
*
@@ -230,6 +232,8 @@ call_omo_agent(subagent_type="librarian", prompt="Find OSS implementations of Z.
- [Risk 2]: [Mitigation]
## Directives for Prometheus
### Core Directives
- MUST: [Required action]
- MUST: [Required action]
- MUST NOT: [Forbidden action]
@@ -237,6 +241,29 @@ call_omo_agent(subagent_type="librarian", prompt="Find OSS implementations of Z.
- PATTERN: Follow \`[file:lines]\`
- TOOL: Use \`[specific tool]\` for [purpose]
### QA/Acceptance Criteria Directives (MANDATORY)
> **ZERO USER INTERVENTION PRINCIPLE**: All acceptance criteria MUST be executable by agents.
- MUST: Write acceptance criteria as executable commands (curl, bun test, playwright actions)
- MUST: Include exact expected outputs, not vague descriptions
- MUST: Specify verification tool for each deliverable type (playwright for UI, curl for API, etc.)
- MUST NOT: Create criteria requiring "user manually tests..."
- MUST NOT: Create criteria requiring "user visually confirms..."
- MUST NOT: Create criteria requiring "user clicks/interacts..."
- MUST NOT: Use placeholders without concrete examples (bad: "[endpoint]", good: "/api/users")
Example of GOOD acceptance criteria:
\`\`\`
curl -s http://localhost:3000/api/health | jq '.status'
# Assert: Output is "ok"
\`\`\`
Example of BAD acceptance criteria (FORBIDDEN):
\`\`\`
User opens browser and checks if the page loads correctly.
User confirms the button works as expected.
\`\`\`
## Recommended Approach
[1-2 sentence summary of how to proceed]
\`\`\`
@@ -263,12 +290,16 @@ call_omo_agent(subagent_type="librarian", prompt="Find OSS implementations of Z.
- Ask generic questions ("What's the scope?")
- Proceed without addressing ambiguity
- Make assumptions about user's codebase
- Suggest acceptance criteria requiring user intervention ("user manually tests", "user confirms", "user clicks")
- Leave QA/acceptance criteria vague or placeholder-heavy
**ALWAYS**:
- Classify intent FIRST
- Be specific ("Should this change UserService only, or also AuthService?")
- Explore before asking (for Build/Research intents)
- Provide actionable directives for Prometheus
- Include QA automation directives in every output
- Ensure acceptance criteria are agent-executable (commands, not human actions)
`
const metisRestrictions = createAgentToolRestrictions([
@@ -281,8 +312,8 @@ const metisRestrictions = createAgentToolRestrictions([
export function createMetisAgent(model: string): AgentConfig {
return {
description:
"Pre-planning consultant that analyzes requests to identify hidden intentions, ambiguities, and AI failure points.",
mode: "subagent" as const,
"Pre-planning consultant that analyzes requests to identify hidden intentions, ambiguities, and AI failure points. (Metis - OhMyOpenCode)",
mode: MODE,
model,
temperature: 0.3,
...metisRestrictions,
@@ -290,7 +321,7 @@ export function createMetisAgent(model: string): AgentConfig {
thinking: { type: "enabled", budgetTokens: 32000 },
} as AgentConfig
}
createMetisAgent.mode = MODE
export const metisPromptMetadata: AgentPromptMetadata = {
category: "advisor",

View File

@@ -1,8 +1,10 @@
import type { AgentConfig } from "@opencode-ai/sdk"
import type { AgentPromptMetadata } from "./types"
import type { AgentMode, AgentPromptMetadata } from "./types"
import { isGptModel } from "./types"
import { createAgentToolRestrictions } from "../shared/permission-compat"
const MODE: AgentMode = "subagent"
/**
* Momus - Plan Reviewer Agent
*
@@ -399,8 +401,8 @@ export function createMomusAgent(model: string): AgentConfig {
const base = {
description:
"Expert reviewer for evaluating work plans against rigorous clarity, verifiability, and completeness standards.",
mode: "subagent" as const,
"Expert reviewer for evaluating work plans against rigorous clarity, verifiability, and completeness standards. (Momus - OhMyOpenCode)",
mode: MODE,
model,
temperature: 0.1,
...restrictions,
@@ -413,7 +415,7 @@ export function createMomusAgent(model: string): AgentConfig {
return { ...base, thinking: { type: "enabled", budgetTokens: 32000 } } as AgentConfig
}
createMomusAgent.mode = MODE
export const momusPromptMetadata: AgentPromptMetadata = {
category: "advisor",

View File

@@ -1,7 +1,9 @@
import type { AgentConfig } from "@opencode-ai/sdk"
import type { AgentPromptMetadata } from "./types"
import type { AgentMode, AgentPromptMetadata } from "./types"
import { createAgentToolAllowlist } from "../shared/permission-compat"
const MODE: AgentMode = "subagent"
export const MULTIMODAL_LOOKER_PROMPT_METADATA: AgentPromptMetadata = {
category: "utility",
cost: "CHEAP",
@@ -14,8 +16,8 @@ export function createMultimodalLookerAgent(model: string): AgentConfig {
return {
description:
"Analyze media files (PDFs, images, diagrams) that require interpretation beyond raw text. Extracts specific information or summaries from documents, describes visual content. Use when you need analyzed/extracted data rather than literal file contents.",
mode: "subagent" as const,
"Analyze media files (PDFs, images, diagrams) that require interpretation beyond raw text. Extracts specific information or summaries from documents, describes visual content. Use when you need analyzed/extracted data rather than literal file contents. (Multimodal-Looker - OhMyOpenCode)",
mode: MODE,
model,
temperature: 0.1,
...restrictions,
@@ -53,4 +55,4 @@ Response rules:
Your output goes straight to the main agent for continued work.`,
}
}
createMultimodalLookerAgent.mode = MODE

View File

@@ -1,8 +1,10 @@
import type { AgentConfig } from "@opencode-ai/sdk"
import type { AgentPromptMetadata } from "./types"
import type { AgentMode, AgentPromptMetadata } from "./types"
import { isGptModel } from "./types"
import { createAgentToolRestrictions } from "../shared/permission-compat"
const MODE: AgentMode = "subagent"
export const ORACLE_PROMPT_METADATA: AgentPromptMetadata = {
category: "advisor",
cost: "EXPENSIVE",
@@ -105,8 +107,8 @@ export function createOracleAgent(model: string): AgentConfig {
const base = {
description:
"Read-only consultation agent. High-IQ reasoning specialist for debugging hard problems and high-difficulty architecture design.",
mode: "subagent" as const,
"Read-only consultation agent. High-IQ reasoning specialist for debugging hard problems and high-difficulty architecture design. (Oracle - OhMyOpenCode)",
mode: MODE,
model,
temperature: 0.1,
...restrictions,
@@ -119,4 +121,5 @@ export function createOracleAgent(model: string): AgentConfig {
return { ...base, thinking: { type: "enabled", budgetTokens: 32000 } } as AgentConfig
}
createOracleAgent.mode = MODE

View File

@@ -953,27 +953,37 @@ Each TODO follows RED-GREEN-REFACTOR:
- Example: Create \`src/__tests__/example.test.ts\`
- Verify: \`bun test\` → 1 test passes
### If Manual QA Only
### If Automated Verification Only (NO User Intervention)
**CRITICAL**: Without automated tests, manual verification MUST be exhaustive.
> **CRITICAL PRINCIPLE: ZERO USER INTERVENTION**
>
> **NEVER** create acceptance criteria that require:
> - "User manually tests..." / "사용자가 직접 테스트..."
> - "User visually confirms..." / "사용자가 눈으로 확인..."
> - "User interacts with..." / "사용자가 직접 조작..."
> - "Ask user to verify..." / "사용자에게 확인 요청..."
> - ANY step that requires a human to perform an action
>
> **ALL verification MUST be automated and executable by the agent.**
> If a verification cannot be automated, find an automated alternative or explicitly note it as a known limitation.
Each TODO includes detailed verification procedures:
Each TODO includes EXECUTABLE verification procedures that agents can run directly:
**By Deliverable Type:**
| Type | Verification Tool | Procedure |
|------|------------------|-----------|
| **Frontend/UI** | Playwright browser | Navigate, interact, screenshot |
| **TUI/CLI** | interactive_bash (tmux) | Run command, verify output |
| **API/Backend** | curl / httpie | Send request, verify response |
| **Library/Module** | Node/Python REPL | Import, call, verify |
| **Config/Infra** | Shell commands | Apply, verify state |
| Type | Verification Tool | Automated Procedure |
|------|------------------|---------------------|
| **Frontend/UI** | Playwright browser via playwright skill | Agent navigates, clicks, screenshots, asserts DOM state |
| **TUI/CLI** | interactive_bash (tmux) | Agent runs command, captures output, validates expected strings |
| **API/Backend** | curl / httpie via Bash | Agent sends request, parses response, validates JSON fields |
| **Library/Module** | Node/Python REPL via Bash | Agent imports, calls function, compares output |
| **Config/Infra** | Shell commands via Bash | Agent applies config, runs state check, validates output |
**Evidence Required:**
- Commands run with actual output
- Screenshots for visual changes
- Response bodies for API changes
- Terminal output for CLI changes
**Evidence Requirements (Agent-Executable):**
- Command output captured and compared against expected patterns
- Screenshots saved to .sisyphus/evidence/ for visual verification
- JSON response fields validated with specific assertions
- Exit codes checked (0 = success)
---
@@ -1083,53 +1093,76 @@ Parallel Speedup: ~40% faster than sequential
**Acceptance Criteria**:
> CRITICAL: Acceptance = EXECUTION, not just "it should work".
> The executor MUST run these commands and verify output.
> **CRITICAL: AGENT-EXECUTABLE VERIFICATION ONLY**
>
> - Acceptance = EXECUTION by the agent, not "user checks if it works"
> - Every criterion MUST be verifiable by running a command or using a tool
> - NO steps like "user opens browser", "user clicks", "user confirms"
> - If you write "[placeholder]" - REPLACE IT with actual values based on task context
**If TDD (tests enabled):**
- [ ] Test file created: \`[path].test.ts\`
- [ ] Test covers: [specific scenario]
- [ ] \`bun test [file]\` → PASS (N tests, 0 failures)
- [ ] Test file created: src/auth/login.test.ts
- [ ] Test covers: successful login returns JWT token
- [ ] bun test src/auth/login.test.ts → PASS (3 tests, 0 failures)
**Manual Execution Verification (ALWAYS include, even with tests):**
**Automated Verification (ALWAYS include, choose by deliverable type):**
*Choose based on deliverable type:*
**For Frontend/UI changes** (using playwright skill):
\\\`\\\`\\\`
# Agent executes via playwright browser automation:
1. Navigate to: http://localhost:3000/login
2. Fill: input[name="email"] with "test@example.com"
3. Fill: input[name="password"] with "password123"
4. Click: button[type="submit"]
5. Wait for: selector ".dashboard-welcome" to be visible
6. Assert: text "Welcome back" appears on page
7. Screenshot: .sisyphus/evidence/task-1-login-success.png
\\\`\\\`\\\`
**For Frontend/UI changes:**
- [ ] Using playwright browser automation:
- Navigate to: \`http://localhost:[port]/[path]\`
- Action: [click X, fill Y, scroll to Z]
- Verify: [visual element appears, animation completes, state changes]
- Screenshot: Save evidence to \`.sisyphus/evidence/[task-id]-[step].png\`
**For TUI/CLI changes** (using interactive_bash):
\\\`\\\`\\\`
# Agent executes via tmux session:
1. Command: ./my-cli --config test.yaml
2. Wait for: "Configuration loaded" in output
3. Send keys: "q" to quit
4. Assert: Exit code 0
5. Assert: Output contains "Goodbye"
\\\`\\\`\\\`
**For TUI/CLI changes:**
- [ ] Using interactive_bash (tmux session):
- Command: \`[exact command to run]\`
- Input sequence: [if interactive, list inputs]
- Expected output contains: \`[expected string or pattern]\`
- Exit code: [0 for success, specific code if relevant]
**For API/Backend changes** (using Bash curl):
\\\`\\\`\\\`bash
# Agent runs:
curl -s -X POST http://localhost:8080/api/users \\
-H "Content-Type: application/json" \\
-d '{"email":"new@test.com","name":"Test User"}' \\
| jq '.id'
# Assert: Returns non-empty UUID
# Assert: HTTP status 201
\\\`\\\`\\\`
**For API/Backend changes:**
- [ ] Request: \`curl -X [METHOD] http://localhost:[port]/[endpoint] -H "Content-Type: application/json" -d '[body]'\`
- [ ] Response status: [200/201/etc]
- [ ] Response body contains: \`{"key": "expected_value"}\`
**For Library/Module changes** (using Bash node/bun):
\\\`\\\`\\\`bash
# Agent runs:
bun -e "import { validateEmail } from './src/utils/validate'; console.log(validateEmail('test@example.com'))"
# Assert: Output is "true"
bun -e "import { validateEmail } from './src/utils/validate'; console.log(validateEmail('invalid'))"
# Assert: Output is "false"
\\\`\\\`\\\`
**For Library/Module changes:**
- [ ] REPL verification:
\`\`\`
> import { [function] } from '[module]'
> [function]([args])
Expected: [output]
\`\`\`
**For Config/Infra changes** (using Bash):
\\\`\\\`\\\`bash
# Agent runs:
docker compose up -d
# Wait 5s for containers
docker compose ps --format json | jq '.[].State'
# Assert: All states are "running"
\\\`\\\`\\\`
**For Config/Infra changes:**
- [ ] Apply: \`[command to apply config]\`
- [ ] Verify state: \`[command to check state]\`\`[expected output]\`
**Evidence Required:**
- [ ] Command output captured (copy-paste actual terminal output)
- [ ] Screenshot saved (for visual changes)
- [ ] Response body logged (for API changes)
**Evidence to Capture:**
- [ ] Terminal output from verification commands (actual output, not expected)
- [ ] Screenshot files in .sisyphus/evidence/ for UI changes
- [ ] JSON response bodies for API changes
**Commit**: YES | NO (groups with N)
- Message: \`type(scope): desc\`

View File

@@ -1,4 +1,5 @@
import type { AgentConfig } from "@opencode-ai/sdk"
import type { AgentMode } from "./types"
import { isGptModel } from "./types"
import type { AgentOverrideConfig } from "../config/schema"
import {
@@ -6,6 +7,8 @@ import {
type PermissionValue,
} from "../shared/permission-compat"
const MODE: AgentMode = "subagent"
const SISYPHUS_JUNIOR_PROMPT = `<Role>
Sisyphus-Junior - Focused executor from OhMyOpenCode.
Execute tasks directly. NEVER delegate or spawn other agents.
@@ -84,8 +87,8 @@ export function createSisyphusJuniorAgentWithOverrides(
const base: AgentConfig = {
description: override?.description ??
"Sisyphus-Junior - Focused task executor. Same discipline, no delegation.",
mode: "subagent" as const,
"Focused task executor. Same discipline, no delegation. (Sisyphus-Junior - OhMyOpenCode)",
mode: MODE,
model,
temperature,
maxTokens: 64000,
@@ -107,3 +110,5 @@ export function createSisyphusJuniorAgentWithOverrides(
thinking: { type: "enabled", budgetTokens: 32000 },
} as AgentConfig
}
createSisyphusJuniorAgentWithOverrides.mode = MODE

View File

@@ -1,5 +1,8 @@
import type { AgentConfig } from "@opencode-ai/sdk"
import type { AgentMode } from "./types"
import { isGptModel } from "./types"
const MODE: AgentMode = "primary"
import type { AvailableAgent, AvailableTool, AvailableSkill, AvailableCategory } from "./dynamic-agent-prompt-builder"
import {
buildKeyTriggersSection,
@@ -433,8 +436,8 @@ export function createSisyphusAgent(
const permission = { question: "allow", call_omo_agent: "deny" } as AgentConfig["permission"]
const base = {
description:
"Sisyphus - Powerful AI orchestrator from OhMyOpenCode. Plans obsessively with todos, assesses search complexity before exploration, delegates strategically via category+skills combinations. Uses explore for internal code (parallel-friendly), librarian for external docs.",
mode: "primary" as const,
"Powerful AI orchestrator. Plans obsessively with todos, assesses search complexity before exploration, delegates strategically via category+skills combinations. Uses explore for internal code (parallel-friendly), librarian for external docs. (Sisyphus - OhMyOpenCode)",
mode: MODE,
model,
maxTokens: 64000,
prompt,
@@ -448,3 +451,4 @@ export function createSisyphusAgent(
return { ...base, thinking: { type: "enabled", budgetTokens: 32000 } }
}
createSisyphusAgent.mode = MODE

View File

@@ -1,6 +1,20 @@
import type { AgentConfig } from "@opencode-ai/sdk"
export type AgentFactory = (model: string) => AgentConfig
/**
* Agent mode determines UI model selection behavior:
* - "primary": Respects user's UI-selected model (sisyphus, atlas)
* - "subagent": Uses own fallback chain, ignores UI selection (oracle, explore, etc.)
* - "all": Available in both contexts (OpenCode compatibility)
*/
export type AgentMode = "primary" | "subagent" | "all"
/**
* Agent factory function with static mode property.
* Mode is exposed as static property for pre-instantiation access.
*/
export type AgentFactory = ((model: string) => AgentConfig) & {
mode: AgentMode
}
/**
* Agent category for grouping in Sisyphus prompt sections

View File

@@ -47,17 +47,16 @@ describe("createBuiltinAgents with model overrides", () => {
expect(agents.sisyphus.reasoningEffort).toBeUndefined()
})
test("Oracle uses connected provider when no availableModels but connected cache exists", async () => {
// #given - connected providers cache exists with openai
test("Oracle uses connected provider fallback when availableModels is empty and cache exists", async () => {
// #given - connected providers cache has "openai", which matches oracle's first fallback entry
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai"])
// #when
const agents = await createBuiltinAgents([], {}, undefined, TEST_DEFAULT_MODEL)
// #then - uses openai from connected cache
// #then - oracle resolves via connected cache fallback to openai/gpt-5.2 (not system default)
expect(agents.oracle.model).toBe("openai/gpt-5.2")
expect(agents.oracle.reasoningEffort).toBe("medium")
expect(agents.oracle.textVerbosity).toBe("high")
expect(agents.oracle.thinking).toBeUndefined()
cacheSpy.mockRestore()
})
@@ -123,39 +122,39 @@ describe("createBuiltinAgents with model overrides", () => {
})
describe("createBuiltinAgents without systemDefaultModel", () => {
test("creates agents with connected provider when cache exists", async () => {
// #given - connected providers cache exists
test("agents created via connected cache fallback even without systemDefaultModel", async () => {
// #given - connected cache has "openai", which matches oracle's fallback chain
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai"])
// #when
const agents = await createBuiltinAgents([], {}, undefined, undefined)
// #then - agents should use connected provider from fallback chain
// #then - connected cache enables model resolution despite no systemDefaultModel
expect(agents.oracle).toBeDefined()
expect(agents.oracle.model).toBe("openai/gpt-5.2")
cacheSpy.mockRestore()
})
test("agents NOT created when no cache and no systemDefaultModel (first run without defaults)", async () => {
// #given - no cache and no system default
// #given
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(null)
// #when
const agents = await createBuiltinAgents([], {}, undefined, undefined)
// #then - oracle should NOT be created (resolveModelWithFallback returns undefined)
// #then
expect(agents.oracle).toBeUndefined()
cacheSpy.mockRestore()
})
test("sisyphus uses connected provider when cache exists", async () => {
// #given - connected providers cache exists with anthropic
test("sisyphus created via connected cache fallback even without systemDefaultModel", async () => {
// #given - connected cache has "anthropic", which matches sisyphus's first fallback entry
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["anthropic"])
// #when
const agents = await createBuiltinAgents([], {}, undefined, undefined)
// #then - sisyphus should use anthropic from connected cache
// #then - connected cache enables model resolution despite no systemDefaultModel
expect(agents.sisyphus).toBeDefined()
expect(agents.sisyphus.model).toBe("anthropic/claude-opus-4-5")
cacheSpy.mockRestore()
@@ -408,3 +407,119 @@ describe("buildAgent with category and skills", () => {
expect(agent.prompt).not.toContain("agent-browser open")
})
})
describe("override.category expansion in createBuiltinAgents", () => {
test("standard agent override with category expands category properties", async () => {
// #given
const overrides = {
oracle: { category: "ultrabrain" } as any,
}
// #when
const agents = await createBuiltinAgents([], overrides, undefined, TEST_DEFAULT_MODEL)
// #then - ultrabrain category: model=openai/gpt-5.2-codex, variant=xhigh
expect(agents.oracle).toBeDefined()
expect(agents.oracle.model).toBe("openai/gpt-5.2-codex")
expect(agents.oracle.variant).toBe("xhigh")
})
test("standard agent override with category AND direct variant - direct wins", async () => {
// #given - ultrabrain has variant=xhigh, but direct override says "max"
const overrides = {
oracle: { category: "ultrabrain", variant: "max" } as any,
}
// #when
const agents = await createBuiltinAgents([], overrides, undefined, TEST_DEFAULT_MODEL)
// #then - direct variant overrides category variant
expect(agents.oracle).toBeDefined()
expect(agents.oracle.variant).toBe("max")
})
test("standard agent override with category AND direct reasoningEffort - direct wins", async () => {
// #given - custom category has reasoningEffort=xhigh, direct override says "low"
const categories = {
"test-cat": {
model: "openai/gpt-5.2",
reasoningEffort: "xhigh" as const,
},
}
const overrides = {
oracle: { category: "test-cat", reasoningEffort: "low" } as any,
}
// #when
const agents = await createBuiltinAgents([], overrides, undefined, TEST_DEFAULT_MODEL, categories)
// #then - direct reasoningEffort wins over category
expect(agents.oracle).toBeDefined()
expect(agents.oracle.reasoningEffort).toBe("low")
})
test("standard agent override with category applies reasoningEffort from category when no direct override", async () => {
// #given - custom category has reasoningEffort, no direct reasoningEffort in override
const categories = {
"reasoning-cat": {
model: "openai/gpt-5.2",
reasoningEffort: "high" as const,
},
}
const overrides = {
oracle: { category: "reasoning-cat" } as any,
}
// #when
const agents = await createBuiltinAgents([], overrides, undefined, TEST_DEFAULT_MODEL, categories)
// #then - category reasoningEffort is applied
expect(agents.oracle).toBeDefined()
expect(agents.oracle.reasoningEffort).toBe("high")
})
test("sisyphus override with category expands category properties", async () => {
// #given
const overrides = {
sisyphus: { category: "ultrabrain" } as any,
}
// #when
const agents = await createBuiltinAgents([], overrides, undefined, TEST_DEFAULT_MODEL)
// #then - ultrabrain category: model=openai/gpt-5.2-codex, variant=xhigh
expect(agents.sisyphus).toBeDefined()
expect(agents.sisyphus.model).toBe("openai/gpt-5.2-codex")
expect(agents.sisyphus.variant).toBe("xhigh")
})
test("atlas override with category expands category properties", async () => {
// #given
const overrides = {
atlas: { category: "ultrabrain" } as any,
}
// #when
const agents = await createBuiltinAgents([], overrides, undefined, TEST_DEFAULT_MODEL)
// #then - ultrabrain category: model=openai/gpt-5.2-codex, variant=xhigh
expect(agents.atlas).toBeDefined()
expect(agents.atlas.model).toBe("openai/gpt-5.2-codex")
expect(agents.atlas.variant).toBe("xhigh")
})
test("override with non-existent category has no effect on config", async () => {
// #given
const overrides = {
oracle: { category: "non-existent-category" } as any,
}
// #when
const agents = await createBuiltinAgents([], overrides, undefined, TEST_DEFAULT_MODEL)
// #then - no category-specific variant/reasoningEffort applied from non-existent category
expect(agents.oracle).toBeDefined()
const agentsWithoutOverride = await createBuiltinAgents([], {}, undefined, TEST_DEFAULT_MODEL)
expect(agents.oracle.model).toBe(agentsWithoutOverride.oracle.model)
})
})

View File

@@ -10,7 +10,7 @@ import { createMetisAgent } from "./metis"
import { createAtlasAgent } from "./atlas"
import { createMomusAgent } from "./momus"
import type { AvailableAgent, AvailableCategory, AvailableSkill } from "./dynamic-agent-prompt-builder"
import { deepMerge, fetchAvailableModels, resolveModelWithFallback, AGENT_MODEL_REQUIREMENTS, findCaseInsensitive, includesCaseInsensitive, readConnectedProvidersCache } from "../shared"
import { deepMerge, fetchAvailableModels, resolveModelWithFallback, AGENT_MODEL_REQUIREMENTS, findCaseInsensitive, includesCaseInsensitive, readConnectedProvidersCache, isModelAvailable } from "../shared"
import { DEFAULT_CATEGORIES, CATEGORY_DESCRIPTIONS } from "../tools/delegate-task/constants"
import { resolveMultipleSkills } from "../features/opencode-skill-loader/skill-content"
import { createBuiltinSkills } from "../features/builtin-skills"
@@ -120,6 +120,33 @@ export function createEnvContext(): string {
</omo-env>`
}
/**
* Expands a category reference from an agent override into concrete config properties.
* Category properties are applied unconditionally (overwriting factory defaults),
* because the user's chosen category should take priority over factory base values.
* Direct override properties applied later via mergeAgentConfig() will supersede these.
*/
function applyCategoryOverride(
config: AgentConfig,
categoryName: string,
mergedCategories: Record<string, CategoryConfig>
): AgentConfig {
const categoryConfig = mergedCategories[categoryName]
if (!categoryConfig) return config
const result = { ...config } as AgentConfig & Record<string, unknown>
if (categoryConfig.model) result.model = categoryConfig.model
if (categoryConfig.variant !== undefined) result.variant = categoryConfig.variant
if (categoryConfig.temperature !== undefined) result.temperature = categoryConfig.temperature
if (categoryConfig.reasoningEffort !== undefined) result.reasoningEffort = categoryConfig.reasoningEffort
if (categoryConfig.textVerbosity !== undefined) result.textVerbosity = categoryConfig.textVerbosity
if (categoryConfig.thinking !== undefined) result.thinking = categoryConfig.thinking
if (categoryConfig.top_p !== undefined) result.top_p = categoryConfig.top_p
if (categoryConfig.maxTokens !== undefined) result.maxTokens = categoryConfig.maxTokens
return result as AgentConfig
}
function mergeAgentConfig(
base: AgentConfig,
override: AgentOverrideConfig
@@ -149,7 +176,8 @@ export async function createBuiltinAgents(
gitMasterConfig?: GitMasterConfig,
discoveredSkills: LoadedSkill[] = [],
client?: any,
browserProvider?: BrowserAutomationProvider
browserProvider?: BrowserAutomationProvider,
uiSelectedModel?: string
): Promise<Record<string, AgentConfig>> {
const connectedProviders = readConnectedProvidersCache()
const availableModels = client
@@ -194,10 +222,20 @@ export async function createBuiltinAgents(
if (agentName === "atlas") continue
if (includesCaseInsensitive(disabledAgents, agentName)) continue
const override = findCaseInsensitive(agentOverrides, agentName)
const requirement = AGENT_MODEL_REQUIREMENTS[agentName]
const resolution = resolveModelWithFallback({
const override = findCaseInsensitive(agentOverrides, agentName)
const requirement = AGENT_MODEL_REQUIREMENTS[agentName]
// Check if agent requires a specific model
if (requirement?.requiresModel && availableModels) {
if (!isModelAvailable(requirement.requiresModel, availableModels)) {
continue
}
}
const isPrimaryAgent = isFactory(source) && source.mode === "primary"
const resolution = resolveModelWithFallback({
uiSelectedModel: isPrimaryAgent ? uiSelectedModel : undefined,
userModel: override?.model,
fallbackChain: requirement?.fallbackChain,
availableModels,
@@ -208,18 +246,23 @@ export async function createBuiltinAgents(
let config = buildAgent(source, model, mergedCategories, gitMasterConfig, browserProvider)
// Apply variant from override or resolved fallback chain
if (override?.variant) {
config = { ...config, variant: override.variant }
} else if (resolvedVariant) {
// Apply resolved variant from model fallback chain
if (resolvedVariant) {
config = { ...config, variant: resolvedVariant }
}
// Expand override.category into concrete properties (higher priority than factory/resolved)
const overrideCategory = (override as Record<string, unknown> | undefined)?.category as string | undefined
if (overrideCategory) {
config = applyCategoryOverride(config, overrideCategory, mergedCategories)
}
if (agentName === "librarian" && directory && config.prompt) {
const envContext = createEnvContext()
config = { ...config, prompt: config.prompt + envContext }
}
// Direct override properties take highest priority
if (override) {
config = mergeAgentConfig(config, override)
}
@@ -241,6 +284,7 @@ export async function createBuiltinAgents(
const sisyphusRequirement = AGENT_MODEL_REQUIREMENTS["sisyphus"]
const sisyphusResolution = resolveModelWithFallback({
uiSelectedModel,
userModel: sisyphusOverride?.model,
fallbackChain: sisyphusRequirement?.fallbackChain,
availableModels,
@@ -258,12 +302,15 @@ export async function createBuiltinAgents(
availableCategories
)
if (sisyphusOverride?.variant) {
sisyphusConfig = { ...sisyphusConfig, variant: sisyphusOverride.variant }
} else if (sisyphusResolvedVariant) {
if (sisyphusResolvedVariant) {
sisyphusConfig = { ...sisyphusConfig, variant: sisyphusResolvedVariant }
}
const sisOverrideCategory = (sisyphusOverride as Record<string, unknown> | undefined)?.category as string | undefined
if (sisOverrideCategory) {
sisyphusConfig = applyCategoryOverride(sisyphusConfig, sisOverrideCategory, mergedCategories)
}
if (directory && sisyphusConfig.prompt) {
const envContext = createEnvContext()
sisyphusConfig = { ...sisyphusConfig, prompt: sisyphusConfig.prompt + envContext }
@@ -282,6 +329,7 @@ export async function createBuiltinAgents(
const atlasRequirement = AGENT_MODEL_REQUIREMENTS["atlas"]
const atlasResolution = resolveModelWithFallback({
// NOTE: Atlas does NOT use uiSelectedModel - respects its own fallbackChain (k2p5 primary)
userModel: orchestratorOverride?.model,
fallbackChain: atlasRequirement?.fallbackChain,
availableModels,
@@ -298,12 +346,15 @@ export async function createBuiltinAgents(
userCategories: categories,
})
if (orchestratorOverride?.variant) {
orchestratorConfig = { ...orchestratorConfig, variant: orchestratorOverride.variant }
} else if (atlasResolvedVariant) {
if (atlasResolvedVariant) {
orchestratorConfig = { ...orchestratorConfig, variant: atlasResolvedVariant }
}
const atlasOverrideCategory = (orchestratorOverride as Record<string, unknown> | undefined)?.category as string | undefined
if (atlasOverrideCategory) {
orchestratorConfig = applyCategoryOverride(orchestratorConfig, atlasOverrideCategory, mergedCategories)
}
if (orchestratorOverride) {
orchestratorConfig = mergeAgentConfig(orchestratorConfig, orchestratorOverride)
}

View File

@@ -5,54 +5,57 @@ exports[`generateModelConfig no providers available returns ULTIMATE_FALLBACK fo
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
"agents": {
"atlas": {
"model": "opencode/big-pickle",
"model": "opencode/glm-4.7-free",
},
"explore": {
"model": "opencode/big-pickle",
"model": "opencode/glm-4.7-free",
},
"librarian": {
"model": "opencode/big-pickle",
"model": "opencode/glm-4.7-free",
},
"metis": {
"model": "opencode/big-pickle",
"model": "opencode/glm-4.7-free",
},
"momus": {
"model": "opencode/big-pickle",
"model": "opencode/glm-4.7-free",
},
"multimodal-looker": {
"model": "opencode/big-pickle",
"model": "opencode/glm-4.7-free",
},
"oracle": {
"model": "opencode/big-pickle",
"model": "opencode/glm-4.7-free",
},
"prometheus": {
"model": "opencode/big-pickle",
"model": "opencode/glm-4.7-free",
},
"sisyphus": {
"model": "opencode/big-pickle",
"model": "opencode/glm-4.7-free",
},
},
"categories": {
"artistry": {
"model": "opencode/big-pickle",
"model": "opencode/glm-4.7-free",
},
"deep": {
"model": "opencode/glm-4.7-free",
},
"quick": {
"model": "opencode/big-pickle",
"model": "opencode/glm-4.7-free",
},
"ultrabrain": {
"model": "opencode/big-pickle",
"model": "opencode/glm-4.7-free",
},
"unspecified-high": {
"model": "opencode/big-pickle",
"model": "opencode/glm-4.7-free",
},
"unspecified-low": {
"model": "opencode/big-pickle",
"model": "opencode/glm-4.7-free",
},
"visual-engineering": {
"model": "opencode/big-pickle",
"model": "opencode/glm-4.7-free",
},
"writing": {
"model": "opencode/big-pickle",
"model": "opencode/glm-4.7-free",
},
},
}
@@ -77,6 +80,7 @@ exports[`generateModelConfig single native provider uses Claude models when only
},
"momus": {
"model": "anthropic/claude-opus-4-5",
"variant": "max",
},
"multimodal-looker": {
"model": "anthropic/claude-haiku-4-5",
@@ -98,6 +102,10 @@ exports[`generateModelConfig single native provider uses Claude models when only
"model": "anthropic/claude-opus-4-5",
"variant": "max",
},
"deep": {
"model": "anthropic/claude-opus-4-5",
"variant": "max",
},
"quick": {
"model": "anthropic/claude-haiku-4-5",
},
@@ -141,6 +149,7 @@ exports[`generateModelConfig single native provider uses Claude models with isMa
},
"momus": {
"model": "anthropic/claude-opus-4-5",
"variant": "max",
},
"multimodal-looker": {
"model": "anthropic/claude-haiku-4-5",
@@ -163,6 +172,10 @@ exports[`generateModelConfig single native provider uses Claude models with isMa
"model": "anthropic/claude-opus-4-5",
"variant": "max",
},
"deep": {
"model": "anthropic/claude-opus-4-5",
"variant": "max",
},
"quick": {
"model": "anthropic/claude-haiku-4-5",
},
@@ -199,7 +212,7 @@ exports[`generateModelConfig single native provider uses OpenAI models when only
"model": "opencode/gpt-5-nano",
},
"librarian": {
"model": "opencode/big-pickle",
"model": "opencode/glm-4.7-free",
},
"metis": {
"model": "openai/gpt-5.2",
@@ -229,8 +242,12 @@ exports[`generateModelConfig single native provider uses OpenAI models when only
"artistry": {
"model": "openai/gpt-5.2",
},
"deep": {
"model": "openai/gpt-5.2-codex",
"variant": "medium",
},
"quick": {
"model": "opencode/big-pickle",
"model": "opencode/glm-4.7-free",
},
"ultrabrain": {
"model": "openai/gpt-5.2-codex",
@@ -245,8 +262,7 @@ exports[`generateModelConfig single native provider uses OpenAI models when only
"variant": "medium",
},
"visual-engineering": {
"model": "openai/gpt-5.2",
"variant": "high",
"model": "opencode/glm-4.7-free",
},
"writing": {
"model": "openai/gpt-5.2",
@@ -266,7 +282,7 @@ exports[`generateModelConfig single native provider uses OpenAI models with isMa
"model": "opencode/gpt-5-nano",
},
"librarian": {
"model": "opencode/big-pickle",
"model": "opencode/glm-4.7-free",
},
"metis": {
"model": "openai/gpt-5.2",
@@ -296,8 +312,12 @@ exports[`generateModelConfig single native provider uses OpenAI models with isMa
"artistry": {
"model": "openai/gpt-5.2",
},
"deep": {
"model": "openai/gpt-5.2-codex",
"variant": "medium",
},
"quick": {
"model": "opencode/big-pickle",
"model": "opencode/glm-4.7-free",
},
"ultrabrain": {
"model": "openai/gpt-5.2-codex",
@@ -312,8 +332,7 @@ exports[`generateModelConfig single native provider uses OpenAI models with isMa
"variant": "medium",
},
"visual-engineering": {
"model": "openai/gpt-5.2",
"variant": "high",
"model": "opencode/glm-4.7-free",
},
"writing": {
"model": "openai/gpt-5.2",
@@ -333,7 +352,7 @@ exports[`generateModelConfig single native provider uses Gemini models when only
"model": "opencode/gpt-5-nano",
},
"librarian": {
"model": "opencode/big-pickle",
"model": "opencode/glm-4.7-free",
},
"metis": {
"model": "google/gemini-3-pro",
@@ -348,6 +367,7 @@ exports[`generateModelConfig single native provider uses Gemini models when only
},
"oracle": {
"model": "google/gemini-3-pro",
"variant": "max",
},
"prometheus": {
"model": "google/gemini-3-pro",
@@ -361,11 +381,16 @@ exports[`generateModelConfig single native provider uses Gemini models when only
"model": "google/gemini-3-pro",
"variant": "max",
},
"deep": {
"model": "google/gemini-3-pro",
"variant": "max",
},
"quick": {
"model": "google/gemini-3-flash",
},
"ultrabrain": {
"model": "google/gemini-3-pro",
"variant": "max",
},
"unspecified-high": {
"model": "google/gemini-3-flash",
@@ -394,7 +419,7 @@ exports[`generateModelConfig single native provider uses Gemini models with isMa
"model": "opencode/gpt-5-nano",
},
"librarian": {
"model": "opencode/big-pickle",
"model": "opencode/glm-4.7-free",
},
"metis": {
"model": "google/gemini-3-pro",
@@ -409,6 +434,7 @@ exports[`generateModelConfig single native provider uses Gemini models with isMa
},
"oracle": {
"model": "google/gemini-3-pro",
"variant": "max",
},
"prometheus": {
"model": "google/gemini-3-pro",
@@ -422,11 +448,16 @@ exports[`generateModelConfig single native provider uses Gemini models with isMa
"model": "google/gemini-3-pro",
"variant": "max",
},
"deep": {
"model": "google/gemini-3-pro",
"variant": "max",
},
"quick": {
"model": "google/gemini-3-flash",
},
"ultrabrain": {
"model": "google/gemini-3-pro",
"variant": "max",
},
"unspecified-high": {
"model": "google/gemini-3-pro",
@@ -485,6 +516,10 @@ exports[`generateModelConfig all native providers uses preferred models from fal
"model": "google/gemini-3-pro",
"variant": "max",
},
"deep": {
"model": "openai/gpt-5.2-codex",
"variant": "medium",
},
"quick": {
"model": "anthropic/claude-haiku-4-5",
},
@@ -550,6 +585,10 @@ exports[`generateModelConfig all native providers uses preferred models with isM
"model": "google/gemini-3-pro",
"variant": "max",
},
"deep": {
"model": "openai/gpt-5.2-codex",
"variant": "medium",
},
"quick": {
"model": "anthropic/claude-haiku-4-5",
},
@@ -579,13 +618,13 @@ exports[`generateModelConfig fallback providers uses OpenCode Zen models when on
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
"agents": {
"atlas": {
"model": "opencode/claude-sonnet-4-5",
"model": "opencode/kimi-k2.5-free",
},
"explore": {
"model": "opencode/claude-haiku-4-5",
},
"librarian": {
"model": "opencode/big-pickle",
"model": "opencode/glm-4.7-free",
},
"metis": {
"model": "opencode/claude-opus-4-5",
@@ -615,6 +654,10 @@ exports[`generateModelConfig fallback providers uses OpenCode Zen models when on
"model": "opencode/gemini-3-pro",
"variant": "max",
},
"deep": {
"model": "opencode/gpt-5.2-codex",
"variant": "medium",
},
"quick": {
"model": "opencode/claude-haiku-4-5",
},
@@ -643,13 +686,13 @@ exports[`generateModelConfig fallback providers uses OpenCode Zen models with is
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
"agents": {
"atlas": {
"model": "opencode/claude-sonnet-4-5",
"model": "opencode/kimi-k2.5-free",
},
"explore": {
"model": "opencode/claude-haiku-4-5",
},
"librarian": {
"model": "opencode/big-pickle",
"model": "opencode/glm-4.7-free",
},
"metis": {
"model": "opencode/claude-opus-4-5",
@@ -680,6 +723,10 @@ exports[`generateModelConfig fallback providers uses OpenCode Zen models with is
"model": "opencode/gemini-3-pro",
"variant": "max",
},
"deep": {
"model": "opencode/gpt-5.2-codex",
"variant": "medium",
},
"quick": {
"model": "opencode/claude-haiku-4-5",
},
@@ -745,6 +792,10 @@ exports[`generateModelConfig fallback providers uses GitHub Copilot models when
"model": "github-copilot/gemini-3-pro",
"variant": "max",
},
"deep": {
"model": "github-copilot/gpt-5.2-codex",
"variant": "medium",
},
"quick": {
"model": "github-copilot/claude-haiku-4.5",
},
@@ -810,6 +861,10 @@ exports[`generateModelConfig fallback providers uses GitHub Copilot models with
"model": "github-copilot/gemini-3-pro",
"variant": "max",
},
"deep": {
"model": "github-copilot/gpt-5.2-codex",
"variant": "medium",
},
"quick": {
"model": "github-copilot/claude-haiku-4.5",
},
@@ -839,7 +894,7 @@ exports[`generateModelConfig fallback providers uses ZAI model for librarian whe
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
"agents": {
"atlas": {
"model": "opencode/big-pickle",
"model": "opencode/glm-4.7-free",
},
"explore": {
"model": "opencode/gpt-5-nano",
@@ -848,42 +903,45 @@ exports[`generateModelConfig fallback providers uses ZAI model for librarian whe
"model": "zai-coding-plan/glm-4.7",
},
"metis": {
"model": "opencode/big-pickle",
"model": "opencode/glm-4.7-free",
},
"momus": {
"model": "opencode/big-pickle",
"model": "opencode/glm-4.7-free",
},
"multimodal-looker": {
"model": "zai-coding-plan/glm-4.6v",
},
"oracle": {
"model": "opencode/big-pickle",
"model": "opencode/glm-4.7-free",
},
"prometheus": {
"model": "opencode/big-pickle",
"model": "opencode/glm-4.7-free",
},
"sisyphus": {
"model": "opencode/big-pickle",
"model": "opencode/glm-4.7-free",
},
},
"categories": {
"artistry": {
"model": "opencode/big-pickle",
"model": "opencode/glm-4.7-free",
},
"deep": {
"model": "opencode/glm-4.7-free",
},
"quick": {
"model": "opencode/big-pickle",
"model": "opencode/glm-4.7-free",
},
"ultrabrain": {
"model": "opencode/big-pickle",
"model": "opencode/glm-4.7-free",
},
"unspecified-high": {
"model": "opencode/big-pickle",
"model": "opencode/glm-4.7-free",
},
"unspecified-low": {
"model": "opencode/big-pickle",
"model": "opencode/glm-4.7-free",
},
"visual-engineering": {
"model": "opencode/big-pickle",
"model": "zai-coding-plan/glm-4.7",
},
"writing": {
"model": "zai-coding-plan/glm-4.7",
@@ -897,7 +955,7 @@ exports[`generateModelConfig fallback providers uses ZAI model for librarian wit
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
"agents": {
"atlas": {
"model": "opencode/big-pickle",
"model": "opencode/glm-4.7-free",
},
"explore": {
"model": "opencode/gpt-5-nano",
@@ -906,19 +964,19 @@ exports[`generateModelConfig fallback providers uses ZAI model for librarian wit
"model": "zai-coding-plan/glm-4.7",
},
"metis": {
"model": "opencode/big-pickle",
"model": "opencode/glm-4.7-free",
},
"momus": {
"model": "opencode/big-pickle",
"model": "opencode/glm-4.7-free",
},
"multimodal-looker": {
"model": "zai-coding-plan/glm-4.6v",
},
"oracle": {
"model": "opencode/big-pickle",
"model": "opencode/glm-4.7-free",
},
"prometheus": {
"model": "opencode/big-pickle",
"model": "opencode/glm-4.7-free",
},
"sisyphus": {
"model": "zai-coding-plan/glm-4.7",
@@ -926,22 +984,25 @@ exports[`generateModelConfig fallback providers uses ZAI model for librarian wit
},
"categories": {
"artistry": {
"model": "opencode/big-pickle",
"model": "opencode/glm-4.7-free",
},
"deep": {
"model": "opencode/glm-4.7-free",
},
"quick": {
"model": "opencode/big-pickle",
"model": "opencode/glm-4.7-free",
},
"ultrabrain": {
"model": "opencode/big-pickle",
"model": "opencode/glm-4.7-free",
},
"unspecified-high": {
"model": "opencode/big-pickle",
"model": "opencode/glm-4.7-free",
},
"unspecified-low": {
"model": "opencode/big-pickle",
"model": "opencode/glm-4.7-free",
},
"visual-engineering": {
"model": "opencode/big-pickle",
"model": "zai-coding-plan/glm-4.7",
},
"writing": {
"model": "zai-coding-plan/glm-4.7",
@@ -955,13 +1016,13 @@ exports[`generateModelConfig mixed provider scenarios uses Claude + OpenCode Zen
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
"agents": {
"atlas": {
"model": "anthropic/claude-sonnet-4-5",
"model": "opencode/kimi-k2.5-free",
},
"explore": {
"model": "anthropic/claude-haiku-4-5",
},
"librarian": {
"model": "opencode/big-pickle",
"model": "opencode/glm-4.7-free",
},
"metis": {
"model": "anthropic/claude-opus-4-5",
@@ -991,6 +1052,10 @@ exports[`generateModelConfig mixed provider scenarios uses Claude + OpenCode Zen
"model": "opencode/gemini-3-pro",
"variant": "max",
},
"deep": {
"model": "opencode/gpt-5.2-codex",
"variant": "medium",
},
"quick": {
"model": "anthropic/claude-haiku-4-5",
},
@@ -1055,6 +1120,10 @@ exports[`generateModelConfig mixed provider scenarios uses OpenAI + Copilot comb
"model": "github-copilot/gemini-3-pro",
"variant": "max",
},
"deep": {
"model": "openai/gpt-5.2-codex",
"variant": "medium",
},
"quick": {
"model": "github-copilot/claude-haiku-4.5",
},
@@ -1097,6 +1166,7 @@ exports[`generateModelConfig mixed provider scenarios uses Claude + ZAI combinat
},
"momus": {
"model": "anthropic/claude-opus-4-5",
"variant": "max",
},
"multimodal-looker": {
"model": "zai-coding-plan/glm-4.6v",
@@ -1118,6 +1188,10 @@ exports[`generateModelConfig mixed provider scenarios uses Claude + ZAI combinat
"model": "anthropic/claude-opus-4-5",
"variant": "max",
},
"deep": {
"model": "anthropic/claude-opus-4-5",
"variant": "max",
},
"quick": {
"model": "anthropic/claude-haiku-4-5",
},
@@ -1161,12 +1235,13 @@ exports[`generateModelConfig mixed provider scenarios uses Gemini + Claude combi
},
"momus": {
"model": "anthropic/claude-opus-4-5",
"variant": "max",
},
"multimodal-looker": {
"model": "google/gemini-3-flash",
},
"oracle": {
"model": "anthropic/claude-opus-4-5",
"model": "google/gemini-3-pro",
"variant": "max",
},
"prometheus": {
@@ -1182,11 +1257,15 @@ exports[`generateModelConfig mixed provider scenarios uses Gemini + Claude combi
"model": "google/gemini-3-pro",
"variant": "max",
},
"deep": {
"model": "anthropic/claude-opus-4-5",
"variant": "max",
},
"quick": {
"model": "anthropic/claude-haiku-4-5",
},
"ultrabrain": {
"model": "anthropic/claude-opus-4-5",
"model": "google/gemini-3-pro",
"variant": "max",
},
"unspecified-high": {
@@ -1210,7 +1289,7 @@ exports[`generateModelConfig mixed provider scenarios uses all fallback provider
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
"agents": {
"atlas": {
"model": "github-copilot/claude-sonnet-4.5",
"model": "opencode/kimi-k2.5-free",
},
"explore": {
"model": "opencode/claude-haiku-4-5",
@@ -1246,6 +1325,10 @@ exports[`generateModelConfig mixed provider scenarios uses all fallback provider
"model": "github-copilot/gemini-3-pro",
"variant": "max",
},
"deep": {
"model": "github-copilot/gpt-5.2-codex",
"variant": "medium",
},
"quick": {
"model": "github-copilot/claude-haiku-4.5",
},
@@ -1274,7 +1357,7 @@ exports[`generateModelConfig mixed provider scenarios uses all providers togethe
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
"agents": {
"atlas": {
"model": "anthropic/claude-sonnet-4-5",
"model": "opencode/kimi-k2.5-free",
},
"explore": {
"model": "anthropic/claude-haiku-4-5",
@@ -1310,6 +1393,10 @@ exports[`generateModelConfig mixed provider scenarios uses all providers togethe
"model": "google/gemini-3-pro",
"variant": "max",
},
"deep": {
"model": "openai/gpt-5.2-codex",
"variant": "medium",
},
"quick": {
"model": "anthropic/claude-haiku-4-5",
},
@@ -1338,7 +1425,7 @@ exports[`generateModelConfig mixed provider scenarios uses all providers with is
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
"agents": {
"atlas": {
"model": "anthropic/claude-sonnet-4-5",
"model": "opencode/kimi-k2.5-free",
},
"explore": {
"model": "anthropic/claude-haiku-4-5",
@@ -1375,6 +1462,10 @@ exports[`generateModelConfig mixed provider scenarios uses all providers with is
"model": "google/gemini-3-pro",
"variant": "max",
},
"deep": {
"model": "openai/gpt-5.2-codex",
"variant": "medium",
},
"quick": {
"model": "anthropic/claude-haiku-4-5",
},

View File

@@ -250,6 +250,7 @@ describe("generateOmoConfig - model fallback system", () => {
hasCopilot: false,
hasOpencodeZen: false,
hasZaiCodingPlan: false,
hasKimiForCoding: false,
}
// #when generating config
@@ -271,6 +272,7 @@ describe("generateOmoConfig - model fallback system", () => {
hasCopilot: false,
hasOpencodeZen: false,
hasZaiCodingPlan: false,
hasKimiForCoding: false,
}
// #when generating config
@@ -290,6 +292,7 @@ describe("generateOmoConfig - model fallback system", () => {
hasCopilot: true,
hasOpencodeZen: false,
hasZaiCodingPlan: false,
hasKimiForCoding: false,
}
// #when generating config
@@ -309,6 +312,7 @@ describe("generateOmoConfig - model fallback system", () => {
hasCopilot: false,
hasOpencodeZen: false,
hasZaiCodingPlan: false,
hasKimiForCoding: false,
}
// #when generating config
@@ -316,7 +320,7 @@ describe("generateOmoConfig - model fallback system", () => {
// #then should use ultimate fallback for all agents
expect(result.$schema).toBe("https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json")
expect((result.agents as Record<string, { model: string }>).sisyphus.model).toBe("opencode/big-pickle")
expect((result.agents as Record<string, { model: string }>).sisyphus.model).toBe("opencode/glm-4.7-free")
})
test("uses zai-coding-plan/glm-4.7 for librarian when Z.ai available", () => {
@@ -329,6 +333,7 @@ describe("generateOmoConfig - model fallback system", () => {
hasCopilot: false,
hasOpencodeZen: false,
hasZaiCodingPlan: true,
hasKimiForCoding: false,
}
// #when generating config
@@ -350,6 +355,7 @@ describe("generateOmoConfig - model fallback system", () => {
hasCopilot: false,
hasOpencodeZen: false,
hasZaiCodingPlan: false,
hasKimiForCoding: false,
}
// #when generating config
@@ -373,6 +379,7 @@ describe("generateOmoConfig - model fallback system", () => {
hasCopilot: false,
hasOpencodeZen: false,
hasZaiCodingPlan: false,
hasKimiForCoding: false,
}
// #when generating config
@@ -392,6 +399,7 @@ describe("generateOmoConfig - model fallback system", () => {
hasCopilot: false,
hasOpencodeZen: false,
hasZaiCodingPlan: false,
hasKimiForCoding: false,
}
// #when generating config

View File

@@ -598,27 +598,28 @@ export function addProviderConfig(config: InstallConfig): ConfigMergeResult {
}
}
function detectProvidersFromOmoConfig(): { hasOpenAI: boolean; hasOpencodeZen: boolean; hasZaiCodingPlan: boolean } {
function detectProvidersFromOmoConfig(): { hasOpenAI: boolean; hasOpencodeZen: boolean; hasZaiCodingPlan: boolean; hasKimiForCoding: boolean } {
const omoConfigPath = getOmoConfig()
if (!existsSync(omoConfigPath)) {
return { hasOpenAI: true, hasOpencodeZen: true, hasZaiCodingPlan: false }
return { hasOpenAI: true, hasOpencodeZen: true, hasZaiCodingPlan: false, hasKimiForCoding: false }
}
try {
const content = readFileSync(omoConfigPath, "utf-8")
const omoConfig = parseJsonc<Record<string, unknown>>(content)
if (!omoConfig || typeof omoConfig !== "object") {
return { hasOpenAI: true, hasOpencodeZen: true, hasZaiCodingPlan: false }
return { hasOpenAI: true, hasOpencodeZen: true, hasZaiCodingPlan: false, hasKimiForCoding: false }
}
const configStr = JSON.stringify(omoConfig)
const hasOpenAI = configStr.includes('"openai/')
const hasOpencodeZen = configStr.includes('"opencode/')
const hasZaiCodingPlan = configStr.includes('"zai-coding-plan/')
const hasKimiForCoding = configStr.includes('"kimi-for-coding/')
return { hasOpenAI, hasOpencodeZen, hasZaiCodingPlan }
return { hasOpenAI, hasOpencodeZen, hasZaiCodingPlan, hasKimiForCoding }
} catch {
return { hasOpenAI: true, hasOpencodeZen: true, hasZaiCodingPlan: false }
return { hasOpenAI: true, hasOpencodeZen: true, hasZaiCodingPlan: false, hasKimiForCoding: false }
}
}
@@ -632,6 +633,7 @@ export function detectCurrentConfig(): DetectedConfig {
hasCopilot: false,
hasOpencodeZen: true,
hasZaiCodingPlan: false,
hasKimiForCoding: false,
}
const { format, path } = detectConfigFormat()
@@ -655,10 +657,11 @@ export function detectCurrentConfig(): DetectedConfig {
// Gemini auth plugin detection still works via plugin presence
result.hasGemini = plugins.some((p) => p.startsWith("opencode-antigravity-auth"))
const { hasOpenAI, hasOpencodeZen, hasZaiCodingPlan } = detectProvidersFromOmoConfig()
const { hasOpenAI, hasOpencodeZen, hasZaiCodingPlan, hasKimiForCoding } = detectProvidersFromOmoConfig()
result.hasOpenAI = hasOpenAI
result.hasOpencodeZen = hasOpencodeZen
result.hasZaiCodingPlan = hasZaiCodingPlan
result.hasKimiForCoding = hasKimiForCoding
return result
}

View File

@@ -8,6 +8,7 @@ import { getDependencyCheckDefinitions } from "./dependencies"
import { getGhCliCheckDefinition } from "./gh"
import { getLspCheckDefinition } from "./lsp"
import { getMcpCheckDefinitions } from "./mcp"
import { getMcpOAuthCheckDefinition } from "./mcp-oauth"
import { getVersionCheckDefinition } from "./version"
export * from "./opencode"
@@ -19,6 +20,7 @@ export * from "./dependencies"
export * from "./gh"
export * from "./lsp"
export * from "./mcp"
export * from "./mcp-oauth"
export * from "./version"
export function getAllCheckDefinitions(): CheckDefinition[] {
@@ -32,6 +34,7 @@ export function getAllCheckDefinitions(): CheckDefinition[] {
getGhCliCheckDefinition(),
getLspCheckDefinition(),
...getMcpCheckDefinitions(),
getMcpOAuthCheckDefinition(),
getVersionCheckDefinition(),
]
}

View File

@@ -0,0 +1,133 @@
import { describe, it, expect, spyOn, afterEach } from "bun:test"
import * as mcpOauth from "./mcp-oauth"
describe("mcp-oauth check", () => {
describe("getMcpOAuthCheckDefinition", () => {
it("returns check definition with correct properties", () => {
// #given
// #when getting definition
const def = mcpOauth.getMcpOAuthCheckDefinition()
// #then should have correct structure
expect(def.id).toBe("mcp-oauth-tokens")
expect(def.name).toBe("MCP OAuth Tokens")
expect(def.category).toBe("tools")
expect(def.critical).toBe(false)
expect(typeof def.check).toBe("function")
})
})
describe("checkMcpOAuthTokens", () => {
let readStoreSpy: ReturnType<typeof spyOn>
afterEach(() => {
readStoreSpy?.mockRestore()
})
it("returns skip when no tokens stored", async () => {
// #given no OAuth tokens configured
readStoreSpy = spyOn(mcpOauth, "readTokenStore").mockReturnValue(null)
// #when checking OAuth tokens
const result = await mcpOauth.checkMcpOAuthTokens()
// #then should skip
expect(result.status).toBe("skip")
expect(result.message).toContain("No OAuth")
})
it("returns pass when all tokens valid", async () => {
// #given valid tokens with future expiry (expiresAt is in epoch seconds)
const futureTime = Math.floor(Date.now() / 1000) + 3600
readStoreSpy = spyOn(mcpOauth, "readTokenStore").mockReturnValue({
"example.com/resource1": {
accessToken: "token1",
expiresAt: futureTime,
},
"example.com/resource2": {
accessToken: "token2",
expiresAt: futureTime,
},
})
// #when checking OAuth tokens
const result = await mcpOauth.checkMcpOAuthTokens()
// #then should pass
expect(result.status).toBe("pass")
expect(result.message).toContain("2")
expect(result.message).toContain("valid")
})
it("returns warn when some tokens expired", async () => {
// #given mix of valid and expired tokens (expiresAt is in epoch seconds)
const futureTime = Math.floor(Date.now() / 1000) + 3600
const pastTime = Math.floor(Date.now() / 1000) - 3600
readStoreSpy = spyOn(mcpOauth, "readTokenStore").mockReturnValue({
"example.com/resource1": {
accessToken: "token1",
expiresAt: futureTime,
},
"example.com/resource2": {
accessToken: "token2",
expiresAt: pastTime,
},
})
// #when checking OAuth tokens
const result = await mcpOauth.checkMcpOAuthTokens()
// #then should warn
expect(result.status).toBe("warn")
expect(result.message).toContain("1")
expect(result.message).toContain("expired")
expect(result.details?.some((d: string) => d.includes("Expired"))).toBe(
true
)
})
it("returns pass when tokens have no expiry", async () => {
// #given tokens without expiry info
readStoreSpy = spyOn(mcpOauth, "readTokenStore").mockReturnValue({
"example.com/resource1": {
accessToken: "token1",
},
})
// #when checking OAuth tokens
const result = await mcpOauth.checkMcpOAuthTokens()
// #then should pass (no expiry = assume valid)
expect(result.status).toBe("pass")
expect(result.message).toContain("1")
})
it("includes token details in output", async () => {
// #given multiple tokens
const futureTime = Math.floor(Date.now() / 1000) + 3600
readStoreSpy = spyOn(mcpOauth, "readTokenStore").mockReturnValue({
"api.example.com/v1": {
accessToken: "token1",
expiresAt: futureTime,
},
"auth.example.com/oauth": {
accessToken: "token2",
expiresAt: futureTime,
},
})
// #when checking OAuth tokens
const result = await mcpOauth.checkMcpOAuthTokens()
// #then should list tokens in details
expect(result.details).toBeDefined()
expect(result.details?.length).toBeGreaterThan(0)
expect(
result.details?.some((d: string) => d.includes("api.example.com"))
).toBe(true)
expect(
result.details?.some((d: string) => d.includes("auth.example.com"))
).toBe(true)
})
})
})

View File

@@ -0,0 +1,80 @@
import type { CheckResult, CheckDefinition } from "../types"
import { CHECK_IDS, CHECK_NAMES } from "../constants"
import { getMcpOauthStoragePath } from "../../../features/mcp-oauth/storage"
import { existsSync, readFileSync } from "node:fs"
interface OAuthTokenData {
accessToken: string
refreshToken?: string
expiresAt?: number
clientInfo?: {
clientId: string
clientSecret?: string
}
}
type TokenStore = Record<string, OAuthTokenData>
export function readTokenStore(): TokenStore | null {
const filePath = getMcpOauthStoragePath()
if (!existsSync(filePath)) {
return null
}
try {
const content = readFileSync(filePath, "utf-8")
return JSON.parse(content) as TokenStore
} catch {
return null
}
}
export async function checkMcpOAuthTokens(): Promise<CheckResult> {
const store = readTokenStore()
if (!store || Object.keys(store).length === 0) {
return {
name: CHECK_NAMES[CHECK_IDS.MCP_OAUTH_TOKENS],
status: "skip",
message: "No OAuth tokens configured",
details: ["Optional: Configure OAuth tokens for MCP servers"],
}
}
const now = Math.floor(Date.now() / 1000)
const tokens = Object.entries(store)
const expiredTokens = tokens.filter(
([, token]) => token.expiresAt && token.expiresAt < now
)
if (expiredTokens.length > 0) {
return {
name: CHECK_NAMES[CHECK_IDS.MCP_OAUTH_TOKENS],
status: "warn",
message: `${expiredTokens.length} of ${tokens.length} token(s) expired`,
details: [
...tokens
.filter(([, token]) => !token.expiresAt || token.expiresAt >= now)
.map(([key]) => `Valid: ${key}`),
...expiredTokens.map(([key]) => `Expired: ${key}`),
],
}
}
return {
name: CHECK_NAMES[CHECK_IDS.MCP_OAUTH_TOKENS],
status: "pass",
message: `${tokens.length} OAuth token(s) valid`,
details: tokens.map(([key]) => `Configured: ${key}`),
}
}
export function getMcpOAuthCheckDefinition(): CheckDefinition {
return {
id: CHECK_IDS.MCP_OAUTH_TOKENS,
name: CHECK_NAMES[CHECK_IDS.MCP_OAUTH_TOKENS],
category: "tools",
check: checkMcpOAuthTokens,
critical: false,
}
}

View File

@@ -32,6 +32,7 @@ export const CHECK_IDS = {
LSP_SERVERS: "lsp-servers",
MCP_BUILTIN: "mcp-builtin",
MCP_USER: "mcp-user",
MCP_OAUTH_TOKENS: "mcp-oauth-tokens",
VERSION_STATUS: "version-status",
} as const
@@ -50,6 +51,7 @@ export const CHECK_NAMES: Record<string, string> = {
[CHECK_IDS.LSP_SERVERS]: "LSP Servers",
[CHECK_IDS.MCP_BUILTIN]: "Built-in MCP Servers",
[CHECK_IDS.MCP_USER]: "User MCP Configuration",
[CHECK_IDS.MCP_OAUTH_TOKENS]: "MCP OAuth Tokens",
[CHECK_IDS.VERSION_STATUS]: "Version Status",
} as const

View File

@@ -4,6 +4,7 @@ import { install } from "./install"
import { run } from "./run"
import { getLocalVersion } from "./get-local-version"
import { doctor } from "./doctor"
import { createMcpOAuthCommand } from "./mcp-oauth"
import type { InstallArgs } from "./types"
import type { RunOptions } from "./run"
import type { GetLocalVersionOptions } from "./get-local-version/types"
@@ -29,6 +30,7 @@ program
.option("--copilot <value>", "GitHub Copilot subscription: no, yes")
.option("--opencode-zen <value>", "OpenCode Zen access: no, yes (default: no)")
.option("--zai-coding-plan <value>", "Z.ai Coding Plan subscription: no, yes (default: no)")
.option("--kimi-for-coding <value>", "Kimi For Coding subscription: no, yes (default: no)")
.option("--skip-auth", "Skip authentication setup hints")
.addHelpText("after", `
Examples:
@@ -36,13 +38,14 @@ Examples:
$ bunx oh-my-opencode install --no-tui --claude=max20 --openai=yes --gemini=yes --copilot=no
$ bunx oh-my-opencode install --no-tui --claude=no --gemini=no --copilot=yes --opencode-zen=yes
Model Providers (Priority: Native > Copilot > OpenCode Zen > Z.ai):
Model Providers (Priority: Native > Copilot > OpenCode Zen > Z.ai > Kimi):
Claude Native anthropic/ models (Opus, Sonnet, Haiku)
OpenAI Native openai/ models (GPT-5.2 for Oracle)
Gemini Native google/ models (Gemini 3 Pro, Flash)
Copilot github-copilot/ models (fallback)
OpenCode Zen opencode/ models (opencode/claude-opus-4-5, etc.)
Z.ai zai-coding-plan/glm-4.7 (Librarian priority)
Kimi kimi-for-coding/k2p5 (Sisyphus/Prometheus fallback)
`)
.action(async (options) => {
const args: InstallArgs = {
@@ -53,6 +56,7 @@ Model Providers (Priority: Native > Copilot > OpenCode Zen > Z.ai):
copilot: options.copilot,
opencodeZen: options.opencodeZen,
zaiCodingPlan: options.zaiCodingPlan,
kimiForCoding: options.kimiForCoding,
skipAuth: options.skipAuth ?? false,
}
const exitCode = await install(args)
@@ -150,4 +154,6 @@ program
console.log(`oh-my-opencode v${VERSION}`)
})
program.addCommand(createMcpOAuthCommand())
program.parse()

View File

@@ -45,6 +45,7 @@ function formatConfigSummary(config: InstallConfig): string {
lines.push(formatProvider("GitHub Copilot", config.hasCopilot, "fallback"))
lines.push(formatProvider("OpenCode Zen", config.hasOpencodeZen, "opencode/ models"))
lines.push(formatProvider("Z.ai Coding Plan", config.hasZaiCodingPlan, "Librarian/Multimodal"))
lines.push(formatProvider("Kimi For Coding", config.hasKimiForCoding, "Sisyphus/Prometheus fallback"))
lines.push("")
lines.push(color.dim("─".repeat(40)))
@@ -141,6 +142,10 @@ function validateNonTuiArgs(args: InstallArgs): { valid: boolean; errors: string
errors.push(`Invalid --zai-coding-plan value: ${args.zaiCodingPlan} (expected: no, yes)`)
}
if (args.kimiForCoding !== undefined && !["no", "yes"].includes(args.kimiForCoding)) {
errors.push(`Invalid --kimi-for-coding value: ${args.kimiForCoding} (expected: no, yes)`)
}
return { valid: errors.length === 0, errors }
}
@@ -153,10 +158,11 @@ function argsToConfig(args: InstallArgs): InstallConfig {
hasCopilot: args.copilot === "yes",
hasOpencodeZen: args.opencodeZen === "yes",
hasZaiCodingPlan: args.zaiCodingPlan === "yes",
hasKimiForCoding: args.kimiForCoding === "yes",
}
}
function detectedToInitialValues(detected: DetectedConfig): { claude: ClaudeSubscription; openai: BooleanArg; gemini: BooleanArg; copilot: BooleanArg; opencodeZen: BooleanArg; zaiCodingPlan: BooleanArg } {
function detectedToInitialValues(detected: DetectedConfig): { claude: ClaudeSubscription; openai: BooleanArg; gemini: BooleanArg; copilot: BooleanArg; opencodeZen: BooleanArg; zaiCodingPlan: BooleanArg; kimiForCoding: BooleanArg } {
let claude: ClaudeSubscription = "no"
if (detected.hasClaude) {
claude = detected.isMax20 ? "max20" : "yes"
@@ -169,6 +175,7 @@ function detectedToInitialValues(detected: DetectedConfig): { claude: ClaudeSubs
copilot: detected.hasCopilot ? "yes" : "no",
opencodeZen: detected.hasOpencodeZen ? "yes" : "no",
zaiCodingPlan: detected.hasZaiCodingPlan ? "yes" : "no",
kimiForCoding: detected.hasKimiForCoding ? "yes" : "no",
}
}
@@ -178,7 +185,7 @@ async function runTuiMode(detected: DetectedConfig): Promise<InstallConfig | nul
const claude = await p.select({
message: "Do you have a Claude Pro/Max subscription?",
options: [
{ value: "no" as const, label: "No", hint: "Will use opencode/big-pickle as fallback" },
{ value: "no" as const, label: "No", hint: "Will use opencode/glm-4.7-free as fallback" },
{ value: "yes" as const, label: "Yes (standard)", hint: "Claude Opus 4.5 for orchestration" },
{ value: "max20" as const, label: "Yes (max20 mode)", hint: "Full power with Claude Sonnet 4.5 for Librarian" },
],
@@ -260,6 +267,20 @@ async function runTuiMode(detected: DetectedConfig): Promise<InstallConfig | nul
return null
}
const kimiForCoding = await p.select({
message: "Do you have a Kimi For Coding subscription?",
options: [
{ value: "no" as const, label: "No", hint: "Will use other configured providers" },
{ value: "yes" as const, label: "Yes", hint: "Kimi K2.5 for Sisyphus/Prometheus fallback" },
],
initialValue: initial.kimiForCoding,
})
if (p.isCancel(kimiForCoding)) {
p.cancel("Installation cancelled.")
return null
}
return {
hasClaude: claude !== "no",
isMax20: claude === "max20",
@@ -268,6 +289,7 @@ async function runTuiMode(detected: DetectedConfig): Promise<InstallConfig | nul
hasCopilot: copilot === "yes",
hasOpencodeZen: opencodeZen === "yes",
hasZaiCodingPlan: zaiCodingPlan === "yes",
hasKimiForCoding: kimiForCoding === "yes",
}
}
@@ -363,7 +385,7 @@ async function runNonTuiInstall(args: InstallArgs): Promise<number> {
}
if (!config.hasClaude && !config.hasOpenAI && !config.hasGemini && !config.hasCopilot && !config.hasOpencodeZen) {
printWarning("No model providers configured. Using opencode/big-pickle as fallback.")
printWarning("No model providers configured. Using opencode/glm-4.7-free as fallback.")
}
console.log(`${SYMBOLS.star} ${color.bold(color.green(isUpdate ? "Configuration updated!" : "Installation complete!"))}`)
@@ -378,7 +400,7 @@ async function runNonTuiInstall(args: InstallArgs): Promise<number> {
)
console.log(`${SYMBOLS.star} ${color.yellow("If you found this helpful, consider starring the repo!")}`)
console.log(` ${color.dim("gh repo star code-yeongyu/oh-my-opencode")}`)
console.log(` ${color.dim("gh api --silent --method PUT /user/starred/code-yeongyu/oh-my-opencode >/dev/null 2>&1 || true")}`)
console.log()
console.log(color.dim("oMoMoMoMo... Enjoy!"))
console.log()
@@ -480,7 +502,7 @@ export async function install(args: InstallArgs): Promise<number> {
}
if (!config.hasClaude && !config.hasOpenAI && !config.hasGemini && !config.hasCopilot && !config.hasOpencodeZen) {
p.log.warn("No model providers configured. Using opencode/big-pickle as fallback.")
p.log.warn("No model providers configured. Using opencode/glm-4.7-free as fallback.")
}
p.note(formatConfigSummary(config), isUpdate ? "Updated Configuration" : "Installation Complete")
@@ -496,7 +518,7 @@ export async function install(args: InstallArgs): Promise<number> {
)
p.log.message(`${color.yellow("★")} If you found this helpful, consider starring the repo!`)
p.log.message(` ${color.dim("gh repo star code-yeongyu/oh-my-opencode")}`)
p.log.message(` ${color.dim("gh api --silent --method PUT /user/starred/code-yeongyu/oh-my-opencode >/dev/null 2>&1 || true")}`)
p.outro(color.green("oMoMoMoMo... Enjoy!"))

View File

@@ -0,0 +1,123 @@
import { describe, it, expect } from "bun:test"
import { Command } from "commander"
import { createMcpOAuthCommand } from "./index"
describe("mcp oauth command", () => {
describe("command structure", () => {
it("creates mcp command group with oauth subcommand", () => {
// given
const mcpCommand = createMcpOAuthCommand()
// when
const subcommands = mcpCommand.commands.map((cmd: Command) => cmd.name())
// then
expect(subcommands).toContain("oauth")
})
it("oauth subcommand has login, logout, and status subcommands", () => {
// given
const mcpCommand = createMcpOAuthCommand()
const oauthCommand = mcpCommand.commands.find((cmd: Command) => cmd.name() === "oauth")
// when
const subcommands = oauthCommand?.commands.map((cmd: Command) => cmd.name()) ?? []
// then
expect(subcommands).toContain("login")
expect(subcommands).toContain("logout")
expect(subcommands).toContain("status")
})
})
describe("login subcommand", () => {
it("exists and has description", () => {
// given
const mcpCommand = createMcpOAuthCommand()
const oauthCommand = mcpCommand.commands.find((cmd: Command) => cmd.name() === "oauth")
const loginCommand = oauthCommand?.commands.find((cmd: Command) => cmd.name() === "login")
// when
const description = loginCommand?.description() ?? ""
// then
expect(loginCommand).toBeDefined()
expect(description).toContain("OAuth")
})
it("accepts --server-url option", () => {
// given
const mcpCommand = createMcpOAuthCommand()
const oauthCommand = mcpCommand.commands.find((cmd: Command) => cmd.name() === "oauth")
const loginCommand = oauthCommand?.commands.find((cmd: Command) => cmd.name() === "login")
// when
const options = loginCommand?.options ?? []
const serverUrlOption = options.find((opt: { long?: string }) => opt.long === "--server-url")
// then
expect(serverUrlOption).toBeDefined()
})
it("accepts --client-id option", () => {
// given
const mcpCommand = createMcpOAuthCommand()
const oauthCommand = mcpCommand.commands.find((cmd: Command) => cmd.name() === "oauth")
const loginCommand = oauthCommand?.commands.find((cmd: Command) => cmd.name() === "login")
// when
const options = loginCommand?.options ?? []
const clientIdOption = options.find((opt: { long?: string }) => opt.long === "--client-id")
// then
expect(clientIdOption).toBeDefined()
})
it("accepts --scopes option", () => {
// given
const mcpCommand = createMcpOAuthCommand()
const oauthCommand = mcpCommand.commands.find((cmd: Command) => cmd.name() === "oauth")
const loginCommand = oauthCommand?.commands.find((cmd: Command) => cmd.name() === "login")
// when
const options = loginCommand?.options ?? []
const scopesOption = options.find((opt: { long?: string }) => opt.long === "--scopes")
// then
expect(scopesOption).toBeDefined()
})
})
describe("logout subcommand", () => {
it("exists and has description", () => {
// given
const mcpCommand = createMcpOAuthCommand()
const oauthCommand = mcpCommand.commands.find((cmd: Command) => cmd.name() === "oauth")
const logoutCommand = oauthCommand?.commands.find((cmd: Command) => cmd.name() === "logout")
// when
const description = logoutCommand?.description() ?? ""
// then
expect(logoutCommand).toBeDefined()
expect(description).toContain("tokens")
})
})
describe("status subcommand", () => {
it("exists and has description", () => {
// given
const mcpCommand = createMcpOAuthCommand()
const oauthCommand = mcpCommand.commands.find((cmd: Command) => cmd.name() === "oauth")
const statusCommand = oauthCommand?.commands.find((cmd: Command) => cmd.name() === "status")
// when
const description = statusCommand?.description() ?? ""
// then
expect(statusCommand).toBeDefined()
expect(description).toContain("status")
})
})
})

View File

@@ -0,0 +1,43 @@
import { Command } from "commander"
import { login } from "./login"
import { logout } from "./logout"
import { status } from "./status"
export function createMcpOAuthCommand(): Command {
const mcp = new Command("mcp").description("MCP server management")
const oauth = new Command("oauth").description("OAuth token management for MCP servers")
oauth
.command("login <server-name>")
.description("Authenticate with an MCP server using OAuth")
.option("--server-url <url>", "OAuth server URL (required if not in config)")
.option("--client-id <id>", "OAuth client ID (optional, uses DCR if not provided)")
.option("--scopes <scopes...>", "OAuth scopes to request")
.action(async (serverName: string, options) => {
const exitCode = await login(serverName, options)
process.exit(exitCode)
})
oauth
.command("logout <server-name>")
.description("Remove stored OAuth tokens for an MCP server")
.option("--server-url <url>", "OAuth server URL (use if server name differs from URL)")
.action(async (serverName: string, options) => {
const exitCode = await logout(serverName, options)
process.exit(exitCode)
})
oauth
.command("status [server-name]")
.description("Show OAuth token status for MCP servers")
.action(async (serverName: string | undefined) => {
const exitCode = await status(serverName)
process.exit(exitCode)
})
mcp.addCommand(oauth)
return mcp
}
export { login, logout, status }

View File

@@ -0,0 +1,80 @@
import { describe, it, expect, beforeEach, afterEach, mock } from "bun:test"
const mockLogin = mock(() => Promise.resolve({ accessToken: "test-token", expiresAt: 1710000000 }))
mock.module("../../features/mcp-oauth/provider", () => ({
McpOAuthProvider: class MockMcpOAuthProvider {
constructor(public options: { serverUrl: string; clientId?: string; scopes?: string[] }) {}
async login() {
return mockLogin()
}
},
}))
const { login } = await import("./login")
describe("login command", () => {
beforeEach(() => {
mockLogin.mockClear()
})
afterEach(() => {
// cleanup
})
it("returns error code when server-url is not provided", async () => {
// given
const serverName = "test-server"
const options = {}
// when
const exitCode = await login(serverName, options)
// then
expect(exitCode).toBe(1)
})
it("returns success code when login succeeds", async () => {
// given
const serverName = "test-server"
const options = {
serverUrl: "https://oauth.example.com",
}
// when
const exitCode = await login(serverName, options)
// then
expect(exitCode).toBe(0)
expect(mockLogin).toHaveBeenCalledTimes(1)
})
it("returns error code when login throws", async () => {
// given
const serverName = "test-server"
const options = {
serverUrl: "https://oauth.example.com",
}
mockLogin.mockRejectedValueOnce(new Error("Network error"))
// when
const exitCode = await login(serverName, options)
// then
expect(exitCode).toBe(1)
})
it("returns error code when server-url is missing", async () => {
// given
const serverName = "test-server"
const options = {
clientId: "test-client-id",
}
// when
const exitCode = await login(serverName, options)
// then
expect(exitCode).toBe(1)
})
})

View File

@@ -0,0 +1,38 @@
import { McpOAuthProvider } from "../../features/mcp-oauth/provider"
export interface LoginOptions {
serverUrl?: string
clientId?: string
scopes?: string[]
}
export async function login(serverName: string, options: LoginOptions): Promise<number> {
try {
const serverUrl = options.serverUrl
if (!serverUrl) {
console.error(`Error: --server-url is required for server "${serverName}"`)
return 1
}
const provider = new McpOAuthProvider({
serverUrl,
clientId: options.clientId,
scopes: options.scopes,
})
console.log(`Authenticating with ${serverName}...`)
const tokenData = await provider.login()
console.log(`✓ Successfully authenticated with ${serverName}`)
if (tokenData.expiresAt) {
const expiryDate = new Date(tokenData.expiresAt * 1000)
console.log(` Token expires at: ${expiryDate.toISOString()}`)
}
return 0
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
console.error(`Error: Failed to authenticate with ${serverName}: ${message}`)
return 1
}
}

View File

@@ -0,0 +1,65 @@
import { describe, it, expect, beforeEach, afterEach, mock } from "bun:test"
import { existsSync, mkdirSync, rmSync } from "node:fs"
import { join } from "node:path"
import { tmpdir } from "node:os"
import { saveToken } from "../../features/mcp-oauth/storage"
const { logout } = await import("./logout")
describe("logout command", () => {
const TEST_CONFIG_DIR = join(tmpdir(), "mcp-oauth-logout-test-" + Date.now())
let originalConfigDir: string | undefined
beforeEach(() => {
originalConfigDir = process.env.OPENCODE_CONFIG_DIR
process.env.OPENCODE_CONFIG_DIR = TEST_CONFIG_DIR
if (!existsSync(TEST_CONFIG_DIR)) {
mkdirSync(TEST_CONFIG_DIR, { recursive: true })
}
})
afterEach(() => {
if (originalConfigDir === undefined) {
delete process.env.OPENCODE_CONFIG_DIR
} else {
process.env.OPENCODE_CONFIG_DIR = originalConfigDir
}
if (existsSync(TEST_CONFIG_DIR)) {
rmSync(TEST_CONFIG_DIR, { recursive: true, force: true })
}
})
it("returns success code when logout succeeds", async () => {
// given
const serverUrl = "https://test-server.example.com"
saveToken(serverUrl, serverUrl, { accessToken: "test-token" })
// when
const exitCode = await logout("test-server", { serverUrl })
// then
expect(exitCode).toBe(0)
})
it("handles non-existent server gracefully", async () => {
// given
const serverName = "non-existent-server"
// when
const exitCode = await logout(serverName, { serverUrl: "https://nonexistent.example.com" })
// then
expect(exitCode).toBe(0)
})
it("returns error when --server-url is not provided", async () => {
// given
const serverName = "test-server"
// when
const exitCode = await logout(serverName)
// then
expect(exitCode).toBe(1)
})
})

View File

@@ -0,0 +1,30 @@
import { deleteToken } from "../../features/mcp-oauth/storage"
export interface LogoutOptions {
serverUrl?: string
}
export async function logout(serverName: string, options?: LogoutOptions): Promise<number> {
try {
const serverUrl = options?.serverUrl
if (!serverUrl) {
console.error(`Error: --server-url is required for logout. Token storage uses server URLs, not names.`)
console.error(` Usage: mcp oauth logout ${serverName} --server-url https://your-server.example.com`)
return 1
}
const success = deleteToken(serverUrl, serverUrl)
if (success) {
console.log(`✓ Successfully removed tokens for ${serverName}`)
return 0
}
console.error(`Error: Failed to remove tokens for ${serverName}`)
return 1
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
console.error(`Error: Failed to remove tokens for ${serverName}: ${message}`)
return 1
}
}

View File

@@ -0,0 +1,48 @@
import { describe, it, expect, beforeEach, afterEach } from "bun:test"
import { status } from "./status"
describe("status command", () => {
beforeEach(() => {
// setup
})
afterEach(() => {
// cleanup
})
it("returns success code when checking status for specific server", async () => {
// given
const serverName = "test-server"
// when
const exitCode = await status(serverName)
// then
expect(typeof exitCode).toBe("number")
expect(exitCode).toBe(0)
})
it("returns success code when checking status for all servers", async () => {
// given
const serverName = undefined
// when
const exitCode = await status(serverName)
// then
expect(typeof exitCode).toBe("number")
expect(exitCode).toBe(0)
})
it("handles non-existent server gracefully", async () => {
// given
const serverName = "non-existent-server"
// when
const exitCode = await status(serverName)
// then
expect(typeof exitCode).toBe("number")
expect(exitCode).toBe(0)
})
})

View File

@@ -0,0 +1,50 @@
import { listAllTokens, listTokensByHost } from "../../features/mcp-oauth/storage"
export async function status(serverName: string | undefined): Promise<number> {
try {
if (serverName) {
const tokens = listTokensByHost(serverName)
if (Object.keys(tokens).length === 0) {
console.log(`No tokens found for ${serverName}`)
return 0
}
console.log(`OAuth Status for ${serverName}:`)
for (const [key, token] of Object.entries(tokens)) {
console.log(` ${key}:`)
console.log(` Access Token: [REDACTED]`)
if (token.refreshToken) {
console.log(` Refresh Token: [REDACTED]`)
}
if (token.expiresAt) {
const expiryDate = new Date(token.expiresAt * 1000)
const now = Date.now() / 1000
const isExpired = token.expiresAt < now
const tokenStatus = isExpired ? "EXPIRED" : "VALID"
console.log(` Expiry: ${expiryDate.toISOString()} (${tokenStatus})`)
}
}
return 0
}
const tokens = listAllTokens()
if (Object.keys(tokens).length === 0) {
console.log("No OAuth tokens stored")
return 0
}
console.log("Stored OAuth Tokens:")
for (const [key, token] of Object.entries(tokens)) {
const isExpired = token.expiresAt && token.expiresAt < Date.now() / 1000
const tokenStatus = isExpired ? "EXPIRED" : "VALID"
console.log(` ${key}: ${tokenStatus}`)
}
return 0
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
console.error(`Error: Failed to get token status: ${message}`)
return 1
}
}

View File

@@ -12,6 +12,7 @@ function createConfig(overrides: Partial<InstallConfig> = {}): InstallConfig {
hasCopilot: false,
hasOpencodeZen: false,
hasZaiCodingPlan: false,
hasKimiForCoding: false,
...overrides,
}
}

View File

@@ -14,6 +14,7 @@ interface ProviderAvailability {
opencodeZen: boolean
copilot: boolean
zai: boolean
kimiForCoding: boolean
isMaxPlan: boolean
}
@@ -36,7 +37,7 @@ export interface GeneratedOmoConfig {
const ZAI_MODEL = "zai-coding-plan/glm-4.7"
const ULTIMATE_FALLBACK = "opencode/big-pickle"
const ULTIMATE_FALLBACK = "opencode/glm-4.7-free"
const SCHEMA_URL = "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json"
function toProviderAvailability(config: InstallConfig): ProviderAvailability {
@@ -49,6 +50,7 @@ function toProviderAvailability(config: InstallConfig): ProviderAvailability {
opencodeZen: config.hasOpencodeZen,
copilot: config.hasCopilot,
zai: config.hasZaiCodingPlan,
kimiForCoding: config.hasKimiForCoding,
isMaxPlan: config.isMax20,
}
}
@@ -61,6 +63,7 @@ function isProviderAvailable(provider: string, avail: ProviderAvailability): boo
"github-copilot": avail.copilot,
opencode: avail.opencodeZen,
"zai-coding-plan": avail.zai,
"kimi-for-coding": avail.kimiForCoding,
}
return mapping[provider] ?? false
}
@@ -102,6 +105,8 @@ function getSisyphusFallbackChain(isMaxPlan: boolean): FallbackEntry[] {
// For non-max plan, use sonnet instead of opus
return [
{ providers: ["anthropic", "github-copilot", "opencode"], model: "claude-sonnet-4-5" },
{ providers: ["kimi-for-coding"], model: "k2p5" },
{ providers: ["opencode"], model: "kimi-k2.5-free" },
{ providers: ["openai", "github-copilot", "opencode"], model: "gpt-5.2", variant: "high" },
{ providers: ["google", "github-copilot", "opencode"], model: "gemini-3-pro" },
]
@@ -115,7 +120,8 @@ export function generateModelConfig(config: InstallConfig): GeneratedOmoConfig {
avail.native.gemini ||
avail.opencodeZen ||
avail.copilot ||
avail.zai
avail.zai ||
avail.kimiForCoding
if (!hasAnyProvider) {
return {

View File

@@ -82,6 +82,7 @@ describe("createEventState", () => {
expect(state.lastOutput).toBe("")
expect(state.lastPartText).toBe("")
expect(state.currentTool).toBe(null)
expect(state.hasReceivedMeaningfulWork).toBe(false)
})
})
@@ -126,6 +127,119 @@ describe("event handling", () => {
expect(state.mainSessionIdle).toBe(false)
})
it("hasReceivedMeaningfulWork is false initially after session.idle", async () => {
// #given - session goes idle without any assistant output (race condition scenario)
const ctx = createMockContext("my-session")
const state = createEventState()
const payload: EventPayload = {
type: "session.idle",
properties: { sessionID: "my-session" },
}
const events = toAsyncIterable([payload])
const { processEvents } = await import("./events")
// #when
await processEvents(ctx, events, state)
// #then - idle but no meaningful work yet
expect(state.mainSessionIdle).toBe(true)
expect(state.hasReceivedMeaningfulWork).toBe(false)
})
it("message.updated with assistant role sets hasReceivedMeaningfulWork", async () => {
// #given
const ctx = createMockContext("my-session")
const state = createEventState()
const payload: EventPayload = {
type: "message.updated",
properties: {
info: { sessionID: "my-session", role: "assistant" },
},
}
const events = toAsyncIterable([payload])
const { processEvents } = await import("./events")
// #when
await processEvents(ctx, events, state)
// #then
expect(state.hasReceivedMeaningfulWork).toBe(true)
})
it("message.updated with user role does not set hasReceivedMeaningfulWork", async () => {
// #given - user message should not count as meaningful work
const ctx = createMockContext("my-session")
const state = createEventState()
const payload: EventPayload = {
type: "message.updated",
properties: {
info: { sessionID: "my-session", role: "user" },
},
}
const events = toAsyncIterable([payload])
const { processEvents } = await import("./events")
// #when
await processEvents(ctx, events, state)
// #then - user role should not count as meaningful work
expect(state.hasReceivedMeaningfulWork).toBe(false)
})
it("tool.execute sets hasReceivedMeaningfulWork", async () => {
// #given
const ctx = createMockContext("my-session")
const state = createEventState()
const payload: EventPayload = {
type: "tool.execute",
properties: {
sessionID: "my-session",
name: "read_file",
input: { filePath: "/src/index.ts" },
},
}
const events = toAsyncIterable([payload])
const { processEvents } = await import("./events")
// #when
await processEvents(ctx, events, state)
// #then
expect(state.hasReceivedMeaningfulWork).toBe(true)
})
it("tool.execute from different session does not set hasReceivedMeaningfulWork", async () => {
// #given
const ctx = createMockContext("my-session")
const state = createEventState()
const payload: EventPayload = {
type: "tool.execute",
properties: {
sessionID: "other-session",
name: "read_file",
input: { filePath: "/src/index.ts" },
},
}
const events = toAsyncIterable([payload])
const { processEvents } = await import("./events")
// #when
await processEvents(ctx, events, state)
// #then - different session's tool call shouldn't count
expect(state.hasReceivedMeaningfulWork).toBe(false)
})
it("session.status with busy type sets mainSessionIdle to false", async () => {
// #given
const ctx = createMockContext("my-session")
@@ -136,6 +250,7 @@ describe("event handling", () => {
lastOutput: "",
lastPartText: "",
currentTool: null,
hasReceivedMeaningfulWork: false,
}
const payload: EventPayload = {

View File

@@ -63,6 +63,8 @@ export interface EventState {
lastOutput: string
lastPartText: string
currentTool: string | null
/** Set to true when the main session has produced meaningful work (text, tool call, or tool result) */
hasReceivedMeaningfulWork: boolean
}
export function createEventState(): EventState {
@@ -73,6 +75,7 @@ export function createEventState(): EventState {
lastOutput: "",
lastPartText: "",
currentTool: null,
hasReceivedMeaningfulWork: false,
}
}
@@ -113,7 +116,9 @@ function logEventVerbose(ctx: RunContext, payload: EventPayload): void {
const isMainSession = sessionID === ctx.sessionID
const sessionTag = isMainSession
? pc.green("[MAIN]")
: pc.yellow(`[${String(sessionID).slice(0, 8)}]`)
: sessionID
? pc.yellow(`[${String(sessionID).slice(0, 8)}]`)
: pc.dim("[system]")
switch (payload.type) {
case "session.idle":
@@ -124,8 +129,6 @@ function logEventVerbose(ctx: RunContext, payload: EventPayload): void {
}
case "message.part.updated": {
// Skip verbose logging for partial message updates
// Only log tool invocation state changes, not text streaming
const partProps = props as MessagePartUpdatedProps | undefined
const part = partProps?.part
if (part?.type === "tool-invocation") {
@@ -133,6 +136,11 @@ function logEventVerbose(ctx: RunContext, payload: EventPayload): void {
console.error(
pc.dim(`${sessionTag} message.part (tool): ${toolPart.toolName} [${toolPart.state}]`)
)
} else if (part?.type === "text" && part.text) {
const preview = part.text.slice(0, 80).replace(/\n/g, "\\n")
console.error(
pc.dim(`${sessionTag} message.part (text): "${preview}${part.text.length > 80 ? "..." : ""}"`)
)
}
break
}
@@ -140,11 +148,10 @@ function logEventVerbose(ctx: RunContext, payload: EventPayload): void {
case "message.updated": {
const msgProps = props as MessageUpdatedProps | undefined
const role = msgProps?.info?.role ?? "unknown"
const content = msgProps?.content ?? ""
const preview = content.slice(0, 100).replace(/\n/g, "\\n")
console.error(
pc.dim(`${sessionTag} message.updated (${role}): "${preview}${content.length > 100 ? "..." : ""}"`)
)
const model = msgProps?.info?.modelID
const agent = msgProps?.info?.agent
const details = [role, agent, model].filter(Boolean).join(", ")
console.error(pc.dim(`${sessionTag} message.updated (${details})`))
break
}
@@ -241,6 +248,7 @@ function handleMessagePartUpdated(
const newText = part.text.slice(state.lastPartText.length)
if (newText) {
process.stdout.write(newText)
state.hasReceivedMeaningfulWork = true
}
state.lastPartText = part.text
}
@@ -257,16 +265,7 @@ function handleMessageUpdated(
if (props?.info?.sessionID !== ctx.sessionID) return
if (props?.info?.role !== "assistant") return
const content = props.content
if (!content || content === state.lastOutput) return
if (state.lastPartText.length === 0) {
const newContent = content.slice(state.lastOutput.length)
if (newContent) {
process.stdout.write(newContent)
}
}
state.lastOutput = content
state.hasReceivedMeaningfulWork = true
}
function handleToolExecute(
@@ -296,6 +295,7 @@ function handleToolExecute(
}
}
state.hasReceivedMeaningfulWork = true
process.stdout.write(`\n${pc.cyan(">")} ${pc.bold(toolName)}${inputPreview}\n`)
}

View File

@@ -143,6 +143,14 @@ export async function run(options: RunOptions): Promise<number> {
process.exit(1)
}
// Guard against premature completion: don't check completion until the
// session has produced meaningful work (text output, tool call, or tool result).
// Without this, a session that goes busy->idle before the LLM responds
// would exit immediately because 0 todos + 0 children = "complete".
if (!eventState.hasReceivedMeaningfulWork) {
continue
}
const shouldExit = await checkCompletionConditions(ctx)
if (shouldExit) {
console.log(pc.green("\n\nAll tasks completed."))

View File

@@ -44,8 +44,13 @@ export interface SessionStatusProps {
}
export interface MessageUpdatedProps {
info?: { sessionID?: string; role?: string }
content?: string
info?: {
sessionID?: string
role?: string
modelID?: string
providerID?: string
agent?: string
}
}
export interface MessagePartUpdatedProps {

View File

@@ -9,6 +9,7 @@ export interface InstallArgs {
copilot?: BooleanArg
opencodeZen?: BooleanArg
zaiCodingPlan?: BooleanArg
kimiForCoding?: BooleanArg
skipAuth?: boolean
}
@@ -20,6 +21,7 @@ export interface InstallConfig {
hasCopilot: boolean
hasOpencodeZen: boolean
hasZaiCodingPlan: boolean
hasKimiForCoding: boolean
}
export interface ConfigMergeResult {
@@ -37,4 +39,5 @@ export interface DetectedConfig {
hasCopilot: boolean
hasOpencodeZen: boolean
hasZaiCodingPlan: boolean
hasKimiForCoding: boolean
}

View File

@@ -187,6 +187,7 @@ export const CategoryConfigSchema = z.object({
export const BuiltinCategoryNameSchema = z.enum([
"visual-engineering",
"ultrabrain",
"deep",
"artistry",
"quick",
"unspecified-low",

View File

@@ -176,8 +176,8 @@ describe("ConcurrencyManager.acquire/release", () => {
await manager.acquire("model-a")
await manager.acquire("model-a")
// #then - both resolved without waiting
expect(true).toBe(true)
// #then - both resolved without waiting, count should be 2
expect(manager.getCount("model-a")).toBe(2)
})
test("should allow acquires up to default limit of 5", async () => {
@@ -190,8 +190,8 @@ describe("ConcurrencyManager.acquire/release", () => {
await manager.acquire("model-a")
await manager.acquire("model-a")
// #then - all 5 resolved
expect(true).toBe(true)
// #then - all 5 resolved, count should be 5
expect(manager.getCount("model-a")).toBe(5)
})
test("should queue when limit reached", async () => {
@@ -276,8 +276,8 @@ describe("ConcurrencyManager.acquire/release", () => {
manager.release("model-a")
await manager.acquire("model-a")
// #then
expect(true).toBe(true)
// #then - count should be 1 after re-acquiring
expect(manager.getCount("model-a")).toBe(1)
})
test("should handle release when no acquire", () => {
@@ -288,21 +288,21 @@ describe("ConcurrencyManager.acquire/release", () => {
// #when - release without acquire
manager.release("model-a")
// #then - should not throw
expect(true).toBe(true)
// #then - count should be 0 (no negative count)
expect(manager.getCount("model-a")).toBe(0)
})
test("should handle release when no prior acquire", () => {
// #given - default config
// #when - release without acquire
manager.release("model-a")
// #when - release without acquire
manager.release("model-a")
// #then - should not throw
expect(true).toBe(true)
})
// #then - count should be 0 (no negative count)
expect(manager.getCount("model-a")).toBe(0)
})
test("should handle multiple acquires and releases correctly", async () => {
test("should handle multiple acquires and releases correctly", async () => {
// #given
const config: BackgroundTaskConfig = { defaultConcurrency: 3 }
manager = new ConcurrencyManager(config)
@@ -317,11 +317,11 @@ describe("ConcurrencyManager.acquire/release", () => {
manager.release("model-a")
manager.release("model-a")
// Should be able to acquire again
await manager.acquire("model-a")
// Should be able to acquire again
await manager.acquire("model-a")
// #then
expect(true).toBe(true)
// #then - count should be 1 after re-acquiring
expect(manager.getCount("model-a")).toBe(1)
})
test("should use model-specific limit for acquire", async () => {

View File

@@ -170,6 +170,7 @@ function createBackgroundManager(): BackgroundManager {
const client = {
session: {
prompt: async () => ({}),
abort: async () => ({}),
},
}
return new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput)
@@ -1053,6 +1054,7 @@ describe("BackgroundManager.resume model persistence", () => {
promptCalls.push(args)
return {}
},
abort: async () => ({}),
},
}
manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput)
@@ -1926,3 +1928,162 @@ describe("BackgroundManager.checkAndInterruptStaleTasks", () => {
})
})
describe("BackgroundManager.shutdown session abort", () => {
test("should call session.abort for all running tasks during shutdown", () => {
// #given
const abortedSessionIDs: string[] = []
const client = {
session: {
prompt: async () => ({}),
abort: async (args: { path: { id: string } }) => {
abortedSessionIDs.push(args.path.id)
return {}
},
},
}
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput)
const task1: BackgroundTask = {
id: "task-1",
sessionID: "session-1",
parentSessionID: "parent-1",
parentMessageID: "msg-1",
description: "Running task 1",
prompt: "Test",
agent: "test-agent",
status: "running",
startedAt: new Date(),
}
const task2: BackgroundTask = {
id: "task-2",
sessionID: "session-2",
parentSessionID: "parent-2",
parentMessageID: "msg-2",
description: "Running task 2",
prompt: "Test",
agent: "test-agent",
status: "running",
startedAt: new Date(),
}
getTaskMap(manager).set(task1.id, task1)
getTaskMap(manager).set(task2.id, task2)
// #when
manager.shutdown()
// #then
expect(abortedSessionIDs).toContain("session-1")
expect(abortedSessionIDs).toContain("session-2")
expect(abortedSessionIDs).toHaveLength(2)
})
test("should not call session.abort for completed or cancelled tasks", () => {
// #given
const abortedSessionIDs: string[] = []
const client = {
session: {
prompt: async () => ({}),
abort: async (args: { path: { id: string } }) => {
abortedSessionIDs.push(args.path.id)
return {}
},
},
}
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput)
const completedTask: BackgroundTask = {
id: "task-completed",
sessionID: "session-completed",
parentSessionID: "parent-1",
parentMessageID: "msg-1",
description: "Completed task",
prompt: "Test",
agent: "test-agent",
status: "completed",
startedAt: new Date(),
completedAt: new Date(),
}
const cancelledTask: BackgroundTask = {
id: "task-cancelled",
sessionID: "session-cancelled",
parentSessionID: "parent-2",
parentMessageID: "msg-2",
description: "Cancelled task",
prompt: "Test",
agent: "test-agent",
status: "cancelled",
startedAt: new Date(),
completedAt: new Date(),
}
const pendingTask: BackgroundTask = {
id: "task-pending",
parentSessionID: "parent-3",
parentMessageID: "msg-3",
description: "Pending task",
prompt: "Test",
agent: "test-agent",
status: "pending",
queuedAt: new Date(),
}
getTaskMap(manager).set(completedTask.id, completedTask)
getTaskMap(manager).set(cancelledTask.id, cancelledTask)
getTaskMap(manager).set(pendingTask.id, pendingTask)
// #when
manager.shutdown()
// #then
expect(abortedSessionIDs).toHaveLength(0)
})
test("should call onShutdown callback during shutdown", () => {
// #given
let shutdownCalled = false
const client = {
session: {
prompt: async () => ({}),
abort: async () => ({}),
},
}
const manager = new BackgroundManager(
{ client, directory: tmpdir() } as unknown as PluginInput,
undefined,
{
onShutdown: () => {
shutdownCalled = true
},
}
)
// #when
manager.shutdown()
// #then
expect(shutdownCalled).toBe(true)
})
test("should not throw when onShutdown callback throws", () => {
// #given
const client = {
session: {
prompt: async () => ({}),
abort: async () => ({}),
},
}
const manager = new BackgroundManager(
{ client, directory: tmpdir() } as unknown as PluginInput,
undefined,
{
onShutdown: () => {
throw new Error("cleanup failed")
},
}
)
// #when / #then
expect(() => manager.shutdown()).not.toThrow()
})
})

View File

@@ -5,7 +5,7 @@ import type {
LaunchInput,
ResumeInput,
} from "./types"
import { log, getAgentToolRestrictions } from "../../shared"
import { log, getAgentToolRestrictions, promptWithModelSuggestionRetry } from "../../shared"
import { ConcurrencyManager } from "./concurrency"
import type { BackgroundTaskConfig, TmuxConfig } from "../../config/schema"
import { isInsideTmux } from "../../shared/tmux"
@@ -79,6 +79,7 @@ export class BackgroundManager {
private config?: BackgroundTaskConfig
private tmuxEnabled: boolean
private onSubagentSessionCreated?: OnSubagentSessionCreated
private onShutdown?: () => void
private queuesByKey: Map<string, QueueItem[]> = new Map()
private processingKeys: Set<string> = new Set()
@@ -89,6 +90,7 @@ export class BackgroundManager {
options?: {
tmuxConfig?: TmuxConfig
onSubagentSessionCreated?: OnSubagentSessionCreated
onShutdown?: () => void
}
) {
this.tasks = new Map()
@@ -100,6 +102,7 @@ export class BackgroundManager {
this.config = config
this.tmuxEnabled = options?.tmuxConfig?.enabled ?? false
this.onSubagentSessionCreated = options?.onSubagentSessionCreated
this.onShutdown = options?.onShutdown
this.registerProcessCleanup()
}
@@ -304,7 +307,7 @@ export class BackgroundManager {
: undefined
const launchVariant = input.model?.variant
this.client.session.prompt({
promptWithModelSuggestionRetry(this.client, {
path: { id: sessionID },
body: {
agent: input.agent,
@@ -1346,7 +1349,25 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
log("[background-agent] Shutting down BackgroundManager")
this.stopPolling()
// Release concurrency for all running tasks first
// Abort all running sessions to prevent zombie processes (#1240)
for (const task of this.tasks.values()) {
if (task.status === "running" && task.sessionID) {
this.client.session.abort({
path: { id: task.sessionID },
}).catch(() => {})
}
}
// Notify shutdown listeners (e.g., tmux cleanup)
if (this.onShutdown) {
try {
this.onShutdown()
} catch (error) {
log("[background-agent] Error in onShutdown callback:", error)
}
}
// Release concurrency for all running tasks
for (const task of this.tasks.values()) {
if (task.concurrencyKey) {
this.concurrencyManager.release(task.concurrencyKey)

View File

@@ -7,6 +7,10 @@ export interface ClaudeCodeMcpServer {
args?: string[]
env?: Record<string, string>
headers?: Record<string, string>
oauth?: {
clientId?: string
scopes?: string[]
}
disabled?: boolean
}

View File

@@ -2,10 +2,11 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from
import { join } from "node:path"
import { MESSAGE_STORAGE, PART_STORAGE } from "./constants"
import type { MessageMeta, OriginalMessageContext, TextPart, ToolPermission } from "./types"
import { log } from "../../shared/logger"
export interface StoredMessage {
agent?: string
model?: { providerID?: string; modelID?: string }
model?: { providerID?: string; modelID?: string; variant?: string }
tools?: Record<string, ToolPermission>
}
@@ -117,7 +118,7 @@ export function injectHookMessage(
): boolean {
// Validate hook content to prevent empty message injection
if (!hookContent || hookContent.trim().length === 0) {
console.warn("[hook-message-injector] Attempted to inject empty hook content, skipping injection", {
log("[hook-message-injector] Attempted to inject empty hook content, skipping injection", {
sessionID,
hasAgent: !!originalMessage.agent,
hasModel: !!(originalMessage.model?.providerID && originalMessage.model?.modelID)
@@ -141,9 +142,17 @@ export function injectHookMessage(
const resolvedAgent = originalMessage.agent ?? fallback?.agent ?? "general"
const resolvedModel =
originalMessage.model?.providerID && originalMessage.model?.modelID
? { providerID: originalMessage.model.providerID, modelID: originalMessage.model.modelID }
? {
providerID: originalMessage.model.providerID,
modelID: originalMessage.model.modelID,
...(originalMessage.model.variant ? { variant: originalMessage.model.variant } : {})
}
: fallback?.model?.providerID && fallback?.model?.modelID
? { providerID: fallback.model.providerID, modelID: fallback.model.modelID }
? {
providerID: fallback.model.providerID,
modelID: fallback.model.modelID,
...(fallback.model.variant ? { variant: fallback.model.variant } : {})
}
: undefined
const resolvedTools = originalMessage.tools ?? fallback?.tools

View File

@@ -12,6 +12,7 @@ export interface MessageMeta {
model?: {
providerID: string
modelID: string
variant?: string
}
path?: {
cwd: string
@@ -25,6 +26,7 @@ export interface OriginalMessageContext {
model?: {
providerID?: string
modelID?: string
variant?: string
}
path?: {
cwd?: string

View File

@@ -0,0 +1,136 @@
import { afterEach, describe, expect, it } from "bun:test"
import { findAvailablePort, startCallbackServer, type CallbackServer } from "./callback-server"
const nativeFetch = Bun.fetch.bind(Bun)
describe("findAvailablePort", () => {
it("returns the start port when it is available", async () => {
//#given
const startPort = 19877
//#when
const port = await findAvailablePort(startPort)
//#then
expect(port).toBeGreaterThanOrEqual(startPort)
expect(port).toBeLessThan(startPort + 20)
})
it("skips busy ports and returns next available", async () => {
//#given
const blocker = Bun.serve({
port: 19877,
hostname: "127.0.0.1",
fetch: () => new Response(),
})
//#when
const port = await findAvailablePort(19877)
//#then
expect(port).toBeGreaterThan(19877)
blocker.stop(true)
})
})
describe("startCallbackServer", () => {
let server: CallbackServer | null = null
afterEach(async () => {
server?.close()
server = null
// Allow time for port to be released before next test
await Bun.sleep(10)
})
it("starts server and returns port", async () => {
//#given - no preconditions
//#when
server = await startCallbackServer()
//#then
expect(server.port).toBeGreaterThanOrEqual(19877)
expect(typeof server.waitForCallback).toBe("function")
expect(typeof server.close).toBe("function")
})
it("resolves callback with code and state from query params", async () => {
//#given
server = await startCallbackServer()
const callbackUrl = `http://127.0.0.1:${server.port}/oauth/callback?code=test-code&state=test-state`
//#when
// Use Promise.all to ensure fetch and waitForCallback run concurrently
// This prevents race condition where waitForCallback blocks before fetch starts
const [result, response] = await Promise.all([
server.waitForCallback(),
nativeFetch(callbackUrl)
])
//#then
expect(result).toEqual({ code: "test-code", state: "test-state" })
expect(response.status).toBe(200)
const html = await response.text()
expect(html).toContain("Authorization successful")
})
it("returns 404 for non-callback routes", async () => {
//#given
server = await startCallbackServer()
//#when
const response = await nativeFetch(`http://127.0.0.1:${server.port}/other`)
//#then
expect(response.status).toBe(404)
})
it("returns 400 and rejects when code is missing", async () => {
//#given
server = await startCallbackServer()
const callbackRejection = server.waitForCallback().catch((e: Error) => e)
//#when
const response = await nativeFetch(`http://127.0.0.1:${server.port}/oauth/callback?state=s`)
//#then
expect(response.status).toBe(400)
const error = await callbackRejection
expect(error).toBeInstanceOf(Error)
expect((error as Error).message).toContain("missing code or state")
})
it("returns 400 and rejects when state is missing", async () => {
//#given
server = await startCallbackServer()
const callbackRejection = server.waitForCallback().catch((e: Error) => e)
//#when
const response = await nativeFetch(`http://127.0.0.1:${server.port}/oauth/callback?code=c`)
//#then
expect(response.status).toBe(400)
const error = await callbackRejection
expect(error).toBeInstanceOf(Error)
expect((error as Error).message).toContain("missing code or state")
})
it("close stops the server immediately", async () => {
//#given
server = await startCallbackServer()
const port = server.port
//#when
server.close()
server = null
//#then
try {
await nativeFetch(`http://127.0.0.1:${port}/oauth/callback?code=c&state=s`)
expect(true).toBe(false)
} catch (error) {
expect(error).toBeDefined()
}
})
})

View File

@@ -0,0 +1,124 @@
const DEFAULT_PORT = 19877
const MAX_PORT_ATTEMPTS = 20
const TIMEOUT_MS = 5 * 60 * 1000
export type OAuthCallbackResult = {
code: string
state: string
}
export type CallbackServer = {
port: number
waitForCallback: () => Promise<OAuthCallbackResult>
close: () => void
}
const SUCCESS_HTML = `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>OAuth Authorized</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, sans-serif; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; background: #0a0a0a; color: #fafafa; }
.container { text-align: center; }
h1 { font-size: 1.5rem; margin-bottom: 0.5rem; }
p { color: #888; }
</style>
</head>
<body>
<div class="container">
<h1>Authorization successful</h1>
<p>You can close this window and return to your terminal.</p>
</div>
</body>
</html>`
async function isPortAvailable(port: number): Promise<boolean> {
try {
const server = Bun.serve({
port,
hostname: "127.0.0.1",
fetch: () => new Response(),
})
server.stop(true)
return true
} catch {
return false
}
}
export async function findAvailablePort(startPort: number = DEFAULT_PORT): Promise<number> {
for (let attempt = 0; attempt < MAX_PORT_ATTEMPTS; attempt++) {
const port = startPort + attempt
if (await isPortAvailable(port)) {
return port
}
}
throw new Error(`No available port found in range ${startPort}-${startPort + MAX_PORT_ATTEMPTS - 1}`)
}
export async function startCallbackServer(startPort: number = DEFAULT_PORT): Promise<CallbackServer> {
const port = await findAvailablePort(startPort)
let resolveCallback: ((result: OAuthCallbackResult) => void) | null = null
let rejectCallback: ((error: Error) => void) | null = null
const callbackPromise = new Promise<OAuthCallbackResult>((resolve, reject) => {
resolveCallback = resolve
rejectCallback = reject
})
const timeoutId = setTimeout(() => {
rejectCallback?.(new Error("OAuth callback timed out after 5 minutes"))
server.stop(true)
}, TIMEOUT_MS)
const server = Bun.serve({
port,
hostname: "127.0.0.1",
fetch(request: Request): Response {
const url = new URL(request.url)
if (url.pathname !== "/oauth/callback") {
return new Response("Not Found", { status: 404 })
}
const oauthError = url.searchParams.get("error")
if (oauthError) {
const description = url.searchParams.get("error_description") ?? oauthError
clearTimeout(timeoutId)
rejectCallback?.(new Error(`OAuth authorization failed: ${description}`))
setTimeout(() => server.stop(true), 100)
return new Response(`Authorization failed: ${description}`, { status: 400 })
}
const code = url.searchParams.get("code")
const state = url.searchParams.get("state")
if (!code || !state) {
clearTimeout(timeoutId)
rejectCallback?.(new Error("OAuth callback missing code or state parameter"))
setTimeout(() => server.stop(true), 100)
return new Response("Missing code or state parameter", { status: 400 })
}
resolveCallback?.({ code, state })
clearTimeout(timeoutId)
setTimeout(() => server.stop(true), 100)
return new Response(SUCCESS_HTML, {
headers: { "content-type": "text/html; charset=utf-8" },
})
},
})
return {
port,
waitForCallback: () => callbackPromise,
close: () => {
clearTimeout(timeoutId)
server.stop(true)
},
}
}

View File

@@ -0,0 +1,164 @@
import { describe, expect, it } from "bun:test"
import {
getOrRegisterClient,
type ClientCredentials,
type ClientRegistrationStorage,
type DcrFetch,
} from "./dcr"
function createStorage(initial: ClientCredentials | null):
& ClientRegistrationStorage
& { getLastKey: () => string | null; getLastSet: () => ClientCredentials | null } {
let stored = initial
let lastKey: string | null = null
let lastSet: ClientCredentials | null = null
return {
getClientRegistration: () => stored,
setClientRegistration: (serverIdentifier: string, credentials: ClientCredentials) => {
lastKey = serverIdentifier
lastSet = credentials
stored = credentials
},
getLastKey: () => lastKey,
getLastSet: () => lastSet,
}
}
describe("getOrRegisterClient", () => {
it("returns cached registration when available", async () => {
// #given
const storage = createStorage({
clientId: "cached-client",
clientSecret: "cached-secret",
})
const fetchMock: DcrFetch = async () => {
throw new Error("fetch should not be called")
}
// #when
const result = await getOrRegisterClient({
registrationEndpoint: "https://server.example.com/register",
serverIdentifier: "server-1",
clientName: "Test Client",
redirectUris: ["https://app.example.com/callback"],
tokenEndpointAuthMethod: "client_secret_post",
storage,
fetch: fetchMock,
})
// #then
expect(result).toEqual({
clientId: "cached-client",
clientSecret: "cached-secret",
})
})
it("registers client and stores credentials when endpoint available", async () => {
// #given
const storage = createStorage(null)
let fetchCalled = false
const fetchMock: DcrFetch = async (
input: string,
init?: { method?: string; headers?: Record<string, string>; body?: string }
) => {
fetchCalled = true
expect(input).toBe("https://server.example.com/register")
if (typeof init?.body !== "string") {
throw new Error("Expected request body string")
}
const payload = JSON.parse(init.body)
expect(payload).toEqual({
redirect_uris: ["https://app.example.com/callback"],
client_name: "Test Client",
grant_types: ["authorization_code", "refresh_token"],
response_types: ["code"],
token_endpoint_auth_method: "client_secret_post",
})
return {
ok: true,
json: async () => ({
client_id: "registered-client",
client_secret: "registered-secret",
}),
}
}
// #when
const result = await getOrRegisterClient({
registrationEndpoint: "https://server.example.com/register",
serverIdentifier: "server-2",
clientName: "Test Client",
redirectUris: ["https://app.example.com/callback"],
tokenEndpointAuthMethod: "client_secret_post",
storage,
fetch: fetchMock,
})
// #then
expect(fetchCalled).toBe(true)
expect(result).toEqual({
clientId: "registered-client",
clientSecret: "registered-secret",
})
expect(storage.getLastKey()).toBe("server-2")
expect(storage.getLastSet()).toEqual({
clientId: "registered-client",
clientSecret: "registered-secret",
})
})
it("uses config client id when registration endpoint missing", async () => {
// #given
const storage = createStorage(null)
let fetchCalled = false
const fetchMock: DcrFetch = async () => {
fetchCalled = true
return {
ok: false,
json: async () => ({}),
}
}
// #when
const result = await getOrRegisterClient({
registrationEndpoint: undefined,
serverIdentifier: "server-3",
clientName: "Test Client",
redirectUris: ["https://app.example.com/callback"],
tokenEndpointAuthMethod: "client_secret_post",
clientId: "config-client",
storage,
fetch: fetchMock,
})
// #then
expect(fetchCalled).toBe(false)
expect(result).toEqual({ clientId: "config-client" })
})
it("falls back to config client id when registration fails", async () => {
// #given
const storage = createStorage(null)
const fetchMock: DcrFetch = async () => {
throw new Error("network error")
}
// #when
const result = await getOrRegisterClient({
registrationEndpoint: "https://server.example.com/register",
serverIdentifier: "server-4",
clientName: "Test Client",
redirectUris: ["https://app.example.com/callback"],
tokenEndpointAuthMethod: "client_secret_post",
clientId: "fallback-client",
storage,
fetch: fetchMock,
})
// #then
expect(result).toEqual({ clientId: "fallback-client" })
expect(storage.getLastSet()).toBeNull()
})
})

View File

@@ -0,0 +1,98 @@
export type ClientRegistrationRequest = {
redirect_uris: string[]
client_name: string
grant_types: ["authorization_code", "refresh_token"]
response_types: ["code"]
token_endpoint_auth_method: "none" | "client_secret_post"
}
export type ClientCredentials = {
clientId: string
clientSecret?: string
}
export type ClientRegistrationStorage = {
getClientRegistration: (serverIdentifier: string) => ClientCredentials | null
setClientRegistration: (
serverIdentifier: string,
credentials: ClientCredentials
) => void
}
export type DynamicClientRegistrationOptions = {
registrationEndpoint?: string | null
serverIdentifier?: string
clientName: string
redirectUris: string[]
tokenEndpointAuthMethod: "none" | "client_secret_post"
clientId?: string | null
storage: ClientRegistrationStorage
fetch?: DcrFetch
}
export type DcrFetch = (
input: string,
init?: { method?: string; headers?: Record<string, string>; body?: string }
) => Promise<{ ok: boolean; json: () => Promise<unknown> }>
export async function getOrRegisterClient(
options: DynamicClientRegistrationOptions
): Promise<ClientCredentials | null> {
const serverIdentifier =
options.serverIdentifier ?? options.registrationEndpoint ?? "default"
const existing = options.storage.getClientRegistration(serverIdentifier)
if (existing) return existing
if (!options.registrationEndpoint) {
return options.clientId ? { clientId: options.clientId } : null
}
const fetchImpl = options.fetch ?? globalThis.fetch
const request: ClientRegistrationRequest = {
redirect_uris: options.redirectUris,
client_name: options.clientName,
grant_types: ["authorization_code", "refresh_token"],
response_types: ["code"],
token_endpoint_auth_method: options.tokenEndpointAuthMethod,
}
try {
const response = await fetchImpl(options.registrationEndpoint, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(request),
})
if (!response.ok) {
return options.clientId ? { clientId: options.clientId } : null
}
const data: unknown = await response.json()
const parsed = parseRegistrationResponse(data)
if (!parsed) {
return options.clientId ? { clientId: options.clientId } : null
}
options.storage.setClientRegistration(serverIdentifier, parsed)
return parsed
} catch {
return options.clientId ? { clientId: options.clientId } : null
}
}
function parseRegistrationResponse(data: unknown): ClientCredentials | null {
if (!isRecord(data)) return null
const clientId = data.client_id
if (typeof clientId !== "string" || clientId.length === 0) return null
const clientSecret = data.client_secret
if (typeof clientSecret === "string" && clientSecret.length > 0) {
return { clientId, clientSecret }
}
return { clientId }
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null
}

View File

@@ -0,0 +1,175 @@
import { describe, test, expect, beforeEach, afterEach } from "bun:test"
import { discoverOAuthServerMetadata, resetDiscoveryCache } from "./discovery"
describe("discoverOAuthServerMetadata", () => {
const originalFetch = globalThis.fetch
beforeEach(() => {
resetDiscoveryCache()
})
afterEach(() => {
Object.defineProperty(globalThis, "fetch", { value: originalFetch, configurable: true })
})
test("returns endpoints from PRM + AS discovery", () => {
// #given
const resource = "https://mcp.example.com"
const prmUrl = new URL("/.well-known/oauth-protected-resource", resource).toString()
const authServer = "https://auth.example.com"
const asUrl = new URL("/.well-known/oauth-authorization-server", authServer).toString()
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(JSON.stringify({ authorization_servers: [authServer] }), { status: 200 })
}
if (url === asUrl) {
return new Response(
JSON.stringify({
authorization_endpoint: "https://auth.example.com/authorize",
token_endpoint: "https://auth.example.com/token",
registration_endpoint: "https://auth.example.com/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://auth.example.com/authorize",
tokenEndpoint: "https://auth.example.com/token",
registrationEndpoint: "https://auth.example.com/register",
resource,
})
expect(calls).toEqual([prmUrl, asUrl])
})
})
test("falls back to RFC 8414 when PRM returns 404", () => {
// #given
const resource = "https://mcp.example.com"
const prmUrl = new URL("/.well-known/oauth-protected-resource", resource).toString()
const asUrl = new URL("/.well-known/oauth-authorization-server", resource).toString()
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 === asUrl) {
return new Response(
JSON.stringify({
authorization_endpoint: "https://mcp.example.com/authorize",
token_endpoint: "https://mcp.example.com/token",
}),
{ 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/authorize",
tokenEndpoint: "https://mcp.example.com/token",
registrationEndpoint: undefined,
resource,
})
expect(calls).toEqual([prmUrl, asUrl])
})
})
test("throws when both PRM and AS discovery return 404", () => {
// #given
const resource = "https://mcp.example.com"
const prmUrl = new URL("/.well-known/oauth-protected-resource", resource).toString()
const asUrl = new URL("/.well-known/oauth-authorization-server", resource).toString()
const fetchMock = async (input: string | URL) => {
const url = typeof input === "string" ? input : input.toString()
if (url === prmUrl || url === asUrl) {
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 AS metadata is malformed", () => {
// #given
const resource = "https://mcp.example.com"
const prmUrl = new URL("/.well-known/oauth-protected-resource", resource).toString()
const authServer = "https://auth.example.com"
const asUrl = new URL("/.well-known/oauth-authorization-server", authServer).toString()
const fetchMock = async (input: string | URL) => {
const url = typeof input === "string" ? input : input.toString()
if (url === prmUrl) {
return new Response(JSON.stringify({ authorization_servers: [authServer] }), { status: 200 })
}
if (url === asUrl) {
return new Response(JSON.stringify({ authorization_endpoint: "https://auth.example.com/authorize" }), {
status: 200,
})
}
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("token_endpoint")
})
test("caches discovery results per resource URL", () => {
// #given
const resource = "https://mcp.example.com"
const prmUrl = new URL("/.well-known/oauth-protected-resource", resource).toString()
const authServer = "https://auth.example.com"
const asUrl = new URL("/.well-known/oauth-authorization-server", authServer).toString()
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(JSON.stringify({ authorization_servers: [authServer] }), { status: 200 })
}
if (url === asUrl) {
return new Response(
JSON.stringify({
authorization_endpoint: "https://auth.example.com/authorize",
token_endpoint: "https://auth.example.com/token",
}),
{ status: 200 }
)
}
return new Response("not found", { status: 404 })
}
Object.defineProperty(globalThis, "fetch", { value: fetchMock, configurable: true })
// #when
return discoverOAuthServerMetadata(resource)
.then(() => discoverOAuthServerMetadata(resource))
.then(() => {
// #then
expect(calls).toEqual([prmUrl, asUrl])
})
})
})

View File

@@ -0,0 +1,123 @@
export interface OAuthServerMetadata {
authorizationEndpoint: string
tokenEndpoint: string
registrationEndpoint?: string
resource: string
}
const discoveryCache = new Map<string, OAuthServerMetadata>()
const pendingDiscovery = new Map<string, Promise<OAuthServerMetadata>>()
function parseHttpsUrl(value: string, label: string): URL {
const parsed = new URL(value)
if (parsed.protocol !== "https:") {
throw new Error(`${label} must use https`)
}
return parsed
}
function readStringField(source: Record<string, unknown>, field: string): string {
const value = source[field]
if (typeof value !== "string" || value.length === 0) {
throw new Error(`OAuth metadata missing ${field}`)
}
return value
}
async function fetchMetadata(url: string): Promise<{ ok: true; json: Record<string, unknown> } | { ok: false; status: number }> {
const response = await fetch(url, { headers: { accept: "application/json" } })
if (!response.ok) {
return { ok: false, status: response.status }
}
const json = (await response.json().catch(() => null)) as Record<string, unknown> | null
if (!json || typeof json !== "object") {
throw new Error("OAuth metadata response is not valid JSON")
}
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})`)
}
const authorizationEndpoint = parseHttpsUrl(
readStringField(metadata.json, "authorization_endpoint"),
"authorization_endpoint"
).toString()
const tokenEndpoint = parseHttpsUrl(
readStringField(metadata.json, "token_endpoint"),
"token_endpoint"
).toString()
const registrationEndpointValue = metadata.json.registration_endpoint
const registrationEndpoint =
typeof registrationEndpointValue === "string" && registrationEndpointValue.length > 0
? parseHttpsUrl(registrationEndpointValue, "registration_endpoint").toString()
: undefined
return {
authorizationEndpoint,
tokenEndpoint,
registrationEndpoint,
resource,
}
}
function parseAuthorizationServers(metadata: Record<string, unknown>): string[] {
const servers = metadata.authorization_servers
if (!Array.isArray(servers)) return []
return servers.filter((server): server is string => typeof server === "string" && server.length > 0)
}
export async function discoverOAuthServerMetadata(resource: string): Promise<OAuthServerMetadata> {
const resourceUrl = parseHttpsUrl(resource, "Resource server URL")
const resourceKey = resourceUrl.toString()
const cached = discoveryCache.get(resourceKey)
if (cached) return cached
const pending = pendingDiscovery.get(resourceKey)
if (pending) return pending
const discoveryPromise = (async () => {
const prmUrl = new URL("/.well-known/oauth-protected-resource", resourceUrl).toString()
const prmResponse = await fetchMetadata(prmUrl)
if (prmResponse.ok) {
const authServers = parseAuthorizationServers(prmResponse.json)
if (authServers.length === 0) {
throw new Error("OAuth protected resource metadata missing authorization_servers")
}
return fetchAuthorizationServerMetadata(authServers[0], resource)
}
if (prmResponse.status !== 404) {
throw new Error(`OAuth protected resource metadata fetch failed (${prmResponse.status})`)
}
return fetchAuthorizationServerMetadata(resourceKey, resource)
})()
pendingDiscovery.set(resourceKey, discoveryPromise)
try {
const result = await discoveryPromise
discoveryCache.set(resourceKey, result)
return result
} finally {
pendingDiscovery.delete(resourceKey)
}
}
export function resetDiscoveryCache(): void {
discoveryCache.clear()
pendingDiscovery.clear()
}

View File

@@ -0,0 +1 @@
export * from "./schema"

View File

@@ -0,0 +1,223 @@
import { describe, expect, it, beforeEach, afterEach, mock } from "bun:test"
import { createHash, randomBytes } from "node:crypto"
import { McpOAuthProvider, generateCodeVerifier, generateCodeChallenge, buildAuthorizationUrl } from "./provider"
import type { OAuthTokenData } from "./storage"
describe("McpOAuthProvider", () => {
describe("generateCodeVerifier", () => {
it("returns a base64url-encoded 32-byte random string", () => {
//#given
const verifier = generateCodeVerifier()
//#when
const decoded = Buffer.from(verifier, "base64url")
//#then
expect(decoded.length).toBe(32)
expect(verifier).toMatch(/^[A-Za-z0-9_-]+$/)
})
it("produces unique values on each call", () => {
//#given
const first = generateCodeVerifier()
//#when
const second = generateCodeVerifier()
//#then
expect(first).not.toBe(second)
})
})
describe("generateCodeChallenge", () => {
it("returns SHA256 base64url digest of the verifier", () => {
//#given
const verifier = "test-verifier-value"
const expected = createHash("sha256").update(verifier).digest("base64url")
//#when
const challenge = generateCodeChallenge(verifier)
//#then
expect(challenge).toBe(expected)
})
})
describe("buildAuthorizationUrl", () => {
it("builds URL with all required PKCE parameters", () => {
//#given
const endpoint = "https://auth.example.com/authorize"
//#when
const url = buildAuthorizationUrl(endpoint, {
clientId: "my-client",
redirectUri: "http://127.0.0.1:8912/callback",
codeChallenge: "challenge-value",
state: "state-value",
scopes: ["openid", "profile"],
resource: "https://mcp.example.com",
})
//#then
const parsed = new URL(url)
expect(parsed.origin + parsed.pathname).toBe("https://auth.example.com/authorize")
expect(parsed.searchParams.get("response_type")).toBe("code")
expect(parsed.searchParams.get("client_id")).toBe("my-client")
expect(parsed.searchParams.get("redirect_uri")).toBe("http://127.0.0.1:8912/callback")
expect(parsed.searchParams.get("code_challenge")).toBe("challenge-value")
expect(parsed.searchParams.get("code_challenge_method")).toBe("S256")
expect(parsed.searchParams.get("state")).toBe("state-value")
expect(parsed.searchParams.get("scope")).toBe("openid profile")
expect(parsed.searchParams.get("resource")).toBe("https://mcp.example.com")
})
it("omits scope when empty", () => {
//#given
const endpoint = "https://auth.example.com/authorize"
//#when
const url = buildAuthorizationUrl(endpoint, {
clientId: "my-client",
redirectUri: "http://127.0.0.1:8912/callback",
codeChallenge: "challenge-value",
state: "state-value",
scopes: [],
})
//#then
const parsed = new URL(url)
expect(parsed.searchParams.has("scope")).toBe(false)
})
it("omits resource when undefined", () => {
//#given
const endpoint = "https://auth.example.com/authorize"
//#when
const url = buildAuthorizationUrl(endpoint, {
clientId: "my-client",
redirectUri: "http://127.0.0.1:8912/callback",
codeChallenge: "challenge-value",
state: "state-value",
})
//#then
const parsed = new URL(url)
expect(parsed.searchParams.has("resource")).toBe(false)
})
})
describe("constructor and basic methods", () => {
it("stores serverUrl and optional clientId and scopes", () => {
//#given
const options = {
serverUrl: "https://mcp.example.com",
clientId: "my-client",
scopes: ["openid"],
}
//#when
const provider = new McpOAuthProvider(options)
//#then
expect(provider.tokens()).toBeNull()
expect(provider.clientInformation()).toBeNull()
expect(provider.codeVerifier()).toBeNull()
})
it("defaults scopes to empty array", () => {
//#given
const options = { serverUrl: "https://mcp.example.com" }
//#when
const provider = new McpOAuthProvider(options)
//#then
expect(provider.redirectUrl()).toBe("http://127.0.0.1:19877/callback")
})
})
describe("saveCodeVerifier / codeVerifier", () => {
it("stores and retrieves code verifier", () => {
//#given
const provider = new McpOAuthProvider({ serverUrl: "https://mcp.example.com" })
//#when
provider.saveCodeVerifier("my-verifier")
//#then
expect(provider.codeVerifier()).toBe("my-verifier")
})
})
describe("saveTokens / tokens", () => {
let originalEnv: string | undefined
beforeEach(() => {
originalEnv = process.env.OPENCODE_CONFIG_DIR
const { mkdirSync } = require("node:fs")
const { tmpdir } = require("node:os")
const { join } = require("node:path")
const testDir = join(tmpdir(), "mcp-oauth-provider-test-" + Date.now())
mkdirSync(testDir, { recursive: true })
process.env.OPENCODE_CONFIG_DIR = testDir
})
afterEach(() => {
if (originalEnv === undefined) {
delete process.env.OPENCODE_CONFIG_DIR
} else {
process.env.OPENCODE_CONFIG_DIR = originalEnv
}
})
it("persists and loads token data via storage", () => {
//#given
const provider = new McpOAuthProvider({ serverUrl: "https://mcp.example.com" })
const tokenData: OAuthTokenData = {
accessToken: "access-token-123",
refreshToken: "refresh-token-456",
expiresAt: 1710000000,
}
//#when
const saved = provider.saveTokens(tokenData)
const loaded = provider.tokens()
//#then
expect(saved).toBe(true)
expect(loaded).toEqual(tokenData)
})
})
describe("redirectToAuthorization", () => {
it("throws when no client information is set", async () => {
//#given
const provider = new McpOAuthProvider({ serverUrl: "https://mcp.example.com" })
const metadata = {
authorizationEndpoint: "https://auth.example.com/authorize",
tokenEndpoint: "https://auth.example.com/token",
resource: "https://mcp.example.com",
}
//#when
const result = provider.redirectToAuthorization(metadata)
//#then
await expect(result).rejects.toThrow("No client information available")
})
})
describe("redirectUrl", () => {
it("returns localhost callback URL with default port", () => {
//#given
const provider = new McpOAuthProvider({ serverUrl: "https://mcp.example.com" })
//#when
const url = provider.redirectUrl()
//#then
expect(url).toBe("http://127.0.0.1:19877/callback")
})
})
})

View File

@@ -0,0 +1,295 @@
import { createHash, randomBytes } from "node:crypto"
import { createServer } from "node:http"
import { spawn } from "node:child_process"
import type { OAuthTokenData } from "./storage"
import { loadToken, saveToken } from "./storage"
import { discoverOAuthServerMetadata } from "./discovery"
import type { OAuthServerMetadata } from "./discovery"
import { getOrRegisterClient } from "./dcr"
import type { ClientCredentials, ClientRegistrationStorage } from "./dcr"
import { findAvailablePort } from "./callback-server"
export type McpOAuthProviderOptions = {
serverUrl: string
clientId?: string
scopes?: string[]
}
type CallbackResult = {
code: string
state: string
}
function generateCodeVerifier(): string {
return randomBytes(32).toString("base64url")
}
function generateCodeChallenge(verifier: string): string {
return createHash("sha256").update(verifier).digest("base64url")
}
function buildAuthorizationUrl(
authorizationEndpoint: string,
options: {
clientId: string
redirectUri: string
codeChallenge: string
state: string
scopes?: string[]
resource?: string
}
): string {
const url = new URL(authorizationEndpoint)
url.searchParams.set("response_type", "code")
url.searchParams.set("client_id", options.clientId)
url.searchParams.set("redirect_uri", options.redirectUri)
url.searchParams.set("code_challenge", options.codeChallenge)
url.searchParams.set("code_challenge_method", "S256")
url.searchParams.set("state", options.state)
if (options.scopes && options.scopes.length > 0) {
url.searchParams.set("scope", options.scopes.join(" "))
}
if (options.resource) {
url.searchParams.set("resource", options.resource)
}
return url.toString()
}
const CALLBACK_TIMEOUT_MS = 5 * 60 * 1000
function startCallbackServer(port: number): Promise<CallbackResult> {
return new Promise((resolve, reject) => {
let timeoutId: ReturnType<typeof setTimeout>
const server = createServer((request, response) => {
clearTimeout(timeoutId)
const requestUrl = new URL(request.url ?? "/", `http://localhost:${port}`)
const code = requestUrl.searchParams.get("code")
const state = requestUrl.searchParams.get("state")
const error = requestUrl.searchParams.get("error")
if (error) {
const errorDescription = requestUrl.searchParams.get("error_description") ?? error
response.writeHead(400, { "content-type": "text/html" })
response.end("<html><body><h1>Authorization failed</h1></body></html>")
server.close()
reject(new Error(`OAuth authorization error: ${errorDescription}`))
return
}
if (!code || !state) {
response.writeHead(400, { "content-type": "text/html" })
response.end("<html><body><h1>Missing code or state</h1></body></html>")
server.close()
reject(new Error("OAuth callback missing code or state parameter"))
return
}
response.writeHead(200, { "content-type": "text/html" })
response.end("<html><body><h1>Authorization successful. You can close this tab.</h1></body></html>")
server.close()
resolve({ code, state })
})
timeoutId = setTimeout(() => {
server.close()
reject(new Error("OAuth callback timed out after 5 minutes"))
}, CALLBACK_TIMEOUT_MS)
server.listen(port, "127.0.0.1")
server.on("error", (err) => {
clearTimeout(timeoutId)
reject(err)
})
})
}
function openBrowser(url: string): void {
const platform = process.platform
let cmd: string
let args: string[]
if (platform === "darwin") {
cmd = "open"
args = [url]
} else if (platform === "win32") {
cmd = "explorer"
args = [url]
} else {
cmd = "xdg-open"
args = [url]
}
try {
const child = spawn(cmd, args, { stdio: "ignore", detached: true })
child.on("error", () => {})
child.unref()
} catch {
// Browser open failed — user must navigate manually
}
}
export class McpOAuthProvider {
private readonly serverUrl: string
private readonly configClientId: string | undefined
private readonly scopes: string[]
private storedCodeVerifier: string | null = null
private storedClientInfo: ClientCredentials | null = null
private callbackPort: number | null = null
constructor(options: McpOAuthProviderOptions) {
this.serverUrl = options.serverUrl
this.configClientId = options.clientId
this.scopes = options.scopes ?? []
}
tokens(): OAuthTokenData | null {
return loadToken(this.serverUrl, this.serverUrl)
}
saveTokens(tokenData: OAuthTokenData): boolean {
return saveToken(this.serverUrl, this.serverUrl, tokenData)
}
clientInformation(): ClientCredentials | null {
if (this.storedClientInfo) return this.storedClientInfo
const tokenData = this.tokens()
if (tokenData?.clientInfo) {
this.storedClientInfo = tokenData.clientInfo
return this.storedClientInfo
}
return null
}
redirectUrl(): string {
return `http://127.0.0.1:${this.callbackPort ?? 19877}/callback`
}
saveCodeVerifier(verifier: string): void {
this.storedCodeVerifier = verifier
}
codeVerifier(): string | null {
return this.storedCodeVerifier
}
async redirectToAuthorization(metadata: OAuthServerMetadata): Promise<CallbackResult> {
const verifier = generateCodeVerifier()
this.saveCodeVerifier(verifier)
const challenge = generateCodeChallenge(verifier)
const state = randomBytes(16).toString("hex")
const clientInfo = this.clientInformation()
if (!clientInfo) {
throw new Error("No client information available. Run login() or register a client first.")
}
if (this.callbackPort === null) {
this.callbackPort = await findAvailablePort()
}
const authUrl = buildAuthorizationUrl(metadata.authorizationEndpoint, {
clientId: clientInfo.clientId,
redirectUri: this.redirectUrl(),
codeChallenge: challenge,
state,
scopes: this.scopes,
resource: metadata.resource,
})
const callbackPromise = startCallbackServer(this.callbackPort)
openBrowser(authUrl)
const result = await callbackPromise
if (result.state !== state) {
throw new Error("OAuth state mismatch")
}
return result
}
async login(): Promise<OAuthTokenData> {
const metadata = await discoverOAuthServerMetadata(this.serverUrl)
const clientRegistrationStorage: ClientRegistrationStorage = {
getClientRegistration: () => this.storedClientInfo,
setClientRegistration: (_serverIdentifier: string, credentials: ClientCredentials) => {
this.storedClientInfo = credentials
},
}
const clientInfo = await getOrRegisterClient({
registrationEndpoint: metadata.registrationEndpoint,
serverIdentifier: this.serverUrl,
clientName: "oh-my-opencode",
redirectUris: [this.redirectUrl()],
tokenEndpointAuthMethod: "none",
clientId: this.configClientId,
storage: clientRegistrationStorage,
})
if (!clientInfo) {
throw new Error("Failed to obtain client credentials. Provide a clientId or ensure the server supports DCR.")
}
this.storedClientInfo = clientInfo
const { code } = await this.redirectToAuthorization(metadata)
const verifier = this.codeVerifier()
if (!verifier) {
throw new Error("Code verifier not found")
}
const tokenResponse = await fetch(metadata.tokenEndpoint, {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: this.redirectUrl(),
client_id: clientInfo.clientId,
code_verifier: verifier,
...(metadata.resource ? { resource: metadata.resource } : {}),
}).toString(),
})
if (!tokenResponse.ok) {
let errorDetail = `${tokenResponse.status}`
try {
const body = (await tokenResponse.json()) as Record<string, unknown>
if (body.error) {
errorDetail = `${tokenResponse.status} ${body.error}`
if (body.error_description) {
errorDetail += `: ${body.error_description}`
}
}
} catch {
// Response body not JSON
}
throw new Error(`Token exchange failed: ${errorDetail}`)
}
const tokenData = (await tokenResponse.json()) as Record<string, unknown>
const accessToken = tokenData.access_token
if (typeof accessToken !== "string") {
throw new Error("Token response missing access_token")
}
const oauthTokenData: OAuthTokenData = {
accessToken,
refreshToken: typeof tokenData.refresh_token === "string" ? tokenData.refresh_token : undefined,
expiresAt:
typeof tokenData.expires_in === "number" ? Math.floor(Date.now() / 1000) + tokenData.expires_in : undefined,
clientInfo: {
clientId: clientInfo.clientId,
clientSecret: clientInfo.clientSecret,
},
}
this.saveTokens(oauthTokenData)
return oauthTokenData
}
}
export { generateCodeVerifier, generateCodeChallenge, buildAuthorizationUrl, startCallbackServer }

View File

@@ -0,0 +1,121 @@
import { describe, expect, it } from "bun:test"
import { addResourceToParams, getResourceIndicator } from "./resource-indicator"
describe("getResourceIndicator", () => {
it("returns URL unchanged when already normalized", () => {
// #given
const url = "https://mcp.example.com"
// #when
const result = getResourceIndicator(url)
// #then
expect(result).toBe("https://mcp.example.com")
})
it("strips trailing slash", () => {
// #given
const url = "https://mcp.example.com/"
// #when
const result = getResourceIndicator(url)
// #then
expect(result).toBe("https://mcp.example.com")
})
it("strips query parameters", () => {
// #given
const url = "https://mcp.example.com/v1?token=abc&debug=true"
// #when
const result = getResourceIndicator(url)
// #then
expect(result).toBe("https://mcp.example.com/v1")
})
it("strips fragment", () => {
// #given
const url = "https://mcp.example.com/v1#section"
// #when
const result = getResourceIndicator(url)
// #then
expect(result).toBe("https://mcp.example.com/v1")
})
it("strips query and trailing slash together", () => {
// #given
const url = "https://mcp.example.com/api/?key=val"
// #when
const result = getResourceIndicator(url)
// #then
expect(result).toBe("https://mcp.example.com/api")
})
it("preserves path segments", () => {
// #given
const url = "https://mcp.example.com/org/project/v2"
// #when
const result = getResourceIndicator(url)
// #then
expect(result).toBe("https://mcp.example.com/org/project/v2")
})
it("preserves port number", () => {
// #given
const url = "https://mcp.example.com:8443/api/"
// #when
const result = getResourceIndicator(url)
// #then
expect(result).toBe("https://mcp.example.com:8443/api")
})
})
describe("addResourceToParams", () => {
it("sets resource parameter on empty params", () => {
// #given
const params = new URLSearchParams()
const resource = "https://mcp.example.com"
// #when
addResourceToParams(params, resource)
// #then
expect(params.get("resource")).toBe("https://mcp.example.com")
})
it("adds resource alongside existing parameters", () => {
// #given
const params = new URLSearchParams({ grant_type: "authorization_code" })
const resource = "https://mcp.example.com/v1"
// #when
addResourceToParams(params, resource)
// #then
expect(params.get("grant_type")).toBe("authorization_code")
expect(params.get("resource")).toBe("https://mcp.example.com/v1")
})
it("overwrites existing resource parameter", () => {
// #given
const params = new URLSearchParams({ resource: "https://old.example.com" })
const resource = "https://new.example.com"
// #when
addResourceToParams(params, resource)
// #then
expect(params.get("resource")).toBe("https://new.example.com")
expect(params.getAll("resource")).toHaveLength(1)
})
})

View File

@@ -0,0 +1,16 @@
export function getResourceIndicator(url: string): string {
const parsed = new URL(url)
parsed.search = ""
parsed.hash = ""
let normalized = parsed.toString()
if (normalized.endsWith("/")) {
normalized = normalized.slice(0, -1)
}
return normalized
}
export function addResourceToParams(params: URLSearchParams, resource: string): void {
params.set("resource", resource)
}

View File

@@ -0,0 +1,60 @@
/// <reference types="bun-types" />
import { describe, expect, test } from "bun:test"
import { McpOauthSchema } from "./schema"
describe("McpOauthSchema", () => {
test("parses empty oauth config", () => {
//#given
const input = {}
//#when
const result = McpOauthSchema.parse(input)
//#then
expect(result).toEqual({})
})
test("parses oauth config with clientId", () => {
//#given
const input = { clientId: "client-123" }
//#when
const result = McpOauthSchema.parse(input)
//#then
expect(result).toEqual({ clientId: "client-123" })
})
test("parses oauth config with scopes", () => {
//#given
const input = { scopes: ["openid", "profile"] }
//#when
const result = McpOauthSchema.parse(input)
//#then
expect(result).toEqual({ scopes: ["openid", "profile"] })
})
test("rejects non-string clientId", () => {
//#given
const input = { clientId: 123 }
//#when
const result = McpOauthSchema.safeParse(input)
//#then
expect(result.success).toBe(false)
})
test("rejects non-string scopes", () => {
//#given
const input = { scopes: ["openid", 42] }
//#when
const result = McpOauthSchema.safeParse(input)
//#then
expect(result.success).toBe(false)
})
})

View File

@@ -0,0 +1,8 @@
import { z } from "zod"
export const McpOauthSchema = z.object({
clientId: z.string().optional(),
scopes: z.array(z.string()).optional(),
})
export type McpOauth = z.infer<typeof McpOauthSchema>

View File

@@ -0,0 +1,223 @@
import { describe, expect, it } from "bun:test"
import { isStepUpRequired, mergeScopes, parseWwwAuthenticate } from "./step-up"
describe("parseWwwAuthenticate", () => {
it("parses scope from simple Bearer header", () => {
// #given
const header = 'Bearer scope="read write"'
// #when
const result = parseWwwAuthenticate(header)
// #then
expect(result).toEqual({ requiredScopes: ["read", "write"] })
})
it("parses scope with error fields", () => {
// #given
const header = 'Bearer error="insufficient_scope", scope="admin"'
// #when
const result = parseWwwAuthenticate(header)
// #then
expect(result).toEqual({
requiredScopes: ["admin"],
error: "insufficient_scope",
})
})
it("parses all fields including error_description", () => {
// #given
const header =
'Bearer realm="example", error="insufficient_scope", error_description="Need admin access", scope="admin write"'
// #when
const result = parseWwwAuthenticate(header)
// #then
expect(result).toEqual({
requiredScopes: ["admin", "write"],
error: "insufficient_scope",
errorDescription: "Need admin access",
})
})
it("returns null for non-Bearer scheme", () => {
// #given
const header = 'Basic realm="example"'
// #when
const result = parseWwwAuthenticate(header)
// #then
expect(result).toBeNull()
})
it("returns null when no scope parameter present", () => {
// #given
const header = 'Bearer error="invalid_token"'
// #when
const result = parseWwwAuthenticate(header)
// #then
expect(result).toBeNull()
})
it("returns null for empty scope value", () => {
// #given
const header = 'Bearer scope=""'
// #when
const result = parseWwwAuthenticate(header)
// #then
expect(result).toBeNull()
})
it("returns null for bare Bearer with no params", () => {
// #given
const header = "Bearer"
// #when
const result = parseWwwAuthenticate(header)
// #then
expect(result).toBeNull()
})
it("handles case-insensitive Bearer prefix", () => {
// #given
const header = 'bearer scope="read"'
// #when
const result = parseWwwAuthenticate(header)
// #then
expect(result).toEqual({ requiredScopes: ["read"] })
})
it("parses single scope value", () => {
// #given
const header = 'Bearer scope="admin"'
// #when
const result = parseWwwAuthenticate(header)
// #then
expect(result).toEqual({ requiredScopes: ["admin"] })
})
})
describe("mergeScopes", () => {
it("merges new scopes into existing", () => {
// #given
const existing = ["read", "write"]
const required = ["admin", "write"]
// #when
const result = mergeScopes(existing, required)
// #then
expect(result).toEqual(["read", "write", "admin"])
})
it("returns required when existing is empty", () => {
// #given
const existing: string[] = []
const required = ["read", "write"]
// #when
const result = mergeScopes(existing, required)
// #then
expect(result).toEqual(["read", "write"])
})
it("returns existing when required is empty", () => {
// #given
const existing = ["read"]
const required: string[] = []
// #when
const result = mergeScopes(existing, required)
// #then
expect(result).toEqual(["read"])
})
it("deduplicates identical scopes", () => {
// #given
const existing = ["read", "write"]
const required = ["read", "write"]
// #when
const result = mergeScopes(existing, required)
// #then
expect(result).toEqual(["read", "write"])
})
})
describe("isStepUpRequired", () => {
it("returns step-up info for 403 with WWW-Authenticate", () => {
// #given
const statusCode = 403
const headers = { "www-authenticate": 'Bearer scope="admin"' }
// #when
const result = isStepUpRequired(statusCode, headers)
// #then
expect(result).toEqual({ requiredScopes: ["admin"] })
})
it("returns null for non-403 status", () => {
// #given
const statusCode = 401
const headers = { "www-authenticate": 'Bearer scope="admin"' }
// #when
const result = isStepUpRequired(statusCode, headers)
// #then
expect(result).toBeNull()
})
it("returns null when no WWW-Authenticate header", () => {
// #given
const statusCode = 403
const headers = { "content-type": "application/json" }
// #when
const result = isStepUpRequired(statusCode, headers)
// #then
expect(result).toBeNull()
})
it("handles capitalized WWW-Authenticate header", () => {
// #given
const statusCode = 403
const headers = { "WWW-Authenticate": 'Bearer scope="read write"' }
// #when
const result = isStepUpRequired(statusCode, headers)
// #then
expect(result).toEqual({ requiredScopes: ["read", "write"] })
})
it("returns null for 403 with unparseable WWW-Authenticate", () => {
// #given
const statusCode = 403
const headers = { "www-authenticate": 'Basic realm="example"' }
// #when
const result = isStepUpRequired(statusCode, headers)
// #then
expect(result).toBeNull()
})
})

View File

@@ -0,0 +1,79 @@
export interface StepUpInfo {
requiredScopes: string[]
error?: string
errorDescription?: string
}
export function parseWwwAuthenticate(header: string): StepUpInfo | null {
const trimmed = header.trim()
const lowerHeader = trimmed.toLowerCase()
const bearerIndex = lowerHeader.indexOf("bearer")
if (bearerIndex === -1) {
return null
}
const params = trimmed.slice(bearerIndex + "bearer".length).trim()
if (params.length === 0) {
return null
}
const scope = extractParam(params, "scope")
if (scope === null) {
return null
}
const requiredScopes = scope
.split(/\s+/)
.filter((s) => s.length > 0)
if (requiredScopes.length === 0) {
return null
}
const info: StepUpInfo = { requiredScopes }
const error = extractParam(params, "error")
if (error !== null) {
info.error = error
}
const errorDescription = extractParam(params, "error_description")
if (errorDescription !== null) {
info.errorDescription = errorDescription
}
return info
}
function extractParam(params: string, name: string): string | null {
const quotedPattern = new RegExp(`${name}="([^"]*)"`)
const quotedMatch = quotedPattern.exec(params)
if (quotedMatch) {
return quotedMatch[1]
}
const unquotedPattern = new RegExp(`${name}=([^\\s,]+)`)
const unquotedMatch = unquotedPattern.exec(params)
return unquotedMatch?.[1] ?? null
}
export function mergeScopes(existing: string[], required: string[]): string[] {
const set = new Set(existing)
for (const scope of required) {
set.add(scope)
}
return [...set]
}
export function isStepUpRequired(statusCode: number, headers: Record<string, string>): StepUpInfo | null {
if (statusCode !== 403) {
return null
}
const wwwAuth = headers["www-authenticate"] ?? headers["WWW-Authenticate"]
if (!wwwAuth) {
return null
}
return parseWwwAuthenticate(wwwAuth)
}

View File

@@ -0,0 +1,136 @@
import { describe, expect, test, beforeEach, afterEach } from "bun:test"
import { existsSync, mkdirSync, rmSync, readFileSync, statSync, writeFileSync } from "node:fs"
import { join } from "node:path"
import { tmpdir } from "node:os"
import {
deleteToken,
getMcpOauthStoragePath,
listAllTokens,
listTokensByHost,
loadToken,
saveToken,
} from "./storage"
import type { OAuthTokenData } from "./storage"
describe("mcp-oauth storage", () => {
const TEST_CONFIG_DIR = join(tmpdir(), "mcp-oauth-test-" + Date.now())
let originalConfigDir: string | undefined
beforeEach(() => {
originalConfigDir = process.env.OPENCODE_CONFIG_DIR
process.env.OPENCODE_CONFIG_DIR = TEST_CONFIG_DIR
if (!existsSync(TEST_CONFIG_DIR)) {
mkdirSync(TEST_CONFIG_DIR, { recursive: true })
}
})
afterEach(() => {
if (originalConfigDir === undefined) {
delete process.env.OPENCODE_CONFIG_DIR
} else {
process.env.OPENCODE_CONFIG_DIR = originalConfigDir
}
if (existsSync(TEST_CONFIG_DIR)) {
rmSync(TEST_CONFIG_DIR, { recursive: true, force: true })
}
})
test("should save tokens with {host}/{resource} key and set 0600 permissions", () => {
// #given
const token: OAuthTokenData = {
accessToken: "access-1",
refreshToken: "refresh-1",
expiresAt: 1710000000,
clientInfo: { clientId: "client-1", clientSecret: "secret-1" },
}
// #when
const success = saveToken("https://example.com:443", "mcp/v1", token)
const storagePath = getMcpOauthStoragePath()
const parsed = JSON.parse(readFileSync(storagePath, "utf-8")) as Record<string, OAuthTokenData>
const mode = statSync(storagePath).mode & 0o777
// #then
expect(success).toBe(true)
expect(Object.keys(parsed)).toEqual(["example.com/mcp/v1"])
expect(parsed["example.com/mcp/v1"].accessToken).toBe("access-1")
expect(mode).toBe(0o600)
})
test("should load a saved token", () => {
// #given
const token: OAuthTokenData = { accessToken: "access-2", refreshToken: "refresh-2" }
saveToken("api.example.com", "resource-a", token)
// #when
const loaded = loadToken("api.example.com:8443", "resource-a")
// #then
expect(loaded).toEqual(token)
})
test("should delete a token", () => {
// #given
const token: OAuthTokenData = { accessToken: "access-3" }
saveToken("api.example.com", "resource-b", token)
// #when
const success = deleteToken("api.example.com", "resource-b")
const loaded = loadToken("api.example.com", "resource-b")
// #then
expect(success).toBe(true)
expect(loaded).toBeNull()
})
test("should list tokens by host", () => {
// #given
saveToken("api.example.com", "resource-a", { accessToken: "access-a" })
saveToken("api.example.com", "resource-b", { accessToken: "access-b" })
saveToken("other.example.com", "resource-c", { accessToken: "access-c" })
// #when
const entries = listTokensByHost("api.example.com:5555")
// #then
expect(Object.keys(entries).sort()).toEqual([
"api.example.com/resource-a",
"api.example.com/resource-b",
])
expect(entries["api.example.com/resource-a"].accessToken).toBe("access-a")
})
test("should handle missing storage file", () => {
// #given
const storagePath = getMcpOauthStoragePath()
if (existsSync(storagePath)) {
rmSync(storagePath, { force: true })
}
// #when
const loaded = loadToken("api.example.com", "resource-a")
const entries = listTokensByHost("api.example.com")
// #then
expect(loaded).toBeNull()
expect(entries).toEqual({})
})
test("should handle invalid JSON", () => {
// #given
const storagePath = getMcpOauthStoragePath()
const dir = join(storagePath, "..")
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true })
}
writeFileSync(storagePath, "{not-valid-json", "utf-8")
// #when
const loaded = loadToken("api.example.com", "resource-a")
const entries = listTokensByHost("api.example.com")
// #then
expect(loaded).toBeNull()
expect(entries).toEqual({})
})
})

View File

@@ -0,0 +1,153 @@
import { chmodSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"
import { dirname, join } from "node:path"
import { getOpenCodeConfigDir } from "../../shared"
export interface OAuthTokenData {
accessToken: string
refreshToken?: string
expiresAt?: number
clientInfo?: {
clientId: string
clientSecret?: string
}
}
type TokenStore = Record<string, OAuthTokenData>
const STORAGE_FILE_NAME = "mcp-oauth.json"
export function getMcpOauthStoragePath(): string {
return join(getOpenCodeConfigDir({ binary: "opencode" }), STORAGE_FILE_NAME)
}
function normalizeHost(serverHost: string): string {
let host = serverHost.trim()
if (!host) return host
if (host.includes("://")) {
try {
host = new URL(host).hostname
} catch {
host = host.split("/")[0]
}
} else {
host = host.split("/")[0]
}
if (host.startsWith("[")) {
const closing = host.indexOf("]")
if (closing !== -1) {
host = host.slice(0, closing + 1)
}
return host
}
if (host.includes(":")) {
host = host.split(":")[0]
}
return host
}
function normalizeResource(resource: string): string {
return resource.replace(/^\/+/, "")
}
function buildKey(serverHost: string, resource: string): string {
const host = normalizeHost(serverHost)
const normalizedResource = normalizeResource(resource)
return `${host}/${normalizedResource}`
}
function readStore(): TokenStore | null {
const filePath = getMcpOauthStoragePath()
if (!existsSync(filePath)) {
return null
}
try {
const content = readFileSync(filePath, "utf-8")
return JSON.parse(content) as TokenStore
} catch {
return null
}
}
function writeStore(store: TokenStore): boolean {
const filePath = getMcpOauthStoragePath()
try {
const dir = dirname(filePath)
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true })
}
writeFileSync(filePath, JSON.stringify(store, null, 2), { encoding: "utf-8", mode: 0o600 })
chmodSync(filePath, 0o600)
return true
} catch {
return false
}
}
export function loadToken(serverHost: string, resource: string): OAuthTokenData | null {
const store = readStore()
if (!store) return null
const key = buildKey(serverHost, resource)
return store[key] ?? null
}
export function saveToken(serverHost: string, resource: string, token: OAuthTokenData): boolean {
const store = readStore() ?? {}
const key = buildKey(serverHost, resource)
store[key] = token
return writeStore(store)
}
export function deleteToken(serverHost: string, resource: string): boolean {
const store = readStore()
if (!store) return true
const key = buildKey(serverHost, resource)
if (!(key in store)) {
return true
}
delete store[key]
if (Object.keys(store).length === 0) {
try {
const filePath = getMcpOauthStoragePath()
if (existsSync(filePath)) {
unlinkSync(filePath)
}
return true
} catch {
return false
}
}
return writeStore(store)
}
export function listTokensByHost(serverHost: string): TokenStore {
const store = readStore()
if (!store) return {}
const host = normalizeHost(serverHost)
const prefix = `${host}/`
const result: TokenStore = {}
for (const [key, value] of Object.entries(store)) {
if (key.startsWith(prefix)) {
result[key] = value
}
}
return result
}
export function listAllTokens(): TokenStore {
return readStore() ?? {}
}

View File

@@ -128,8 +128,15 @@ $ARGUMENTS
}
}
function parseAllowedTools(allowedTools: string | undefined): string[] | undefined {
function parseAllowedTools(allowedTools: string | string[] | undefined): string[] | undefined {
if (!allowedTools) return undefined
// Handle YAML array format: already parsed as string[]
if (Array.isArray(allowedTools)) {
return allowedTools.map(t => t.trim()).filter(Boolean)
}
// Handle space-separated string format: "Read Write Edit Bash"
return allowedTools.split(/\s+/).filter(Boolean)
}

View File

@@ -268,6 +268,123 @@ Skill body.
} finally {
process.chdir(originalCwd)
}
})
})
describe("allowed-tools parsing", () => {
it("parses space-separated allowed-tools string", async () => {
// #given
const skillContent = `---
name: space-separated-tools
description: Skill with space-separated allowed-tools
allowed-tools: Read Write Edit Bash
---
Skill body.
`
createTestSkill("space-separated-tools", skillContent)
// #when
const { discoverSkills } = await import("./loader")
const originalCwd = process.cwd()
process.chdir(TEST_DIR)
try {
const skills = await discoverSkills({ includeClaudeCodePaths: false })
const skill = skills.find(s => s.name === "space-separated-tools")
// #then
expect(skill).toBeDefined()
expect(skill?.allowedTools).toEqual(["Read", "Write", "Edit", "Bash"])
} finally {
process.chdir(originalCwd)
}
})
it("parses YAML inline array allowed-tools", async () => {
// #given
const skillContent = `---
name: yaml-inline-array
description: Skill with YAML inline array allowed-tools
allowed-tools: [Read, Write, Edit, Bash]
---
Skill body.
`
createTestSkill("yaml-inline-array", skillContent)
// #when
const { discoverSkills } = await import("./loader")
const originalCwd = process.cwd()
process.chdir(TEST_DIR)
try {
const skills = await discoverSkills({ includeClaudeCodePaths: false })
const skill = skills.find(s => s.name === "yaml-inline-array")
// #then
expect(skill).toBeDefined()
expect(skill?.allowedTools).toEqual(["Read", "Write", "Edit", "Bash"])
} finally {
process.chdir(originalCwd)
}
})
it("parses YAML multi-line array allowed-tools", async () => {
// #given
const skillContent = `---
name: yaml-multiline-array
description: Skill with YAML multi-line array allowed-tools
allowed-tools:
- Read
- Write
- Edit
- Bash
---
Skill body.
`
createTestSkill("yaml-multiline-array", skillContent)
// #when
const { discoverSkills } = await import("./loader")
const originalCwd = process.cwd()
process.chdir(TEST_DIR)
try {
const skills = await discoverSkills({ includeClaudeCodePaths: false })
const skill = skills.find(s => s.name === "yaml-multiline-array")
// #then
expect(skill).toBeDefined()
expect(skill?.allowedTools).toEqual(["Read", "Write", "Edit", "Bash"])
} finally {
process.chdir(originalCwd)
}
})
it("returns undefined for skill without allowed-tools", async () => {
// #given
const skillContent = `---
name: no-allowed-tools
description: Skill without allowed-tools field
---
Skill body.
`
createTestSkill("no-allowed-tools", skillContent)
// #when
const { discoverSkills } = await import("./loader")
const originalCwd = process.cwd()
process.chdir(TEST_DIR)
try {
const skills = await discoverSkills({ includeClaudeCodePaths: false })
const skill = skills.find(s => s.name === "no-allowed-tools")
// #then
expect(skill).toBeDefined()
expect(skill?.allowedTools).toBeUndefined()
} finally {
process.chdir(originalCwd)
}
})
})
})

View File

@@ -50,8 +50,15 @@ async function loadMcpJsonFromDir(skillDir: string): Promise<SkillMcpConfig | un
return undefined
}
function parseAllowedTools(allowedTools: string | undefined): string[] | undefined {
function parseAllowedTools(allowedTools: string | string[] | undefined): string[] | undefined {
if (!allowedTools) return undefined
// Handle YAML array format: already parsed as string[]
if (Array.isArray(allowedTools)) {
return allowedTools.map(t => t.trim()).filter(Boolean)
}
// Handle space-separated string format: "Read Write Edit Bash"
return allowedTools.split(/\s+/).filter(Boolean)
}

View File

@@ -9,6 +9,14 @@ import { parseFrontmatter } from "../../shared/frontmatter"
import { sanitizeModelField } from "../../shared/model-sanitizer"
import { deepMerge } from "../../shared/deep-merge"
function parseAllowedToolsFromMetadata(allowedTools: string | string[] | undefined): string[] | undefined {
if (!allowedTools) return undefined
if (Array.isArray(allowedTools)) {
return allowedTools.map(t => t.trim()).filter(Boolean)
}
return allowedTools.split(/\s+/).filter(Boolean)
}
const SCOPE_PRIORITY: Record<SkillScope, number> = {
builtin: 1,
config: 2,
@@ -119,7 +127,7 @@ $ARGUMENTS
}
const allowedTools = entry["allowed-tools"] ||
(fileMetadata["allowed-tools"] ? fileMetadata["allowed-tools"].split(/\s+/).filter(Boolean) : undefined)
(fileMetadata["allowed-tools"] ? parseAllowedToolsFromMetadata(fileMetadata["allowed-tools"]) : undefined)
return {
name,

View File

@@ -13,7 +13,7 @@ export interface SkillMetadata {
license?: string
compatibility?: string
metadata?: Record<string, string>
"allowed-tools"?: string
"allowed-tools"?: string | string[]
mcp?: SkillMcpConfig
}

View File

@@ -3,8 +3,6 @@ import { SkillMcpManager } from "./manager"
import type { SkillMcpClientInfo, SkillMcpServerContext } from "./types"
import type { ClaudeCodeMcpServer } from "../claude-code-mcp-loader/types"
// Mock the MCP SDK transports to avoid network calls
const mockHttpConnect = mock(() => Promise.reject(new Error("Mocked HTTP connection failure")))
const mockHttpClose = mock(() => Promise.resolve())
@@ -24,6 +22,21 @@ mock.module("@modelcontextprotocol/sdk/client/streamableHttp.js", () => ({
},
}))
const mockTokens = mock(() => null as { accessToken: string; refreshToken?: string; expiresAt?: number } | null)
const mockLogin = mock(() => Promise.resolve({ accessToken: "new-token" }))
mock.module("../mcp-oauth/provider", () => ({
McpOAuthProvider: class MockMcpOAuthProvider {
constructor(public options: { serverUrl: string; clientId?: string; scopes?: string[] }) {}
tokens() {
return mockTokens()
}
async login() {
return mockLogin()
}
},
}))
@@ -518,7 +531,6 @@ describe("SkillMcpManager", () => {
skillName: "retry-skill",
}
// Mock client that fails first time with "Not connected", then succeeds
let callCount = 0
const mockClient = {
callTool: mock(async () => {
@@ -531,7 +543,6 @@ describe("SkillMcpManager", () => {
close: mock(() => Promise.resolve()),
}
// Spy on getOrCreateClientWithRetry to inject mock client
const getOrCreateSpy = spyOn(manager as any, "getOrCreateClientWithRetry")
getOrCreateSpy.mockResolvedValue(mockClient)
@@ -539,9 +550,9 @@ describe("SkillMcpManager", () => {
const result = await manager.callTool(info, context, "test-tool", {})
// #then
expect(callCount).toBe(2) // First call fails, second succeeds
expect(callCount).toBe(2)
expect(result).toEqual([{ type: "text", text: "success" }])
expect(getOrCreateSpy).toHaveBeenCalledTimes(2) // Called twice due to retry
expect(getOrCreateSpy).toHaveBeenCalledTimes(2)
})
it("should fail after 3 retry attempts", async () => {
@@ -558,7 +569,6 @@ describe("SkillMcpManager", () => {
skillName: "fail-skill",
}
// Mock client that always fails with "Not connected"
const mockClient = {
callTool: mock(async () => {
throw new Error("Not connected")
@@ -573,7 +583,7 @@ describe("SkillMcpManager", () => {
await expect(manager.callTool(info, context, "test-tool", {})).rejects.toThrow(
/Failed after 3 reconnection attempts/
)
expect(getOrCreateSpy).toHaveBeenCalledTimes(3) // Initial + 2 retries
expect(getOrCreateSpy).toHaveBeenCalledTimes(3)
})
it("should not retry on non-connection errors", async () => {
@@ -590,7 +600,6 @@ describe("SkillMcpManager", () => {
skillName: "error-skill",
}
// Mock client that fails with non-connection error
const mockClient = {
callTool: mock(async () => {
throw new Error("Tool not found")
@@ -605,7 +614,194 @@ describe("SkillMcpManager", () => {
await expect(manager.callTool(info, context, "test-tool", {})).rejects.toThrow(
"Tool not found"
)
expect(getOrCreateSpy).toHaveBeenCalledTimes(1) // No retry
expect(getOrCreateSpy).toHaveBeenCalledTimes(1)
})
})
describe("OAuth integration", () => {
beforeEach(() => {
mockTokens.mockClear()
mockLogin.mockClear()
})
it("injects Authorization header when oauth config has stored tokens", async () => {
// #given
const info: SkillMcpClientInfo = {
serverName: "oauth-server",
skillName: "oauth-skill",
sessionID: "session-oauth-1",
}
const config: ClaudeCodeMcpServer = {
url: "https://mcp.example.com/mcp",
oauth: {
clientId: "my-client",
scopes: ["read", "write"],
},
}
mockTokens.mockReturnValue({ accessToken: "stored-access-token" })
// #when
try {
await manager.getOrCreateClient(info, config)
} catch { /* connection fails in test */ }
// #then
const headers = lastTransportInstance.options?.requestInit?.headers as Record<string, string> | undefined
expect(headers?.Authorization).toBe("Bearer stored-access-token")
})
it("does not inject Authorization header when no stored tokens exist and login fails", async () => {
// #given
const info: SkillMcpClientInfo = {
serverName: "oauth-no-token",
skillName: "oauth-skill",
sessionID: "session-oauth-2",
}
const config: ClaudeCodeMcpServer = {
url: "https://mcp.example.com/mcp",
oauth: {
clientId: "my-client",
},
}
mockTokens.mockReturnValue(null)
mockLogin.mockRejectedValue(new Error("Login failed"))
// #when
try {
await manager.getOrCreateClient(info, config)
} catch { /* connection fails in test */ }
// #then
const headers = lastTransportInstance.options?.requestInit?.headers as Record<string, string> | undefined
expect(headers?.Authorization).toBeUndefined()
})
it("preserves existing static headers alongside OAuth token", async () => {
// #given
const info: SkillMcpClientInfo = {
serverName: "oauth-with-headers",
skillName: "oauth-skill",
sessionID: "session-oauth-3",
}
const config: ClaudeCodeMcpServer = {
url: "https://mcp.example.com/mcp",
headers: {
"X-Custom": "custom-value",
},
oauth: {
clientId: "my-client",
},
}
mockTokens.mockReturnValue({ accessToken: "oauth-token" })
// #when
try {
await manager.getOrCreateClient(info, config)
} catch { /* connection fails in test */ }
// #then
const headers = lastTransportInstance.options?.requestInit?.headers as Record<string, string> | undefined
expect(headers?.["X-Custom"]).toBe("custom-value")
expect(headers?.Authorization).toBe("Bearer oauth-token")
})
it("does not create auth provider when oauth config is absent", async () => {
// #given
const info: SkillMcpClientInfo = {
serverName: "no-oauth-server",
skillName: "test-skill",
sessionID: "session-no-oauth",
}
const config: ClaudeCodeMcpServer = {
url: "https://mcp.example.com/mcp",
headers: {
Authorization: "Bearer static-token",
},
}
// #when
try {
await manager.getOrCreateClient(info, config)
} catch { /* connection fails in test */ }
// #then
const headers = lastTransportInstance.options?.requestInit?.headers as Record<string, string> | undefined
expect(headers?.Authorization).toBe("Bearer static-token")
expect(mockTokens).not.toHaveBeenCalled()
})
it("handles step-up auth by triggering re-login on 403 with scope", async () => {
// #given
const info: SkillMcpClientInfo = {
serverName: "stepup-server",
skillName: "stepup-skill",
sessionID: "session-stepup-1",
}
const config: ClaudeCodeMcpServer = {
url: "https://mcp.example.com/mcp",
oauth: {
clientId: "my-client",
scopes: ["read"],
},
}
const context: SkillMcpServerContext = {
config,
skillName: "stepup-skill",
}
mockTokens.mockReturnValue({ accessToken: "initial-token" })
mockLogin.mockResolvedValue({ accessToken: "upgraded-token" })
let callCount = 0
const mockClient = {
callTool: mock(async () => {
callCount++
if (callCount === 1) {
throw new Error('403 WWW-Authenticate: Bearer scope="admin write"')
}
return { content: [{ type: "text", text: "success" }] }
}),
close: mock(() => Promise.resolve()),
}
const getOrCreateSpy = spyOn(manager as any, "getOrCreateClientWithRetry")
getOrCreateSpy.mockResolvedValue(mockClient)
// #when
const result = await manager.callTool(info, context, "test-tool", {})
// #then
expect(result).toEqual([{ type: "text", text: "success" }])
expect(mockLogin).toHaveBeenCalled()
})
it("does not attempt step-up when oauth config is absent", async () => {
// #given
const info: SkillMcpClientInfo = {
serverName: "no-stepup-server",
skillName: "no-stepup-skill",
sessionID: "session-no-stepup",
}
const context: SkillMcpServerContext = {
config: {
url: "https://mcp.example.com/mcp",
},
skillName: "no-stepup-skill",
}
const mockClient = {
callTool: mock(async () => {
throw new Error('403 WWW-Authenticate: Bearer scope="admin"')
}),
close: mock(() => Promise.resolve()),
}
const getOrCreateSpy = spyOn(manager as any, "getOrCreateClientWithRetry")
getOrCreateSpy.mockResolvedValue(mockClient)
// #when / #then
await expect(manager.callTool(info, context, "test-tool", {})).rejects.toThrow(/403/)
expect(mockLogin).not.toHaveBeenCalled()
})
})
})

View File

@@ -4,6 +4,8 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/
import type { Tool, Resource, Prompt } from "@modelcontextprotocol/sdk/types.js"
import type { ClaudeCodeMcpServer } from "../claude-code-mcp-loader/types"
import { expandEnvVarsInObject } from "../claude-code-mcp-loader/env-expander"
import { McpOAuthProvider } from "../mcp-oauth/provider"
import { isStepUpRequired, mergeScopes } from "../mcp-oauth/step-up"
import { createCleanMcpEnvironment } from "./env-cleaner"
import type { SkillMcpClientInfo, SkillMcpServerContext } from "./types"
@@ -60,6 +62,7 @@ function getConnectionType(config: ClaudeCodeMcpServer): ConnectionType | null {
export class SkillMcpManager {
private clients: Map<string, ManagedClient> = new Map()
private pendingConnections: Map<string, Promise<Client>> = new Map()
private authProviders: Map<string, McpOAuthProvider> = new Map()
private cleanupRegistered = false
private cleanupInterval: ReturnType<typeof setInterval> | null = null
private readonly IDLE_TIMEOUT = 5 * 60 * 1000
@@ -68,6 +71,28 @@ export class SkillMcpManager {
return `${info.sessionID}:${info.skillName}:${info.serverName}`
}
/**
* Get or create an McpOAuthProvider for a given server URL + oauth config.
* Providers are cached by server URL to reuse tokens across reconnections.
*/
private getOrCreateAuthProvider(
serverUrl: string,
oauth: NonNullable<ClaudeCodeMcpServer["oauth"]>
): McpOAuthProvider {
const existing = this.authProviders.get(serverUrl)
if (existing) {
return existing
}
const provider = new McpOAuthProvider({
serverUrl,
clientId: oauth.clientId,
scopes: oauth.scopes,
})
this.authProviders.set(serverUrl, provider)
return provider
}
private registerProcessCleanup(): void {
if (this.cleanupRegistered) return
this.cleanupRegistered = true
@@ -204,7 +229,30 @@ export class SkillMcpManager {
// Build request init with headers if provided
const requestInit: RequestInit = {}
if (config.headers && Object.keys(config.headers).length > 0) {
requestInit.headers = config.headers
requestInit.headers = { ...config.headers }
}
let authProvider: McpOAuthProvider | undefined
if (config.oauth) {
authProvider = this.getOrCreateAuthProvider(config.url, config.oauth)
let tokenData = authProvider.tokens()
const isExpired = tokenData?.expiresAt != null && tokenData.expiresAt < Math.floor(Date.now() / 1000)
if (!tokenData || isExpired) {
try {
tokenData = await authProvider.login()
} catch {
// Login failed — proceed without auth header
}
}
if (tokenData) {
const existingHeaders = (requestInit.headers ?? {}) as Record<string, string>
requestInit.headers = {
...existingHeaders,
Authorization: `Bearer ${tokenData.accessToken}`,
}
}
}
const transport = new StreamableHTTPClientTransport(url, {
@@ -460,6 +508,12 @@ export class SkillMcpManager {
lastError = error instanceof Error ? error : new Error(String(error))
const errorMessage = lastError.message.toLowerCase()
const stepUpHandled = await this.handleStepUpIfNeeded(lastError, config)
if (stepUpHandled) {
await this.forceReconnect(info)
continue
}
if (!errorMessage.includes("not connected")) {
throw lastError
}
@@ -470,23 +524,66 @@ export class SkillMcpManager {
)
}
const key = this.getClientKey(info)
const existing = this.clients.get(key)
if (existing) {
this.clients.delete(key)
try {
await existing.client.close()
} catch { /* process may already be terminated */ }
try {
await existing.transport.close()
} catch { /* transport may already be terminated */ }
}
await this.forceReconnect(info)
}
}
throw lastError || new Error("Operation failed with unknown error")
}
private async handleStepUpIfNeeded(
error: Error,
config: ClaudeCodeMcpServer
): Promise<boolean> {
if (!config.oauth || !config.url) {
return false
}
const statusMatch = /\b403\b/.exec(error.message)
if (!statusMatch) {
return false
}
const headers: Record<string, string> = {}
const wwwAuthMatch = /WWW-Authenticate:\s*(.+)/i.exec(error.message)
if (wwwAuthMatch?.[1]) {
headers["www-authenticate"] = wwwAuthMatch[1]
}
const stepUp = isStepUpRequired(403, headers)
if (!stepUp) {
return false
}
const currentScopes = config.oauth.scopes ?? []
const merged = mergeScopes(currentScopes, stepUp.requiredScopes)
config.oauth.scopes = merged
this.authProviders.delete(config.url)
const provider = this.getOrCreateAuthProvider(config.url, config.oauth)
try {
await provider.login()
return true
} catch {
return false
}
}
private async forceReconnect(info: SkillMcpClientInfo): Promise<void> {
const key = this.getClientKey(info)
const existing = this.clients.get(key)
if (existing) {
this.clients.delete(key)
try {
await existing.client.close()
} catch { /* process may already be terminated */ }
try {
await existing.transport.close()
} catch { /* transport may already be terminated */ }
}
}
private async getOrCreateClientWithRetry(
info: SkillMcpClientInfo,
config: ClaudeCodeMcpServer

View File

@@ -2,6 +2,7 @@ import { describe, test, expect, mock, beforeEach } from 'bun:test'
import type { TmuxConfig } from '../../config/schema'
import type { WindowState, PaneAction } from './types'
import type { ActionResult, ExecuteContext } from './action-executor'
import type { TmuxUtilDeps } from './manager'
type ExecuteActionsResult = {
success: boolean
@@ -33,6 +34,11 @@ const mockExecuteAction = mock<(
const mockIsInsideTmux = mock<() => boolean>(() => true)
const mockGetCurrentPaneId = mock<() => string | undefined>(() => '%0')
const mockTmuxDeps: TmuxUtilDeps = {
isInsideTmux: mockIsInsideTmux,
getCurrentPaneId: mockGetCurrentPaneId,
}
mock.module('./pane-state-querier', () => ({
queryWindowState: mockQueryWindowState,
paneExists: mockPaneExists,
@@ -51,15 +57,19 @@ mock.module('./action-executor', () => ({
executeAction: mockExecuteAction,
}))
mock.module('../../shared/tmux', () => ({
isInsideTmux: mockIsInsideTmux,
getCurrentPaneId: mockGetCurrentPaneId,
POLL_INTERVAL_BACKGROUND_MS: 2000,
SESSION_TIMEOUT_MS: 600000,
SESSION_MISSING_GRACE_MS: 6000,
SESSION_READY_POLL_INTERVAL_MS: 100,
SESSION_READY_TIMEOUT_MS: 500,
}))
mock.module('../../shared/tmux', () => {
const { isInsideTmux, getCurrentPaneId } = require('../../shared/tmux/tmux-utils')
const { POLL_INTERVAL_BACKGROUND_MS, SESSION_TIMEOUT_MS, SESSION_MISSING_GRACE_MS } = require('../../shared/tmux/constants')
return {
isInsideTmux,
getCurrentPaneId,
POLL_INTERVAL_BACKGROUND_MS,
SESSION_TIMEOUT_MS,
SESSION_MISSING_GRACE_MS,
SESSION_READY_POLL_INTERVAL_MS: 100,
SESSION_READY_TIMEOUT_MS: 500,
}
})
const trackedSessions = new Set<string>()
@@ -148,7 +158,7 @@ describe('TmuxSessionManager', () => {
}
//#when
const manager = new TmuxSessionManager(ctx, config)
const manager = new TmuxSessionManager(ctx, config, mockTmuxDeps)
//#then
expect(manager).toBeDefined()
@@ -168,7 +178,7 @@ describe('TmuxSessionManager', () => {
}
//#when
const manager = new TmuxSessionManager(ctx, config)
const manager = new TmuxSessionManager(ctx, config, mockTmuxDeps)
//#then
expect(manager).toBeDefined()
@@ -188,7 +198,7 @@ describe('TmuxSessionManager', () => {
}
//#when
const manager = new TmuxSessionManager(ctx, config)
const manager = new TmuxSessionManager(ctx, config, mockTmuxDeps)
//#then
expect(manager).toBeDefined()
@@ -210,7 +220,7 @@ describe('TmuxSessionManager', () => {
main_pane_min_width: 80,
agent_pane_min_width: 40,
}
const manager = new TmuxSessionManager(ctx, config)
const manager = new TmuxSessionManager(ctx, config, mockTmuxDeps)
const event = createSessionCreatedEvent(
'ses_child',
'ses_parent',
@@ -271,7 +281,7 @@ describe('TmuxSessionManager', () => {
main_pane_min_width: 80,
agent_pane_min_width: 40,
}
const manager = new TmuxSessionManager(ctx, config)
const manager = new TmuxSessionManager(ctx, config, mockTmuxDeps)
//#when - first agent
await manager.onSessionCreated(
@@ -305,7 +315,7 @@ describe('TmuxSessionManager', () => {
main_pane_min_width: 80,
agent_pane_min_width: 40,
}
const manager = new TmuxSessionManager(ctx, config)
const manager = new TmuxSessionManager(ctx, config, mockTmuxDeps)
const event = createSessionCreatedEvent('ses_root', undefined, 'Root Session')
//#when
@@ -327,7 +337,7 @@ describe('TmuxSessionManager', () => {
main_pane_min_width: 80,
agent_pane_min_width: 40,
}
const manager = new TmuxSessionManager(ctx, config)
const manager = new TmuxSessionManager(ctx, config, mockTmuxDeps)
const event = createSessionCreatedEvent(
'ses_child',
'ses_parent',
@@ -353,7 +363,7 @@ describe('TmuxSessionManager', () => {
main_pane_min_width: 80,
agent_pane_min_width: 40,
}
const manager = new TmuxSessionManager(ctx, config)
const manager = new TmuxSessionManager(ctx, config, mockTmuxDeps)
const event = {
type: 'session.deleted',
properties: {
@@ -398,7 +408,7 @@ describe('TmuxSessionManager', () => {
main_pane_min_width: 120,
agent_pane_min_width: 40,
}
const manager = new TmuxSessionManager(ctx, config)
const manager = new TmuxSessionManager(ctx, config, mockTmuxDeps)
//#when
await manager.onSessionCreated(
@@ -450,7 +460,7 @@ describe('TmuxSessionManager', () => {
main_pane_min_width: 80,
agent_pane_min_width: 40,
}
const manager = new TmuxSessionManager(ctx, config)
const manager = new TmuxSessionManager(ctx, config, mockTmuxDeps)
await manager.onSessionCreated(
createSessionCreatedEvent(
@@ -487,7 +497,7 @@ describe('TmuxSessionManager', () => {
main_pane_min_width: 80,
agent_pane_min_width: 40,
}
const manager = new TmuxSessionManager(ctx, config)
const manager = new TmuxSessionManager(ctx, config, mockTmuxDeps)
//#when
await manager.onSessionDeleted({ sessionID: 'ses_unknown' })
@@ -521,7 +531,7 @@ describe('TmuxSessionManager', () => {
main_pane_min_width: 80,
agent_pane_min_width: 40,
}
const manager = new TmuxSessionManager(ctx, config)
const manager = new TmuxSessionManager(ctx, config, mockTmuxDeps)
await manager.onSessionCreated(
createSessionCreatedEvent('ses_1', 'ses_parent', 'Task 1')

View File

@@ -2,8 +2,8 @@ import type { PluginInput } from "@opencode-ai/plugin"
import type { TmuxConfig } from "../../config/schema"
import type { TrackedSession, CapacityConfig } from "./types"
import {
isInsideTmux,
getCurrentPaneId,
isInsideTmux as defaultIsInsideTmux,
getCurrentPaneId as defaultGetCurrentPaneId,
POLL_INTERVAL_BACKGROUND_MS,
SESSION_MISSING_GRACE_MS,
SESSION_READY_POLL_INTERVAL_MS,
@@ -21,6 +21,16 @@ interface SessionCreatedEvent {
properties?: { info?: { id?: string; parentID?: string; title?: string } }
}
export interface TmuxUtilDeps {
isInsideTmux: () => boolean
getCurrentPaneId: () => string | undefined
}
const defaultTmuxDeps: TmuxUtilDeps = {
isInsideTmux: defaultIsInsideTmux,
getCurrentPaneId: defaultGetCurrentPaneId,
}
const SESSION_TIMEOUT_MS = 10 * 60 * 1000
/**
@@ -43,13 +53,15 @@ export class TmuxSessionManager {
private sessions = new Map<string, TrackedSession>()
private pendingSessions = new Set<string>()
private pollInterval?: ReturnType<typeof setInterval>
private deps: TmuxUtilDeps
constructor(ctx: PluginInput, tmuxConfig: TmuxConfig) {
constructor(ctx: PluginInput, tmuxConfig: TmuxConfig, deps: TmuxUtilDeps = defaultTmuxDeps) {
this.client = ctx.client
this.tmuxConfig = tmuxConfig
this.deps = deps
const defaultPort = process.env.OPENCODE_PORT ?? "4096"
this.serverUrl = ctx.serverUrl?.toString() ?? `http://localhost:${defaultPort}`
this.sourcePaneId = getCurrentPaneId()
this.sourcePaneId = deps.getCurrentPaneId()
log("[tmux-session-manager] initialized", {
configEnabled: this.tmuxConfig.enabled,
@@ -60,7 +72,7 @@ export class TmuxSessionManager {
}
private isEnabled(): boolean {
return this.tmuxConfig.enabled && isInsideTmux()
return this.tmuxConfig.enabled && this.deps.isInsideTmux()
}
private getCapacityConfig(): CapacityConfig {
@@ -113,7 +125,7 @@ export class TmuxSessionManager {
log("[tmux-session-manager] onSessionCreated called", {
enabled,
tmuxConfigEnabled: this.tmuxConfig.enabled,
isInsideTmux: isInsideTmux(),
isInsideTmux: this.deps.isInsideTmux(),
eventType: event.type,
infoId: event.properties?.info?.id,
infoParentID: event.properties?.info?.parentID,

View File

@@ -1,11 +1,83 @@
import { describe, test, expect, mock, beforeEach, spyOn } from "bun:test"
import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test"
import { executeCompact } from "./executor"
import type { AutoCompactState } from "./types"
import * as storage from "./storage"
type TimerCallback = (...args: any[]) => void
interface FakeTimeouts {
advanceBy: (ms: number) => Promise<void>
restore: () => void
}
function createFakeTimeouts(): FakeTimeouts {
let now = 0
let nextId = 1
const timers = new Map<number, { id: number; time: number; callback: TimerCallback; args: any[] }>()
const cleared = new Set<number>()
const original = {
setTimeout: globalThis.setTimeout,
clearTimeout: globalThis.clearTimeout,
}
const normalizeDelay = (delay?: number) => {
if (typeof delay !== "number" || !Number.isFinite(delay)) return 0
return delay < 0 ? 0 : delay
}
globalThis.setTimeout = ((callback: TimerCallback, delay?: number, ...args: any[]) => {
const id = nextId++
timers.set(id, {
id,
time: now + normalizeDelay(delay),
callback,
args,
})
return id as unknown as ReturnType<typeof setTimeout>
}) as typeof setTimeout
globalThis.clearTimeout = ((id?: number) => {
if (typeof id !== "number") return
cleared.add(id)
timers.delete(id)
}) as typeof clearTimeout
const advanceBy = async (ms: number) => {
const target = now + Math.max(0, ms)
while (true) {
let next: { id: number; time: number; callback: TimerCallback; args: any[] } | undefined
for (const timer of timers.values()) {
if (timer.time <= target && (!next || timer.time < next.time)) {
next = timer
}
}
if (!next) break
now = next.time
timers.delete(next.id)
if (!cleared.has(next.id)) {
next.callback(...next.args)
}
cleared.delete(next.id)
await Promise.resolve()
}
now = target
await Promise.resolve()
}
const restore = () => {
globalThis.setTimeout = original.setTimeout
globalThis.clearTimeout = original.clearTimeout
}
return { advanceBy, restore }
}
describe("executeCompact lock management", () => {
let autoCompactState: AutoCompactState
let mockClient: any
let fakeTimeouts: FakeTimeouts
const sessionID = "test-session-123"
const directory = "/test/dir"
const msg = { providerID: "anthropic", modelID: "claude-opus-4-5" }
@@ -32,6 +104,12 @@ describe("executeCompact lock management", () => {
showToast: mock(() => Promise.resolve()),
},
}
fakeTimeouts = createFakeTimeouts()
})
afterEach(() => {
fakeTimeouts.restore()
})
test("clears lock on successful summarize completion", async () => {
@@ -216,7 +294,7 @@ describe("executeCompact lock management", () => {
await executeCompact(sessionID, msg, autoCompactState, mockClient, directory)
// Wait for setTimeout callback
await new Promise((resolve) => setTimeout(resolve, 600))
await fakeTimeouts.advanceBy(600)
// #then: Lock should be cleared
// The continuation happens in setTimeout, but lock is cleared in finally before that
@@ -288,7 +366,7 @@ describe("executeCompact lock management", () => {
await executeCompact(sessionID, msg, autoCompactState, mockClient, directory)
// Wait for setTimeout callback
await new Promise((resolve) => setTimeout(resolve, 600))
await fakeTimeouts.advanceBy(600)
// #then: Truncation was attempted
expect(truncateSpy).toHaveBeenCalled()

View File

@@ -66,6 +66,20 @@ describe("atlas hook", () => {
})
describe("tool.execute.after handler", () => {
test("should handle undefined output gracefully (issue #1035)", async () => {
// #given - hook and undefined output (e.g., from /review command)
const hook = createAtlasHook(createMockPluginInput())
// #when - calling with undefined output
const result = await hook["tool.execute.after"](
{ tool: "delegate_task", sessionID: "session-123" },
undefined as unknown as { title: string; output: string; metadata: Record<string, unknown> }
)
// #then - returns undefined without throwing
expect(result).toBeUndefined()
})
test("should ignore non-delegate_task tools", async () => {
// #given - hook and non-delegate_task tool
const hook = createAtlasHook(createMockPluginInput())

View File

@@ -663,6 +663,11 @@ export function createAtlasHook(
input: ToolExecuteAfterInput,
output: ToolExecuteAfterOutput
): Promise<void> => {
// Guard against undefined output (e.g., from /review command - see issue #1035)
if (!output) {
return
}
if (!isCallerOrchestrator(input.sessionID)) {
return
}

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