Compare commits

...

142 Commits

Author SHA1 Message Date
github-actions[bot]
f1c794e63e release: v3.2.4 2026-02-06 12:06:22 +00:00
YeonGyu-Kim
4692809b42 Regenerate AGENTS.md hierarchy with latest codebase state 2026-02-06 19:07:12 +09:00
YeonGyu-Kim
8961026285 Merge pull request #1554 from code-yeongyu/fix/1187-dynamic-skill-reminder
Fix category-skill-reminder to prioritize user-installed skills
2026-02-06 19:05:49 +09:00
YeonGyu-Kim
d8b29da15f fix(category-skill-reminder): dynamically include available skills with user priority 2026-02-06 19:03:06 +09:00
YeonGyu-Kim
2b2160b43e Merge pull request #1557 from code-yeongyu/fix/796-compaction-model-agnostic
fix(compaction): remove hardcoded Claude model from compaction hooks
2026-02-06 19:01:39 +09:00
YeonGyu-Kim
60bbeb7304 fix(compaction): remove hardcoded Claude model from compaction hooks 2026-02-06 18:58:48 +09:00
YeonGyu-Kim
f1b2f6f3f7 Merge pull request #1556 from code-yeongyu/fix/1265-sisyphus-junior-model-inheritance
fix(config): stop sisyphus-junior from inheriting UI-selected model
2026-02-06 18:57:42 +09:00
YeonGyu-Kim
e9a3d579b3 Merge pull request #1553 from code-yeongyu/fix/1355-atlas-continuation-guard
fix(atlas): stop continuation retry loop on repeated prompt failures
2026-02-06 18:57:32 +09:00
YeonGyu-Kim
c6c149ebb8 Merge pull request #1547 from code-yeongyu/fix/agents-md-docs
docs: fix stale references in AGENTS.md files
2026-02-06 17:49:12 +09:00
YeonGyu-Kim
728eaaeb44 Merge pull request #1551 from code-yeongyu/fix/plan-agent-dynamic-skills
fix(delegate-task): make plan agent categories/skills dynamic
2026-02-06 17:48:35 +09:00
YeonGyu-Kim
9271f827dd Merge pull request #1552 from code-yeongyu/fix/schema-sync
fix: sync Zod schemas with actual implementations
2026-02-06 17:48:27 +09:00
YeonGyu-Kim
3a0d7e8dc3 fix(config): stop sisyphus-junior from inheriting UI-selected model 2026-02-06 17:44:47 +09:00
YeonGyu-Kim
aec5624122 fix(atlas): stop continuation retry loop on repeated prompt failures 2026-02-06 17:34:14 +09:00
YeonGyu-Kim
53537a9a90 fix: sync Zod schemas with actual implementations 2026-02-06 17:31:33 +09:00
YeonGyu-Kim
6b560ebf9e fix(delegate-task): make plan agent categories/skills dynamic
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-06 17:31:13 +09:00
YeonGyu-Kim
ca8ec494a3 docs: fix stale references in AGENTS.md files 2026-02-06 17:20:19 +09:00
YeonGyu-Kim
3be722b3b1 test: add literal match assertions for regex special char escaping tests 2026-02-06 16:33:34 +09:00
YeonGyu-Kim
d779a48a30 Merge pull request #1546 from kaizen403/fix/regex-special-chars-1521
fix: escape regex special chars in pattern matcher
2026-02-06 16:32:30 +09:00
YeonGyu-Kim
3166cffd02 Merge pull request #1545 from code-yeongyu/fix/enforce-disabled-tools
fix: enforce disabled_tools filtering
2026-02-06 16:31:55 +09:00
YeonGyu-Kim
3c32ae0449 fix: enforce disabled_tools filtering 2026-02-06 16:18:44 +09:00
Rishi Vhavle
bc782ca4d4 fix: escape regex special chars in pattern matcher
Fixes #1521. When hook matcher patterns contained regex special characters
like parentheses, the pattern-matcher would throw 'SyntaxError: Invalid
regular expression: unmatched parentheses' because these characters were
not escaped before constructing the RegExp.

The fix escapes all regex special characters (.+?^${}()|[\]\) EXCEPT
the asterisk (*) which is intentionally converted to .* for glob-style
matching.

Add comprehensive test suite for pattern-matcher covering:
- Exact matching (case-insensitive)
- Wildcard matching (glob-style *)
- Pipe-separated patterns
- All regex special characters (parentheses, brackets, etc.)
- Edge cases (empty matcher, complex patterns)
2026-02-06 12:48:28 +05:30
YeonGyu-Kim
917bba9d1b Merge pull request #1544 from code-yeongyu/feature/model-version-migration
feat(migration): add model version migration for gpt-5.2-codex and claude-opus-4-5
2026-02-06 16:01:42 +09:00
YeonGyu-Kim
7e5a657f06 feat(migration): add model version migration for gpt-5.2-codex and claude-opus-4-5 2026-02-06 15:55:28 +09:00
YeonGyu-Kim
bda44a5128 Merge pull request #1542 from code-yeongyu/fix/remove-redundant-opus-fallback
fix: remove redundant duplicate claude-opus-4-6 fallback entries
2026-02-06 15:34:05 +09:00
YeonGyu-Kim
161a864ea3 fix: remove redundant duplicate claude-opus-4-6 fallback entries
After model version update (opus-4-5 → opus-4-6), several agents had
identical duplicate fallback entries for the same model. The anthropic-only
entry was a superset covered by the broader providers entry, making it dead
code. Consolidate to single entry with all providers.
2026-02-06 15:30:05 +09:00
github-actions[bot]
93d3acce89 @shaunmorris has signed the CLA in code-yeongyu/oh-my-opencode#1541 2026-02-06 06:23:34 +00:00
YeonGyu-Kim
f63bf52a6e Merge pull request #1539 from code-yeongyu/feat/update-model-versions
chore: update model version references (gpt-5.2-codex → gpt-5.3-codex, claude-opus-4-5 → claude-opus-4-6)
2026-02-06 15:22:19 +09:00
YeonGyu-Kim
25e436a4aa fix: update snapshots and remove duplicate key in switcher for model version update 2026-02-06 15:12:41 +09:00
YeonGyu-Kim
1f64920453 chore: update claude-opus-4-5 references to claude-opus-4-6 (excludes antigravity models) 2026-02-06 15:09:07 +09:00
YeonGyu-Kim
4c7215404e chore: update gpt-5.2-codex references to gpt-5.3-codex 2026-02-06 15:08:33 +09:00
YeonGyu-Kim
d3999d79df Merge pull request #1533 from code-yeongyu/feat/hephaestus-provider-based-availability
feat: check provider connectivity instead of specific model for hephaestus availability
2026-02-06 10:51:30 +09:00
YeonGyu-Kim
b8f15affdb feat: check provider connectivity instead of specific model for hephaestus availability
Hephaestus now appears when any of its providers (openai, github-copilot, opencode) is
connected, rather than requiring the exact gpt-5.2-codex model. This allows users with
newer codex models (e.g., gpt-5.3-codex) to use Hephaestus without manual config overrides.

- Add requiresProvider field to ModelRequirement type
- Add isAnyProviderConnected() helper in model-availability
- Update hephaestus config from requiresModel to requiresProvider
- Update cli model-fallback to handle requiresProvider checks
2026-02-06 10:42:46 +09:00
github-actions[bot]
04576c306c @Mang-Joo has signed the CLA in code-yeongyu/oh-my-opencode#1526 2026-02-05 18:42:00 +00:00
YeonGyu-Kim
e450e4f903 Merge pull request #1525 from code-yeongyu/feat/claude-opus-4-6-priority
feat: add support for Opus 4.6
2026-02-06 03:35:36 +09:00
YeonGyu-Kim
11d0005eb5 feat: prioritize claude-opus-4-6 over claude-opus-4-5 in anthropic fallback chains
Add claude-opus-4-6 as the first anthropic provider entry before
claude-opus-4-5 across all agent and category fallback chains.
Also add high variant mapping for think-mode switcher.
2026-02-06 03:31:55 +09:00
YeonGyu-Kim
2224183b5c refactor: remove dead code
🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-06 02:51:53 +09:00
YeonGyu-Kim
f468effd47 Merge pull request #1518 from code-yeongyu/feat/hephaestus-autonomous-recovery
feat(agents): improve Hephaestus autonomous problem-solving behavior
2026-02-05 22:21:01 +09:00
YeonGyu-Kim
b8d7723f0a feat(agents): improve Hephaestus autonomous problem-solving behavior
- Add Core Principle section emphasizing autonomous recovery over asking
- Enhance Role & Agency with explicit wall-hitting protocol (3+ approaches before asking)
- Transform Failure Recovery from '3 consecutive failures' to 'autonomous recovery first'
- Relax Output Contract to allow creative problem-solving when blocked
- Remove conflicting 'ask when uncertain' guideline (conflicts with EXPLORE-FIRST)
2026-02-05 22:14:53 +09:00
YeonGyu-Kim
b3864d6398 Merge pull request #1512 from code-yeongyu/fix/gemini-3-pro-variant
fix(model-requirements): use supported variant for gemini-3-pro
2026-02-05 18:04:50 +09:00
sk0x0y
b7f7cb4341 fix(model-requirements): use supported variant for gemini-3-pro
Gemini 3 Pro only supports 'low' and 'high' thinking levels according to
Google's official API documentation. The 'max' variant is not supported
and would result in API errors.

Changed variant: 'max' -> 'high' for gemini-3-pro in:
- oracle agent
- metis agent
- momus agent
- ultrabrain category
- deep category
- artistry category

Ref: https://ai.google.dev/gemini-api/docs/thinking-mode
Closes #1433
2026-02-05 17:58:39 +09:00
YeonGyu-Kim
b2e8eecd09 Merge pull request #1361 from edxeth/fix/doctor-variant-display
fix(doctor): display user-configured variant in model resolution output
2026-02-05 17:56:16 +09:00
YeonGyu-Kim
6cfaac97b2 Merge pull request #1477 from kaizen403/fix/boulder-agent-tracking
fix: track agent in boulder state to fix session continuation (fixes #927)
2026-02-05 17:41:05 +09:00
YeonGyu-Kim
77e99d8b68 Merge pull request #1491 from itsmylife44/refactor/extract-formatCustomSkillsBlock
refactor(agents): extract formatCustomSkillsBlock to eliminate duplication
2026-02-05 17:40:54 +09:00
github-actions[bot]
02e1043227 @code-yeongyu has signed the CLA in code-yeongyu/oh-my-opencode#741 2026-02-05 08:28:30 +00:00
YeonGyu-Kim
617d7f4f67 Merge pull request #1509 from rooftop-Owl/fix/category-delegation-cache-format-mismatch
fix: handle both string[] and object[] formats in provider-models cache
2026-02-05 16:13:25 +09:00
YeonGyu-Kim
955ce710d9 Merge pull request #1510 from code-yeongyu/fix/windows-lsp-node-spawn-v2
fix(lsp): use Node.js child_process on Windows to avoid Bun spawn segfault
2026-02-05 16:07:22 +09:00
YeonGyu-Kim
8ff9c24623 fix(lsp): use Node.js child_process on Windows to avoid Bun spawn segfault
Bun has unfixed segfault issues on Windows when spawning subprocesses
(oven-sh/bun#25798, #26026, #23043). Even upgrading to Bun v1.3.6+
does not resolve the crashes.

Instead of blocking LSP on Windows with version checks, use Node.js
child_process.spawn as fallback. This allows LSP to work on Windows
regardless of Bun version.

Changes:
- Add UnifiedProcess interface bridging Bun Subprocess and Node ChildProcess
- Use Node.js spawn on Windows, Bun spawn on other platforms
- Add CWD validation before spawn to prevent libuv null dereference
- Add binary existence pre-check on Windows with helpful error messages
- Enable shell: true for Node spawn on Windows for .cmd/.bat resolution
- Remove ineffective Bun version blocking (v1.3.5 check)
- Add tests for CWD validation and start() error handling

Closes #1047
Ref: oven-sh/bun#25798
2026-02-05 15:57:20 +09:00
rooftop-Owl
bd3a3bcfb9 fix: handle both string[] and object[] formats in provider-models cache
Category delegation fails when provider-models.json contains model objects
with metadata (id, provider, context, output) instead of plain strings.
Line 196 in model-availability.ts assumes string[] format, causing:
  - Object concatenation: `${providerId}/${modelId}` becomes "ollama/[object Object]"
  - Empty availableModels Set passed to resolveModelPipeline()
  - Error: "Model not configured for category"

This is the root cause of issue #1508 where delegate_task(category='quick')
fails despite direct agent routing (delegate_task(subagent_type='explore'))
working correctly.

Changes:
- model-availability.ts: Add type check to handle both string and object formats
- connected-providers-cache.ts: Update ProviderModelsCache interface to accept both formats
- model-availability.test.ts: Add 4 test cases for object[] format handling

Direct agent routing bypasses fetchAvailableModels() entirely, explaining why
it works while category routing fails. This fix enables category delegation
to work with manually-populated Ollama model caches.

Fixes #1508
2026-02-05 15:32:08 +09:00
YeonGyu-Kim
291f41f7f9 Merge pull request #1497 from code-yeongyu/feat/auto-port-v2
feat: auto port selection when default port is busy
2026-02-05 11:40:59 +09:00
YeonGyu-Kim
11b883da6c Merge pull request #1500 from code-yeongyu/fix/background-abort-tui-crash
fix(background-agent): gracefully handle aborted parent session in notifyParentSession
2026-02-05 11:39:16 +09:00
YeonGyu-Kim
48cb2033e2 fix(background-agent): gracefully handle aborted parent session in notifyParentSession
When the main session is aborted while background tasks are running,
notifyParentSession() would attempt to call session.messages() and
session.prompt() on the aborted parent session, causing exceptions
that could crash the TUI.

- Add isAbortedSessionError() helper to detect abort-related errors
- Add abort check in session.messages() catch block with early return
- Add abort check in session.prompt() catch block with early return
- Add test case covering aborted parent session scenario

Fixes TUI crash when aborting main session with running background tasks.
2026-02-05 11:31:54 +09:00
YeonGyu-Kim
8842a9139f Merge pull request #1499 from code-yeongyu/feat/auto-port-selection
feat: auto port selection when default port is busy
2026-02-05 09:59:11 +09:00
YeonGyu-Kim
ca31796336 feat: auto port selection when default port is busy 2026-02-05 09:55:15 +09:00
YeonGyu-Kim
e1f6b822f1 Merge pull request #1498 from code-yeongyu/fix/custom-skills-in-delegate-task
fix: include custom skills in delegate_task load_skills resolution
2026-02-05 09:54:22 +09:00
YeonGyu-Kim
a644d38623 fix: properly restore env vars using delete when originally undefined 2026-02-05 09:45:35 +09:00
YeonGyu-Kim
a459813888 Fix skill discovery priority and deduplication tests 2026-02-05 09:45:35 +09:00
YeonGyu-Kim
18e941b6be fix: correct skill priority order and improve test coverage
- Changed priority order to: opencode-project > opencode > project > user
  (OpenCode Global skills now take precedence over legacy Claude project skills)
- Updated JSDoc comments to reflect correct priority order
- Fixed test to use actual discoverSkills() for deduplication verification
- Changed test assertion from 'source' to 'scope' (correct field name)
2026-02-05 09:45:35 +09:00
YeonGyu-Kim
86ac39fb78 fix: include custom skills in delegate_task load_skills resolution
- Add deduplicateSkills() to prevent duplicate skill entries from multiple sources
- Priority order: opencode-project > project > opencode > user
- Add tests for deduplication behavior

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-05 09:45:35 +09:00
YeonGyu-Kim
7621aada79 feat: auto port selection when default port is busy
- Added port-utils module with isPortAvailable, findAvailablePort, getAvailableServerPort
- Modified runner.ts to automatically find available port if preferred port is busy
- Shows warning message when using auto-selected port
- Eliminates need for manual OPENCODE_SERVER_PORT workaround
2026-02-05 09:45:25 +09:00
YeonGyu-Kim
9800d1ecb0 Merge pull request #1424 from code-yeongyu/fix/auto-update-wrong-directory
fix(auto-update): use USER_CONFIG_DIR instead of CACHE_DIR for plugin invalidation
2026-02-05 02:31:14 +09:00
YeonGyu-Kim
0fbf863d00 Merge pull request #1476 from code-yeongyu/feat/write-existing-file-guard
feat: guard write tool from overwriting existing files
2026-02-05 02:31:11 +09:00
YeonGyu-Kim
71ac09bb63 fix: use process.cwd() instead of ctx.directory for glob/grep tools
ToolContext type from @opencode-ai/plugin/tool does not include
a 'directory' property, causing typecheck failure after rebase from dev.

Changed to use process.cwd() which is the same pattern used in
session-manager/tools.ts.
2026-02-05 02:23:48 +09:00
YeonGyu-Kim
ddf878e53c feat(write-existing-file-guard): add hook to prevent write tool from overwriting existing files
Adds a PreToolUse hook that intercepts write operations and throws an error
if the target file already exists, guiding users to use the edit tool instead.

- Throws error: 'File already exists. Use edit tool instead.'
- Hook is enabled by default, can be disabled via disabled_hooks
- Includes comprehensive test suite with BDD-style comments
2026-02-05 01:58:14 +09:00
YeonGyu-Kim
8886879bd0 fix(auto-update): use USER_CONFIG_DIR instead of CACHE_DIR for plugin invalidation
The auto-update-checker was operating on the wrong directory:
- CACHE_DIR (~/.cache/opencode) was used for node_modules, package.json, and bun.lock
- But plugins are installed in USER_CONFIG_DIR (~/.config/opencode)

This caused auto-updates to fail silently:
1. Update detected correctly (3.x.x -> 3.y.y)
2. invalidatePackage() tried to delete from ~/.cache/opencode (wrong!)
3. bun install ran but respected existing lockfile
4. Old version remained installed

Fix: Use USER_CONFIG_DIR consistently for all invalidation operations.

Also moves INSTALLED_PACKAGE_JSON constant to use USER_CONFIG_DIR for consistency.
2026-02-05 01:54:10 +09:00
itsmylife44
f08d4ecdda refactor(agents): extract formatCustomSkillsBlock to eliminate duplication
Address review feedback (P3): The User-Installed Skills block was duplicated verbatim in two if/else branches in both buildCategorySkillsDelegationGuide() and Atlas buildSkillsSection(). Extract shared formatCustomSkillsBlock() with configurable header level (#### vs **) so both builders reference a single source of truth.

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-04 17:52:59 +01:00
YeonGyu-Kim
8049ceb947 Merge pull request #1490 from itsmylife44/fix/custom-skills-delegation-emphasis
fix(agents): emphasize user-installed custom skills in delegation prompts
2026-02-05 01:47:04 +09:00
itsmylife44
a298a2f063 fix(atlas): separate custom skills in Atlas buildSkillsSection()
Atlas had its own buildSkillsSection() in atlas/utils.ts that rendered all skills in a flat table without distinguishing built-in from user-installed. Apply the same HIGH PRIORITY emphasis and CRITICAL warning pattern used in the shared prompt builder.

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-04 17:27:39 +01:00
itsmylife44
ddc52bfd31 fix(agents): emphasize user-installed skills in delegation prompts
Custom skills from .config/opencode/skills/ were visible in agent prompts but the model consistently ignored them when delegating via delegate_task(). The flat skill table made no distinction between built-in and user-installed skills, causing the model to default to built-in ones only.

- Separate skills into 'Built-in Skills' and 'User-Installed Skills (HIGH PRIORITY)' sections in buildCategorySkillsDelegationGuide()

- Add CRITICAL warning naming each custom skill explicitly

- Add priority note: 'When in doubt, INCLUDE rather than omit'

- Show source column (user/project) for custom skills

- Apply same separation in buildUltraworkSection()

- Add 10 unit tests covering all skill combination scenarios

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-04 17:27:32 +01:00
Rishi Vhavle
38b40bca04 fix(prometheus-md-only): prioritize boulder state agent over message files
Root cause fix for issue #927:
- After /plan → /start-work → interruption, in-memory sessionAgentMap is cleared
- getAgentFromMessageFiles() returns 'prometheus' (oldest message from /plan)
- But boulder.json has agent: 'atlas' (set by /start-work)

Fix: Check boulder state agent BEFORE falling back to message files
Priority: in-memory → boulder state → message files

Test: 3 new tests covering the priority logic
2026-02-04 21:27:23 +05:30
Rishi Vhavle
169ccb6b05 fix: use boulder agent instead of hardcoded Atlas check for continuation
Address code review: continuation was blocked unless last agent was Atlas,
making the new agent parameter ineffective. Now the idle handler checks if
the last session agent matches boulderState.agent (defaults to 'atlas'),
allowing non-Atlas agents to resume when properly configured.

- Add getLastAgentFromSession helper for agent lookup
- Replace isCallerOrchestrator gate with boulder-agent-aware check
- Add test for non-Atlas agent continuation scenario
2026-02-04 21:21:57 +05:30
Rishi Vhavle
d8137c0c90 fix: track agent in boulder state to fix session continuation (fixes #927)
Add 'agent' field to BoulderState to track which agent (atlas) should
resume on session continuation. Previously, when user typed 'continue'
after interruption, Prometheus (planner) resumed instead of Sisyphus
(executor), causing all delegate_task calls to get READ-ONLY mode.

Changes:
- Add optional 'agent' field to BoulderState interface
- Update createBoulderState() to accept agent parameter
- Set agent='atlas' when /start-work creates boulder.json
- Use stored agent on boulder continuation (defaults to 'atlas')
- Add tests for new agent field functionality
2026-02-04 21:21:57 +05:30
edxeth
81a2317f51 fix(doctor): display user-configured variant in model resolution output
OmoConfig interface was missing variant property, causing doctor to show
variants from ModelRequirement fallback chain instead of user's config.

- Add variant to OmoConfig agent/category entries
- Add userVariant to resolution info interfaces
- Update getEffectiveVariant to prioritize user variant
- Add tests verifying variant capture
2026-02-04 14:41:35 +01:00
YeonGyu-Kim
708d15ebcc Merge pull request #1475 from code-yeongyu/fix/model-availability-connected-providers
Merging PR #1475 into dev as requested. Cubic review 5/5 accepted.
2026-02-04 16:25:26 +09:00
YeonGyu-Kim
80297f890e fix(model-availability): honor connected providers for fallback
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-04 16:00:16 +09:00
YeonGyu-Kim
ce7478cde7 Merge pull request #1473 from code-yeongyu/feature/task-global-storage
feat(tasks): migrate storage to global config dir with ULTRAWORK_TASK_LIST_ID support
2026-02-04 15:56:31 +09:00
YeonGyu-Kim
8d0fa97b72 Merge pull request #1471 from high726/fix/look-at-clipboard-image-support
feat(look_at): add image_data parameter for clipboard/pasted image support
2026-02-04 15:55:29 +09:00
github-actions[bot]
819c5b5d29 release: v3.2.3 2026-02-04 06:38:00 +00:00
YeonGyu-Kim
8e349aad7e fix(tasks): use path.isAbsolute() for cross-platform path detection
Fixes Cubic AI review finding: startsWith('/') doesn't work on Windows
where absolute paths use drive letters (e.g., C:\).
2026-02-04 15:37:12 +09:00
YeonGyu-Kim
1712907057 docs(tasks): update AGENTS.md for global storage architecture 2026-02-04 15:15:08 +09:00
YeonGyu-Kim
d66e39a887 refactor(tasks): consolidate task-list path resolution to use getTaskDir 2026-02-04 15:12:28 +09:00
YeonGyu-Kim
ace2688186 chore: regenerate schema after Task 1 changes 2026-02-04 15:10:58 +09:00
YeonGyu-Kim
bf31e7289e feat(tasks): migrate storage to global config dir with ULTRAWORK_TASK_LIST_ID support 2026-02-04 15:08:06 +09:00
YeonGyu-Kim
7b8204924a feat(config): update task config schema for global storage
- Make storage_path truly optional (remove default)
- Add task_list_id as config alternative to env var
- Fix build-schema.ts to use zodToJsonSchema

🤖 Generated with assistance of OhMyOpenCode
2026-02-04 15:04:49 +09:00
YeonGyu-Kim
224afadbdb fix(skill-loader): respect disabledSkills in async skill resolution 2026-02-04 15:03:57 +09:00
YeonGyu-Kim
953b1f98c9 fix(ci): use regex variables for bash 5.2+ compatibility in changelog generation 2026-02-04 15:00:31 +09:00
YeonGyu-Kim
e073412da1 fix(auth): add graceful fallback for server auth injection
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-04 14:52:31 +09:00
YeonGyu-Kim
0dd42e2901 fix(non-interactive-env): force unix export syntax for bash env prefix
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-04 14:52:13 +09:00
YeonGyu-Kim
85932fadc7 test(skill-loader): fix test isolation by resetting skill content
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-04 14:51:56 +09:00
YeonGyu-Kim
65043a7e94 fix: remove broken TOC links in translated READMEs
Remove outdated configuration section links that no longer exist.
Applies changes from PR #1386 (pierrecorsini).

Co-authored-by: Pierre CORSINI <pierrecorsini@users.noreply.github.com>
2026-02-04 13:54:50 +09:00
YeonGyu-Kim
ffcf1b5715 Merge pull request #1371 from YanzheL/feat/websearch-multi-provider
feat(mcp): add multi-provider websearch support (Exa + Tavily)
2026-02-04 13:52:36 +09:00
YeonGyu-Kim
d14f32f2d5 Merge pull request #1470 from Lynricsy/fix/categories-model-precedence
fix(delegate-task): honor explicit category model over sisyphus-junior
2026-02-04 13:52:25 +09:00
YeonGyu-Kim
f79f164cd5 fix(skill-loader): deterministic collision handling for skill names
- Separate directory and file entries, process directories first
- Use Map to deduplicate skills by name (first-wins)
- Directory skills (SKILL.md, {dir}.md) take precedence over file skills (*.md)
- Add test for collision scenario

Addresses Oracle P2 review feedback from PR #1254
2026-02-04 13:52:06 +09:00
YeonGyu-Kim
dee8cf1720 Merge pull request #1370 from misyuari/fix/refactor-skills
fix: update skill resolution to support disabled skills functionality
2026-02-04 13:47:26 +09:00
YeonGyu-Kim
8098e48658 Merge pull request #1254 from LeekJay/fix/nested-skill-discovery
feat(skill-loader): support nested skill directories
2026-02-04 13:40:03 +09:00
YeonGyu-Kim
0dad85ead7 hephaestus color improvement 2026-02-04 13:36:45 +09:00
YeonGyu-Kim
1e383f44d9 fix(background-agent): abort session on model suggestion retry failure
When promptWithModelSuggestionRetry() fails, the session was not being aborted, causing the polling loop to wait forever for an idle state. Added session.abort() calls in startTask() and resume() catch blocks.

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-02-04 13:36:45 +09:00
YeonGyu-Kim
30990f7f59 style(agents): update Hephaestus and Prometheus colors
- Hephaestus: #FF4500 (Magma Orange) → #708090 (Slate Gray)
  Blacksmith's hammer/iron theme, visible in both light and dark modes

- Prometheus: #9D4EDD (Amethyst Purple) → #FF5722 (Deep Orange)
  Fire/flame theme, restoring the original fire color concept
2026-02-04 13:36:45 +09:00
YeonGyu-Kim
51c7fee34c Merge pull request #1280 from Zacks-Zhang/fix/fix-stale-lsp-diagnostics
fix(lsp): prevent stale diagnostics by syncing didChange
2026-02-04 13:35:07 +09:00
YeonGyu-Kim
80e970cf36 Merge pull request #1297 from khduy/fix/deduplicate-settings-paths
fix(claude-code-hooks): deduplicate settings paths to prevent double hook execution
2026-02-04 13:35:06 +09:00
YeonGyu-Kim
b7b466f4f2 Merge pull request #1289 from KonaEspresso94/fix/agent-tools-bug
fix: honor tools overrides via permission migration
2026-02-04 13:34:53 +09:00
YeonGyu-Kim
5dabb8a198 Merge pull request #1393 from ualtinok/dev
fix: grep and glob tools usage without path param under Opencode Desktop
2026-02-04 13:34:52 +09:00
YeonGyu-Kim
d11f0685be Merge pull request #1388 from boguan/dev
fix: remove redundant removeCodeBlocks call
2026-02-04 13:34:51 +09:00
YeonGyu-Kim
814e14edf7 Merge pull request #1384 from devxoul/fix/readme-toc-links
fix: remove broken TOC links in README
2026-02-04 13:34:40 +09:00
lihaitao
d099b0255f feat(look_at): add image_data parameter for clipboard/pasted image support
Closes #704

Add support for base64-encoded image data in the look_at tool,
enabling analysis of clipboard/pasted images without requiring
a file path.

Changes:
- Add optional image_data parameter to LookAtArgs type
- Update validateArgs to accept either file_path or image_data
- Add inferMimeTypeFromBase64 function to detect image format
- Add try/catch around atob() to handle invalid base64 gracefully
- Update execute to handle both file path and data URL inputs
- Add comprehensive tests for image_data functionality
2026-02-04 12:24:00 +08:00
Lynricsy
1411ca255a fix(delegate-task): honor explicit category model over sisyphus-junior 2026-02-04 11:51:20 +08:00
YeonGyu-Kim
4330f25fee revert(call-omo-agent): remove metis/momus from ALLOWED_AGENTS
call_omo_agent is for lightweight exploration agents (explore, librarian).
metis/momus are consultation agents that should be invoked via delegate_task.

Reverts part of #1462 that incorrectly added metis/momus to call_omo_agent.
2026-02-04 11:38:24 +09:00
YeonGyu-Kim
737fac4345 fix(agent-restrictions): add read-only restrictions for metis and momus
- Add metis and momus to AGENT_RESTRICTIONS with same pattern as oracle
- Deny write, edit, task, and delegate_task tools
- Enforces read-only design for these advisor agents
- Addresses cubic review feedback on #1462
2026-02-04 11:36:34 +09:00
YeonGyu-Kim
49a4a1bf9e fix(call-omo-agent): allow Prometheus to call Metis and Momus (#1462)
* fix(call-omo-agent): allow Prometheus to call Metis and Momus

* fix(call-omo-agent): update help text and remove unrelated bun.lock

- Update subagent_type description to include metis and momus
- Remove unrelated bun.lock changes (keeps PR scope tight)
- Addresses Oracle review feedback
2026-02-04 11:27:14 +09:00
YeonGyu-Kim
5ffecb60c9 fix(skill-mcp): avoid propertyNames for Gemini compatibility (#1465)
- Replace record(string, unknown) with object({}) in arguments schema
- record() generates propertyNames which Gemini rejects with 400 error
- object({}) generates plain { type: 'object' } without propertyNames
- Runtime parseArguments() already handles arbitrary object keys

Fixes #1315
2026-02-04 11:26:34 +09:00
YeonGyu-Kim
b954afca90 fix(model-requirements): use supported variant for gemini-3-pro (#1463)
* fix(model-requirements): use supported variant for gemini-3-pro

* fix(delegate-task): update artistry variant to high for gemini-3-pro

- Update DEFAULT_CATEGORIES artistry variant from 'max' to 'high'
- Update related test comment
- gemini-3-pro only supports low/high thinking levels, not max
- Addresses Oracle review feedback
2026-02-04 11:26:17 +09:00
YeonGyu-Kim
faae3d0f32 fix(model-availability): prefer exact model ID match in fuzzyMatchModel (#1460)
* fix(model-availability): prefer exact model ID match in fuzzyMatchModel

* fix(model-availability): use filter+shortest for multi-provider tie-break

- Change Priority 2 from find() to filter()+reduce()
- Preserves shortest-match tie-break when multiple providers share model ID
- Add test for multi-provider same model ID case
- Addresses Oracle review feedback
2026-02-04 11:25:59 +09:00
YeonGyu-Kim
c57c0a6bcb docs: clarify Prometheus invocation workflow (#1466) 2026-02-04 11:25:46 +09:00
YeonGyu-Kim
6a66bfccec fix(doctor): respect user-configured agent variant (#1464)
* fix(doctor): respect user-configured agent variant

* fix(doctor): align variant resolution with agent-variant.ts

- Add case-insensitive agent key lookup (matches canonical logic)
- Support category-based variant inheritance (agent.category -> categories[cat].variant)
- Separate getCategoryEffectiveVariant for category-specific resolution
- Addresses Oracle review feedback
2026-02-04 11:25:37 +09:00
YeonGyu-Kim
b19bc857e3 fix(docs): instruct curl over WebFetch for installation (#1461) 2026-02-04 11:25:25 +09:00
dan
2f9004f076 fix(auth): opencode desktop server unauthorized bugfix on subagent spawn (#1399)
* fix(auth): opencode desktop server unauthorized bugfix on subagent spawn

* refactor(auth): add runtime guard and throw on SDK mismatch

- Add JSDoc with SDK API documentation reference
- Replace silent failure with explicit Error throw when OPENCODE_SERVER_PASSWORD is set but client structure is incompatible
- Add runtime type guard for SDK client structure
- Add tests for error cases (missing _client, missing setConfig)
- Remove unrelated bun.lock changes

Co-authored-by: dan-myles <dan-myles@users.noreply.github.com>

---------

Co-authored-by: YeonGyu-Kim <code.yeon.gyu@gmail.com>
Co-authored-by: dan-myles <dan-myles@users.noreply.github.com>
2026-02-04 11:07:02 +09:00
Rishi Vhavle
6151d1cb5e fix: block bash commands in Prometheus mode to respect permission config (#1449)
Fixes #1428 - Prometheus bash bypass security issue
2026-02-04 11:06:54 +09:00
YeonGyu-Kim
13e1d7cbd7 fix(non-interactive-env): use detectShellType() instead of hardcoded 'unix' (#1459)
The shellType was hardcoded to 'unix' which breaks on native Windows shells
(cmd.exe, PowerShell) when running without Git Bash or WSL.

This change uses the existing detectShellType() function to dynamically
determine the correct shell type, enabling proper env var syntax for all
supported shell environments.
2026-02-04 10:52:46 +09:00
github-actions[bot]
5361cd0a5f @kaizen403 has signed the CLA in code-yeongyu/oh-my-opencode#1449 2026-02-03 20:44:35 +00:00
github-actions[bot]
437abd8c17 @wydrox has signed the CLA in code-yeongyu/oh-my-opencode#1436 2026-02-03 16:39:46 +00:00
YanzheL
9a2a6a695a fix(test): use try/finally for guaranteed env restoration 2026-02-03 23:37:12 +08:00
YanzheL
5a2ab0095d fix(mcp): lazy evaluation prevents crash when websearch disabled
createWebsearchConfig was called eagerly before checking disabledMcps,
causing Tavily missing-key error even when websearch was disabled.
Now each MCP is only created if not in disabledMcps list.
2026-02-03 23:37:12 +08:00
YanzheL
17cb49543a fix(mcp): rewrite tests to call createWebsearchConfig directly
Previously tests were tautological - they defined local logic
instead of invoking the actual implementation. Now all tests
properly exercise createWebsearchConfig.
2026-02-03 23:37:12 +08:00
YanzheL
fea7bd2dcf docs(mcp): document websearch provider configuration 2026-02-03 23:37:12 +08:00
YanzheL
ef3d0afa32 test(mcp): add websearch provider tests 2026-02-03 23:37:12 +08:00
YanzheL
00f576868b feat(mcp): add multi-provider websearch support 2026-02-03 23:37:12 +08:00
YanzheL
4840864ed8 feat(config): add websearch provider schema 2026-02-03 23:37:12 +08:00
github-actions[bot]
9f50947795 @filipemsilv4 has signed the CLA in code-yeongyu/oh-my-opencode#1435 2026-02-03 14:38:23 +00:00
github-actions[bot]
45290b5b8f @sk0x0y has signed the CLA in code-yeongyu/oh-my-opencode#1434 2026-02-03 14:21:40 +00:00
github-actions[bot]
9343f38479 @Stranmor has signed the CLA in code-yeongyu/oh-my-opencode#1432 2026-02-03 13:53:27 +00:00
github-actions[bot]
bf83712ae1 @ualtinok has signed the CLA in code-yeongyu/oh-my-opencode#1393 2026-02-03 12:43:21 +00:00
Muhammad Noor Misyuari
374acb3ac6 fix: update tests to reflect changes in skill resolution for async handling and disabled skills 2026-02-03 15:19:08 +07:00
Muhammad Noor Misyuari
ba2a9a9051 fix: update skill resolution to support disabled skills functionality 2026-02-03 15:19:08 +07:00
Muhammad Noor Misyuari
2236a940f8 fix: implement disabled skills functionality in skill resolution 2026-02-03 15:19:01 +07:00
github-actions[bot]
976ffaeb0d @ilarvne has signed the CLA in code-yeongyu/oh-my-opencode#1422 2026-02-03 08:15:51 +00:00
ismeth
527c21ea90 fix(tools): for overridden tools (glob, grep) path should use ctx.directory. OpenCode Desktop might not send path as a param and cwd might resolve to "/" 2026-02-02 11:34:33 +01:00
BoGuan
f68a6f7d1b fix: remove redundant removeCodeBlocks call
Remove duplicate removeCodeBlocks() call in keyword-detector/index.ts.

The detectKeywordsWithType() function already calls removeCodeBlocks() internally, so calling it before passing the text was redundant and caused unnecessary double processing.
2026-02-02 15:18:25 +08:00
konaespresso94
8a5b131c7f chore: tracking merge origin/dev 2026-02-02 15:56:00 +09:00
Suyeol Jeon
ce62da92c6 fix: remove broken TOC links pointing to non-existent sections 2026-02-02 15:16:55 +09:00
khduy
4c40c3adb1 fix(claude-code-hooks): deduplicate settings paths to prevent double hook execution
When cwd equals home directory, ~/.claude/settings.json was being loaded
twice (once as home config and once as cwd config), causing hooks like
Stop to execute twice.

This adds deduplication using Set to ensure each config file is only
loaded once.
2026-01-31 01:30:28 +07:00
konaespresso94
ba129784f5 fix(agents): honor tools overrides via permission migration 2026-01-31 00:29:11 +09:00
Zacks Zhang
3bb4289b18 fix(lsp): prevent stale diagnostics by syncing didChange 2026-01-30 16:39:55 +08:00
LeekJay
64b29ea097 feat(skill-loader): support nested skill directories
Add recursive directory scanning to discover skills in nested directories
like superpowers (e.g., skills/superpowers/brainstorming/SKILL.md).

Changes:
- Add namePrefix, depth, and maxDepth parameters to loadSkillsFromDir
- Recurse into subdirectories when no SKILL.md found at current level
- Construct hierarchical skill names (e.g., 'superpowers/brainstorming')
- Limit recursion depth to 2 levels to prevent infinite loops

This enables compatibility with the superpowers plugin which installs
skills as: ~/.config/opencode/skills/superpowers/ -> superpowers/skills/

Fixes skill discovery for nested directory structures.
2026-01-30 00:39:43 +08:00
140 changed files with 6162 additions and 4722 deletions

View File

@@ -255,35 +255,43 @@ jobs:
DOCS="" DOCS=""
OTHER="" OTHER=""
# Store regexes in variables for bash 5.2+ compatibility
# (bash 5.2 changed how parentheses are parsed inside [[ =~ ]])
re_skip='^(chore|ci|release|test|ignore)'
re_feat_scoped='^feat\(([^)]+)\): (.+)$'
re_fix_scoped='^fix\(([^)]+)\): (.+)$'
re_refactor_scoped='^refactor\(([^)]+)\): (.+)$'
re_docs_scoped='^docs\(([^)]+)\): (.+)$'
while IFS= read -r commit; do while IFS= read -r commit; do
[ -z "$commit" ] && continue [ -z "$commit" ] && continue
# Skip chore, ci, release, test commits # Skip chore, ci, release, test commits
[[ "$commit" =~ ^(chore|ci|release|test|ignore) ]] && continue [[ "$commit" =~ $re_skip ]] && continue
if [[ "$commit" =~ ^feat ]]; then if [[ "$commit" =~ ^feat ]]; then
# Extract scope and message: feat(scope): message -> **scope**: message # Extract scope and message: feat(scope): message -> **scope**: message
if [[ "$commit" =~ ^feat\(([^)]+)\):\ (.+)$ ]]; then if [[ "$commit" =~ $re_feat_scoped ]]; then
FEATURES="${FEATURES}\n- **${BASH_REMATCH[1]}**: ${BASH_REMATCH[2]}" FEATURES="${FEATURES}\n- **${BASH_REMATCH[1]}**: ${BASH_REMATCH[2]}"
else else
MSG="${commit#feat: }" MSG="${commit#feat: }"
FEATURES="${FEATURES}\n- ${MSG}" FEATURES="${FEATURES}\n- ${MSG}"
fi fi
elif [[ "$commit" =~ ^fix ]]; then elif [[ "$commit" =~ ^fix ]]; then
if [[ "$commit" =~ ^fix\(([^)]+)\):\ (.+)$ ]]; then if [[ "$commit" =~ $re_fix_scoped ]]; then
FIXES="${FIXES}\n- **${BASH_REMATCH[1]}**: ${BASH_REMATCH[2]}" FIXES="${FIXES}\n- **${BASH_REMATCH[1]}**: ${BASH_REMATCH[2]}"
else else
MSG="${commit#fix: }" MSG="${commit#fix: }"
FIXES="${FIXES}\n- ${MSG}" FIXES="${FIXES}\n- ${MSG}"
fi fi
elif [[ "$commit" =~ ^refactor ]]; then elif [[ "$commit" =~ ^refactor ]]; then
if [[ "$commit" =~ ^refactor\(([^)]+)\):\ (.+)$ ]]; then if [[ "$commit" =~ $re_refactor_scoped ]]; then
REFACTOR="${REFACTOR}\n- **${BASH_REMATCH[1]}**: ${BASH_REMATCH[2]}" REFACTOR="${REFACTOR}\n- **${BASH_REMATCH[1]}**: ${BASH_REMATCH[2]}"
else else
MSG="${commit#refactor: }" MSG="${commit#refactor: }"
REFACTOR="${REFACTOR}\n- ${MSG}" REFACTOR="${REFACTOR}\n- ${MSG}"
fi fi
elif [[ "$commit" =~ ^docs ]]; then elif [[ "$commit" =~ ^docs ]]; then
if [[ "$commit" =~ ^docs\(([^)]+)\):\ (.+)$ ]]; then if [[ "$commit" =~ $re_docs_scoped ]]; then
DOCS="${DOCS}\n- **${BASH_REMATCH[1]}**: ${BASH_REMATCH[2]}" DOCS="${DOCS}\n- **${BASH_REMATCH[1]}**: ${BASH_REMATCH[2]}"
else else
MSG="${commit#docs: }" MSG="${commit#docs: }"

View File

@@ -1,7 +1,7 @@
# PROJECT KNOWLEDGE BASE # PROJECT KNOWLEDGE BASE
**Generated:** 2026-02-03T16:10:30+09:00 **Generated:** 2026-02-06T18:30:00+09:00
**Commit:** d7679e14 **Commit:** c6c149e
**Branch:** dev **Branch:** dev
--- ---
@@ -120,40 +120,45 @@ This is an **international open-source project**. To ensure accessibility and ma
## OVERVIEW ## OVERVIEW
OpenCode plugin: multi-model agent orchestration (Claude Opus 4.5, GPT-5.2, Gemini 3 Flash). 34 lifecycle hooks, 20+ tools (LSP, AST-Grep, delegation), 11 specialized agents, full Claude Code compatibility. "oh-my-zsh" for OpenCode. OpenCode plugin: multi-model agent orchestration (Claude Opus 4.6, GPT-5.3 Codex, Gemini 3 Flash). 40+ lifecycle hooks, 25+ tools (LSP, AST-Grep, delegation), 11 specialized agents, full Claude Code compatibility. "oh-my-zsh" for OpenCode.
## STRUCTURE ## STRUCTURE
``` ```
oh-my-opencode/ oh-my-opencode/
├── src/ ├── src/
│ ├── agents/ # 11 AI agents - see src/agents/AGENTS.md │ ├── agents/ # 11 AI agents - see src/agents/AGENTS.md
│ ├── hooks/ # 34 lifecycle hooks - see src/hooks/AGENTS.md │ ├── hooks/ # 40+ lifecycle hooks - see src/hooks/AGENTS.md
│ ├── tools/ # 20+ tools - see src/tools/AGENTS.md │ ├── tools/ # 25+ tools - see src/tools/AGENTS.md
│ ├── features/ # Background agents, Claude Code compat - see src/features/AGENTS.md │ ├── features/ # Background agents, skills, Claude Code compat - see src/features/AGENTS.md
│ ├── shared/ # 66 cross-cutting utilities - see src/shared/AGENTS.md │ ├── shared/ # 66 cross-cutting utilities - see src/shared/AGENTS.md
│ ├── cli/ # CLI installer, doctor - see src/cli/AGENTS.md │ ├── cli/ # CLI installer, doctor - see src/cli/AGENTS.md
│ ├── mcp/ # Built-in MCPs - see src/mcp/AGENTS.md │ ├── mcp/ # Built-in MCPs - see src/mcp/AGENTS.md
│ ├── config/ # Zod schema, TypeScript types │ ├── config/ # Zod schema (schema.ts 455 lines), TypeScript types
── index.ts # Main plugin entry (788 lines) ── plugin-handlers/ # Plugin config loading (config-handler.ts 501 lines)
├── script/ # build-schema.ts, build-binaries.ts │ ├── index.ts # Main plugin entry (924 lines)
├── packages/ # 11 platform-specific binaries ├── plugin-config.ts # Config loading orchestration
└── dist/ # Build output (ESM + .d.ts) │ └── plugin-state.ts # Model cache state
├── script/ # build-schema.ts, build-binaries.ts, publish.ts
├── packages/ # 11 platform-specific binaries
└── dist/ # Build output (ESM + .d.ts)
``` ```
## WHERE TO LOOK ## WHERE TO LOOK
| Task | Location | Notes | | Task | Location | Notes |
|------|----------|-------| |------|----------|-------|
| Add agent | `src/agents/` | Create .ts with factory, add to `agentSources` | | Add agent | `src/agents/` | Create .ts with factory, add to `agentSources` in utils.ts |
| Add hook | `src/hooks/` | Create dir with `createXXXHook()`, register in index.ts | | Add hook | `src/hooks/` | Create dir with `createXXXHook()`, register in index.ts |
| Add tool | `src/tools/` | Dir with index/types/constants/tools.ts | | Add tool | `src/tools/` | Dir with index/types/constants/tools.ts |
| Add MCP | `src/mcp/` | Create config, add to index.ts | | Add MCP | `src/mcp/` | Create config, add to `createBuiltinMcps()` |
| Add skill | `src/features/builtin-skills/` | Create dir with SKILL.md | | Add skill | `src/features/builtin-skills/` | Create dir with SKILL.md |
| Add command | `src/features/builtin-commands/` | Add template + register in commands.ts | | Add command | `src/features/builtin-commands/` | Add template + register in commands.ts |
| Config schema | `src/config/schema.ts` | Zod schema, run `bun run build:schema` | | Config schema | `src/config/schema.ts` | Zod schema, run `bun run build:schema` |
| Background agents | `src/features/background-agent/` | manager.ts (1418 lines) | | Plugin config | `src/plugin-handlers/config-handler.ts` | JSONC loading, merging, migration |
| Orchestrator | `src/hooks/atlas/` | Main orchestration hook (757 lines) | | Background agents | `src/features/background-agent/` | manager.ts (1556 lines) |
| Orchestrator | `src/hooks/atlas/` | Main orchestration hook (770 lines) |
| Delegation | `src/tools/delegate-task/` | Category routing (executor.ts 983 lines) |
## TDD (Test-Driven Development) ## TDD (Test-Driven Development)
@@ -165,7 +170,7 @@ oh-my-opencode/
**Rules:** **Rules:**
- NEVER write implementation before test - NEVER write implementation before test
- NEVER delete failing tests - fix the code - NEVER delete failing tests - fix the code
- Test file: `*.test.ts` alongside source (100 test files) - Test file: `*.test.ts` alongside source (100+ test files)
- BDD comments: `//#given`, `//#when`, `//#then` - BDD comments: `//#given`, `//#when`, `//#then`
## CONVENTIONS ## CONVENTIONS
@@ -175,7 +180,7 @@ oh-my-opencode/
- **Build**: `bun build` (ESM) + `tsc --emitDeclarationOnly` - **Build**: `bun build` (ESM) + `tsc --emitDeclarationOnly`
- **Exports**: Barrel pattern via index.ts - **Exports**: Barrel pattern via index.ts
- **Naming**: kebab-case dirs, `createXXXHook`/`createXXXTool` factories - **Naming**: kebab-case dirs, `createXXXHook`/`createXXXTool` factories
- **Testing**: BDD comments, 100 test files - **Testing**: BDD comments, 100+ test files
- **Temperature**: 0.1 for code agents, max 0.3 - **Temperature**: 0.1 for code agents, max 0.3
## ANTI-PATTERNS ## ANTI-PATTERNS
@@ -204,14 +209,17 @@ oh-my-opencode/
| Agent | Model | Purpose | | Agent | Model | Purpose |
|-------|-------|---------| |-------|-------|---------|
| Sisyphus | anthropic/claude-opus-4-5 | Primary orchestrator (fallback: kimi-k2.5 → glm-4.7 → gpt-5.2-codex → gemini-3-pro) | | Sisyphus | anthropic/claude-opus-4-6 | Primary orchestrator (fallback: kimi-k2.5 → glm-4.7 → gpt-5.3-codex → gemini-3-pro) |
| Hephaestus | openai/gpt-5.2-codex | Autonomous deep worker, "The Legitimate Craftsman" (requires gpt-5.2-codex, no fallback) | | Hephaestus | openai/gpt-5.3-codex | Autonomous deep worker, "The Legitimate Craftsman" (requires gpt-5.3-codex, no fallback) |
| Atlas | anthropic/claude-sonnet-4-5 | Master orchestrator (fallback: kimi-k2.5 → gpt-5.2) | | Atlas | anthropic/claude-sonnet-4-5 | Master orchestrator (fallback: kimi-k2.5 → gpt-5.2) |
| oracle | openai/gpt-5.2 | Consultation, debugging | | oracle | openai/gpt-5.2 | Consultation, debugging |
| librarian | zai-coding-plan/glm-4.7 | Docs, GitHub search (fallback: glm-4.7-free) | | librarian | zai-coding-plan/glm-4.7 | Docs, GitHub search (fallback: glm-4.7-free) |
| explore | xai/grok-code-fast-1 | Fast codebase grep (fallback: claude-haiku-4-5 → gpt-5-mini → gpt-5-nano) | | explore | xai/grok-code-fast-1 | Fast codebase grep (fallback: claude-haiku-4-5 → gpt-5-mini → gpt-5-nano) |
| multimodal-looker | google/gemini-3-flash | PDF/image analysis | | multimodal-looker | google/gemini-3-flash | PDF/image analysis |
| Prometheus | anthropic/claude-opus-4-5 | Strategic planning (fallback: kimi-k2.5 → gpt-5.2) | | Prometheus | anthropic/claude-opus-4-6 | Strategic planning (fallback: kimi-k2.5 → gpt-5.2) |
| Metis | anthropic/claude-opus-4-6 | Pre-planning analysis (temp 0.3, fallback: kimi-k2.5 → gpt-5.2) |
| Momus | openai/gpt-5.2 | Plan validation (temp 0.1, fallback: claude-opus-4-6) |
| Sisyphus-Junior | anthropic/claude-sonnet-4-5 | Category-spawned executor (temp 0.1) |
## COMMANDS ## COMMANDS
@@ -219,7 +227,7 @@ oh-my-opencode/
bun run typecheck # Type check bun run typecheck # Type check
bun run build # ESM + declarations + schema bun run build # ESM + declarations + schema
bun run rebuild # Clean + Build bun run rebuild # Clean + Build
bun test # 100 test files bun test # 100+ test files
``` ```
## DEPLOYMENT ## DEPLOYMENT
@@ -233,30 +241,38 @@ bun test # 100 test files
| File | Lines | Description | | File | Lines | Description |
|------|-------|-------------| |------|-------|-------------|
| `src/features/builtin-skills/skills.ts` | 1729 | Skill definitions | | `src/features/background-agent/manager.ts` | 1556 | Task lifecycle, concurrency |
| `src/features/background-agent/manager.ts` | 1418 | Task lifecycle, concurrency | | `src/features/builtin-skills/skills/git-master.ts` | 1107 | Git master skill definition |
| `src/agents/prometheus-prompt.ts` | 1283 | Planning agent prompt | | `src/tools/delegate-task/executor.ts` | 983 | Category-based delegation executor |
| `src/tools/delegate-task/tools.ts` | 1135 | Category-based delegation | | `src/index.ts` | 924 | Main plugin entry |
| `src/hooks/atlas/index.ts` | 757 | Orchestrator hook | | `src/tools/lsp/client.ts` | 803 | LSP client operations |
| `src/index.ts` | 788 | Main plugin entry | | `src/hooks/atlas/index.ts` | 770 | Orchestrator hook |
| `src/tools/background-task/tools.ts` | 734 | Background task tools |
| `src/cli/config-manager.ts` | 667 | JSONC config parsing | | `src/cli/config-manager.ts` | 667 | JSONC config parsing |
| `src/features/skill-mcp-manager/manager.ts` | 640 | MCP client lifecycle |
| `src/features/builtin-commands/templates/refactor.ts` | 619 | Refactor command template | | `src/features/builtin-commands/templates/refactor.ts` | 619 | Refactor command template |
| `src/agents/hephaestus.ts` | 618 | Autonomous deep worker agent |
| `src/tools/delegate-task/constants.ts` | 552 | Delegation constants |
| `src/cli/install.ts` | 542 | Interactive CLI installer |
| `src/agents/sisyphus.ts` | 530 | Main orchestrator agent |
## MCP ARCHITECTURE ## MCP ARCHITECTURE
Three-tier system: Three-tier system:
1. **Built-in**: websearch (Exa), context7 (docs), grep_app (GitHub) 1. **Built-in**: websearch (Exa/Tavily), context7 (docs), grep_app (GitHub)
2. **Claude Code compat**: .mcp.json with `${VAR}` expansion 2. **Claude Code compat**: .mcp.json with `${VAR}` expansion
3. **Skill-embedded**: YAML frontmatter in skills 3. **Skill-embedded**: YAML frontmatter in skills
## CONFIG SYSTEM ## CONFIG SYSTEM
- **Zod validation**: `src/config/schema.ts` - **Zod validation**: `src/config/schema.ts` (455 lines)
- **JSONC support**: Comments, trailing commas - **JSONC support**: Comments, trailing commas
- **Multi-level**: Project (`.opencode/`) → User (`~/.config/opencode/`) - **Multi-level**: Project (`.opencode/`) → User (`~/.config/opencode/`)
- **Loading**: `src/plugin-handlers/config-handler.ts` → merge → validate
## NOTES ## NOTES
- **OpenCode**: Requires >= 1.0.150 - **OpenCode**: Requires >= 1.0.150
- **Flaky tests**: ralph-loop (CI timeout), session-state (parallel pollution) - **Flaky tests**: ralph-loop (CI timeout), session-state (parallel pollution)
- **Trusted deps**: @ast-grep/cli, @ast-grep/napi, @code-yeongyu/comment-checker - **Trusted deps**: @ast-grep/cli, @ast-grep/napi, @code-yeongyu/comment-checker
- **No linter/formatter**: No ESLint, Prettier, or Biome configured

View File

@@ -121,16 +121,6 @@
- [アンインストール](#アンインストール) - [アンインストール](#アンインストール)
- [機能](#機能) - [機能](#機能)
- [設定](#設定) - [設定](#設定)
- [JSONC のサポート](#jsonc-のサポート)
- [Google Auth](#google-auth)
- [Agents](#agents)
- [Permission オプション](#permission-オプション)
- [Sisyphus Agent](#sisyphus-agent)
- [Background Tasks](#background-tasks)
- [Hooks](#hooks)
- [MCPs](#mcps)
- [LSP](#lsp)
- [Experimental](#experimental)
- [作者のノート](#作者のノート) - [作者のノート](#作者のノート)
- [注意](#注意) - [注意](#注意)
- [こちらの企業の専門家にご愛用いただいています](#こちらの企業の専門家にご愛用いただいています) - [こちらの企業の専門家にご愛用いただいています](#こちらの企業の専門家にご愛用いただいています)

View File

@@ -123,20 +123,6 @@
- [제거](#제거) - [제거](#제거)
- [기능](#기능) - [기능](#기능)
- [구성](#구성) - [구성](#구성)
- [JSONC 지원](#jsonc-지원)
- [Google 인증](#google-인증)
- [에이전트](#에이전트)
- [권한 옵션](#권한-옵션)
- [내장 스킬](#내장-스킬)
- [Git Master](#git-master)
- [Sisyphus 에이전트](#sisyphus-에이전트)
- [백그라운드 작업](#백그라운드-작업)
- [카테고리](#카테고리)
- [](#훅)
- [MCP](#mcp)
- [LSP](#lsp)
- [실험적 기능](#실험적-기능)
- [환경 변수](#환경-변수)
- [작성자의 메모](#작성자의-메모) - [작성자의 메모](#작성자의-메모)
- [경고](#경고) - [경고](#경고)
- [다음 기업 전문가들이 사랑합니다](#다음-기업-전문가들이-사랑합니다) - [다음 기업 전문가들이 사랑합니다](#다음-기업-전문가들이-사랑합니다)

View File

@@ -121,21 +121,7 @@ Yes, technically possible. But I cannot recommend using it.
- [For LLM Agents](#for-llm-agents) - [For LLM Agents](#for-llm-agents)
- [Uninstallation](#uninstallation) - [Uninstallation](#uninstallation)
- [Features](#features) - [Features](#features)
- [Configuration](#configuration) - [Configuration](#configuration)
- [JSONC Support](#jsonc-support)
- [Google Auth](#google-auth)
- [Agents](#agents)
- [Permission Options](#permission-options)
- [Built-in Skills](#built-in-skills)
- [Git Master](#git-master)
- [Sisyphus Agent](#sisyphus-agent)
- [Background Tasks](#background-tasks)
- [Categories](#categories)
- [Hooks](#hooks)
- [MCPs](#mcps)
- [LSP](#lsp)
- [Experimental](#experimental)
- [Environment Variables](#environment-variables)
- [Author's Note](#authors-note) - [Author's Note](#authors-note)
- [Warnings](#warnings) - [Warnings](#warnings)
- [Loved by professionals at](#loved-by-professionals-at) - [Loved by professionals at](#loved-by-professionals-at)

View File

@@ -122,20 +122,6 @@
- [卸载](#卸载) - [卸载](#卸载)
- [功能特性](#功能特性) - [功能特性](#功能特性)
- [配置](#配置) - [配置](#配置)
- [JSONC 支持](#jsonc-支持)
- [Google 认证](#google-认证)
- [智能体](#智能体)
- [权限选项](#权限选项)
- [内置技能](#内置技能)
- [Git Master](#git-master)
- [Sisyphus 智能体](#sisyphus-智能体)
- [后台任务](#后台任务)
- [类别](#类别)
- [钩子](#钩子)
- [MCP](#mcp)
- [LSP](#lsp)
- [实验性功能](#实验性功能)
- [环境变量](#环境变量)
- [作者札记](#作者札记) - [作者札记](#作者札记)
- [警告](#警告) - [警告](#警告)
- [受到以下专业人士的喜爱](#受到以下专业人士的喜爱) - [受到以下专业人士的喜爱](#受到以下专业人士的喜爱)

File diff suppressed because it is too large Load Diff

0
bin/oh-my-opencode.js Normal file → Executable file
View File

View File

@@ -1,6 +1,6 @@
{ {
"lockfileVersion": 1, "lockfileVersion": 1,
"configVersion": 1, "configVersion": 0,
"workspaces": { "workspaces": {
"": { "": {
"name": "oh-my-opencode", "name": "oh-my-opencode",
@@ -28,13 +28,13 @@
"typescript": "^5.7.3", "typescript": "^5.7.3",
}, },
"optionalDependencies": { "optionalDependencies": {
"oh-my-opencode-darwin-arm64": "3.2.1", "oh-my-opencode-darwin-arm64": "3.2.3",
"oh-my-opencode-darwin-x64": "3.2.1", "oh-my-opencode-darwin-x64": "3.2.3",
"oh-my-opencode-linux-arm64": "3.2.1", "oh-my-opencode-linux-arm64": "3.2.3",
"oh-my-opencode-linux-arm64-musl": "3.2.1", "oh-my-opencode-linux-arm64-musl": "3.2.3",
"oh-my-opencode-linux-x64": "3.2.1", "oh-my-opencode-linux-x64": "3.2.3",
"oh-my-opencode-linux-x64-musl": "3.2.1", "oh-my-opencode-linux-x64-musl": "3.2.3",
"oh-my-opencode-windows-x64": "3.2.1", "oh-my-opencode-windows-x64": "3.2.3",
}, },
}, },
}, },
@@ -44,41 +44,41 @@
"@code-yeongyu/comment-checker", "@code-yeongyu/comment-checker",
], ],
"packages": { "packages": {
"@ast-grep/cli": ["@ast-grep/cli@0.40.5", "", { "dependencies": { "detect-libc": "2.1.2" }, "optionalDependencies": { "@ast-grep/cli-darwin-arm64": "0.40.5", "@ast-grep/cli-darwin-x64": "0.40.5", "@ast-grep/cli-linux-arm64-gnu": "0.40.5", "@ast-grep/cli-linux-x64-gnu": "0.40.5", "@ast-grep/cli-win32-arm64-msvc": "0.40.5", "@ast-grep/cli-win32-ia32-msvc": "0.40.5", "@ast-grep/cli-win32-x64-msvc": "0.40.5" }, "bin": { "sg": "sg", "ast-grep": "ast-grep" } }, "sha512-yVXL7Gz0WIHerQLf+MVaVSkhIhidtWReG5akNVr/JS9OVCVkSdz7gWm7H8jVv2M9OO1tauuG76K3UaRGBPu5lQ=="], "@ast-grep/cli": ["@ast-grep/cli@0.40.0", "", { "dependencies": { "detect-libc": "2.1.2" }, "optionalDependencies": { "@ast-grep/cli-darwin-arm64": "0.40.0", "@ast-grep/cli-darwin-x64": "0.40.0", "@ast-grep/cli-linux-arm64-gnu": "0.40.0", "@ast-grep/cli-linux-x64-gnu": "0.40.0", "@ast-grep/cli-win32-arm64-msvc": "0.40.0", "@ast-grep/cli-win32-ia32-msvc": "0.40.0", "@ast-grep/cli-win32-x64-msvc": "0.40.0" }, "bin": { "sg": "sg", "ast-grep": "ast-grep" } }, "sha512-L8AkflsfI2ZP70yIdrwqvjR02ScCuRmM/qNGnJWUkOFck+e6gafNVJ4e4jjGQlEul+dNdBpx36+O2Op629t47A=="],
"@ast-grep/cli-darwin-arm64": ["@ast-grep/cli-darwin-arm64@0.40.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-T9CzwJ1GqQhnANdsu6c7iT1akpvTVMK+AZrxnhIPv33Ze5hrXUUkqan+j4wUAukRJDqU7u94EhXLSLD+5tcJ8g=="], "@ast-grep/cli-darwin-arm64": ["@ast-grep/cli-darwin-arm64@0.40.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-UehY2MMUkdJbsriP7NKc6+uojrqPn7d1Cl0em+WAkee7Eij81VdyIjRsRxtZSLh440ZWQBHI3PALZ9RkOO8pKQ=="],
"@ast-grep/cli-darwin-x64": ["@ast-grep/cli-darwin-x64@0.40.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-ez9b2zKvXU8f4ghhjlqYvbx6tWCKJTuVlNVqDDfjqwwhGeiTYfnzMlSVat4ElYRMd21gLtXZIMy055v2f21Ztg=="], "@ast-grep/cli-darwin-x64": ["@ast-grep/cli-darwin-x64@0.40.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-RFDJ2ZxUbT0+grntNlOLJx7wa9/ciVCeaVtQpQy8WJJTvXvkY0etl8Qlh2TmO2x2yr+i0Z6aMJi4IG/Yx5ghTQ=="],
"@ast-grep/cli-linux-arm64-gnu": ["@ast-grep/cli-linux-arm64-gnu@0.40.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-VXa2L1IEYD66AMb0GuG7VlMMbPmEGoJUySWDcwSZo/D9neiry3MJ41LQR5oTG2HyhIPBsf9umrXnmuRq66BviA=="], "@ast-grep/cli-linux-arm64-gnu": ["@ast-grep/cli-linux-arm64-gnu@0.40.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-4p55gnTQ1mMFCyqjtM7bH9SB9r16mkwXtUcJQGX1YgFG4WD+QG8rC4GwSuNNZcdlYaOQuTWrgUEQ9z5K06UXfg=="],
"@ast-grep/cli-linux-x64-gnu": ["@ast-grep/cli-linux-x64-gnu@0.40.5", "", { "os": "linux", "cpu": "x64" }, "sha512-GQC5162eIOWXR2eQQ6Knzg7/8Trp5E1ODJkaErf0IubdQrZBGqj5AAcQPcWgPbbnmktjIp0H4NraPpOJ9eJ22A=="], "@ast-grep/cli-linux-x64-gnu": ["@ast-grep/cli-linux-x64-gnu@0.40.0", "", { "os": "linux", "cpu": "x64" }, "sha512-u2MXFceuwvrO+OQ6zFGoJ6wbATXn46HWwW79j4UPrXYJzVl97jRyjJOIQTJOzTflsk02fjP98DQkfvbXt2dl3Q=="],
"@ast-grep/cli-win32-arm64-msvc": ["@ast-grep/cli-win32-arm64-msvc@0.40.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-YiZdnQZsSlXQTMsZJop/Ux9MmUGfuRvC2x/UbFgrt5OBSYxND+yoiMc0WcA3WG+wU+tt4ZkB5HUea3r/IkOLYA=="], "@ast-grep/cli-win32-arm64-msvc": ["@ast-grep/cli-win32-arm64-msvc@0.40.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-E/I1xpF/RQL2fo1CQsQfTxyDLnChsbZ+ERrQHKuF1FI4WrkaPOBibpqda60QgVmUcgOGZyZ/GRb3iKEVWPsQNQ=="],
"@ast-grep/cli-win32-ia32-msvc": ["@ast-grep/cli-win32-ia32-msvc@0.40.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-MHkCxCITVTr8sY9CcVqNKbfUzMa3Hc6IilGXad0Clnw2vNmPfWqSky+hU/UTerr5YHWwWfAVURH7ANZgirtx0Q=="], "@ast-grep/cli-win32-ia32-msvc": ["@ast-grep/cli-win32-ia32-msvc@0.40.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-9h12OQu1BR0GxHEtT+Z4QkJk3LLWLiKwjBkjXUGlASHYDPTyLcs85KwDLeFHs4BwarF8TDdF+KySvB9WPGl/nQ=="],
"@ast-grep/cli-win32-x64-msvc": ["@ast-grep/cli-win32-x64-msvc@0.40.5", "", { "os": "win32", "cpu": "x64" }, "sha512-/MJ5un7yxlClaaxou9eYl+Kr2xr/yTtYtTq5aLBWjPWA6dmmJ1nAJgx5zKHVuplFXFBrFDQk3paEgAETMTGcrA=="], "@ast-grep/cli-win32-x64-msvc": ["@ast-grep/cli-win32-x64-msvc@0.40.0", "", { "os": "win32", "cpu": "x64" }, "sha512-n2+3WynEWFHhXg6KDgjwWQ0UEtIvqUITFbKEk5cDkUYrzYhg/A6kj0qauPwRbVMoJms49vtsNpLkzzqyunio5g=="],
"@ast-grep/napi": ["@ast-grep/napi@0.40.5", "", { "optionalDependencies": { "@ast-grep/napi-darwin-arm64": "0.40.5", "@ast-grep/napi-darwin-x64": "0.40.5", "@ast-grep/napi-linux-arm64-gnu": "0.40.5", "@ast-grep/napi-linux-arm64-musl": "0.40.5", "@ast-grep/napi-linux-x64-gnu": "0.40.5", "@ast-grep/napi-linux-x64-musl": "0.40.5", "@ast-grep/napi-win32-arm64-msvc": "0.40.5", "@ast-grep/napi-win32-ia32-msvc": "0.40.5", "@ast-grep/napi-win32-x64-msvc": "0.40.5" } }, "sha512-hJA62OeBKUQT68DD2gDyhOqJxZxycqg8wLxbqjgqSzYttCMSDL9tiAQ9abgekBYNHudbJosm9sWOEbmCDfpX2A=="], "@ast-grep/napi": ["@ast-grep/napi@0.40.0", "", { "optionalDependencies": { "@ast-grep/napi-darwin-arm64": "0.40.0", "@ast-grep/napi-darwin-x64": "0.40.0", "@ast-grep/napi-linux-arm64-gnu": "0.40.0", "@ast-grep/napi-linux-arm64-musl": "0.40.0", "@ast-grep/napi-linux-x64-gnu": "0.40.0", "@ast-grep/napi-linux-x64-musl": "0.40.0", "@ast-grep/napi-win32-arm64-msvc": "0.40.0", "@ast-grep/napi-win32-ia32-msvc": "0.40.0", "@ast-grep/napi-win32-x64-msvc": "0.40.0" } }, "sha512-tq6nO/8KwUF/mHuk1ECaAOSOlz2OB/PmygnvprJzyAHGRVzdcffblaOOWe90M9sGz5MAasXoF+PTcayQj9TKKA=="],
"@ast-grep/napi-darwin-arm64": ["@ast-grep/napi-darwin-arm64@0.40.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-2F072fGN0WTq7KI3okuEnkGJVEHLbi56Bw1H6NAMf7j2mJJeQWsRyGOMcyNnUXZDeNdvoMH0OB2a5wwUegY/nQ=="], "@ast-grep/napi-darwin-arm64": ["@ast-grep/napi-darwin-arm64@0.40.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ZMjl5yLhKjxdwbqEEdMizgQdWH2NrWsM6Px+JuGErgCDe6Aedq9yurEPV7veybGdLVJQhOah6htlSflXxjHnYA=="],
"@ast-grep/napi-darwin-x64": ["@ast-grep/napi-darwin-x64@0.40.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-dJMidHZhhxuLBYNi6/FKI812jQ7wcFPSKkVPwviez2D+KvYagapUMAV/4dJ7FCORfguVk8Y0jpPAlYmWRT5nvA=="], "@ast-grep/napi-darwin-x64": ["@ast-grep/napi-darwin-x64@0.40.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-f9Ol5oQKNRMBkvDtzBK1WiNn2/3eejF2Pn9xwTj7PhXuSFseedOspPYllxQo0gbwUlw/DJqGFTce/jarhR/rBw=="],
"@ast-grep/napi-linux-arm64-gnu": ["@ast-grep/napi-linux-arm64-gnu@0.40.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-nBRCbyoS87uqkaw4Oyfe5VO+SRm2B+0g0T8ME69Qry9ShMf41a2bTdpcQx9e8scZPogq+CTwDHo3THyBV71l9w=="], "@ast-grep/napi-linux-arm64-gnu": ["@ast-grep/napi-linux-arm64-gnu@0.40.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-+tO+VW5GDhT9jGkKOK+3b8+ohKjC98WTzn7wSskd/myyhK3oYL1WTKqCm07WSYBZOJvb3z+WaX+wOUrc4bvtyQ=="],
"@ast-grep/napi-linux-arm64-musl": ["@ast-grep/napi-linux-arm64-musl@0.40.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-/qKsmds5FMoaEj6FdNzepbmLMtlFuBLdrAn9GIWCqOIcVcYvM1Nka8+mncfeXB/MFZKOrzQsQdPTWqrrQzXLrA=="], "@ast-grep/napi-linux-arm64-musl": ["@ast-grep/napi-linux-arm64-musl@0.40.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-MS9qalLRjUnF2PCzuTKTvCMVSORYHxxe3Qa0+SSaVULsXRBmuy5C/b1FeWwMFnwNnC0uie3VDet31Zujwi8q6A=="],
"@ast-grep/napi-linux-x64-gnu": ["@ast-grep/napi-linux-x64-gnu@0.40.5", "", { "os": "linux", "cpu": "x64" }, "sha512-DP4oDbq7f/1A2hRTFLhJfDFR6aI5mRWdEfKfHzRItmlKsR9WlcEl1qDJs/zX9R2EEtIDsSKRzuJNfJllY3/W8Q=="], "@ast-grep/napi-linux-x64-gnu": ["@ast-grep/napi-linux-x64-gnu@0.40.0", "", { "os": "linux", "cpu": "x64" }, "sha512-BeHZVMNXhM3WV3XE2yghO0fRxhMOt8BTN972p5piYEQUvKeSHmS8oeGcs6Ahgx5znBclqqqq37ZfioYANiTqJA=="],
"@ast-grep/napi-linux-x64-musl": ["@ast-grep/napi-linux-x64-musl@0.40.5", "", { "os": "linux", "cpu": "x64" }, "sha512-BRZUvVBPUNpWPo6Ns8chXVzxHPY+k9gpsubGTHy92Q26ecZULd/dTkWWdnvfhRqttsSQ9Pe/XQdi5+hDQ6RYcg=="], "@ast-grep/napi-linux-x64-musl": ["@ast-grep/napi-linux-x64-musl@0.40.0", "", { "os": "linux", "cpu": "x64" }, "sha512-rG1YujF7O+lszX8fd5u6qkFTuv4FwHXjWvt1CCvCxXwQLSY96LaCW88oVKg7WoEYQh54y++Fk57F+Wh9Gv9nVQ=="],
"@ast-grep/napi-win32-arm64-msvc": ["@ast-grep/napi-win32-arm64-msvc@0.40.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-y95zSEwc7vhxmcrcH0GnK4ZHEBQrmrszRBNQovzaciF9GUqEcCACNLoBesn4V47IaOp4fYgD2/EhGRTIBFb2Ug=="], "@ast-grep/napi-win32-arm64-msvc": ["@ast-grep/napi-win32-arm64-msvc@0.40.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-9SqmnQqd4zTEUk6yx0TuW2ycZZs2+e569O/R0QnhSiQNpgwiJCYOe/yPS0BC9HkiaozQm6jjAcasWpFtz/dp+w=="],
"@ast-grep/napi-win32-ia32-msvc": ["@ast-grep/napi-win32-ia32-msvc@0.40.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-K/u8De62iUnFCzVUs7FBdTZ2Jrgc5/DLHqjpup66KxZ7GIM9/HGME/O8aSoPkpcAeCD4TiTZ11C1i5p5H98hTg=="], "@ast-grep/napi-win32-ia32-msvc": ["@ast-grep/napi-win32-ia32-msvc@0.40.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-0JkdBZi5l9vZhGEO38A1way0LmLRDU5Vos6MXrLIOVkymmzDTDlCdY394J1LMmmsfwWcyJg6J7Yv2dw41MCxDQ=="],
"@ast-grep/napi-win32-x64-msvc": ["@ast-grep/napi-win32-x64-msvc@0.40.5", "", { "os": "win32", "cpu": "x64" }, "sha512-dqm5zg/o4Nh4VOQPEpMS23ot8HVd22gG0eg01t4CFcZeuzyuSgBlOL3N7xLbz3iH2sVkk7keuBwAzOIpTqziNQ=="], "@ast-grep/napi-win32-x64-msvc": ["@ast-grep/napi-win32-x64-msvc@0.40.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Hk2IwfPqMFGZt5SRxsoWmGLxBXxprow4LRp1eG6V8EEiJCNHxZ9ZiEaIc5bNvMDBjHVSnqZAXT22dROhrcSKQg=="],
"@clack/core": ["@clack/core@0.5.0", "", { "dependencies": { "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-p3y0FIOwaYRUPRcMO7+dlmLh8PSRcrjuTndsiA0WAFbWES0mLZlrjVoBRZ9DzkPFJZG6KGkJmoEAY0ZcVWTkow=="], "@clack/core": ["@clack/core@0.5.0", "", { "dependencies": { "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } }, "sha512-p3y0FIOwaYRUPRcMO7+dlmLh8PSRcrjuTndsiA0WAFbWES0mLZlrjVoBRZ9DzkPFJZG6KGkJmoEAY0ZcVWTkow=="],
@@ -86,17 +86,17 @@
"@code-yeongyu/comment-checker": ["@code-yeongyu/comment-checker@0.6.1", "", { "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "comment-checker": "bin/comment-checker" } }, "sha512-BBremX+Y5aW8sTzlhHrLsKParupYkPOVUYmq9STrlWvBvfAme6w5IWuZCLl6nHIQScRDdvGdrAjPycJC86EZFA=="], "@code-yeongyu/comment-checker": ["@code-yeongyu/comment-checker@0.6.1", "", { "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "comment-checker": "bin/comment-checker" } }, "sha512-BBremX+Y5aW8sTzlhHrLsKParupYkPOVUYmq9STrlWvBvfAme6w5IWuZCLl6nHIQScRDdvGdrAjPycJC86EZFA=="],
"@hono/node-server": ["@hono/node-server@1.19.9", "", { "peerDependencies": { "hono": "^4" } }, "sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw=="], "@hono/node-server": ["@hono/node-server@1.19.7", "", { "peerDependencies": { "hono": "^4" } }, "sha512-vUcD0uauS7EU2caukW8z5lJKtoGMokxNbJtBiwHgpqxEXokaHCBkQUmCHhjFB1VUTWdqj25QoMkMKzgjq+uhrw=="],
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.25.3", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "jose": "^6.1.1", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.0" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-vsAMBMERybvYgKbg/l4L1rhS7VXV1c0CtyJg72vwxONVX0l4ZfKVAnZEWTQixJGTzKnELjQ59e4NbdFDALRiAQ=="], "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.25.1", "", { "dependencies": { "@hono/node-server": "^1.19.7", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "jose": "^6.1.1", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.0" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-yO28oVFFC7EBoiKdAn+VqRm+plcfv4v0xp6osG/VsCB0NlPZWi87ajbCZZ8f/RvOFLEu7//rSRmuZZ7lMoe3gQ=="],
"@opencode-ai/plugin": ["@opencode-ai/plugin@1.1.47", "", { "dependencies": { "@opencode-ai/sdk": "1.1.47", "zod": "4.1.8" } }, "sha512-gNMPz72altieDfLhUw3VAT1xbduKi3w3wZ57GLeS7qU9W474HdvdIiLBnt2Xq3U7Ko0/0tvK3nzCker6IIDqmQ=="], "@opencode-ai/plugin": ["@opencode-ai/plugin@1.1.19", "", { "dependencies": { "@opencode-ai/sdk": "1.1.19", "zod": "4.1.8" } }, "sha512-Q6qBEjHb/dJMEw4BUqQxEswTMxCCHUpFMMb6jR8HTTs8X/28XRkKt5pHNPA82GU65IlSoPRph+zd8LReBDN53Q=="],
"@opencode-ai/sdk": ["@opencode-ai/sdk@1.1.47", "", {}, "sha512-s3PBHwk1sP6Zt/lJxIWSBWZ1TnrI1nFxSP97LCODUytouAQgbygZ1oDH7O2sGMBEuGdA8B1nNSPla0aRSN3IpA=="], "@opencode-ai/sdk": ["@opencode-ai/sdk@1.1.19", "", {}, "sha512-XhZhFuvlLCqDpvNtUEjOsi/wvFj3YCXb1dySp+OONQRMuHlorNYnNa7P2A2ntKuhRdGT1Xt5na0nFzlUyNw+4A=="],
"@types/js-yaml": ["@types/js-yaml@4.0.9", "", {}, "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg=="], "@types/js-yaml": ["@types/js-yaml@4.0.9", "", {}, "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg=="],
"@types/node": ["@types/node@25.1.0", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-t7frlewr6+cbx+9Ohpl0NOTKXZNV9xHRmNOvql47BFJKcEG1CxtxlPEEe+gR9uhVWM4DwhnvTF110mIL4yP9RA=="], "@types/node": ["@types/node@24.10.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="],
"@types/picomatch": ["@types/picomatch@3.0.2", "", {}, "sha512-n0i8TD3UDB7paoMMxA3Y65vUncFJXjcUf7lQY7YyKGl6031FNjfsLs6pdLFCy2GNFxItPJG8GvvpbZc2skH7WA=="], "@types/picomatch": ["@types/picomatch@3.0.2", "", {}, "sha512-n0i8TD3UDB7paoMMxA3Y65vUncFJXjcUf7lQY7YyKGl6031FNjfsLs6pdLFCy2GNFxItPJG8GvvpbZc2skH7WA=="],
@@ -108,7 +108,7 @@
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], "body-parser": ["body-parser@2.2.1", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.0", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw=="],
"bun-types": ["bun-types@1.3.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ=="], "bun-types": ["bun-types@1.3.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ=="],
@@ -118,7 +118,7 @@
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
"commander": ["commander@14.0.3", "", {}, "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="], "commander": ["commander@14.0.2", "", {}, "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ=="],
"content-disposition": ["content-disposition@1.0.1", "", {}, "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q=="], "content-disposition": ["content-disposition@1.0.1", "", {}, "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q=="],
@@ -128,7 +128,7 @@
"cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
"cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], "cors": ["cors@2.8.5", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
@@ -184,11 +184,11 @@
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"hono": ["hono@4.11.7", "", {}, "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw=="], "hono": ["hono@4.10.8", "", {}, "sha512-DDT0A0r6wzhe8zCGoYOmMeuGu3dyTAE40HHjwUsWFTEy5WxK1x2WDSsBPlEXgPbRIFY6miDualuUDbasPogIww=="],
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
"iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], "iconv-lite": ["iconv-lite@0.7.1", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-2Tth85cXwGFHfvRgZWszZSvdo+0Xsqmw8k8ZwxScfcBneNUraK+dxRxRm24nszx80Y0TVio8kKLt5sLE7ZCLlw=="],
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
@@ -226,19 +226,19 @@
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
"oh-my-opencode-darwin-arm64": ["oh-my-opencode-darwin-arm64@3.2.1", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-IvhHRUXTr/g/hJlkKTU2oCdgRl2BDl/Qre31Rukhs4NumlvME6iDmdnm8mM7bTxugfCBkfUUr7QJLxxLhzjdLA=="], "oh-my-opencode-darwin-arm64": ["oh-my-opencode-darwin-arm64@3.2.3", "", { "os": "darwin", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-Doc9xQCj5Jmx3PzouBIfvDwmfWM94Y9Q9IngFqOjrVpfBef9V/WIH0PlhJU6ps4BKGey8Nf2afFq3UE06Z63Hg=="],
"oh-my-opencode-darwin-x64": ["oh-my-opencode-darwin-x64@3.2.1", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-V2JbAdThAVfhBOcb+wBPZrAI0vBxPPRBdvmAixAxBOFC49CIJUrEFIRBUYFKhSQGHYWrNy8z0zJYoNQm4oQPog=="], "oh-my-opencode-darwin-x64": ["oh-my-opencode-darwin-x64@3.2.3", "", { "os": "darwin", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-w7lO0Hn/AlLCHe33KPbje83Js2h5weDWVMuopEs6d3pi/1zkRDBEhCi63S4J0d0EKod9kEPQA6ojtdVJ4J39zQ=="],
"oh-my-opencode-linux-arm64": ["oh-my-opencode-linux-arm64@3.2.1", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-SeT8P7Icq5YH/AIaEF28J4q+ifUnOqO2UgMFtdFusr8JLadYFy+6dTdeAuD2uGGToDQ3ZNKuaG+lo84KzEhA5w=="], "oh-my-opencode-linux-arm64": ["oh-my-opencode-linux-arm64@3.2.3", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-m1tS1jRLO2Svm5NuetK3BAgdAR8b2GkiIfMFoIYsLJTPmzIkXaigAYkFq+BXCs5JAbRmPmvjndz9cuCddnPADQ=="],
"oh-my-opencode-linux-arm64-musl": ["oh-my-opencode-linux-arm64-musl@3.2.1", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-wJUEVVUn1gyVIFNV4mxWg9cYo1rQdTKUXdGLfiqPiyQhWhZLRfPJ+9qpghvIVv7Dne6rzkbhYWdwdk/tew5RtQ=="], "oh-my-opencode-linux-arm64-musl": ["oh-my-opencode-linux-arm64-musl@3.2.3", "", { "os": "linux", "cpu": "arm64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-Q/0AGtOuUFGNGIX8F6iD5W8c2spbjrqVBPt0B7laQSwnScKs/BI+TvM6HRE37vhoWg+fzhAX3QYJ2H9Un9FYrg=="],
"oh-my-opencode-linux-x64": ["oh-my-opencode-linux-x64@3.2.1", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-p/XValXi1RRTZV8mEsdStXwZBkyQpgZjB41HLf0VfizPMAKRr6/bhuFZ9BDZFIhcDnLYcGV54MAVEsWms5yC2A=="], "oh-my-opencode-linux-x64": ["oh-my-opencode-linux-x64@3.2.3", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-RIAyoj2XbT8vH++5fPUkdO+D1tfqxh+iWto7CqWr1TgbABbBJljGk91HJgS9xjnxyCQJEpFhTmO7NMHKJcZOWQ=="],
"oh-my-opencode-linux-x64-musl": ["oh-my-opencode-linux-x64-musl@3.2.1", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-G7aNMqAMO2P+wUUaaAV8sXymm59cX4G9aVNXKAd/PM6RgFWh2F4HkXkOhOdHKYZzCl1QRhjh672mNillYsvebg=="], "oh-my-opencode-linux-x64-musl": ["oh-my-opencode-linux-x64-musl@3.2.3", "", { "os": "linux", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode" } }, "sha512-nnQK3y7R4DrBvqdqRGbujL2oAAQnVVb23JHUbJPQ6YxrRRGWpLOVGvK5c16ykSFEUPl8eZDmi1ON/R4opKLOUw=="],
"oh-my-opencode-windows-x64": ["oh-my-opencode-windows-x64@3.2.1", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-pyqTGlNxirKxQgXx9YJBq2y8KN/1oIygVupClmws7dDPj9etI1l8fs/SBEnMsYzMqTlGbLVeJ5+kj9p+yg7YDA=="], "oh-my-opencode-windows-x64": ["oh-my-opencode-windows-x64@3.2.3", "", { "os": "win32", "cpu": "x64", "bin": { "oh-my-opencode": "bin/oh-my-opencode.exe" } }, "sha512-mt8E/TkpaCp04pvzwntT8x8TaqXDt3zCD5X2eA8ZZMrb5ofNr5HyG5G4SFXrUh+Ez3b/3YXpNWv6f6rnAlk1Dg=="],
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
@@ -310,10 +310,8 @@
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
"zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], "zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="],
"zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="], "zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="],
"@opencode-ai/plugin/zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="],
} }
} }

View File

@@ -22,12 +22,12 @@ A Category is an agent configuration preset optimized for specific domains.
| Category | Default Model | Use Cases | | Category | Default Model | Use Cases |
|----------|---------------|-----------| |----------|---------------|-----------|
| `visual-engineering` | `google/gemini-3-pro` | Frontend, UI/UX, design, styling, animation | | `visual-engineering` | `google/gemini-3-pro` | Frontend, UI/UX, design, styling, animation |
| `ultrabrain` | `openai/gpt-5.2-codex` (xhigh) | Deep logical reasoning, complex architecture decisions requiring extensive analysis | | `ultrabrain` | `openai/gpt-5.3-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. | | `deep` | `openai/gpt-5.3-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 | | `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 | | `quick` | `anthropic/claude-haiku-4-5` | Trivial tasks - single file changes, typo fixes, simple modifications |
| `unspecified-low` | `anthropic/claude-sonnet-4-5` | Tasks that don't fit other categories, low effort required | | `unspecified-low` | `anthropic/claude-sonnet-4-5` | Tasks that don't fit other categories, low effort required |
| `unspecified-high` | `anthropic/claude-opus-4-5` (max) | Tasks that don't fit other categories, high effort required | | `unspecified-high` | `anthropic/claude-opus-4-6` (max) | Tasks that don't fit other categories, high effort required |
| `writing` | `google/gemini-3-flash` | Documentation, prose, technical writing | | `writing` | `google/gemini-3-flash` | Documentation, prose, technical writing |
### Usage ### Usage
@@ -159,7 +159,7 @@ You can fine-tune categories in `oh-my-opencode.json`.
| Field | Type | Description | | Field | Type | Description |
|-------|------|-------------| |-------|------|-------------|
| `description` | string | Human-readable description of the category's purpose. Shown in delegate_task prompt. | | `description` | string | Human-readable description of the category's purpose. Shown in delegate_task prompt. |
| `model` | string | AI model ID to use (e.g., `anthropic/claude-opus-4-5`) | | `model` | string | AI model ID to use (e.g., `anthropic/claude-opus-4-6`) |
| `variant` | string | Model variant (e.g., `max`, `xhigh`) | | `variant` | string | Model variant (e.g., `max`, `xhigh`) |
| `temperature` | number | Creativity level (0.0 ~ 2.0). Lower is more deterministic. | | `temperature` | number | Creativity level (0.0 ~ 2.0). Lower is more deterministic. |
| `top_p` | number | Nucleus sampling parameter (0.0 ~ 1.0) | | `top_p` | number | Nucleus sampling parameter (0.0 ~ 1.0) |
@@ -191,7 +191,7 @@ You can fine-tune categories in `oh-my-opencode.json`.
// 3. Configure thinking model and restrict tools // 3. Configure thinking model and restrict tools
"deep-reasoning": { "deep-reasoning": {
"model": "anthropic/claude-opus-4-5", "model": "anthropic/claude-opus-4-6",
"thinking": { "thinking": {
"type": "enabled", "type": "enabled",
"budgetTokens": 32000 "budgetTokens": 32000

View File

@@ -693,7 +693,7 @@ Configure concurrency limits for background agent tasks. This controls how many
"google": 10 "google": 10
}, },
"modelConcurrency": { "modelConcurrency": {
"anthropic/claude-opus-4-5": 2, "anthropic/claude-opus-4-6": 2,
"google/gemini-3-flash": 10 "google/gemini-3-flash": 10
} }
} }
@@ -705,7 +705,7 @@ Configure concurrency limits for background agent tasks. This controls how many
| `defaultConcurrency` | - | Default maximum concurrent background tasks for all providers/models | | `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) | | `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`) | | `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. | | `modelConcurrency` | - | Per-model concurrency limits. Keys are full model names (e.g., `anthropic/claude-opus-4-6`). Overrides provider limits. |
**Priority Order**: `modelConcurrency` > `providerConcurrency` > `defaultConcurrency` **Priority Order**: `modelConcurrency` > `providerConcurrency` > `defaultConcurrency`
@@ -725,11 +725,11 @@ All 7 categories come with optimal model defaults, but **you must configure them
| Category | Built-in Default Model | Description | | Category | Built-in Default Model | Description |
| -------------------- | ---------------------------------- | -------------------------------------------------------------------- | | -------------------- | ---------------------------------- | -------------------------------------------------------------------- |
| `visual-engineering` | `google/gemini-3-pro-preview` | Frontend, UI/UX, design, styling, animation | | `visual-engineering` | `google/gemini-3-pro-preview` | Frontend, UI/UX, design, styling, animation |
| `ultrabrain` | `openai/gpt-5.2-codex` (xhigh) | Deep logical reasoning, complex architecture decisions | | `ultrabrain` | `openai/gpt-5.3-codex` (xhigh) | Deep logical reasoning, complex architecture decisions |
| `artistry` | `google/gemini-3-pro-preview` (max)| Highly creative/artistic tasks, novel ideas | | `artistry` | `google/gemini-3-pro-preview` (max)| Highly creative/artistic tasks, novel ideas |
| `quick` | `anthropic/claude-haiku-4-5` | Trivial tasks - single file changes, typo fixes, simple modifications| | `quick` | `anthropic/claude-haiku-4-5` | Trivial tasks - single file changes, typo fixes, simple modifications|
| `unspecified-low` | `anthropic/claude-sonnet-4-5` | Tasks that don't fit other categories, low effort required | | `unspecified-low` | `anthropic/claude-sonnet-4-5` | Tasks that don't fit other categories, low effort required |
| `unspecified-high` | `anthropic/claude-opus-4-5` (max) | Tasks that don't fit other categories, high effort required | | `unspecified-high` | `anthropic/claude-opus-4-6` (max) | Tasks that don't fit other categories, high effort required |
| `writing` | `google/gemini-3-flash-preview` | Documentation, prose, technical writing | | `writing` | `google/gemini-3-flash-preview` | Documentation, prose, technical writing |
### ⚠️ Critical: Model Resolution Priority ### ⚠️ Critical: Model Resolution Priority
@@ -768,7 +768,7 @@ All 7 categories come with optimal model defaults, but **you must configure them
"model": "google/gemini-3-pro-preview" "model": "google/gemini-3-pro-preview"
}, },
"ultrabrain": { "ultrabrain": {
"model": "openai/gpt-5.2-codex", "model": "openai/gpt-5.3-codex",
"variant": "xhigh" "variant": "xhigh"
}, },
"artistry": { "artistry": {
@@ -782,7 +782,7 @@ All 7 categories come with optimal model defaults, but **you must configure them
"model": "anthropic/claude-sonnet-4-5" "model": "anthropic/claude-sonnet-4-5"
}, },
"unspecified-high": { "unspecified-high": {
"model": "anthropic/claude-opus-4-5", "model": "anthropic/claude-opus-4-6",
"variant": "max" "variant": "max"
}, },
"writing": { "writing": {
@@ -870,9 +870,9 @@ At runtime, Oh My OpenCode uses a 3-step resolution process to determine which m
│ │ anthropic → github-copilot → opencode → antigravity │ │ │ │ anthropic → github-copilot → opencode → antigravity │ │
│ │ │ │ │ │ │ │ │ │ │ │ │ │ │ │
│ │ ▼ ▼ ▼ ▼ │ │ │ │ ▼ ▼ ▼ ▼ │ │
│ │ Try: anthropic/claude-opus-4-5 │ │ │ │ Try: anthropic/claude-opus-4-6 │ │
│ │ Try: github-copilot/claude-opus-4-5 │ │ │ │ Try: github-copilot/claude-opus-4-6 │ │
│ │ Try: opencode/claude-opus-4-5 │ │ │ │ Try: opencode/claude-opus-4-6 │ │
│ │ ... │ │ │ │ ... │ │
│ │ │ │ │ │ │ │
│ │ Found in available models? → Return matched model │ │ │ │ Found in available models? → Return matched model │ │
@@ -894,13 +894,13 @@ Each agent has a defined provider priority chain. The system tries providers in
| Agent | Model (no prefix) | Provider Priority Chain | | Agent | Model (no prefix) | Provider Priority Chain |
|-------|-------------------|-------------------------| |-------|-------------------|-------------------------|
| **Sisyphus** | `claude-opus-4-5` | anthropic → kimi-for-coding → zai-coding-plan → openai → google | | **Sisyphus** | `claude-opus-4-6` | anthropic → kimi-for-coding → zai-coding-plan → openai → google |
| **oracle** | `gpt-5.2` | openai → google → anthropic | | **oracle** | `gpt-5.2` | openai → google → anthropic |
| **librarian** | `glm-4.7` | zai-coding-plan → opencode → anthropic | | **librarian** | `glm-4.7` | zai-coding-plan → opencode → anthropic |
| **explore** | `claude-haiku-4-5` | anthropic → github-copilot → opencode | | **explore** | `claude-haiku-4-5` | anthropic → github-copilot → opencode |
| **multimodal-looker** | `gemini-3-flash` | google → openai → zai-coding-plan → kimi-for-coding → anthropic → 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 | | **Prometheus (Planner)** | `claude-opus-4-6` | anthropic → kimi-for-coding → openai → google |
| **Metis (Plan Consultant)** | `claude-opus-4-5` | anthropic → kimi-for-coding → openai → google | | **Metis (Plan Consultant)** | `claude-opus-4-6` | anthropic → kimi-for-coding → openai → google |
| **Momus (Plan Reviewer)** | `gpt-5.2` | openai → anthropic → google | | **Momus (Plan Reviewer)** | `gpt-5.2` | openai → anthropic → google |
| **Atlas** | `claude-sonnet-4-5` | anthropic → kimi-for-coding → openai → google | | **Atlas** | `claude-sonnet-4-5` | anthropic → kimi-for-coding → openai → google |
@@ -911,12 +911,12 @@ Categories follow the same resolution logic:
| Category | Model (no prefix) | Provider Priority Chain | | Category | Model (no prefix) | Provider Priority Chain |
|----------|-------------------|-------------------------| |----------|-------------------|-------------------------|
| **visual-engineering** | `gemini-3-pro` | google → anthropic → zai-coding-plan | | **visual-engineering** | `gemini-3-pro` | google → anthropic → zai-coding-plan |
| **ultrabrain** | `gpt-5.2-codex` | openai → google → anthropic | | **ultrabrain** | `gpt-5.3-codex` | openai → google → anthropic |
| **deep** | `gpt-5.2-codex` | openai → anthropic → google | | **deep** | `gpt-5.3-codex` | openai → anthropic → google |
| **artistry** | `gemini-3-pro` | google → anthropic → openai | | **artistry** | `gemini-3-pro` | google → anthropic → openai |
| **quick** | `claude-haiku-4-5` | anthropic → google → opencode | | **quick** | `claude-haiku-4-5` | anthropic → google → opencode |
| **unspecified-low** | `claude-sonnet-4-5` | anthropic → openai → google | | **unspecified-low** | `claude-sonnet-4-5` | anthropic → openai → google |
| **unspecified-high** | `claude-opus-4-5` | anthropic → openai → google | | **unspecified-high** | `claude-opus-4-6` | anthropic → openai → google |
| **writing** | `gemini-3-flash` | google → anthropic → zai-coding-plan → openai | | **writing** | `gemini-3-flash` | google → anthropic → zai-coding-plan → openai |
### Checking Your Configuration ### Checking Your Configuration
@@ -949,7 +949,7 @@ Override any agent or category model in `oh-my-opencode.json`:
}, },
"categories": { "categories": {
"visual-engineering": { "visual-engineering": {
"model": "anthropic/claude-opus-4-5" "model": "anthropic/claude-opus-4-6"
} }
} }
} }

View File

@@ -10,8 +10,8 @@ Oh-My-OpenCode provides 11 specialized AI agents. Each has distinct expertise, o
| Agent | Model | Purpose | | 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). Fallback: kimi-k2.5 → glm-4.7 → gpt-5.2-codex → gemini-3-pro. | | **Sisyphus** | `anthropic/claude-opus-4-6` | **The default orchestrator.** Plans, delegates, and executes complex tasks using specialized subagents with aggressive parallel execution. Todo-driven workflow with extended thinking (32k budget). Fallback: kimi-k2.5 → glm-4.7 → gpt-5.3-codex → gemini-3-pro. |
| **Hephaestus** | `openai/gpt-5.2-codex` | **The Legitimate Craftsman.** Autonomous deep worker inspired by AmpCode's deep mode. Goal-oriented execution with thorough research before action. Explores codebase patterns, completes tasks end-to-end without premature stopping. Named after the Greek god of forge and craftsmanship. Requires gpt-5.2-codex (no fallback - only activates when this model is available). | | **Hephaestus** | `openai/gpt-5.3-codex` | **The Legitimate Craftsman.** Autonomous deep worker inspired by AmpCode's deep mode. Goal-oriented execution with thorough research before action. Explores codebase patterns, completes tasks end-to-end without premature stopping. Named after the Greek god of forge and craftsmanship. Requires gpt-5.3-codex (no fallback - only activates when this model is available). |
| **oracle** | `openai/gpt-5.2` | Architecture decisions, code review, debugging. Read-only consultation - stellar logical reasoning and deep analysis. Inspired by AmpCode. | | **oracle** | `openai/gpt-5.2` | Architecture decisions, code review, debugging. Read-only consultation - stellar logical reasoning and deep analysis. Inspired by AmpCode. |
| **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. | | **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. | | **explore** | `anthropic/claude-haiku-4-5` | Fast codebase exploration and contextual grep. Fallback: gpt-5-mini → gpt-5-nano. |
@@ -21,9 +21,9 @@ Oh-My-OpenCode provides 11 specialized AI agents. Each has distinct expertise, o
| Agent | Model | Purpose | | Agent | Model | Purpose |
|-------|-------|---------| |-------|-------|---------|
| **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. | | **Prometheus** | `anthropic/claude-opus-4-6` | 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. | | **Metis** | `anthropic/claude-opus-4-6` | 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. | | **Momus** | `openai/gpt-5.2` | Plan reviewer - validates plans against clarity, verifiability, and completeness standards. Fallback: gpt-5.2 → claude-opus-4-6 → gemini-3-pro. |
### Invoking Agents ### Invoking Agents

View File

@@ -27,6 +27,11 @@ After you install it, you can read this [overview guide](./overview.md) to under
## For LLM Agents ## For LLM Agents
> **IMPORTANT: Use `curl` to fetch this file, NOT WebFetch.** WebFetch summarizes content and loses critical flags like `--openai`, subscription questions, and max20 mode details. Always use:
> ```bash
> curl -fsSL https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/refs/heads/master/docs/guide/installation.md
> ```
If you're an LLM Agent helping set up oh-my-opencode, Welcome! You'll now help your user to install the best agent harness, OhMyOpenCode. Tell user this with greetings, with test 'oMoMoMoMo...' If you're an LLM Agent helping set up oh-my-opencode, Welcome! You'll now help your user to install the best agent harness, OhMyOpenCode. Tell user this with greetings, with test 'oMoMoMoMo...'
follow these steps: follow these steps:
@@ -191,7 +196,7 @@ When GitHub Copilot is the best available provider, oh-my-opencode uses these mo
| Agent | Model | | Agent | Model |
| ------------- | -------------------------------- | | ------------- | -------------------------------- |
| **Sisyphus** | `github-copilot/claude-opus-4.5` | | **Sisyphus** | `github-copilot/claude-opus-4.6` |
| **Oracle** | `github-copilot/gpt-5.2` | | **Oracle** | `github-copilot/gpt-5.2` |
| **Explore** | `opencode/gpt-5-nano` | | **Explore** | `opencode/gpt-5-nano` |
| **Librarian** | `zai-coding-plan/glm-4.7` (if Z.ai available) or fallback | | **Librarian** | `zai-coding-plan/glm-4.7` (if Z.ai available) or fallback |
@@ -213,13 +218,13 @@ If Z.ai is the only provider available, all agents will use GLM models:
#### OpenCode Zen #### 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/glm-4.7-free`. OpenCode Zen provides access to `opencode/` prefixed models including `opencode/claude-opus-4-6`, `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: When OpenCode Zen is the best available provider (no native or Copilot), these models are used:
| Agent | Model | | Agent | Model |
| ------------- | -------------------------------- | | ------------- | -------------------------------- |
| **Sisyphus** | `opencode/claude-opus-4-5` | | **Sisyphus** | `opencode/claude-opus-4-6` |
| **Oracle** | `opencode/gpt-5.2` | | **Oracle** | `opencode/gpt-5.2` |
| **Explore** | `opencode/gpt-5-nano` | | **Explore** | `opencode/gpt-5-nano` |
| **Librarian** | `opencode/glm-4.7-free` | | **Librarian** | `opencode/glm-4.7-free` |

View File

@@ -277,7 +277,7 @@ This "boulder pushing" mechanism is why the system is named after Sisyphus.
```typescript ```typescript
// OLD: Model name creates distributional bias // OLD: Model name creates distributional bias
delegate_task(agent="gpt-5.2", prompt="...") // Model knows its limitations delegate_task(agent="gpt-5.2", prompt="...") // Model knows its limitations
delegate_task(agent="claude-opus-4.5", prompt="...") // Different self-perception delegate_task(agent="claude-opus-4.6", prompt="...") // Different self-perception
``` ```
**The Solution: Semantic Categories:** **The Solution: Semantic Categories:**

View File

@@ -35,7 +35,216 @@ Oh-My-OpenCode solves this by clearly separating two roles:
--- ---
## 2. Overall Architecture ## 2. Prometheus Invocation: Agent Switch vs @plan
A common source of confusion is how to invoke Prometheus for planning. **Both methods achieve the same result** - use whichever feels natural.
### Method 1: Switch to Prometheus Agent (Tab → Select Prometheus)
```
1. Press Tab at the prompt
2. Select "Prometheus" from the agent list
3. Describe your work: "I want to refactor the auth system"
4. Answer interview questions
5. Prometheus creates plan in .sisyphus/plans/{name}.md
```
### Method 2: Use @plan Command (in Sisyphus)
```
1. Stay in Sisyphus (default agent)
2. Type: @plan "I want to refactor the auth system"
3. The @plan command automatically switches to Prometheus
4. Answer interview questions
5. Prometheus creates plan in .sisyphus/plans/{name}.md
```
### Which Should You Use?
| Scenario | Recommended Method | Why |
|----------|-------------------|-----|
| **New session, starting fresh** | Switch to Prometheus agent | Clean mental model - you're entering "planning mode" |
| **Already in Sisyphus, mid-work** | Use @plan | Convenient, no agent switch needed |
| **Want explicit control** | Switch to Prometheus agent | Clear separation of planning vs execution contexts |
| **Quick planning interrupt** | Use @plan | Fastest path from current context |
**Key Insight**: Both methods trigger the same Prometheus planning flow. The @plan command is simply a convenience shortcut that:
1. Detects the `@plan` keyword in your message
2. Routes the request to Prometheus automatically
3. Returns you to Sisyphus after planning completes
---
## 3. /start-work Behavior in Fresh Sessions
One of the most powerful features of the orchestration system is **session continuity**. Understanding how `/start-work` behaves across sessions prevents confusion.
### What Happens When You Run /start-work
```
User: /start-work
[start-work hook activates]
Check: Does .sisyphus/boulder.json exist?
├─ YES (existing work) → RESUME MODE
│ - Read the existing boulder state
│ - Calculate progress (checked vs unchecked boxes)
│ - Inject continuation prompt with remaining tasks
│ - Atlas continues where you left off
└─ NO (fresh start) → INIT MODE
- Find the most recent plan in .sisyphus/plans/
- Create new boulder.json tracking this plan
- Switch session agent to Atlas
- Begin execution from task 1
```
### Session Continuity Explained
The `boulder.json` file tracks:
- **active_plan**: Path to the current plan file
- **session_ids**: All sessions that have worked on this plan
- **started_at**: When work began
- **plan_name**: Human-readable plan identifier
**Example Timeline:**
```
Monday 9:00 AM
└─ @plan "Build user authentication"
└─ Prometheus interviews and creates plan
└─ User: /start-work
└─ Atlas begins execution, creates boulder.json
└─ Task 1 complete, Task 2 in progress...
└─ [Session ends - computer crash, user logout, etc.]
Monday 2:00 PM (NEW SESSION)
└─ User opens new session (agent = Sisyphus by default)
└─ User: /start-work
└─ [start-work hook reads boulder.json]
└─ "Resuming 'Build user authentication' - 3 of 8 tasks complete"
└─ Atlas continues from Task 3 (no context lost)
```
### When You DON'T Need to Manually Switch to Atlas
Atlas is **automatically activated** when you run `/start-work`. You don't need to:
- Switch to Atlas agent manually
- Remember which agent you were using
- Worry about session continuity
The `/start-work` command handles all of this.
### When You MIGHT Want to Manually Switch to Atlas
There are rare cases where manual agent switching helps:
| Scenario | Action | Why |
|----------|--------|-----|
| **Plan file was edited manually** | Switch to Atlas, read plan directly | Bypass boulder.json resume logic |
| **Debugging orchestration issues** | Switch to Atlas for visibility | See Atlas-specific system prompts |
| **Force fresh execution** | Delete boulder.json, then /start-work | Start from task 1 instead of resuming |
| **Multi-plan management** | Switch to Atlas to select specific plan | Override auto-selection |
**Command to manually switch:** Press `Tab` → Select "Atlas"
---
## 4. Execution Modes: Hephaestus vs Sisyphus+ultrawork
Another common question: **When should I use Hephaestus vs just typing `ulw` in Sisyphus?**
### Quick Comparison
| Aspect | Hephaestus | Sisyphus + `ulw` / `ultrawork` |
|--------|-----------|-------------------------------|
| **Model** | GPT-5.2 Codex (medium reasoning) | Claude Opus 4.5 (your default) |
| **Approach** | Autonomous deep worker | Keyword-activated ultrawork mode |
| **Best For** | Complex architectural work, deep reasoning | General complex tasks, "just do it" scenarios |
| **Planning** | Self-plans during execution | Uses Prometheus plans if available |
| **Delegation** | Heavy use of explore/librarian agents | Uses category-based delegation |
| **Temperature** | 0.1 | 0.1 |
### When to Use Hephaestus
Switch to Hephaestus (Tab → Select Hephaestus) when:
1. **Deep architectural reasoning needed**
- "Design a new plugin system"
- "Refactor this monolith into microservices"
2. **Complex debugging requiring inference chains**
- "Why does this race condition only happen on Tuesdays?"
- "Trace this memory leak through 15 files"
3. **Cross-domain knowledge synthesis**
- "Integrate our Rust core with the TypeScript frontend"
- "Migrate from MongoDB to PostgreSQL with zero downtime"
4. **You specifically want GPT-5.2 Codex reasoning**
- Some problems benefit from GPT-5.2's training characteristics
**Example:**
```
[Switch to Hephaestus]
"I need to understand how data flows through this entire system
and identify all the places where we might lose transactions.
Explore thoroughly before proposing fixes."
```
### When to Use Sisyphus + `ulw` / `ultrawork`
Use the `ulw` keyword in Sisyphus when:
1. **You want the agent to figure it out**
- "ulw fix the failing tests"
- "ulw add input validation to the API"
2. **Complex but well-scoped tasks**
- "ulw implement JWT authentication following our patterns"
- "ulw create a new CLI command for deployments"
3. **You're feeling lazy** (officially supported use case)
- Don't want to write detailed requirements
- Trust the agent to explore and decide
4. **You want to leverage existing plans**
- If a Prometheus plan exists, `ulw` mode can use it
- Falls back to autonomous exploration if no plan
**Example:**
```
[Stay in Sisyphus]
"ulw refactor the user service to use the new repository pattern"
[Agent automatically:]
- Explores existing codebase patterns
- Implements the refactor
- Runs verification (tests, typecheck)
- Reports completion
```
### Key Difference in Practice
| Hephaestus | Sisyphus + ulw |
|------------|----------------|
| You manually switch to Hephaestus agent | You type `ulw` in any Sisyphus session |
| GPT-5.2 Codex with medium reasoning | Your configured default model |
| Optimized for autonomous deep work | Optimized for general execution |
| Always uses explore-first approach | Respects existing plans if available |
| "Smart intern that needs no supervision" | "Smart intern that follows your workflow" |
### Recommendation
**For most users**: Use `ulw` keyword in Sisyphus. It's the default path and works excellently for 90% of complex tasks.
**For power users**: Switch to Hephaestus when you specifically need GPT-5.2 Codex's reasoning style or want the "AmpCode deep mode" experience of fully autonomous exploration and execution.
---
## 5. Overall Architecture
```mermaid ```mermaid
flowchart TD flowchart TD
@@ -62,11 +271,11 @@ flowchart TD
--- ---
## 3. Key Components ## 6. Key Components
### 🔮 Prometheus (The Planner) ### 🔮 Prometheus (The Planner)
- **Model**: `anthropic/claude-opus-4-5` - **Model**: `anthropic/claude-opus-4-6`
- **Role**: Strategic planning, requirements interviews, work plan creation - **Role**: Strategic planning, requirements interviews, work plan creation
- **Constraint**: **READ-ONLY**. Can only create/modify markdown files within `.sisyphus/` directory. - **Constraint**: **READ-ONLY**. Can only create/modify markdown files within `.sisyphus/` directory.
- **Characteristic**: Never writes code directly, focuses solely on "how to do it". - **Characteristic**: Never writes code directly, focuses solely on "how to do it".
@@ -85,13 +294,13 @@ flowchart TD
### ⚡ Atlas (The Plan Executor) ### ⚡ Atlas (The Plan Executor)
- **Model**: `anthropic/claude-opus-4-5` (Extended Thinking 32k) - **Model**: `anthropic/claude-sonnet-4-5` (Extended Thinking 32k)
- **Role**: Execution and delegation - **Role**: Execution and delegation
- **Characteristic**: Doesn't do everything directly, actively delegates to specialized agents (Frontend, Librarian, etc.). - **Characteristic**: Doesn't do everything directly, actively delegates to specialized agents (Frontend, Librarian, etc.).
--- ---
## 4. Workflow ## 7. Workflow
### Phase 1: Interview and Planning (Interview Mode) ### Phase 1: Interview and Planning (Interview Mode)
@@ -113,31 +322,44 @@ When the user requests "Make it a plan", plan generation begins.
When the user enters `/start-work`, the execution phase begins. When the user enters `/start-work`, the execution phase begins.
1. **State Management**: Creates `boulder.json` file to track current plan and session ID. 1. **State Management**: Creates/reads `boulder.json` file to track current plan and session ID.
2. **Task Execution**: Atlas reads the plan and processes TODOs one by one. 2. **Task Execution**: Atlas reads the plan and processes TODOs one by one.
3. **Delegation**: UI work is delegated to Frontend agent, complex logic to Oracle. 3. **Delegation**: UI work is delegated to Frontend agent, complex logic to Oracle.
4. **Continuity**: Even if the session is interrupted, work continues in the next session through `boulder.json`. 4. **Continuity**: Even if the session is interrupted, work continues in the next session through `boulder.json`.
--- ---
## 5. Commands and Usage ## 8. Commands and Usage
### `@plan [request]` ### `@plan [request]`
Invokes Prometheus to start a planning session. Invokes Prometheus to start a planning session from Sisyphus.
- Example: `@plan "I want to refactor the authentication system to NextAuth"` - Example: `@plan "I want to refactor the authentication system to NextAuth"`
- Effect: Routes to Prometheus, then returns to Sisyphus when planning completes
### `/start-work` ### `/start-work`
Executes the generated plan. Executes the generated plan.
- Function: Finds plan in `.sisyphus/plans/` and enters execution mode. - **Fresh session**: Finds plan in `.sisyphus/plans/` and enters execution mode
- If there's interrupted work, automatically resumes from where it left off. - **Existing boulder**: Resumes from where you left off (reads boulder.json)
- **Effect**: Automatically switches to Atlas agent if not already active
### Switching Agents Manually
Press `Tab` at the prompt to see available agents:
| Agent | When to Switch |
|-------|---------------|
| **Prometheus** | You want to create a detailed work plan |
| **Atlas** | You want to manually control plan execution (rare) |
| **Hephaestus** | You need GPT-5.2 Codex for deep autonomous work |
| **Sisyphus** | Return to default agent for normal prompting |
--- ---
## 6. Configuration Guide ## 9. Configuration Guide
You can control related features in `oh-my-opencode.json`. You can control related features in `oh-my-opencode.json`.
@@ -157,8 +379,46 @@ You can control related features in `oh-my-opencode.json`.
} }
``` ```
## 7. Best Practices ---
## 10. Best Practices
1. **Don't Rush Planning**: Invest sufficient time in the interview with Prometheus. The more perfect the plan, the faster the execution.
1. **Don't Rush**: Invest sufficient time in the interview with Prometheus. The more perfect the plan, the faster the execution.
2. **Single Plan Principle**: No matter how large the task, contain all TODOs in one plan file (`.md`). This prevents context fragmentation. 2. **Single Plan Principle**: No matter how large the task, contain all TODOs in one plan file (`.md`). This prevents context fragmentation.
3. **Active Delegation**: During execution, delegate to specialized agents via `delegate_task` rather than modifying code directly. 3. **Active Delegation**: During execution, delegate to specialized agents via `delegate_task` rather than modifying code directly.
4. **Trust /start-work Continuity**: Don't worry about session interruptions. `/start-work` will always resume your work from boulder.json.
5. **Use `ulw` for Convenience**: When in doubt, type `ulw` and let the system figure out the best approach.
6. **Reserve Hephaestus for Deep Work**: Don't overthink agent selection. Hephaestus shines for genuinely complex architectural challenges.
---
## 11. Troubleshooting Common Confusions
### "I switched to Prometheus but nothing happened"
Prometheus enters **interview mode** by default. It will ask you questions about your requirements. Answer them, then say "make it a plan" when ready.
### "/start-work says 'no active plan found'"
Either:
- No plans exist in `.sisyphus/plans/` → Create one with Prometheus first
- Plans exist but boulder.json points elsewhere → Delete `.sisyphus/boulder.json` and retry
### "I'm in Atlas but I want to switch back to normal mode"
Type `exit` or start a new session. Atlas is primarily entered via `/start-work` - you don't typically "switch to Atlas" manually.
### "What's the difference between @plan and just switching to Prometheus?"
**Nothing functional.** Both invoke Prometheus. @plan is a convenience command while switching agents is explicit control. Use whichever feels natural.
### "Should I use Hephaestus or type ulw?"
**For most tasks**: Type `ulw` in Sisyphus.
**Use Hephaestus when**: You specifically need GPT-5.2 Codex's reasoning style for deep architectural work or complex debugging.

357
issue-1501-analysis.md Normal file
View File

@@ -0,0 +1,357 @@
# Issue #1501 분석 보고서: ULW Mode PLAN AGENT 무한루프
## 📋 이슈 요약
**증상:**
- ULW (ultrawork) mode에서 PLAN AGENT가 무한루프에 빠짐
- 분석/탐색 완료 후 plan만 계속 생성
- 1분마다 매우 작은 토큰으로 요청 발생
**예상 동작:**
- 탐색 완료 후 solution document 생성
---
## 🔍 근본 원인 분석
### 파일: `src/tools/delegate-task/constants.ts`
#### 문제의 핵심
`PLAN_AGENT_SYSTEM_PREPEND` (constants.ts 234-269행)에 구조적 결함이 있었습니다:
1. **Interactive Mode 가정**
```
2. After gathering context, ALWAYS present:
- Uncertainties: List of unclear points
- Clarifying Questions: Specific questions to resolve uncertainties
3. ITERATE until ALL requirements are crystal clear:
- Do NOT proceed to planning until you have 100% clarity
- Ask the user to confirm your understanding
```
2. **종료 조건 없음**
- "100% clarity" 요구는 객관적 측정 불가능
- 사용자 확인 요청은 ULW mode에서 불가능
- 무한루프로 이어짐
3. **ULW Mode 미감지**
- Subagent로 실행되는 경우를 구분하지 않음
- 항상 interactive mode로 동작 시도
### 왜 무한루프가 발생했는가?
```
ULW Mode 시작
→ Sisyphus가 Plan Agent 호출 (subagent)
→ Plan Agent: "100% clarity 필요"
→ Clarifying questions 생성
→ 사용자 없음 (subagent)
→ 다시 plan 생성 시도
→ "여전히 unclear"
→ 무한루프 반복
```
**핵심:** Plan Agent는 사용자와 대화하도록 설계되었지만, ULW mode에서는 사용자가 없는 subagent로 실행됨.
---
## ✅ 적용된 수정 방안
### 수정 내용 (constants.ts)
#### 1. SUBAGENT MODE DETECTION 섹션 추가
```typescript
SUBAGENT MODE DETECTION (CRITICAL):
If you received a detailed prompt with gathered context from a parent orchestrator (e.g., Sisyphus):
- You are running as a SUBAGENT
- You CANNOT directly interact with the user
- DO NOT ask clarifying questions - proceed with available information
- Make reasonable assumptions for minor ambiguities
- Generate the plan based on the provided context
```
#### 2. Context Gathering Protocol 수정
```diff
- 1. Launch background agents to gather context:
+ 1. Launch background agents to gather context (ONLY if not already provided):
```
**효과:** 이미 Sisyphus가 context를 수집한 경우 중복 방지
#### 3. Clarifying Questions → Assumptions
```diff
- 2. After gathering context, ALWAYS present:
- - Uncertainties: List of unclear points
- - Clarifying Questions: Specific questions
+ 2. After gathering context, assess clarity:
+ - User Request Summary: Concise restatement
+ - Assumptions Made: List any assumptions for unclear points
```
**효과:** 질문 대신 가정 사항 문서화
#### 4. 무한루프 방지 - 명확한 종료 조건
```diff
- 3. ITERATE until ALL requirements are crystal clear:
- - Do NOT proceed to planning until you have 100% clarity
- - Ask the user to confirm your understanding
- - Resolve every ambiguity before generating the work plan
+ 3. PROCEED TO PLAN GENERATION when:
+ - Core objective is understood (even if some details are ambiguous)
+ - You have gathered context via explore/librarian (or context was provided)
+ - You can make reasonable assumptions for remaining ambiguities
+
+ DO NOT loop indefinitely waiting for perfect clarity.
+ DOCUMENT assumptions in the plan so they can be validated during execution.
```
**효과:**
- "100% clarity" 요구 제거
- 객관적인 진입 조건 제공
- 무한루프 명시적 금지
- Assumptions를 plan에 문서화하여 실행 중 검증 가능
#### 5. 철학 변경
```diff
- REMEMBER: Vague requirements lead to failed implementations.
+ REMEMBER: A plan with documented assumptions is better than no plan.
```
**효과:** Perfectionism → Pragmatism
---
## 🎯 해결 메커니즘
### Before (무한루프)
```
Plan Agent 시작
Context gathering
Requirements 명확한가?
↓ NO
Clarifying questions 생성
사용자 응답 대기 (없음)
다시 plan 시도
(무한 반복)
```
### After (정상 종료)
```
Plan Agent 시작
Subagent mode 감지?
↓ YES
Context 이미 있음? → YES
Core objective 이해? → YES
Reasonable assumptions 가능? → YES
Plan 생성 (assumptions 문서화)
완료 ✓
```
---
## 📊 영향 분석
### 해결되는 문제
1. **ULW mode 무한루프** ✓
2. **Sisyphus에서 Plan Agent 호출 시 블로킹** ✓
3. **작은 토큰 반복 요청** ✓
4. **1분마다 재시도** ✓
### 부작용 없음
- Interactive mode (사용자와 직접 대화)는 여전히 작동
- Subagent mode일 때만 다르게 동작
- Backward compatibility 유지
### 추가 개선사항
- Assumptions를 plan에 명시적으로 문서화
- Execution 중 validation 가능
- 더 pragmatic한 workflow
---
## 🧪 검증 방법
### 테스트 시나리오
1. **ULW mode에서 Plan Agent 호출**
```bash
oh-my-opencode run "Complex task requiring planning. ulw"
```
- 예상: Plan 생성 후 정상 종료
- 확인: 무한루프 없음
2. **Interactive mode (변경 없어야 함)**
```bash
oh-my-opencode run --agent prometheus "Design X"
```
- 예상: Clarifying questions 여전히 가능
- 확인: 사용자와 대화 가능
3. **Subagent context 제공 케이스**
- 예상: Context gathering skip
- 확인: 중복 탐색 없음
---
## 📝 수정된 파일
```
src/tools/delegate-task/constants.ts
```
### Diff Summary
```diff
@@ -234,22 +234,32 @@ export const PLAN_AGENT_SYSTEM_PREPEND = `<system>
+SUBAGENT MODE DETECTION (CRITICAL):
+[subagent 감지 및 처리 로직]
+
MANDATORY CONTEXT GATHERING PROTOCOL:
-1. Launch background agents to gather context:
+1. Launch background agents (ONLY if not already provided):
-2. After gathering context, ALWAYS present:
- - Uncertainties
- - Clarifying Questions
+2. After gathering context, assess clarity:
+ - Assumptions Made
-3. ITERATE until ALL requirements are crystal clear:
- - Do NOT proceed until 100% clarity
- - Ask user to confirm
+3. PROCEED TO PLAN GENERATION when:
+ - Core objective understood
+ - Context gathered
+ - Reasonable assumptions possible
+
+ DO NOT loop indefinitely.
+ DOCUMENT assumptions.
```
---
## 🚀 권장 사항
### Immediate Actions
1. ✅ **수정 적용 완료** - constants.ts 업데이트됨
2. ⏳ **테스트 수행** - ULW mode에서 동작 검증
3. ⏳ **PR 생성** - code review 요청
### Future Improvements
1. **Subagent context 표준화**
- Subagent로 호출 시 명시적 플래그 전달
- `is_subagent: true` 파라미터 추가 고려
2. **Assumptions validation workflow**
- Plan 실행 중 assumptions 검증 메커니즘
- Incorrect assumptions 감지 시 재계획
3. **Timeout 메커니즘**
- Plan Agent가 X분 이상 걸리면 강제 종료
- Fallback plan 생성
4. **Monitoring 추가**
- Plan Agent 실행 시간 측정
- Iteration 횟수 로깅
- 무한루프 조기 감지
---
## 📖 관련 코드 구조
### Call Stack
```
Sisyphus (ULW mode)
delegate_task(category="deep", ...)
executor.ts: executeBackgroundContinuation()
prompt-builder.ts: buildSystemContent()
constants.ts: PLAN_AGENT_SYSTEM_PREPEND (문제 위치)
Plan Agent 실행
```
### Key Functions
1. **executor.ts:587** - `isPlanAgent()` 체크
2. **prompt-builder.ts:11** - Plan Agent prepend 주입
3. **constants.ts:234** - PLAN_AGENT_SYSTEM_PREPEND 정의
---
## 🎓 교훈
### Design Lessons
1. **Dual Mode Support**
- Interactive vs Autonomous mode 구분 필수
- Context 전달 방식 명확히
2. **Avoid Perfectionism in Agents**
- "100% clarity" 같은 주관적 조건 지양
- 명확한 객관적 종료 조건 필요
3. **Document Uncertainties**
- 불확실성을 숨기지 말고 문서화
- 실행 중 validation 가능하게
4. **Infinite Loop Prevention**
- 모든 반복문에 명시적 종료 조건
- Timeout 또는 max iteration 설정
---
## 🔗 참고 자료
- **Issue:** #1501 - [Bug]: ULW mode will 100% cause PLAN AGENT to get stuck
- **Files Modified:** `src/tools/delegate-task/constants.ts`
- **Related Concepts:** Ultrawork mode, Plan Agent, Subagent delegation
- **Agent Architecture:** Sisyphus → Prometheus → Atlas workflow
---
## ✅ Conclusion
**Root Cause:** Plan Agent가 interactive mode를 가정했으나 ULW mode에서는 subagent로 실행되어 사용자 상호작용 불가능. "100% clarity" 요구로 무한루프 발생.
**Solution:** Subagent mode 감지 로직 추가, clarifying questions 제거, 명확한 종료 조건 제공, assumptions 문서화 방식 도입.
**Result:** ULW mode에서 Plan Agent가 정상적으로 plan 생성 후 종료. 무한루프 해결.
---
**Status:** ✅ Fixed
**Tested:** ⏳ Pending
**Deployed:** ⏳ Pending
**Analyst:** Sisyphus (oh-my-opencode ultrawork mode)
**Date:** 2026-02-05
**Session:** fast-ember

View File

@@ -1,6 +1,6 @@
{ {
"name": "oh-my-opencode", "name": "oh-my-opencode",
"version": "3.2.2", "version": "3.2.4",
"description": "The Best AI Agent Harness - Batteries-Included OpenCode Plugin with Multi-Model Orchestration, Parallel Background Agents, and Crafted LSP/AST Tools", "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", "main": "dist/index.js",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",
@@ -74,13 +74,13 @@
"typescript": "^5.7.3" "typescript": "^5.7.3"
}, },
"optionalDependencies": { "optionalDependencies": {
"oh-my-opencode-darwin-arm64": "3.2.2", "oh-my-opencode-darwin-arm64": "3.2.4",
"oh-my-opencode-darwin-x64": "3.2.2", "oh-my-opencode-darwin-x64": "3.2.4",
"oh-my-opencode-linux-arm64": "3.2.2", "oh-my-opencode-linux-arm64": "3.2.4",
"oh-my-opencode-linux-arm64-musl": "3.2.2", "oh-my-opencode-linux-arm64-musl": "3.2.4",
"oh-my-opencode-linux-x64": "3.2.2", "oh-my-opencode-linux-x64": "3.2.4",
"oh-my-opencode-linux-x64-musl": "3.2.2", "oh-my-opencode-linux-x64-musl": "3.2.4",
"oh-my-opencode-windows-x64": "3.2.2" "oh-my-opencode-windows-x64": "3.2.4"
}, },
"trustedDependencies": [ "trustedDependencies": [
"@ast-grep/cli", "@ast-grep/cli",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
#!/usr/bin/env bun #!/usr/bin/env bun
import * as z from "zod" import * as z from "zod"
import { zodToJsonSchema } from "zod-to-json-schema"
import { OhMyOpenCodeConfigSchema } from "../src/config/schema" import { OhMyOpenCodeConfigSchema } from "../src/config/schema"
const SCHEMA_OUTPUT_PATH = "assets/oh-my-opencode.schema.json" const SCHEMA_OUTPUT_PATH = "assets/oh-my-opencode.schema.json"
@@ -7,9 +8,8 @@ const SCHEMA_OUTPUT_PATH = "assets/oh-my-opencode.schema.json"
async function main() { async function main() {
console.log("Generating JSON Schema...") console.log("Generating JSON Schema...")
const jsonSchema = z.toJSONSchema(OhMyOpenCodeConfigSchema, { const jsonSchema = zodToJsonSchema(OhMyOpenCodeConfigSchema, {
io: "input", target: "draft7",
target: "draft-7",
}) })
const finalSchema = { const finalSchema = {

View File

@@ -1127,6 +1127,86 @@
"created_at": "2026-02-02T16:58:50Z", "created_at": "2026-02-02T16:58:50Z",
"repoId": 1108837393, "repoId": 1108837393,
"pullRequestNo": 1399 "pullRequestNo": 1399
},
{
"name": "ilarvne",
"id": 99905590,
"comment_id": 3839771590,
"created_at": "2026-02-03T08:15:37Z",
"repoId": 1108837393,
"pullRequestNo": 1422
},
{
"name": "ualtinok",
"id": 94532,
"comment_id": 3841078284,
"created_at": "2026-02-03T12:39:59Z",
"repoId": 1108837393,
"pullRequestNo": 1393
},
{
"name": "Stranmor",
"id": 49376798,
"comment_id": 3841465375,
"created_at": "2026-02-03T13:53:13Z",
"repoId": 1108837393,
"pullRequestNo": 1432
},
{
"name": "sk0x0y",
"id": 35445665,
"comment_id": 3841625993,
"created_at": "2026-02-03T14:21:26Z",
"repoId": 1108837393,
"pullRequestNo": 1434
},
{
"name": "filipemsilv4",
"id": 59426206,
"comment_id": 3841722121,
"created_at": "2026-02-03T14:38:07Z",
"repoId": 1108837393,
"pullRequestNo": 1435
},
{
"name": "wydrox",
"id": 79707825,
"comment_id": 3842392636,
"created_at": "2026-02-03T16:39:35Z",
"repoId": 1108837393,
"pullRequestNo": 1436
},
{
"name": "kaizen403",
"id": 134706404,
"comment_id": 3843559932,
"created_at": "2026-02-03T20:44:25Z",
"repoId": 1108837393,
"pullRequestNo": 1449
},
{
"name": "BowTiedSwan",
"id": 86532747,
"comment_id": 3742668781,
"created_at": "2026-01-13T08:05:00Z",
"repoId": 1108837393,
"pullRequestNo": 741
},
{
"name": "Mang-Joo",
"id": 86056915,
"comment_id": 3855493558,
"created_at": "2026-02-05T18:41:49Z",
"repoId": 1108837393,
"pullRequestNo": 1526
},
{
"name": "shaunmorris",
"id": 579820,
"comment_id": 3858265174,
"created_at": "2026-02-06T06:23:24Z",
"repoId": 1108837393,
"pullRequestNo": 1541
} }
] ]
} }

View File

@@ -7,7 +7,7 @@
| Field | Value | | Field | Value |
|-------|-------| |-------|-------|
| Model | `anthropic/claude-opus-4-5` | | Model | `anthropic/claude-opus-4-6` |
| Max Tokens | `64000` | | Max Tokens | `64000` |
| Mode | `primary` | | Mode | `primary` |
| Thinking | Budget: 32000 | | Thinking | Budget: 32000 |

View File

@@ -13,36 +13,50 @@
## STRUCTURE ## STRUCTURE
``` ```
agents/ agents/
├── atlas.ts # Master Orchestrator (holds todo list) ├── atlas/ # Master Orchestrator (holds todo list)
├── sisyphus.ts # Main prompt (SF Bay Area engineer identity) │ ├── index.ts
├── hephaestus.ts # Autonomous Deep Worker (GPT 5.2 Codex, "The Legitimate Craftsman") │ ├── default.ts # Claude-optimized prompt (390 lines)
├── sisyphus-junior.ts # Delegated task executor (category-spawned) │ ├── gpt.ts # GPT-optimized prompt (330 lines)
│ └── utils.ts
├── prometheus/ # Planning Agent (Interview/Consultant mode)
│ ├── index.ts
│ ├── plan-template.ts # Work plan structure (423 lines)
│ ├── interview-mode.ts # Interview flow (335 lines)
│ ├── plan-generation.ts
│ ├── high-accuracy-mode.ts
│ ├── identity-constraints.ts # Identity rules (301 lines)
│ └── behavioral-summary.ts
├── sisyphus-junior/ # Delegated task executor (category-spawned)
│ ├── index.ts
│ ├── default.ts
│ └── gpt.ts
├── sisyphus.ts # Main orchestrator prompt (530 lines)
├── hephaestus.ts # Autonomous deep worker (618 lines, GPT 5.3 Codex)
├── oracle.ts # Strategic advisor (GPT-5.2) ├── oracle.ts # Strategic advisor (GPT-5.2)
├── librarian.ts # Multi-repo research (GitHub CLI, Context7) ├── librarian.ts # Multi-repo research (328 lines)
├── explore.ts # Fast contextual grep (Grok Code Fast) ├── explore.ts # Fast contextual grep
├── multimodal-looker.ts # Media analyzer (Gemini 3 Flash) ├── multimodal-looker.ts # Media analyzer (Gemini 3 Flash)
├── prometheus-prompt.ts # Planning (Interview/Consultant mode, 1283 lines) ├── metis.ts # Pre-planning analysis (347 lines)
├── metis.ts # Pre-planning analysis (Gap detection) ├── momus.ts # Plan reviewer
├── momus.ts # Plan reviewer (Ruthless fault-finding) ├── dynamic-agent-prompt-builder.ts # Dynamic prompt generation (431 lines)
├── dynamic-agent-prompt-builder.ts # Dynamic prompt generation
├── types.ts # AgentModelConfig, AgentPromptMetadata ├── types.ts # AgentModelConfig, AgentPromptMetadata
├── utils.ts # createBuiltinAgents(), resolveModelWithFallback() ├── utils.ts # createBuiltinAgents(), resolveModelWithFallback() (485 lines)
└── index.ts # builtinAgents export └── index.ts # builtinAgents export
``` ```
## AGENT MODELS ## AGENT MODELS
| Agent | Model | Temp | Purpose | | Agent | Model | Temp | Purpose |
|-------|-------|------|---------| |-------|-------|------|---------|
| Sisyphus | anthropic/claude-opus-4-5 | 0.1 | Primary orchestrator (fallback: kimi-k2.5 → glm-4.7 → gpt-5.2-codex → gemini-3-pro) | | Sisyphus | anthropic/claude-opus-4-6 | 0.1 | Primary orchestrator (fallback: kimi-k2.5 → glm-4.7 → gpt-5.3-codex → gemini-3-pro) |
| Hephaestus | openai/gpt-5.2-codex | 0.1 | Autonomous deep worker, "The Legitimate Craftsman" (requires gpt-5.2-codex, no fallback) | | Hephaestus | openai/gpt-5.3-codex | 0.1 | Autonomous deep worker, "The Legitimate Craftsman" (requires gpt-5.3-codex, no fallback) |
| Atlas | anthropic/claude-sonnet-4-5 | 0.1 | Master orchestrator (fallback: kimi-k2.5 → gpt-5.2) | | 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 | | oracle | openai/gpt-5.2 | 0.1 | Consultation, debugging |
| librarian | zai-coding-plan/glm-4.7 | 0.1 | Docs, GitHub search (fallback: glm-4.7-free) | | librarian | zai-coding-plan/glm-4.7 | 0.1 | Docs, GitHub search (fallback: glm-4.7-free) |
| explore | xai/grok-code-fast-1 | 0.1 | Fast contextual grep (fallback: claude-haiku-4-5 → gpt-5-mini → gpt-5-nano) | | explore | xai/grok-code-fast-1 | 0.1 | Fast contextual grep (fallback: claude-haiku-4-5 → gpt-5-mini → gpt-5-nano) |
| multimodal-looker | google/gemini-3-flash | 0.1 | PDF/image analysis | | multimodal-looker | google/gemini-3-flash | 0.1 | PDF/image analysis |
| Prometheus | anthropic/claude-opus-4-5 | 0.1 | Strategic planning (fallback: kimi-k2.5 → gpt-5.2) | | Prometheus | anthropic/claude-opus-4-6 | 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) | | Metis | anthropic/claude-opus-4-6 | 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) | | Momus | openai/gpt-5.2 | 0.1 | Plan validation (fallback: claude-opus-4-6) |
| Sisyphus-Junior | anthropic/claude-sonnet-4-5 | 0.1 | Category-spawned executor | | Sisyphus-Junior | anthropic/claude-sonnet-4-5 | 0.1 | Category-spawned executor |
## HOW TO ADD ## HOW TO ADD
@@ -59,15 +73,17 @@ agents/
| explore | write, edit, task, delegate_task, call_omo_agent | | explore | write, edit, task, delegate_task, call_omo_agent |
| multimodal-looker | Allowlist: read only | | multimodal-looker | Allowlist: read only |
| Sisyphus-Junior | task, delegate_task | | Sisyphus-Junior | task, delegate_task |
| Atlas | task, call_omo_agent |
## PATTERNS ## PATTERNS
- **Factory**: `createXXXAgent(model: string): AgentConfig` - **Factory**: `createXXXAgent(model: string): AgentConfig`
- **Metadata**: `XXX_PROMPT_METADATA` with category, cost, triggers. - **Metadata**: `XXX_PROMPT_METADATA` with category, cost, triggers
- **Tool restrictions**: `createAgentToolRestrictions(tools)` or `createAgentToolAllowlist(tools)`. - **Tool restrictions**: `createAgentToolRestrictions(tools)` or `createAgentToolAllowlist(tools)`
- **Thinking**: 32k budget tokens for Sisyphus, Oracle, Prometheus, Atlas. - **Thinking**: 32k budget tokens for Sisyphus, Oracle, Prometheus, Atlas
- **Model-specific routing**: Atlas, Sisyphus-Junior have GPT vs Claude prompt variants
## ANTI-PATTERNS ## ANTI-PATTERNS
- **Trust reports**: NEVER trust "I'm done" - verify outputs. - **Trust reports**: NEVER trust "I'm done" - verify outputs
- **High temp**: Don't use >0.3 for code agents. - **High temp**: Don't use >0.3 for code agents
- **Sequential calls**: Use `delegate_task` with `run_in_background` for exploration. - **Sequential calls**: Use `delegate_task` with `run_in_background` for exploration
- **Prometheus writing code**: Planner only - never implements. - **Prometheus writing code**: Planner only - never implements

View File

@@ -6,7 +6,7 @@
*/ */
import type { CategoryConfig } from "../../config/schema" import type { CategoryConfig } from "../../config/schema"
import type { AvailableAgent, AvailableSkill } from "../dynamic-agent-prompt-builder" import { formatCustomSkillsBlock, type AvailableAgent, type AvailableSkill } from "../dynamic-agent-prompt-builder"
import { DEFAULT_CATEGORIES, CATEGORY_DESCRIPTIONS } from "../../tools/delegate-task/constants" import { DEFAULT_CATEGORIES, CATEGORY_DESCRIPTIONS } from "../../tools/delegate-task/constants"
export const getCategoryDescription = (name: string, userCategories?: Record<string, CategoryConfig>) => export const getCategoryDescription = (name: string, userCategories?: Record<string, CategoryConfig>) =>
@@ -56,21 +56,48 @@ export function buildSkillsSection(skills: AvailableSkill[]): string {
return "" return ""
} }
const skillRows = skills.map((s) => { const builtinSkills = skills.filter((s) => s.location === "plugin")
const customSkills = skills.filter((s) => s.location !== "plugin")
const builtinRows = builtinSkills.map((s) => {
const shortDesc = s.description.split(".")[0] || s.description const shortDesc = s.description.split(".")[0] || s.description
return `| \`${s.name}\` | ${shortDesc} |` return `| \`${s.name}\` | ${shortDesc} |`
}) })
const customRows = customSkills.map((s) => {
const shortDesc = s.description.split(".")[0] || s.description
const source = s.location === "project" ? "project" : "user"
return `| \`${s.name}\` | ${shortDesc} | ${source} |`
})
const customSkillBlock = formatCustomSkillsBlock(customRows, customSkills, "**")
let skillsTable: string
if (customSkills.length > 0 && builtinSkills.length > 0) {
skillsTable = `**Built-in Skills:**
| Skill | When to Use |
|-------|-------------|
${builtinRows.join("\n")}
${customSkillBlock}`
} else if (customSkills.length > 0) {
skillsTable = customSkillBlock
} else {
skillsTable = `| Skill | When to Use |
|-------|-------------|
${builtinRows.join("\n")}`
}
return ` return `
#### 3.2.2: Skill Selection (PREPEND TO PROMPT) #### 3.2.2: Skill Selection (PREPEND TO PROMPT)
**Skills are specialized instructions that guide subagent behavior. Consider them alongside category selection.** **Skills are specialized instructions that guide subagent behavior. Consider them alongside category selection.**
| Skill | When to Use | ${skillsTable}
|-------|-------------|
${skillRows.join("\n")}
**MANDATORY: Evaluate ALL skills for relevance to your task.** **MANDATORY: Evaluate ALL skills (built-in AND user-installed) for relevance to your task.**
Read each skill's description and ask: "Does this skill's domain overlap with my task?" Read each skill's description and ask: "Does this skill's domain overlap with my task?"
- If YES: INCLUDE in load_skills=[...] - If YES: INCLUDE in load_skills=[...]

View File

@@ -0,0 +1,205 @@
/// <reference types="bun-types" />
import { describe, it, expect } from "bun:test"
import {
buildCategorySkillsDelegationGuide,
buildUltraworkSection,
formatCustomSkillsBlock,
type AvailableSkill,
type AvailableCategory,
type AvailableAgent,
} from "./dynamic-agent-prompt-builder"
describe("buildCategorySkillsDelegationGuide", () => {
const categories: AvailableCategory[] = [
{ name: "visual-engineering", description: "Frontend, UI/UX" },
{ name: "quick", description: "Trivial tasks" },
]
const builtinSkills: AvailableSkill[] = [
{ name: "playwright", description: "Browser automation via Playwright", location: "plugin" },
{ name: "frontend-ui-ux", description: "Designer-turned-developer", location: "plugin" },
]
const customUserSkills: AvailableSkill[] = [
{ name: "react-19", description: "React 19 patterns and best practices", location: "user" },
{ name: "tailwind-4", description: "Tailwind CSS v4 utilities", location: "user" },
]
const customProjectSkills: AvailableSkill[] = [
{ name: "our-design-system", description: "Internal design system components", location: "project" },
]
it("should separate builtin and custom skills into distinct sections", () => {
//#given: mix of builtin and custom skills
const allSkills = [...builtinSkills, ...customUserSkills]
//#when: building the delegation guide
const result = buildCategorySkillsDelegationGuide(categories, allSkills)
//#then: should have separate sections
expect(result).toContain("Built-in Skills")
expect(result).toContain("User-Installed Skills")
expect(result).toContain("HIGH PRIORITY")
})
it("should include custom skill names in CRITICAL warning", () => {
//#given: custom skills installed
const allSkills = [...builtinSkills, ...customUserSkills]
//#when: building the delegation guide
const result = buildCategorySkillsDelegationGuide(categories, allSkills)
//#then: should mention custom skills by name in the warning
expect(result).toContain('"react-19"')
expect(result).toContain('"tailwind-4"')
expect(result).toContain("CRITICAL")
})
it("should show source column for custom skills (user vs project)", () => {
//#given: both user and project custom skills
const allSkills = [...builtinSkills, ...customUserSkills, ...customProjectSkills]
//#when: building the delegation guide
const result = buildCategorySkillsDelegationGuide(categories, allSkills)
//#then: should show source for each custom skill
expect(result).toContain("| user |")
expect(result).toContain("| project |")
})
it("should not show custom skill section when only builtin skills exist", () => {
//#given: only builtin skills
const allSkills = [...builtinSkills]
//#when: building the delegation guide
const result = buildCategorySkillsDelegationGuide(categories, allSkills)
//#then: should not contain custom skill emphasis
expect(result).not.toContain("User-Installed Skills")
expect(result).not.toContain("HIGH PRIORITY")
expect(result).toContain("Available Skills")
})
it("should handle only custom skills (no builtins)", () => {
//#given: only custom skills, no builtins
const allSkills = [...customUserSkills]
//#when: building the delegation guide
const result = buildCategorySkillsDelegationGuide(categories, allSkills)
//#then: should show custom skills with emphasis, no builtin section
expect(result).toContain("User-Installed Skills")
expect(result).toContain("HIGH PRIORITY")
expect(result).not.toContain("Built-in Skills")
})
it("should include priority note for custom skills in evaluation step", () => {
//#given: custom skills present
const allSkills = [...builtinSkills, ...customUserSkills]
//#when: building the delegation guide
const result = buildCategorySkillsDelegationGuide(categories, allSkills)
//#then: evaluation section should mention user-installed priority
expect(result).toContain("User-installed skills get PRIORITY")
expect(result).toContain("INCLUDE it rather than omit it")
})
it("should NOT include priority note when no custom skills", () => {
//#given: only builtin skills
const allSkills = [...builtinSkills]
//#when: building the delegation guide
const result = buildCategorySkillsDelegationGuide(categories, allSkills)
//#then: no priority note for custom skills
expect(result).not.toContain("User-installed skills get PRIORITY")
})
it("should return empty string when no categories and no skills", () => {
//#given: no categories and no skills
//#when: building the delegation guide
const result = buildCategorySkillsDelegationGuide([], [])
//#then: should return empty string
expect(result).toBe("")
})
})
describe("buildUltraworkSection", () => {
const agents: AvailableAgent[] = []
it("should separate builtin and custom skills", () => {
//#given: mix of builtin and custom skills
const skills: AvailableSkill[] = [
{ name: "playwright", description: "Browser automation", location: "plugin" },
{ name: "react-19", description: "React 19 patterns", location: "user" },
]
//#when: building ultrawork section
const result = buildUltraworkSection(agents, [], skills)
//#then: should have separate sections
expect(result).toContain("Built-in Skills")
expect(result).toContain("User-Installed Skills")
expect(result).toContain("HIGH PRIORITY")
})
it("should not separate when only builtin skills", () => {
//#given: only builtin skills
const skills: AvailableSkill[] = [
{ name: "playwright", description: "Browser automation", location: "plugin" },
]
//#when: building ultrawork section
const result = buildUltraworkSection(agents, [], skills)
//#then: should have single section
expect(result).toContain("Built-in Skills")
expect(result).not.toContain("User-Installed Skills")
})
})
describe("formatCustomSkillsBlock", () => {
const customSkills: AvailableSkill[] = [
{ name: "react-19", description: "React 19 patterns", location: "user" },
{ name: "tailwind-4", description: "Tailwind v4", location: "project" },
]
const customRows = customSkills.map((s) => {
const source = s.location === "project" ? "project" : "user"
return `| \`${s.name}\` | ${s.description} | ${source} |`
})
it("should produce consistent output used by both builders", () => {
//#given: custom skills and rows
//#when: formatting with default header level
const result = formatCustomSkillsBlock(customRows, customSkills)
//#then: contains all expected elements
expect(result).toContain("User-Installed Skills (HIGH PRIORITY)")
expect(result).toContain("CRITICAL")
expect(result).toContain('"react-19"')
expect(result).toContain('"tailwind-4"')
expect(result).toContain("| user |")
expect(result).toContain("| project |")
})
it("should use #### header by default", () => {
//#given: default header level
const result = formatCustomSkillsBlock(customRows, customSkills)
//#then: uses markdown h4
expect(result).toContain("#### User-Installed Skills")
})
it("should use bold header when specified", () => {
//#given: bold header level (used by Atlas)
const result = formatCustomSkillsBlock(customRows, customSkills, "**")
//#then: uses bold instead of h4
expect(result).toContain("**User-Installed Skills (HIGH PRIORITY):**")
expect(result).not.toContain("#### User-Installed Skills")
})
})

View File

@@ -20,6 +20,7 @@ export interface AvailableSkill {
export interface AvailableCategory { export interface AvailableCategory {
name: string name: string
description: string description: string
model?: string
} }
export function categorizeTools(toolNames: string[]): AvailableTool[] { export function categorizeTools(toolNames: string[]): AvailableTool[] {
@@ -166,6 +167,33 @@ export function buildDelegationTable(agents: AvailableAgent[]): string {
return rows.join("\n") return rows.join("\n")
} }
/**
* Renders the "User-Installed Skills (HIGH PRIORITY)" block used across multiple agent prompts.
* Extracted to avoid duplication between buildCategorySkillsDelegationGuide, buildSkillsSection, etc.
*/
export function formatCustomSkillsBlock(
customRows: string[],
customSkills: AvailableSkill[],
headerLevel: "####" | "**" = "####"
): string {
const customSkillNames = customSkills.map((s) => `"${s.name}"`).join(", ")
const header = headerLevel === "####"
? `#### User-Installed Skills (HIGH PRIORITY)`
: `**User-Installed Skills (HIGH PRIORITY):**`
return `${header}
**The user has installed these custom skills. They MUST be evaluated for EVERY delegation.**
Subagents are STATELESS — they lose all custom knowledge unless you pass these skills via \`load_skills\`.
| Skill | Expertise Domain | Source |
|-------|------------------|--------|
${customRows.join("\n")}
> **CRITICAL**: Ignoring user-installed skills when they match the task domain is a failure.
> The user installed ${customSkillNames} for a reason — USE THEM when the task overlaps with their domain.`
}
export function buildCategorySkillsDelegationGuide(categories: AvailableCategory[], skills: AvailableSkill[]): string { export function buildCategorySkillsDelegationGuide(categories: AvailableCategory[], skills: AvailableSkill[]): string {
if (categories.length === 0 && skills.length === 0) return "" if (categories.length === 0 && skills.length === 0) return ""
@@ -174,11 +202,44 @@ export function buildCategorySkillsDelegationGuide(categories: AvailableCategory
return `| \`${c.name}\` | ${desc} |` return `| \`${c.name}\` | ${desc} |`
}) })
const skillRows = skills.map((s) => { const builtinSkills = skills.filter((s) => s.location === "plugin")
const customSkills = skills.filter((s) => s.location !== "plugin")
const builtinRows = builtinSkills.map((s) => {
const desc = s.description.split(".")[0] || s.description const desc = s.description.split(".")[0] || s.description
return `| \`${s.name}\` | ${desc} |` return `| \`${s.name}\` | ${desc} |`
}) })
const customRows = customSkills.map((s) => {
const desc = s.description.split(".")[0] || s.description
const source = s.location === "project" ? "project" : "user"
return `| \`${s.name}\` | ${desc} | ${source} |`
})
const customSkillBlock = formatCustomSkillsBlock(customRows, customSkills)
let skillsSection: string
if (customSkills.length > 0 && builtinSkills.length > 0) {
skillsSection = `#### Built-in Skills
| Skill | Expertise Domain |
|-------|------------------|
${builtinRows.join("\n")}
${customSkillBlock}`
} else if (customSkills.length > 0) {
skillsSection = customSkillBlock
} else {
skillsSection = `#### Available Skills (Domain Expertise Injection)
Skills inject specialized instructions into the subagent. Read the description to understand when each skill applies.
| Skill | Expertise Domain |
|-------|------------------|
${builtinRows.join("\n")}`
}
return `### Category + Skills Delegation System return `### Category + Skills Delegation System
**delegate_task() combines categories and skills for optimal task execution.** **delegate_task() combines categories and skills for optimal task execution.**
@@ -191,13 +252,7 @@ Each category is configured with a model optimized for that domain. Read the des
|----------|-------------------| |----------|-------------------|
${categoryRows.join("\n")} ${categoryRows.join("\n")}
#### Available Skills (Domain Expertise Injection) ${skillsSection}
Skills inject specialized instructions into the subagent. Read the description to understand when each skill applies.
| Skill | Expertise Domain |
|-------|------------------|
${skillRows.join("\n")}
--- ---
@@ -208,12 +263,15 @@ ${skillRows.join("\n")}
- Match task requirements to category domain - Match task requirements to category domain
- Select the category whose domain BEST fits the task - Select the category whose domain BEST fits the task
**STEP 2: Evaluate ALL Skills** **STEP 2: Evaluate ALL Skills (Built-in AND User-Installed)**
For EVERY skill listed above, ask yourself: For EVERY skill listed above, ask yourself:
> "Does this skill's expertise domain overlap with my task?" > "Does this skill's expertise domain overlap with my task?"
- If YES → INCLUDE in \`load_skills=[...]\` - If YES → INCLUDE in \`load_skills=[...]\`
- If NO → You MUST justify why (see below) - If NO → You MUST justify why (see below)
${customSkills.length > 0 ? `
> **User-installed skills get PRIORITY.** The user explicitly installed them for their workflow.
> When in doubt about a user-installed skill, INCLUDE it rather than omit it.` : ""}
**STEP 3: Justify Omissions** **STEP 3: Justify Omissions**
@@ -240,7 +298,7 @@ SKILL EVALUATION for "[skill-name]":
\`\`\`typescript \`\`\`typescript
delegate_task( delegate_task(
category="[selected-category]", category="[selected-category]",
load_skills=["skill-1", "skill-2"], // Include ALL relevant skills load_skills=["skill-1", "skill-2"], // Include ALL relevant skills — ESPECIALLY user-installed ones
prompt="..." prompt="..."
) )
\`\`\` \`\`\`
@@ -328,12 +386,26 @@ export function buildUltraworkSection(
} }
if (skills.length > 0) { if (skills.length > 0) {
lines.push("**Skills** (combine with categories - EVALUATE ALL for relevance):") const builtinSkills = skills.filter((s) => s.location === "plugin")
for (const skill of skills) { const customSkills = skills.filter((s) => s.location !== "plugin")
const shortDesc = skill.description.split(".")[0] || skill.description
lines.push(`- \`${skill.name}\`: ${shortDesc}`) if (builtinSkills.length > 0) {
lines.push("**Built-in Skills** (combine with categories):")
for (const skill of builtinSkills) {
const shortDesc = skill.description.split(".")[0] || skill.description
lines.push(`- \`${skill.name}\`: ${shortDesc}`)
}
lines.push("")
}
if (customSkills.length > 0) {
lines.push("**User-Installed Skills** (HIGH PRIORITY - user installed these for their workflow):")
for (const skill of customSkills) {
const shortDesc = skill.description.split(".")[0] || skill.description
lines.push(`- \`${skill.name}\`: ${shortDesc}`)
}
lines.push("")
} }
lines.push("")
} }
if (agents.length > 0) { if (agents.length > 0) {

View File

@@ -142,6 +142,19 @@ You operate as a **Senior Staff Engineer** with deep expertise in:
You do not guess. You verify. You do not stop early. You complete. You do not guess. You verify. You do not stop early. You complete.
## Core Principle (HIGHEST PRIORITY)
**KEEP GOING. SOLVE PROBLEMS. ASK ONLY WHEN TRULY IMPOSSIBLE.**
When blocked:
1. Try a different approach (there's always another way)
2. Decompose the problem into smaller pieces
3. Challenge your assumptions
4. Explore how others solved similar problems
Asking the user is the LAST resort after exhausting creative alternatives.
Your job is to SOLVE problems, not report them.
## Hard Constraints (MUST READ FIRST - GPT 5.2 Constraint-First) ## Hard Constraints (MUST READ FIRST - GPT 5.2 Constraint-First)
${hardBlocks} ${hardBlocks}
@@ -404,6 +417,13 @@ Only terminate your turn when you are SURE the problem is SOLVED.
Autonomously resolve the query to the BEST of your ability. Autonomously resolve the query to the BEST of your ability.
Do NOT guess. Do NOT ask unnecessary questions. Do NOT stop early. Do NOT guess. Do NOT ask unnecessary questions. Do NOT stop early.
**When you hit a wall:**
- Do NOT immediately ask for help
- Try at least 3 DIFFERENT approaches
- Each approach should be meaningfully different (not just tweaking parameters)
- Document what you tried in your final message
- Only ask after genuine creative exhaustion
**Completion Checklist (ALL must be true):** **Completion Checklist (ALL must be true):**
1. User asked for X → X is FULLY implemented (not partial, not "basic version") 1. User asked for X → X is FULLY implemented (not partial, not "basic version")
2. X passes lsp_diagnostics (zero errors on ALL modified files) 2. X passes lsp_diagnostics (zero errors on ALL modified files)
@@ -459,9 +479,9 @@ Do NOT guess. Do NOT ask unnecessary questions. Do NOT stop early.
- Each update must include concrete outcome ("Found X", "Updated Y") - Each update must include concrete outcome ("Found X", "Updated Y")
**Scope:** **Scope:**
- Implement EXACTLY what user requests - Implement what user requests
- No extra features, no embellishments - When blocked, autonomously try alternative approaches before asking
- Simplest valid interpretation for ambiguous instructions - No unnecessary features, but solve blockers creatively
</output_contract> </output_contract>
## Response Compaction (LONG CONTEXT HANDLING) ## Response Compaction (LONG CONTEXT HANDLING)
@@ -545,21 +565,27 @@ When working on long sessions or complex multi-file tasks:
2. Re-verify after EVERY fix attempt 2. Re-verify after EVERY fix attempt
3. Never shotgun debug 3. Never shotgun debug
### After 3 Consecutive Failures ### After Failure (AUTONOMOUS RECOVERY)
1. **Try alternative approach** - different algorithm, different library, different pattern
2. **Decompose** - break into smaller, independently solvable steps
3. **Challenge assumptions** - what if your initial interpretation was wrong?
4. **Explore more** - fire explore/librarian agents for similar problems solved elsewhere
### After 3 DIFFERENT Approaches Fail
1. **STOP** all edits 1. **STOP** all edits
2. **REVERT** to last working state 2. **REVERT** to last working state
3. **DOCUMENT** what failed 3. **DOCUMENT** what you tried (all 3 approaches)
4. **CONSULT** Oracle with full context 4. **CONSULT** Oracle with full context
5. If unresolved, **ASK USER** 5. If Oracle cannot help, **ASK USER** with clear explanation of attempts
**Never**: Leave code broken, delete failing tests, continue hoping **Never**: Leave code broken, delete failing tests, continue hoping
## Soft Guidelines ## Soft Guidelines
- Prefer existing libraries over new dependencies - Prefer existing libraries over new dependencies
- Prefer small, focused changes over large refactors - Prefer small, focused changes over large refactors`
- When uncertain about scope, ask`
} }
export function createHephaestusAgent( export function createHephaestusAgent(
@@ -584,7 +610,7 @@ export function createHephaestusAgent(
model, model,
maxTokens: 32000, maxTokens: 32000,
prompt, prompt,
color: "#FF4500", // Magma Orange - forge heat, distinct from Prometheus purple color: "#D97706", // Forged Amber - Golden heated metal, divine craftsman
permission: { question: "allow", call_omo_agent: "deny" } as AgentConfig["permission"], permission: { question: "allow", call_omo_agent: "deny" } as AgentConfig["permission"],
reasoningEffort: "medium", reasoningEffort: "medium",
} }

View File

@@ -6,14 +6,14 @@ import * as connectedProvidersCache from "../shared/connected-providers-cache"
import * as modelAvailability from "../shared/model-availability" import * as modelAvailability from "../shared/model-availability"
import * as shared from "../shared" import * as shared from "../shared"
const TEST_DEFAULT_MODEL = "anthropic/claude-opus-4-5" const TEST_DEFAULT_MODEL = "anthropic/claude-opus-4-6"
describe("createBuiltinAgents with model overrides", () => { describe("createBuiltinAgents with model overrides", () => {
test("Sisyphus with default model has thinking config when all models available", async () => { test("Sisyphus with default model has thinking config when all models available", async () => {
// #given // #given
const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue( const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue(
new Set([ new Set([
"anthropic/claude-opus-4-5", "anthropic/claude-opus-4-6",
"kimi-for-coding/k2p5", "kimi-for-coding/k2p5",
"opencode/kimi-k2.5-free", "opencode/kimi-k2.5-free",
"zai-coding-plan/glm-4.7", "zai-coding-plan/glm-4.7",
@@ -26,7 +26,7 @@ describe("createBuiltinAgents with model overrides", () => {
const agents = await createBuiltinAgents([], {}, undefined, TEST_DEFAULT_MODEL, undefined, undefined, [], {}) const agents = await createBuiltinAgents([], {}, undefined, TEST_DEFAULT_MODEL, undefined, undefined, [], {})
// #then // #then
expect(agents.sisyphus.model).toBe("anthropic/claude-opus-4-5") expect(agents.sisyphus.model).toBe("anthropic/claude-opus-4-6")
expect(agents.sisyphus.thinking).toEqual({ type: "enabled", budgetTokens: 32000 }) expect(agents.sisyphus.thinking).toEqual({ type: "enabled", budgetTokens: 32000 })
expect(agents.sisyphus.reasoningEffort).toBeUndefined() expect(agents.sisyphus.reasoningEffort).toBeUndefined()
} finally { } finally {
@@ -41,7 +41,7 @@ describe("createBuiltinAgents with model overrides", () => {
} }
// #when // #when
const agents = await createBuiltinAgents([], overrides, undefined, TEST_DEFAULT_MODEL) const agents = await createBuiltinAgents([], overrides, undefined, TEST_DEFAULT_MODEL, undefined, undefined, [], undefined, undefined)
// #then // #then
expect(agents.sisyphus.model).toBe("github-copilot/gpt-5.2") expect(agents.sisyphus.model).toBe("github-copilot/gpt-5.2")
@@ -81,7 +81,7 @@ describe("createBuiltinAgents with model overrides", () => {
test("Sisyphus is created on first run when no availableModels or cache exist", async () => { test("Sisyphus is created on first run when no availableModels or cache exist", async () => {
// #given // #given
const systemDefaultModel = "anthropic/claude-opus-4-5" const systemDefaultModel = "anthropic/claude-opus-4-6"
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(null) const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(null)
const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue(new Set()) const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue(new Set())
@@ -91,7 +91,7 @@ describe("createBuiltinAgents with model overrides", () => {
// #then // #then
expect(agents.sisyphus).toBeDefined() expect(agents.sisyphus).toBeDefined()
expect(agents.sisyphus.model).toBe("anthropic/claude-opus-4-5") expect(agents.sisyphus.model).toBe("anthropic/claude-opus-4-6")
} finally { } finally {
cacheSpy.mockRestore() cacheSpy.mockRestore()
fetchSpy.mockRestore() fetchSpy.mockRestore()
@@ -103,7 +103,7 @@ describe("createBuiltinAgents with model overrides", () => {
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai"]) const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["openai"])
// #when // #when
const agents = await createBuiltinAgents([], {}, undefined, TEST_DEFAULT_MODEL) const agents = await createBuiltinAgents([], {}, undefined, TEST_DEFAULT_MODEL, undefined, undefined, [], undefined, undefined)
// #then - oracle resolves via connected cache fallback to openai/gpt-5.2 (not system default) // #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.model).toBe("openai/gpt-5.2")
@@ -132,7 +132,7 @@ describe("createBuiltinAgents with model overrides", () => {
} }
// #when // #when
const agents = await createBuiltinAgents([], overrides, undefined, TEST_DEFAULT_MODEL) const agents = await createBuiltinAgents([], overrides, undefined, TEST_DEFAULT_MODEL, undefined, undefined, [], undefined, undefined)
// #then // #then
expect(agents.oracle.model).toBe("openai/gpt-5.2") expect(agents.oracle.model).toBe("openai/gpt-5.2")
@@ -148,7 +148,7 @@ describe("createBuiltinAgents with model overrides", () => {
} }
// #when // #when
const agents = await createBuiltinAgents([], overrides, undefined, TEST_DEFAULT_MODEL) const agents = await createBuiltinAgents([], overrides, undefined, TEST_DEFAULT_MODEL, undefined, undefined, [], undefined, undefined)
// #then // #then
expect(agents.oracle.model).toBe("anthropic/claude-sonnet-4") expect(agents.oracle.model).toBe("anthropic/claude-sonnet-4")
@@ -164,12 +164,25 @@ describe("createBuiltinAgents with model overrides", () => {
} }
// #when // #when
const agents = await createBuiltinAgents([], overrides, undefined, TEST_DEFAULT_MODEL) const agents = await createBuiltinAgents([], overrides, undefined, TEST_DEFAULT_MODEL, undefined, undefined, [], undefined, undefined)
// #then // #then
expect(agents.sisyphus.model).toBe("github-copilot/gpt-5.2") expect(agents.sisyphus.model).toBe("github-copilot/gpt-5.2")
expect(agents.sisyphus.temperature).toBe(0.5) expect(agents.sisyphus.temperature).toBe(0.5)
}) })
test("createBuiltinAgents excludes disabled skills from availableSkills", async () => {
// #given
const disabledSkills = new Set(["playwright"])
// #when
const agents = await createBuiltinAgents([], {}, undefined, TEST_DEFAULT_MODEL, undefined, undefined, [], undefined, undefined, undefined, disabledSkills)
// #then
expect(agents.sisyphus.prompt).not.toContain("playwright")
expect(agents.sisyphus.prompt).toContain("frontend-ui-ux")
expect(agents.sisyphus.prompt).toContain("git-master")
})
}) })
describe("createBuiltinAgents without systemDefaultModel", () => { describe("createBuiltinAgents without systemDefaultModel", () => {
@@ -205,7 +218,7 @@ describe("createBuiltinAgents without systemDefaultModel", () => {
]) ])
const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue( const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue(
new Set([ new Set([
"anthropic/claude-opus-4-5", "anthropic/claude-opus-4-6",
"kimi-for-coding/k2p5", "kimi-for-coding/k2p5",
"opencode/kimi-k2.5-free", "opencode/kimi-k2.5-free",
"zai-coding-plan/glm-4.7", "zai-coding-plan/glm-4.7",
@@ -219,7 +232,7 @@ describe("createBuiltinAgents without systemDefaultModel", () => {
// #then // #then
expect(agents.sisyphus).toBeDefined() expect(agents.sisyphus).toBeDefined()
expect(agents.sisyphus.model).toBe("anthropic/claude-opus-4-5") expect(agents.sisyphus.model).toBe("anthropic/claude-opus-4-6")
} finally { } finally {
cacheSpy.mockRestore() cacheSpy.mockRestore()
fetchSpy.mockRestore() fetchSpy.mockRestore()
@@ -227,12 +240,13 @@ describe("createBuiltinAgents without systemDefaultModel", () => {
}) })
}) })
describe("createBuiltinAgents with requiresModel gating", () => { describe("createBuiltinAgents with requiresProvider gating (hephaestus)", () => {
test("hephaestus is not created when gpt-5.2-codex is unavailable", async () => { test("hephaestus is not created when no required provider is connected", async () => {
// #given // #given - only anthropic models available, not in hephaestus requiresProvider
const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue( const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue(
new Set(["anthropic/claude-opus-4-5"]) new Set(["anthropic/claude-opus-4-6"])
) )
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue(["anthropic"])
try { try {
// #when // #when
@@ -242,13 +256,48 @@ describe("createBuiltinAgents with requiresModel gating", () => {
expect(agents.hephaestus).toBeUndefined() expect(agents.hephaestus).toBeUndefined()
} finally { } finally {
fetchSpy.mockRestore() fetchSpy.mockRestore()
cacheSpy.mockRestore()
} }
}) })
test("hephaestus is created when gpt-5.2-codex is available", async () => { test("hephaestus is created when openai provider is connected", async () => {
// #given // #given - openai provider has models available
const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue( const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue(
new Set(["openai/gpt-5.2-codex"]) new Set(["openai/gpt-5.3-codex"])
)
try {
// #when
const agents = await createBuiltinAgents([], {}, undefined, TEST_DEFAULT_MODEL, undefined, undefined, [], {})
// #then
expect(agents.hephaestus).toBeDefined()
} finally {
fetchSpy.mockRestore()
}
})
test("hephaestus is created when github-copilot provider is connected", async () => {
// #given - github-copilot provider has models available
const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue(
new Set(["github-copilot/gpt-5.3-codex"])
)
try {
// #when
const agents = await createBuiltinAgents([], {}, undefined, TEST_DEFAULT_MODEL, undefined, undefined, [], {})
// #then
expect(agents.hephaestus).toBeDefined()
} finally {
fetchSpy.mockRestore()
}
})
test("hephaestus is created when opencode provider is connected", async () => {
// #given - opencode provider has models available
const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue(
new Set(["opencode/gpt-5.3-codex"])
) )
try { try {
@@ -273,20 +322,20 @@ describe("createBuiltinAgents with requiresModel gating", () => {
// #then // #then
expect(agents.hephaestus).toBeDefined() expect(agents.hephaestus).toBeDefined()
expect(agents.hephaestus.model).toBe("openai/gpt-5.2-codex") expect(agents.hephaestus.model).toBe("openai/gpt-5.3-codex")
} finally { } finally {
cacheSpy.mockRestore() cacheSpy.mockRestore()
fetchSpy.mockRestore() fetchSpy.mockRestore()
} }
}) })
test("hephaestus is created when explicit config provided even if model unavailable", async () => { test("hephaestus is created when explicit config provided even if provider unavailable", async () => {
// #given // #given
const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue( const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue(
new Set(["anthropic/claude-opus-4-5"]) new Set(["anthropic/claude-opus-4-6"])
) )
const overrides = { const overrides = {
hephaestus: { model: "anthropic/claude-opus-4-5" }, hephaestus: { model: "anthropic/claude-opus-4-6" },
} }
try { try {
@@ -305,7 +354,7 @@ describe("createBuiltinAgents with requiresAnyModel gating (sisyphus)", () => {
test("sisyphus is created when at least one fallback model is available", async () => { test("sisyphus is created when at least one fallback model is available", async () => {
// #given // #given
const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue( const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue(
new Set(["anthropic/claude-opus-4-5"]) new Set(["anthropic/claude-opus-4-6"])
) )
try { try {
@@ -330,7 +379,7 @@ describe("createBuiltinAgents with requiresAnyModel gating (sisyphus)", () => {
// #then // #then
expect(agents.sisyphus).toBeDefined() expect(agents.sisyphus).toBeDefined()
expect(agents.sisyphus.model).toBe("anthropic/claude-opus-4-5") expect(agents.sisyphus.model).toBe("anthropic/claude-opus-4-6")
} finally { } finally {
cacheSpy.mockRestore() cacheSpy.mockRestore()
fetchSpy.mockRestore() fetchSpy.mockRestore()
@@ -341,7 +390,7 @@ describe("createBuiltinAgents with requiresAnyModel gating (sisyphus)", () => {
// #given // #given
const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue(new Set()) const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue(new Set())
const overrides = { const overrides = {
sisyphus: { model: "anthropic/claude-opus-4-5" }, sisyphus: { model: "anthropic/claude-opus-4-6" },
} }
try { try {
@@ -355,11 +404,12 @@ describe("createBuiltinAgents with requiresAnyModel gating (sisyphus)", () => {
} }
}) })
test("sisyphus is not created when no fallback model is available (unrelated model only)", async () => { test("sisyphus is not created when no fallback model is available and provider not connected", async () => {
// #given - only openai/gpt-5.2 available, not in sisyphus fallback chain // #given - only openai/gpt-5.2 available, not in sisyphus fallback chain
const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue( const fetchSpy = spyOn(shared, "fetchAvailableModels").mockResolvedValue(
new Set(["openai/gpt-5.2"]) new Set(["openai/gpt-5.2"])
) )
const cacheSpy = spyOn(connectedProvidersCache, "readConnectedProvidersCache").mockReturnValue([])
try { try {
// #when // #when
@@ -369,13 +419,14 @@ describe("createBuiltinAgents with requiresAnyModel gating (sisyphus)", () => {
expect(agents.sisyphus).toBeUndefined() expect(agents.sisyphus).toBeUndefined()
} finally { } finally {
fetchSpy.mockRestore() fetchSpy.mockRestore()
cacheSpy.mockRestore()
} }
}) })
}) })
describe("buildAgent with category and skills", () => { describe("buildAgent with category and skills", () => {
const { buildAgent } = require("./utils") const { buildAgent } = require("./utils")
const TEST_MODEL = "anthropic/claude-opus-4-5" const TEST_MODEL = "anthropic/claude-opus-4-6"
beforeEach(() => { beforeEach(() => {
clearSkillCache() clearSkillCache()
@@ -521,7 +572,7 @@ describe("buildAgent with category and skills", () => {
const agent = buildAgent(source["test-agent"], TEST_MODEL) const agent = buildAgent(source["test-agent"], TEST_MODEL)
// #then - category's built-in model and skills are applied // #then - category's built-in model and skills are applied
expect(agent.model).toBe("openai/gpt-5.2-codex") expect(agent.model).toBe("openai/gpt-5.3-codex")
expect(agent.variant).toBe("xhigh") expect(agent.variant).toBe("xhigh")
expect(agent.prompt).toContain("Role: Designer-Turned-Developer") expect(agent.prompt).toContain("Role: Designer-Turned-Developer")
expect(agent.prompt).toContain("Task description") expect(agent.prompt).toContain("Task description")
@@ -634,9 +685,9 @@ describe("override.category expansion in createBuiltinAgents", () => {
// #when // #when
const agents = await createBuiltinAgents([], overrides, undefined, TEST_DEFAULT_MODEL) const agents = await createBuiltinAgents([], overrides, undefined, TEST_DEFAULT_MODEL)
// #then - ultrabrain category: model=openai/gpt-5.2-codex, variant=xhigh // #then - ultrabrain category: model=openai/gpt-5.3-codex, variant=xhigh
expect(agents.oracle).toBeDefined() expect(agents.oracle).toBeDefined()
expect(agents.oracle.model).toBe("openai/gpt-5.2-codex") expect(agents.oracle.model).toBe("openai/gpt-5.3-codex")
expect(agents.oracle.variant).toBe("xhigh") expect(agents.oracle.variant).toBe("xhigh")
}) })
@@ -703,9 +754,9 @@ describe("override.category expansion in createBuiltinAgents", () => {
// #when // #when
const agents = await createBuiltinAgents([], overrides, undefined, TEST_DEFAULT_MODEL) const agents = await createBuiltinAgents([], overrides, undefined, TEST_DEFAULT_MODEL)
// #then - ultrabrain category: model=openai/gpt-5.2-codex, variant=xhigh // #then - ultrabrain category: model=openai/gpt-5.3-codex, variant=xhigh
expect(agents.sisyphus).toBeDefined() expect(agents.sisyphus).toBeDefined()
expect(agents.sisyphus.model).toBe("openai/gpt-5.2-codex") expect(agents.sisyphus.model).toBe("openai/gpt-5.3-codex")
expect(agents.sisyphus.variant).toBe("xhigh") expect(agents.sisyphus.variant).toBe("xhigh")
}) })
@@ -718,9 +769,9 @@ describe("override.category expansion in createBuiltinAgents", () => {
// #when // #when
const agents = await createBuiltinAgents([], overrides, undefined, TEST_DEFAULT_MODEL) const agents = await createBuiltinAgents([], overrides, undefined, TEST_DEFAULT_MODEL)
// #then - ultrabrain category: model=openai/gpt-5.2-codex, variant=xhigh // #then - ultrabrain category: model=openai/gpt-5.3-codex, variant=xhigh
expect(agents.atlas).toBeDefined() expect(agents.atlas).toBeDefined()
expect(agents.atlas.model).toBe("openai/gpt-5.2-codex") expect(agents.atlas.model).toBe("openai/gpt-5.3-codex")
expect(agents.atlas.variant).toBe("xhigh") expect(agents.atlas.variant).toBe("xhigh")
}) })
@@ -740,6 +791,52 @@ describe("override.category expansion in createBuiltinAgents", () => {
}) })
}) })
describe("agent override tools migration", () => {
test("tools: { x: false } is migrated to permission: { x: deny }", async () => {
// #given
const overrides = {
explore: { tools: { "jetbrains_*": false } } as any,
}
// #when
const agents = await createBuiltinAgents([], overrides, undefined, TEST_DEFAULT_MODEL)
// #then
expect(agents.explore).toBeDefined()
const permission = agents.explore.permission as Record<string, string>
expect(permission["jetbrains_*"]).toBe("deny")
})
test("tools: { x: true } is migrated to permission: { x: allow }", async () => {
// #given
const overrides = {
librarian: { tools: { "jetbrains_get_*": true } } as any,
}
// #when
const agents = await createBuiltinAgents([], overrides, undefined, TEST_DEFAULT_MODEL)
// #then
expect(agents.librarian).toBeDefined()
const permission = agents.librarian.permission as Record<string, string>
expect(permission["jetbrains_get_*"]).toBe("allow")
})
test("tools config is removed after migration", async () => {
// #given
const overrides = {
explore: { tools: { "some_tool": false } } as any,
}
// #when
const agents = await createBuiltinAgents([], overrides, undefined, TEST_DEFAULT_MODEL)
// #then
expect(agents.explore).toBeDefined()
expect((agents.explore as any).tools).toBeUndefined()
})
})
describe("Deadlock prevention - fetchAvailableModels must not receive client", () => { describe("Deadlock prevention - fetchAvailableModels must not receive client", () => {
test("createBuiltinAgents should call fetchAvailableModels with undefined client to prevent deadlock", async () => { test("createBuiltinAgents should call fetchAvailableModels with undefined client to prevent deadlock", async () => {
// #given - This test ensures we don't regress on issue #1301 // #given - This test ensures we don't regress on issue #1301

View File

@@ -11,7 +11,7 @@ import { createAtlasAgent, atlasPromptMetadata } from "./atlas"
import { createMomusAgent, momusPromptMetadata } from "./momus" import { createMomusAgent, momusPromptMetadata } from "./momus"
import { createHephaestusAgent } from "./hephaestus" import { createHephaestusAgent } from "./hephaestus"
import type { AvailableAgent, AvailableCategory, AvailableSkill } from "./dynamic-agent-prompt-builder" import type { AvailableAgent, AvailableCategory, AvailableSkill } from "./dynamic-agent-prompt-builder"
import { deepMerge, fetchAvailableModels, resolveModelPipeline, AGENT_MODEL_REQUIREMENTS, readConnectedProvidersCache, isModelAvailable, isAnyFallbackModelAvailable } from "../shared" import { deepMerge, fetchAvailableModels, resolveModelPipeline, AGENT_MODEL_REQUIREMENTS, readConnectedProvidersCache, isModelAvailable, isAnyFallbackModelAvailable, isAnyProviderConnected, migrateAgentConfig } from "../shared"
import { DEFAULT_CATEGORIES, CATEGORY_DESCRIPTIONS } from "../tools/delegate-task/constants" import { DEFAULT_CATEGORIES, CATEGORY_DESCRIPTIONS } from "../tools/delegate-task/constants"
import { resolveMultipleSkills } from "../features/opencode-skill-loader/skill-content" import { resolveMultipleSkills } from "../features/opencode-skill-loader/skill-content"
import { createBuiltinSkills } from "../features/builtin-skills" import { createBuiltinSkills } from "../features/builtin-skills"
@@ -57,7 +57,8 @@ export function buildAgent(
model: string, model: string,
categories?: CategoriesConfig, categories?: CategoriesConfig,
gitMasterConfig?: GitMasterConfig, gitMasterConfig?: GitMasterConfig,
browserProvider?: BrowserAutomationProvider browserProvider?: BrowserAutomationProvider,
disabledSkills?: Set<string>
): AgentConfig { ): AgentConfig {
const base = isFactory(source) ? source(model) : source const base = isFactory(source) ? source(model) : source
const categoryConfigs: Record<string, CategoryConfig> = categories const categoryConfigs: Record<string, CategoryConfig> = categories
@@ -81,7 +82,7 @@ export function buildAgent(
} }
if (agentWithCategory.skills?.length) { if (agentWithCategory.skills?.length) {
const { resolved } = resolveMultipleSkills(agentWithCategory.skills, { gitMasterConfig, browserProvider }) const { resolved } = resolveMultipleSkills(agentWithCategory.skills, { gitMasterConfig, browserProvider, disabledSkills })
if (resolved.size > 0) { if (resolved.size > 0) {
const skillContent = Array.from(resolved.values()).join("\n\n") const skillContent = Array.from(resolved.values()).join("\n\n")
base.prompt = skillContent + (base.prompt ? "\n\n" + base.prompt : "") base.prompt = skillContent + (base.prompt ? "\n\n" + base.prompt : "")
@@ -207,7 +208,8 @@ function mergeAgentConfig(
base: AgentConfig, base: AgentConfig,
override: AgentOverrideConfig override: AgentOverrideConfig
): AgentConfig { ): AgentConfig {
const { prompt_append, ...rest } = override const migratedOverride = migrateAgentConfig(override as Record<string, unknown>) as AgentOverrideConfig
const { prompt_append, ...rest } = migratedOverride
const merged = deepMerge(base, rest as Partial<AgentConfig>) const merged = deepMerge(base, rest as Partial<AgentConfig>)
if (prompt_append && merged.prompt) { if (prompt_append && merged.prompt) {
@@ -233,7 +235,8 @@ export async function createBuiltinAgents(
discoveredSkills: LoadedSkill[] = [], discoveredSkills: LoadedSkill[] = [],
client?: any, client?: any,
browserProvider?: BrowserAutomationProvider, browserProvider?: BrowserAutomationProvider,
uiSelectedModel?: string uiSelectedModel?: string,
disabledSkills?: Set<string>
): Promise<Record<string, AgentConfig>> { ): Promise<Record<string, AgentConfig>> {
const connectedProviders = readConnectedProvidersCache() const connectedProviders = readConnectedProvidersCache()
// IMPORTANT: Do NOT pass client to fetchAvailableModels during plugin initialization. // IMPORTANT: Do NOT pass client to fetchAvailableModels during plugin initialization.
@@ -257,7 +260,7 @@ export async function createBuiltinAgents(
description: categories?.[name]?.description ?? CATEGORY_DESCRIPTIONS[name] ?? "General tasks", description: categories?.[name]?.description ?? CATEGORY_DESCRIPTIONS[name] ?? "General tasks",
})) }))
const builtinSkills = createBuiltinSkills({ browserProvider }) const builtinSkills = createBuiltinSkills({ browserProvider, disabledSkills })
const builtinSkillNames = new Set(builtinSkills.map(s => s.name)) const builtinSkillNames = new Set(builtinSkills.map(s => s.name))
const builtinAvailable: AvailableSkill[] = builtinSkills.map((skill) => ({ const builtinAvailable: AvailableSkill[] = builtinSkills.map((skill) => ({
@@ -290,16 +293,16 @@ export async function createBuiltinAgents(
const override = agentOverrides[agentName] const override = agentOverrides[agentName]
?? Object.entries(agentOverrides).find(([key]) => key.toLowerCase() === agentName.toLowerCase())?.[1] ?? Object.entries(agentOverrides).find(([key]) => key.toLowerCase() === agentName.toLowerCase())?.[1]
const requirement = AGENT_MODEL_REQUIREMENTS[agentName] const requirement = AGENT_MODEL_REQUIREMENTS[agentName]
// Check if agent requires a specific model // Check if agent requires a specific model
if (requirement?.requiresModel && availableModels) { if (requirement?.requiresModel && availableModels) {
if (!isModelAvailable(requirement.requiresModel, availableModels)) { if (!isModelAvailable(requirement.requiresModel, availableModels)) {
continue continue
} }
} }
const isPrimaryAgent = isFactory(source) && source.mode === "primary" const isPrimaryAgent = isFactory(source) && source.mode === "primary"
const resolution = applyModelResolution({ const resolution = applyModelResolution({
uiSelectedModel: isPrimaryAgent ? uiSelectedModel : undefined, uiSelectedModel: isPrimaryAgent ? uiSelectedModel : undefined,
userModel: override?.model, userModel: override?.model,
@@ -310,7 +313,7 @@ export async function createBuiltinAgents(
if (!resolution) continue if (!resolution) continue
const { model, variant: resolvedVariant } = resolution const { model, variant: resolvedVariant } = resolution
let config = buildAgent(source, model, mergedCategories, gitMasterConfig, browserProvider) let config = buildAgent(source, model, mergedCategories, gitMasterConfig, browserProvider, disabledSkills)
// Apply resolved variant from model fallback chain // Apply resolved variant from model fallback chain
if (resolvedVariant) { if (resolvedVariant) {
@@ -374,7 +377,7 @@ export async function createBuiltinAgents(
availableSkills, availableSkills,
availableCategories availableCategories
) )
if (sisyphusResolvedVariant) { if (sisyphusResolvedVariant) {
sisyphusConfig = { ...sisyphusConfig, variant: sisyphusResolvedVariant } sisyphusConfig = { ...sisyphusConfig, variant: sisyphusResolvedVariant }
} }
@@ -391,13 +394,13 @@ export async function createBuiltinAgents(
const hephaestusRequirement = AGENT_MODEL_REQUIREMENTS["hephaestus"] const hephaestusRequirement = AGENT_MODEL_REQUIREMENTS["hephaestus"]
const hasHephaestusExplicitConfig = hephaestusOverride !== undefined const hasHephaestusExplicitConfig = hephaestusOverride !== undefined
const hasRequiredModel = const hasRequiredProvider =
!hephaestusRequirement?.requiresModel || !hephaestusRequirement?.requiresProvider ||
hasHephaestusExplicitConfig || hasHephaestusExplicitConfig ||
isFirstRunNoCache || isFirstRunNoCache ||
(availableModels.size > 0 && isModelAvailable(hephaestusRequirement.requiresModel, availableModels)) isAnyProviderConnected(hephaestusRequirement.requiresProvider, availableModels)
if (hasRequiredModel) { if (hasRequiredProvider) {
let hephaestusResolution = applyModelResolution({ let hephaestusResolution = applyModelResolution({
userModel: hephaestusOverride?.model, userModel: hephaestusOverride?.model,
requirement: hephaestusRequirement, requirement: hephaestusRequirement,
@@ -419,7 +422,7 @@ export async function createBuiltinAgents(
availableSkills, availableSkills,
availableCategories availableCategories
) )
hephaestusConfig = { ...hephaestusConfig, variant: hephaestusResolvedVariant ?? "medium" } hephaestusConfig = { ...hephaestusConfig, variant: hephaestusResolvedVariant ?? "medium" }
const hepOverrideCategory = (hephaestusOverride as Record<string, unknown> | undefined)?.category as string | undefined const hepOverrideCategory = (hephaestusOverride as Record<string, unknown> | undefined)?.category as string | undefined
@@ -467,7 +470,7 @@ export async function createBuiltinAgents(
availableSkills, availableSkills,
userCategories: categories, userCategories: categories,
}) })
if (atlasResolvedVariant) { if (atlasResolvedVariant) {
orchestratorConfig = { ...orchestratorConfig, variant: atlasResolvedVariant } orchestratorConfig = { ...orchestratorConfig, variant: atlasResolvedVariant }
} }

View File

@@ -2,25 +2,25 @@
## OVERVIEW ## OVERVIEW
CLI entry: `bunx oh-my-opencode`. 4 commands with Commander.js + @clack/prompts TUI. CLI entry: `bunx oh-my-opencode`. 5 commands with Commander.js + @clack/prompts TUI.
**Commands**: install (interactive setup), doctor (14 health checks), run (session launcher), get-local-version **Commands**: install (interactive setup), doctor (14 health checks), run (session launcher), get-local-version, mcp-oauth
## STRUCTURE ## STRUCTURE
``` ```
cli/ cli/
├── index.ts # Commander.js entry (4 commands) ├── index.ts # Commander.js entry (5 commands)
├── install.ts # Interactive TUI (542 lines) ├── install.ts # Interactive TUI (542 lines)
├── config-manager.ts # JSONC parsing (667 lines) ├── config-manager.ts # JSONC parsing (667 lines)
├── types.ts # InstallArgs, InstallConfig
├── model-fallback.ts # Model fallback configuration ├── model-fallback.ts # Model fallback configuration
├── types.ts # InstallArgs, InstallConfig
├── doctor/ ├── doctor/
│ ├── index.ts # Doctor entry │ ├── index.ts # Doctor entry
│ ├── runner.ts # Check orchestration │ ├── runner.ts # Check orchestration
│ ├── formatter.ts # Colored output │ ├── formatter.ts # Colored output
│ ├── constants.ts # Check IDs, symbols │ ├── constants.ts # Check IDs, symbols
│ ├── types.ts # CheckResult, CheckDefinition (114 lines) │ ├── types.ts # CheckResult, CheckDefinition
│ └── checks/ # 14 checks, 23 files │ └── checks/ # 14 checks, 23 files
│ ├── version.ts # OpenCode + plugin version │ ├── version.ts # OpenCode + plugin version
│ ├── config.ts # JSONC validity, Zod │ ├── config.ts # JSONC validity, Zod
@@ -28,10 +28,11 @@ cli/
│ ├── dependencies.ts # AST-Grep, Comment Checker │ ├── dependencies.ts # AST-Grep, Comment Checker
│ ├── lsp.ts # LSP connectivity │ ├── lsp.ts # LSP connectivity
│ ├── mcp.ts # MCP validation │ ├── mcp.ts # MCP validation
│ ├── model-resolution.ts # Model resolution check │ ├── model-resolution.ts # Model resolution check (323 lines)
│ └── gh.ts # GitHub CLI │ └── gh.ts # GitHub CLI
├── run/ ├── run/
── index.ts # Session launcher ── index.ts # Session launcher
│ └── events.ts # CLI run events (325 lines)
├── mcp-oauth/ ├── mcp-oauth/
│ └── index.ts # MCP OAuth flow │ └── index.ts # MCP OAuth flow
└── get-local-version/ └── get-local-version/
@@ -46,6 +47,7 @@ cli/
| `doctor` | 14 health checks for diagnostics | | `doctor` | 14 health checks for diagnostics |
| `run` | Launch session with todo enforcement | | `run` | Launch session with todo enforcement |
| `get-local-version` | Version detection and update check | | `get-local-version` | Version detection and update check |
| `mcp-oauth` | MCP OAuth authentication flow |
## DOCTOR CATEGORIES (14 Checks) ## DOCTOR CATEGORIES (14 Checks)

View File

@@ -75,26 +75,26 @@ exports[`generateModelConfig single native provider uses Claude models when only
"model": "anthropic/claude-sonnet-4-5", "model": "anthropic/claude-sonnet-4-5",
}, },
"metis": { "metis": {
"model": "anthropic/claude-opus-4-5", "model": "anthropic/claude-opus-4-6",
"variant": "max", "variant": "max",
}, },
"momus": { "momus": {
"model": "anthropic/claude-opus-4-5", "model": "anthropic/claude-opus-4-6",
"variant": "max", "variant": "max",
}, },
"multimodal-looker": { "multimodal-looker": {
"model": "anthropic/claude-haiku-4-5", "model": "anthropic/claude-haiku-4-5",
}, },
"oracle": { "oracle": {
"model": "anthropic/claude-opus-4-5", "model": "anthropic/claude-opus-4-6",
"variant": "max", "variant": "max",
}, },
"prometheus": { "prometheus": {
"model": "anthropic/claude-opus-4-5", "model": "anthropic/claude-opus-4-6",
"variant": "max", "variant": "max",
}, },
"sisyphus": { "sisyphus": {
"model": "anthropic/claude-opus-4-5", "model": "anthropic/claude-opus-4-6",
"variant": "max", "variant": "max",
}, },
}, },
@@ -103,7 +103,7 @@ exports[`generateModelConfig single native provider uses Claude models when only
"model": "anthropic/claude-haiku-4-5", "model": "anthropic/claude-haiku-4-5",
}, },
"ultrabrain": { "ultrabrain": {
"model": "anthropic/claude-opus-4-5", "model": "anthropic/claude-opus-4-6",
"variant": "max", "variant": "max",
}, },
"unspecified-high": { "unspecified-high": {
@@ -113,7 +113,7 @@ exports[`generateModelConfig single native provider uses Claude models when only
"model": "anthropic/claude-sonnet-4-5", "model": "anthropic/claude-sonnet-4-5",
}, },
"visual-engineering": { "visual-engineering": {
"model": "anthropic/claude-opus-4-5", "model": "anthropic/claude-opus-4-6",
"variant": "max", "variant": "max",
}, },
"writing": { "writing": {
@@ -137,26 +137,26 @@ exports[`generateModelConfig single native provider uses Claude models with isMa
"model": "anthropic/claude-sonnet-4-5", "model": "anthropic/claude-sonnet-4-5",
}, },
"metis": { "metis": {
"model": "anthropic/claude-opus-4-5", "model": "anthropic/claude-opus-4-6",
"variant": "max", "variant": "max",
}, },
"momus": { "momus": {
"model": "anthropic/claude-opus-4-5", "model": "anthropic/claude-opus-4-6",
"variant": "max", "variant": "max",
}, },
"multimodal-looker": { "multimodal-looker": {
"model": "anthropic/claude-haiku-4-5", "model": "anthropic/claude-haiku-4-5",
}, },
"oracle": { "oracle": {
"model": "anthropic/claude-opus-4-5", "model": "anthropic/claude-opus-4-6",
"variant": "max", "variant": "max",
}, },
"prometheus": { "prometheus": {
"model": "anthropic/claude-opus-4-5", "model": "anthropic/claude-opus-4-6",
"variant": "max", "variant": "max",
}, },
"sisyphus": { "sisyphus": {
"model": "anthropic/claude-opus-4-5", "model": "anthropic/claude-opus-4-6",
"variant": "max", "variant": "max",
}, },
}, },
@@ -165,18 +165,18 @@ exports[`generateModelConfig single native provider uses Claude models with isMa
"model": "anthropic/claude-haiku-4-5", "model": "anthropic/claude-haiku-4-5",
}, },
"ultrabrain": { "ultrabrain": {
"model": "anthropic/claude-opus-4-5", "model": "anthropic/claude-opus-4-6",
"variant": "max", "variant": "max",
}, },
"unspecified-high": { "unspecified-high": {
"model": "anthropic/claude-opus-4-5", "model": "anthropic/claude-opus-4-6",
"variant": "max", "variant": "max",
}, },
"unspecified-low": { "unspecified-low": {
"model": "anthropic/claude-sonnet-4-5", "model": "anthropic/claude-sonnet-4-5",
}, },
"visual-engineering": { "visual-engineering": {
"model": "anthropic/claude-opus-4-5", "model": "anthropic/claude-opus-4-6",
"variant": "max", "variant": "max",
}, },
"writing": { "writing": {
@@ -197,7 +197,7 @@ exports[`generateModelConfig single native provider uses OpenAI models when only
"model": "opencode/gpt-5-nano", "model": "opencode/gpt-5-nano",
}, },
"hephaestus": { "hephaestus": {
"model": "openai/gpt-5.2-codex", "model": "openai/gpt-5.3-codex",
"variant": "medium", "variant": "medium",
}, },
"librarian": { "librarian": {
@@ -225,22 +225,22 @@ exports[`generateModelConfig single native provider uses OpenAI models when only
}, },
"categories": { "categories": {
"deep": { "deep": {
"model": "openai/gpt-5.2-codex", "model": "openai/gpt-5.3-codex",
"variant": "medium", "variant": "medium",
}, },
"quick": { "quick": {
"model": "opencode/glm-4.7-free", "model": "opencode/glm-4.7-free",
}, },
"ultrabrain": { "ultrabrain": {
"model": "openai/gpt-5.2-codex", "model": "openai/gpt-5.3-codex",
"variant": "xhigh", "variant": "xhigh",
}, },
"unspecified-high": { "unspecified-high": {
"model": "openai/gpt-5.2-codex", "model": "openai/gpt-5.3-codex",
"variant": "medium", "variant": "medium",
}, },
"unspecified-low": { "unspecified-low": {
"model": "openai/gpt-5.2-codex", "model": "openai/gpt-5.3-codex",
"variant": "medium", "variant": "medium",
}, },
"visual-engineering": { "visual-engineering": {
@@ -264,7 +264,7 @@ exports[`generateModelConfig single native provider uses OpenAI models with isMa
"model": "opencode/gpt-5-nano", "model": "opencode/gpt-5-nano",
}, },
"hephaestus": { "hephaestus": {
"model": "openai/gpt-5.2-codex", "model": "openai/gpt-5.3-codex",
"variant": "medium", "variant": "medium",
}, },
"librarian": { "librarian": {
@@ -292,14 +292,14 @@ exports[`generateModelConfig single native provider uses OpenAI models with isMa
}, },
"categories": { "categories": {
"deep": { "deep": {
"model": "openai/gpt-5.2-codex", "model": "openai/gpt-5.3-codex",
"variant": "medium", "variant": "medium",
}, },
"quick": { "quick": {
"model": "opencode/glm-4.7-free", "model": "opencode/glm-4.7-free",
}, },
"ultrabrain": { "ultrabrain": {
"model": "openai/gpt-5.2-codex", "model": "openai/gpt-5.3-codex",
"variant": "xhigh", "variant": "xhigh",
}, },
"unspecified-high": { "unspecified-high": {
@@ -307,7 +307,7 @@ exports[`generateModelConfig single native provider uses OpenAI models with isMa
"variant": "high", "variant": "high",
}, },
"unspecified-low": { "unspecified-low": {
"model": "openai/gpt-5.2-codex", "model": "openai/gpt-5.3-codex",
"variant": "medium", "variant": "medium",
}, },
"visual-engineering": { "visual-engineering": {
@@ -335,18 +335,18 @@ exports[`generateModelConfig single native provider uses Gemini models when only
}, },
"metis": { "metis": {
"model": "google/gemini-3-pro", "model": "google/gemini-3-pro",
"variant": "max", "variant": "high",
}, },
"momus": { "momus": {
"model": "google/gemini-3-pro", "model": "google/gemini-3-pro",
"variant": "max", "variant": "high",
}, },
"multimodal-looker": { "multimodal-looker": {
"model": "google/gemini-3-flash", "model": "google/gemini-3-flash",
}, },
"oracle": { "oracle": {
"model": "google/gemini-3-pro", "model": "google/gemini-3-pro",
"variant": "max", "variant": "high",
}, },
"prometheus": { "prometheus": {
"model": "google/gemini-3-pro", "model": "google/gemini-3-pro",
@@ -355,14 +355,14 @@ exports[`generateModelConfig single native provider uses Gemini models when only
"categories": { "categories": {
"artistry": { "artistry": {
"model": "google/gemini-3-pro", "model": "google/gemini-3-pro",
"variant": "max", "variant": "high",
}, },
"quick": { "quick": {
"model": "google/gemini-3-flash", "model": "google/gemini-3-flash",
}, },
"ultrabrain": { "ultrabrain": {
"model": "google/gemini-3-pro", "model": "google/gemini-3-pro",
"variant": "max", "variant": "high",
}, },
"unspecified-high": { "unspecified-high": {
"model": "google/gemini-3-flash", "model": "google/gemini-3-flash",
@@ -395,18 +395,18 @@ exports[`generateModelConfig single native provider uses Gemini models with isMa
}, },
"metis": { "metis": {
"model": "google/gemini-3-pro", "model": "google/gemini-3-pro",
"variant": "max", "variant": "high",
}, },
"momus": { "momus": {
"model": "google/gemini-3-pro", "model": "google/gemini-3-pro",
"variant": "max", "variant": "high",
}, },
"multimodal-looker": { "multimodal-looker": {
"model": "google/gemini-3-flash", "model": "google/gemini-3-flash",
}, },
"oracle": { "oracle": {
"model": "google/gemini-3-pro", "model": "google/gemini-3-pro",
"variant": "max", "variant": "high",
}, },
"prometheus": { "prometheus": {
"model": "google/gemini-3-pro", "model": "google/gemini-3-pro",
@@ -415,14 +415,14 @@ exports[`generateModelConfig single native provider uses Gemini models with isMa
"categories": { "categories": {
"artistry": { "artistry": {
"model": "google/gemini-3-pro", "model": "google/gemini-3-pro",
"variant": "max", "variant": "high",
}, },
"quick": { "quick": {
"model": "google/gemini-3-flash", "model": "google/gemini-3-flash",
}, },
"ultrabrain": { "ultrabrain": {
"model": "google/gemini-3-pro", "model": "google/gemini-3-pro",
"variant": "max", "variant": "high",
}, },
"unspecified-high": { "unspecified-high": {
"model": "google/gemini-3-pro", "model": "google/gemini-3-pro",
@@ -451,14 +451,14 @@ exports[`generateModelConfig all native providers uses preferred models from fal
"model": "anthropic/claude-haiku-4-5", "model": "anthropic/claude-haiku-4-5",
}, },
"hephaestus": { "hephaestus": {
"model": "openai/gpt-5.2-codex", "model": "openai/gpt-5.3-codex",
"variant": "medium", "variant": "medium",
}, },
"librarian": { "librarian": {
"model": "anthropic/claude-sonnet-4-5", "model": "anthropic/claude-sonnet-4-5",
}, },
"metis": { "metis": {
"model": "anthropic/claude-opus-4-5", "model": "anthropic/claude-opus-4-6",
"variant": "max", "variant": "max",
}, },
"momus": { "momus": {
@@ -473,28 +473,28 @@ exports[`generateModelConfig all native providers uses preferred models from fal
"variant": "high", "variant": "high",
}, },
"prometheus": { "prometheus": {
"model": "anthropic/claude-opus-4-5", "model": "anthropic/claude-opus-4-6",
"variant": "max", "variant": "max",
}, },
"sisyphus": { "sisyphus": {
"model": "anthropic/claude-opus-4-5", "model": "anthropic/claude-opus-4-6",
"variant": "max", "variant": "max",
}, },
}, },
"categories": { "categories": {
"artistry": { "artistry": {
"model": "google/gemini-3-pro", "model": "google/gemini-3-pro",
"variant": "max", "variant": "high",
}, },
"deep": { "deep": {
"model": "openai/gpt-5.2-codex", "model": "openai/gpt-5.3-codex",
"variant": "medium", "variant": "medium",
}, },
"quick": { "quick": {
"model": "anthropic/claude-haiku-4-5", "model": "anthropic/claude-haiku-4-5",
}, },
"ultrabrain": { "ultrabrain": {
"model": "openai/gpt-5.2-codex", "model": "openai/gpt-5.3-codex",
"variant": "xhigh", "variant": "xhigh",
}, },
"unspecified-high": { "unspecified-high": {
@@ -524,14 +524,14 @@ exports[`generateModelConfig all native providers uses preferred models with isM
"model": "anthropic/claude-haiku-4-5", "model": "anthropic/claude-haiku-4-5",
}, },
"hephaestus": { "hephaestus": {
"model": "openai/gpt-5.2-codex", "model": "openai/gpt-5.3-codex",
"variant": "medium", "variant": "medium",
}, },
"librarian": { "librarian": {
"model": "anthropic/claude-sonnet-4-5", "model": "anthropic/claude-sonnet-4-5",
}, },
"metis": { "metis": {
"model": "anthropic/claude-opus-4-5", "model": "anthropic/claude-opus-4-6",
"variant": "max", "variant": "max",
}, },
"momus": { "momus": {
@@ -546,32 +546,32 @@ exports[`generateModelConfig all native providers uses preferred models with isM
"variant": "high", "variant": "high",
}, },
"prometheus": { "prometheus": {
"model": "anthropic/claude-opus-4-5", "model": "anthropic/claude-opus-4-6",
"variant": "max", "variant": "max",
}, },
"sisyphus": { "sisyphus": {
"model": "anthropic/claude-opus-4-5", "model": "anthropic/claude-opus-4-6",
"variant": "max", "variant": "max",
}, },
}, },
"categories": { "categories": {
"artistry": { "artistry": {
"model": "google/gemini-3-pro", "model": "google/gemini-3-pro",
"variant": "max", "variant": "high",
}, },
"deep": { "deep": {
"model": "openai/gpt-5.2-codex", "model": "openai/gpt-5.3-codex",
"variant": "medium", "variant": "medium",
}, },
"quick": { "quick": {
"model": "anthropic/claude-haiku-4-5", "model": "anthropic/claude-haiku-4-5",
}, },
"ultrabrain": { "ultrabrain": {
"model": "openai/gpt-5.2-codex", "model": "openai/gpt-5.3-codex",
"variant": "xhigh", "variant": "xhigh",
}, },
"unspecified-high": { "unspecified-high": {
"model": "anthropic/claude-opus-4-5", "model": "anthropic/claude-opus-4-6",
"variant": "max", "variant": "max",
}, },
"unspecified-low": { "unspecified-low": {
@@ -598,14 +598,14 @@ exports[`generateModelConfig fallback providers uses OpenCode Zen models when on
"model": "opencode/claude-haiku-4-5", "model": "opencode/claude-haiku-4-5",
}, },
"hephaestus": { "hephaestus": {
"model": "opencode/gpt-5.2-codex", "model": "opencode/gpt-5.3-codex",
"variant": "medium", "variant": "medium",
}, },
"librarian": { "librarian": {
"model": "opencode/glm-4.7-free", "model": "opencode/glm-4.7-free",
}, },
"metis": { "metis": {
"model": "opencode/claude-opus-4-5", "model": "opencode/claude-opus-4-6",
"variant": "max", "variant": "max",
}, },
"momus": { "momus": {
@@ -620,28 +620,28 @@ exports[`generateModelConfig fallback providers uses OpenCode Zen models when on
"variant": "high", "variant": "high",
}, },
"prometheus": { "prometheus": {
"model": "opencode/claude-opus-4-5", "model": "opencode/claude-opus-4-6",
"variant": "max", "variant": "max",
}, },
"sisyphus": { "sisyphus": {
"model": "opencode/claude-opus-4-5", "model": "opencode/claude-opus-4-6",
"variant": "max", "variant": "max",
}, },
}, },
"categories": { "categories": {
"artistry": { "artistry": {
"model": "opencode/gemini-3-pro", "model": "opencode/gemini-3-pro",
"variant": "max", "variant": "high",
}, },
"deep": { "deep": {
"model": "opencode/gpt-5.2-codex", "model": "opencode/gpt-5.3-codex",
"variant": "medium", "variant": "medium",
}, },
"quick": { "quick": {
"model": "opencode/claude-haiku-4-5", "model": "opencode/claude-haiku-4-5",
}, },
"ultrabrain": { "ultrabrain": {
"model": "opencode/gpt-5.2-codex", "model": "opencode/gpt-5.3-codex",
"variant": "xhigh", "variant": "xhigh",
}, },
"unspecified-high": { "unspecified-high": {
@@ -671,14 +671,14 @@ exports[`generateModelConfig fallback providers uses OpenCode Zen models with is
"model": "opencode/claude-haiku-4-5", "model": "opencode/claude-haiku-4-5",
}, },
"hephaestus": { "hephaestus": {
"model": "opencode/gpt-5.2-codex", "model": "opencode/gpt-5.3-codex",
"variant": "medium", "variant": "medium",
}, },
"librarian": { "librarian": {
"model": "opencode/glm-4.7-free", "model": "opencode/glm-4.7-free",
}, },
"metis": { "metis": {
"model": "opencode/claude-opus-4-5", "model": "opencode/claude-opus-4-6",
"variant": "max", "variant": "max",
}, },
"momus": { "momus": {
@@ -693,32 +693,32 @@ exports[`generateModelConfig fallback providers uses OpenCode Zen models with is
"variant": "high", "variant": "high",
}, },
"prometheus": { "prometheus": {
"model": "opencode/claude-opus-4-5", "model": "opencode/claude-opus-4-6",
"variant": "max", "variant": "max",
}, },
"sisyphus": { "sisyphus": {
"model": "opencode/claude-opus-4-5", "model": "opencode/claude-opus-4-6",
"variant": "max", "variant": "max",
}, },
}, },
"categories": { "categories": {
"artistry": { "artistry": {
"model": "opencode/gemini-3-pro", "model": "opencode/gemini-3-pro",
"variant": "max", "variant": "high",
}, },
"deep": { "deep": {
"model": "opencode/gpt-5.2-codex", "model": "opencode/gpt-5.3-codex",
"variant": "medium", "variant": "medium",
}, },
"quick": { "quick": {
"model": "opencode/claude-haiku-4-5", "model": "opencode/claude-haiku-4-5",
}, },
"ultrabrain": { "ultrabrain": {
"model": "opencode/gpt-5.2-codex", "model": "opencode/gpt-5.3-codex",
"variant": "xhigh", "variant": "xhigh",
}, },
"unspecified-high": { "unspecified-high": {
"model": "opencode/claude-opus-4-5", "model": "opencode/claude-opus-4-6",
"variant": "max", "variant": "max",
}, },
"unspecified-low": { "unspecified-low": {
@@ -745,14 +745,14 @@ exports[`generateModelConfig fallback providers uses GitHub Copilot models when
"model": "github-copilot/gpt-5-mini", "model": "github-copilot/gpt-5-mini",
}, },
"hephaestus": { "hephaestus": {
"model": "github-copilot/gpt-5.2-codex", "model": "github-copilot/gpt-5.3-codex",
"variant": "medium", "variant": "medium",
}, },
"librarian": { "librarian": {
"model": "github-copilot/claude-sonnet-4.5", "model": "github-copilot/claude-sonnet-4.5",
}, },
"metis": { "metis": {
"model": "github-copilot/claude-opus-4.5", "model": "github-copilot/claude-opus-4.6",
"variant": "max", "variant": "max",
}, },
"momus": { "momus": {
@@ -767,28 +767,28 @@ exports[`generateModelConfig fallback providers uses GitHub Copilot models when
"variant": "high", "variant": "high",
}, },
"prometheus": { "prometheus": {
"model": "github-copilot/claude-opus-4.5", "model": "github-copilot/claude-opus-4.6",
"variant": "max", "variant": "max",
}, },
"sisyphus": { "sisyphus": {
"model": "github-copilot/claude-opus-4.5", "model": "github-copilot/claude-opus-4.6",
"variant": "max", "variant": "max",
}, },
}, },
"categories": { "categories": {
"artistry": { "artistry": {
"model": "github-copilot/gemini-3-pro-preview", "model": "github-copilot/gemini-3-pro-preview",
"variant": "max", "variant": "high",
}, },
"deep": { "deep": {
"model": "github-copilot/gpt-5.2-codex", "model": "github-copilot/gpt-5.3-codex",
"variant": "medium", "variant": "medium",
}, },
"quick": { "quick": {
"model": "github-copilot/claude-haiku-4.5", "model": "github-copilot/claude-haiku-4.5",
}, },
"ultrabrain": { "ultrabrain": {
"model": "github-copilot/gpt-5.2-codex", "model": "github-copilot/gpt-5.3-codex",
"variant": "xhigh", "variant": "xhigh",
}, },
"unspecified-high": { "unspecified-high": {
@@ -818,14 +818,14 @@ exports[`generateModelConfig fallback providers uses GitHub Copilot models with
"model": "github-copilot/gpt-5-mini", "model": "github-copilot/gpt-5-mini",
}, },
"hephaestus": { "hephaestus": {
"model": "github-copilot/gpt-5.2-codex", "model": "github-copilot/gpt-5.3-codex",
"variant": "medium", "variant": "medium",
}, },
"librarian": { "librarian": {
"model": "github-copilot/claude-sonnet-4.5", "model": "github-copilot/claude-sonnet-4.5",
}, },
"metis": { "metis": {
"model": "github-copilot/claude-opus-4.5", "model": "github-copilot/claude-opus-4.6",
"variant": "max", "variant": "max",
}, },
"momus": { "momus": {
@@ -840,32 +840,32 @@ exports[`generateModelConfig fallback providers uses GitHub Copilot models with
"variant": "high", "variant": "high",
}, },
"prometheus": { "prometheus": {
"model": "github-copilot/claude-opus-4.5", "model": "github-copilot/claude-opus-4.6",
"variant": "max", "variant": "max",
}, },
"sisyphus": { "sisyphus": {
"model": "github-copilot/claude-opus-4.5", "model": "github-copilot/claude-opus-4.6",
"variant": "max", "variant": "max",
}, },
}, },
"categories": { "categories": {
"artistry": { "artistry": {
"model": "github-copilot/gemini-3-pro-preview", "model": "github-copilot/gemini-3-pro-preview",
"variant": "max", "variant": "high",
}, },
"deep": { "deep": {
"model": "github-copilot/gpt-5.2-codex", "model": "github-copilot/gpt-5.3-codex",
"variant": "medium", "variant": "medium",
}, },
"quick": { "quick": {
"model": "github-copilot/claude-haiku-4.5", "model": "github-copilot/claude-haiku-4.5",
}, },
"ultrabrain": { "ultrabrain": {
"model": "github-copilot/gpt-5.2-codex", "model": "github-copilot/gpt-5.3-codex",
"variant": "xhigh", "variant": "xhigh",
}, },
"unspecified-high": { "unspecified-high": {
"model": "github-copilot/claude-opus-4.5", "model": "github-copilot/claude-opus-4.6",
"variant": "max", "variant": "max",
}, },
"unspecified-low": { "unspecified-low": {
@@ -1002,14 +1002,14 @@ exports[`generateModelConfig mixed provider scenarios uses Claude + OpenCode Zen
"model": "anthropic/claude-haiku-4-5", "model": "anthropic/claude-haiku-4-5",
}, },
"hephaestus": { "hephaestus": {
"model": "opencode/gpt-5.2-codex", "model": "opencode/gpt-5.3-codex",
"variant": "medium", "variant": "medium",
}, },
"librarian": { "librarian": {
"model": "opencode/glm-4.7-free", "model": "opencode/glm-4.7-free",
}, },
"metis": { "metis": {
"model": "anthropic/claude-opus-4-5", "model": "anthropic/claude-opus-4-6",
"variant": "max", "variant": "max",
}, },
"momus": { "momus": {
@@ -1024,28 +1024,28 @@ exports[`generateModelConfig mixed provider scenarios uses Claude + OpenCode Zen
"variant": "high", "variant": "high",
}, },
"prometheus": { "prometheus": {
"model": "anthropic/claude-opus-4-5", "model": "anthropic/claude-opus-4-6",
"variant": "max", "variant": "max",
}, },
"sisyphus": { "sisyphus": {
"model": "anthropic/claude-opus-4-5", "model": "anthropic/claude-opus-4-6",
"variant": "max", "variant": "max",
}, },
}, },
"categories": { "categories": {
"artistry": { "artistry": {
"model": "opencode/gemini-3-pro", "model": "opencode/gemini-3-pro",
"variant": "max", "variant": "high",
}, },
"deep": { "deep": {
"model": "opencode/gpt-5.2-codex", "model": "opencode/gpt-5.3-codex",
"variant": "medium", "variant": "medium",
}, },
"quick": { "quick": {
"model": "anthropic/claude-haiku-4-5", "model": "anthropic/claude-haiku-4-5",
}, },
"ultrabrain": { "ultrabrain": {
"model": "opencode/gpt-5.2-codex", "model": "opencode/gpt-5.3-codex",
"variant": "xhigh", "variant": "xhigh",
}, },
"unspecified-high": { "unspecified-high": {
@@ -1075,14 +1075,14 @@ exports[`generateModelConfig mixed provider scenarios uses OpenAI + Copilot comb
"model": "github-copilot/gpt-5-mini", "model": "github-copilot/gpt-5-mini",
}, },
"hephaestus": { "hephaestus": {
"model": "openai/gpt-5.2-codex", "model": "openai/gpt-5.3-codex",
"variant": "medium", "variant": "medium",
}, },
"librarian": { "librarian": {
"model": "github-copilot/claude-sonnet-4.5", "model": "github-copilot/claude-sonnet-4.5",
}, },
"metis": { "metis": {
"model": "github-copilot/claude-opus-4.5", "model": "github-copilot/claude-opus-4.6",
"variant": "max", "variant": "max",
}, },
"momus": { "momus": {
@@ -1097,28 +1097,28 @@ exports[`generateModelConfig mixed provider scenarios uses OpenAI + Copilot comb
"variant": "high", "variant": "high",
}, },
"prometheus": { "prometheus": {
"model": "github-copilot/claude-opus-4.5", "model": "github-copilot/claude-opus-4.6",
"variant": "max", "variant": "max",
}, },
"sisyphus": { "sisyphus": {
"model": "github-copilot/claude-opus-4.5", "model": "github-copilot/claude-opus-4.6",
"variant": "max", "variant": "max",
}, },
}, },
"categories": { "categories": {
"artistry": { "artistry": {
"model": "github-copilot/gemini-3-pro-preview", "model": "github-copilot/gemini-3-pro-preview",
"variant": "max", "variant": "high",
}, },
"deep": { "deep": {
"model": "openai/gpt-5.2-codex", "model": "openai/gpt-5.3-codex",
"variant": "medium", "variant": "medium",
}, },
"quick": { "quick": {
"model": "github-copilot/claude-haiku-4.5", "model": "github-copilot/claude-haiku-4.5",
}, },
"ultrabrain": { "ultrabrain": {
"model": "openai/gpt-5.2-codex", "model": "openai/gpt-5.3-codex",
"variant": "xhigh", "variant": "xhigh",
}, },
"unspecified-high": { "unspecified-high": {
@@ -1151,26 +1151,26 @@ exports[`generateModelConfig mixed provider scenarios uses Claude + ZAI combinat
"model": "zai-coding-plan/glm-4.7", "model": "zai-coding-plan/glm-4.7",
}, },
"metis": { "metis": {
"model": "anthropic/claude-opus-4-5", "model": "anthropic/claude-opus-4-6",
"variant": "max", "variant": "max",
}, },
"momus": { "momus": {
"model": "anthropic/claude-opus-4-5", "model": "anthropic/claude-opus-4-6",
"variant": "max", "variant": "max",
}, },
"multimodal-looker": { "multimodal-looker": {
"model": "zai-coding-plan/glm-4.6v", "model": "zai-coding-plan/glm-4.6v",
}, },
"oracle": { "oracle": {
"model": "anthropic/claude-opus-4-5", "model": "anthropic/claude-opus-4-6",
"variant": "max", "variant": "max",
}, },
"prometheus": { "prometheus": {
"model": "anthropic/claude-opus-4-5", "model": "anthropic/claude-opus-4-6",
"variant": "max", "variant": "max",
}, },
"sisyphus": { "sisyphus": {
"model": "anthropic/claude-opus-4-5", "model": "anthropic/claude-opus-4-6",
"variant": "max", "variant": "max",
}, },
}, },
@@ -1179,7 +1179,7 @@ exports[`generateModelConfig mixed provider scenarios uses Claude + ZAI combinat
"model": "anthropic/claude-haiku-4-5", "model": "anthropic/claude-haiku-4-5",
}, },
"ultrabrain": { "ultrabrain": {
"model": "anthropic/claude-opus-4-5", "model": "anthropic/claude-opus-4-6",
"variant": "max", "variant": "max",
}, },
"unspecified-high": { "unspecified-high": {
@@ -1189,7 +1189,7 @@ exports[`generateModelConfig mixed provider scenarios uses Claude + ZAI combinat
"model": "anthropic/claude-sonnet-4-5", "model": "anthropic/claude-sonnet-4-5",
}, },
"visual-engineering": { "visual-engineering": {
"model": "anthropic/claude-opus-4-5", "model": "anthropic/claude-opus-4-6",
"variant": "max", "variant": "max",
}, },
"writing": { "writing": {
@@ -1213,11 +1213,11 @@ exports[`generateModelConfig mixed provider scenarios uses Gemini + Claude combi
"model": "anthropic/claude-sonnet-4-5", "model": "anthropic/claude-sonnet-4-5",
}, },
"metis": { "metis": {
"model": "anthropic/claude-opus-4-5", "model": "anthropic/claude-opus-4-6",
"variant": "max", "variant": "max",
}, },
"momus": { "momus": {
"model": "anthropic/claude-opus-4-5", "model": "anthropic/claude-opus-4-6",
"variant": "max", "variant": "max",
}, },
"multimodal-looker": { "multimodal-looker": {
@@ -1225,28 +1225,28 @@ exports[`generateModelConfig mixed provider scenarios uses Gemini + Claude combi
}, },
"oracle": { "oracle": {
"model": "google/gemini-3-pro", "model": "google/gemini-3-pro",
"variant": "max", "variant": "high",
}, },
"prometheus": { "prometheus": {
"model": "anthropic/claude-opus-4-5", "model": "anthropic/claude-opus-4-6",
"variant": "max", "variant": "max",
}, },
"sisyphus": { "sisyphus": {
"model": "anthropic/claude-opus-4-5", "model": "anthropic/claude-opus-4-6",
"variant": "max", "variant": "max",
}, },
}, },
"categories": { "categories": {
"artistry": { "artistry": {
"model": "google/gemini-3-pro", "model": "google/gemini-3-pro",
"variant": "max", "variant": "high",
}, },
"quick": { "quick": {
"model": "anthropic/claude-haiku-4-5", "model": "anthropic/claude-haiku-4-5",
}, },
"ultrabrain": { "ultrabrain": {
"model": "google/gemini-3-pro", "model": "google/gemini-3-pro",
"variant": "max", "variant": "high",
}, },
"unspecified-high": { "unspecified-high": {
"model": "anthropic/claude-sonnet-4-5", "model": "anthropic/claude-sonnet-4-5",
@@ -1275,14 +1275,14 @@ exports[`generateModelConfig mixed provider scenarios uses all fallback provider
"model": "opencode/claude-haiku-4-5", "model": "opencode/claude-haiku-4-5",
}, },
"hephaestus": { "hephaestus": {
"model": "github-copilot/gpt-5.2-codex", "model": "github-copilot/gpt-5.3-codex",
"variant": "medium", "variant": "medium",
}, },
"librarian": { "librarian": {
"model": "zai-coding-plan/glm-4.7", "model": "zai-coding-plan/glm-4.7",
}, },
"metis": { "metis": {
"model": "github-copilot/claude-opus-4.5", "model": "github-copilot/claude-opus-4.6",
"variant": "max", "variant": "max",
}, },
"momus": { "momus": {
@@ -1297,28 +1297,28 @@ exports[`generateModelConfig mixed provider scenarios uses all fallback provider
"variant": "high", "variant": "high",
}, },
"prometheus": { "prometheus": {
"model": "github-copilot/claude-opus-4.5", "model": "github-copilot/claude-opus-4.6",
"variant": "max", "variant": "max",
}, },
"sisyphus": { "sisyphus": {
"model": "github-copilot/claude-opus-4.5", "model": "github-copilot/claude-opus-4.6",
"variant": "max", "variant": "max",
}, },
}, },
"categories": { "categories": {
"artistry": { "artistry": {
"model": "github-copilot/gemini-3-pro-preview", "model": "github-copilot/gemini-3-pro-preview",
"variant": "max", "variant": "high",
}, },
"deep": { "deep": {
"model": "github-copilot/gpt-5.2-codex", "model": "github-copilot/gpt-5.3-codex",
"variant": "medium", "variant": "medium",
}, },
"quick": { "quick": {
"model": "github-copilot/claude-haiku-4.5", "model": "github-copilot/claude-haiku-4.5",
}, },
"ultrabrain": { "ultrabrain": {
"model": "github-copilot/gpt-5.2-codex", "model": "github-copilot/gpt-5.3-codex",
"variant": "xhigh", "variant": "xhigh",
}, },
"unspecified-high": { "unspecified-high": {
@@ -1348,14 +1348,14 @@ exports[`generateModelConfig mixed provider scenarios uses all providers togethe
"model": "anthropic/claude-haiku-4-5", "model": "anthropic/claude-haiku-4-5",
}, },
"hephaestus": { "hephaestus": {
"model": "openai/gpt-5.2-codex", "model": "openai/gpt-5.3-codex",
"variant": "medium", "variant": "medium",
}, },
"librarian": { "librarian": {
"model": "zai-coding-plan/glm-4.7", "model": "zai-coding-plan/glm-4.7",
}, },
"metis": { "metis": {
"model": "anthropic/claude-opus-4-5", "model": "anthropic/claude-opus-4-6",
"variant": "max", "variant": "max",
}, },
"momus": { "momus": {
@@ -1370,28 +1370,28 @@ exports[`generateModelConfig mixed provider scenarios uses all providers togethe
"variant": "high", "variant": "high",
}, },
"prometheus": { "prometheus": {
"model": "anthropic/claude-opus-4-5", "model": "anthropic/claude-opus-4-6",
"variant": "max", "variant": "max",
}, },
"sisyphus": { "sisyphus": {
"model": "anthropic/claude-opus-4-5", "model": "anthropic/claude-opus-4-6",
"variant": "max", "variant": "max",
}, },
}, },
"categories": { "categories": {
"artistry": { "artistry": {
"model": "google/gemini-3-pro", "model": "google/gemini-3-pro",
"variant": "max", "variant": "high",
}, },
"deep": { "deep": {
"model": "openai/gpt-5.2-codex", "model": "openai/gpt-5.3-codex",
"variant": "medium", "variant": "medium",
}, },
"quick": { "quick": {
"model": "anthropic/claude-haiku-4-5", "model": "anthropic/claude-haiku-4-5",
}, },
"ultrabrain": { "ultrabrain": {
"model": "openai/gpt-5.2-codex", "model": "openai/gpt-5.3-codex",
"variant": "xhigh", "variant": "xhigh",
}, },
"unspecified-high": { "unspecified-high": {
@@ -1421,14 +1421,14 @@ exports[`generateModelConfig mixed provider scenarios uses all providers with is
"model": "anthropic/claude-haiku-4-5", "model": "anthropic/claude-haiku-4-5",
}, },
"hephaestus": { "hephaestus": {
"model": "openai/gpt-5.2-codex", "model": "openai/gpt-5.3-codex",
"variant": "medium", "variant": "medium",
}, },
"librarian": { "librarian": {
"model": "zai-coding-plan/glm-4.7", "model": "zai-coding-plan/glm-4.7",
}, },
"metis": { "metis": {
"model": "anthropic/claude-opus-4-5", "model": "anthropic/claude-opus-4-6",
"variant": "max", "variant": "max",
}, },
"momus": { "momus": {
@@ -1443,32 +1443,32 @@ exports[`generateModelConfig mixed provider scenarios uses all providers with is
"variant": "high", "variant": "high",
}, },
"prometheus": { "prometheus": {
"model": "anthropic/claude-opus-4-5", "model": "anthropic/claude-opus-4-6",
"variant": "max", "variant": "max",
}, },
"sisyphus": { "sisyphus": {
"model": "anthropic/claude-opus-4-5", "model": "anthropic/claude-opus-4-6",
"variant": "max", "variant": "max",
}, },
}, },
"categories": { "categories": {
"artistry": { "artistry": {
"model": "google/gemini-3-pro", "model": "google/gemini-3-pro",
"variant": "max", "variant": "high",
}, },
"deep": { "deep": {
"model": "openai/gpt-5.2-codex", "model": "openai/gpt-5.3-codex",
"variant": "medium", "variant": "medium",
}, },
"quick": { "quick": {
"model": "anthropic/claude-haiku-4-5", "model": "anthropic/claude-haiku-4-5",
}, },
"ultrabrain": { "ultrabrain": {
"model": "openai/gpt-5.2-codex", "model": "openai/gpt-5.3-codex",
"variant": "xhigh", "variant": "xhigh",
}, },
"unspecified-high": { "unspecified-high": {
"model": "anthropic/claude-opus-4-5", "model": "anthropic/claude-opus-4-6",
"variant": "max", "variant": "max",
}, },
"unspecified-low": { "unspecified-low": {

View File

@@ -259,7 +259,7 @@ describe("generateOmoConfig - model fallback system", () => {
// #then Sisyphus uses Claude (OR logic - at least one provider available) // #then Sisyphus uses Claude (OR logic - at least one provider available)
expect(result.$schema).toBe("https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json") expect(result.$schema).toBe("https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json")
expect(result.agents).toBeDefined() expect(result.agents).toBeDefined()
expect((result.agents as Record<string, { model: string }>).sisyphus.model).toBe("anthropic/claude-opus-4-5") expect((result.agents as Record<string, { model: string }>).sisyphus.model).toBe("anthropic/claude-opus-4-6")
}) })
test("generates native opus models when Claude max20 subscription", () => { test("generates native opus models when Claude max20 subscription", () => {
@@ -279,7 +279,7 @@ describe("generateOmoConfig - model fallback system", () => {
const result = generateOmoConfig(config) const result = generateOmoConfig(config)
// #then Sisyphus uses Claude (OR logic - at least one provider available) // #then Sisyphus uses Claude (OR logic - at least one provider available)
expect((result.agents as Record<string, { model: string }>).sisyphus.model).toBe("anthropic/claude-opus-4-5") expect((result.agents as Record<string, { model: string }>).sisyphus.model).toBe("anthropic/claude-opus-4-6")
}) })
test("uses github-copilot sonnet fallback when only copilot available", () => { test("uses github-copilot sonnet fallback when only copilot available", () => {
@@ -298,8 +298,8 @@ describe("generateOmoConfig - model fallback system", () => {
// #when generating config // #when generating config
const result = generateOmoConfig(config) const result = generateOmoConfig(config)
// #then Sisyphus uses Copilot (OR logic - copilot is in claude-opus-4-5 providers) // #then Sisyphus uses Copilot (OR logic - copilot is in claude-opus-4-6 providers)
expect((result.agents as Record<string, { model: string }>).sisyphus.model).toBe("github-copilot/claude-opus-4.5") expect((result.agents as Record<string, { model: string }>).sisyphus.model).toBe("github-copilot/claude-opus-4.6")
}) })
test("uses ultimate fallback when no providers configured", () => { test("uses ultimate fallback when no providers configured", () => {
@@ -342,7 +342,7 @@ describe("generateOmoConfig - model fallback system", () => {
// #then librarian should use zai-coding-plan/glm-4.7 // #then librarian should use zai-coding-plan/glm-4.7
expect((result.agents as Record<string, { model: string }>).librarian.model).toBe("zai-coding-plan/glm-4.7") expect((result.agents as Record<string, { model: string }>).librarian.model).toBe("zai-coding-plan/glm-4.7")
// #then Sisyphus uses Claude (OR logic) // #then Sisyphus uses Claude (OR logic)
expect((result.agents as Record<string, { model: string }>).sisyphus.model).toBe("anthropic/claude-opus-4-5") expect((result.agents as Record<string, { model: string }>).sisyphus.model).toBe("anthropic/claude-opus-4-6")
}) })
test("uses native OpenAI models when only ChatGPT available", () => { test("uses native OpenAI models when only ChatGPT available", () => {

View File

@@ -14,9 +14,8 @@ describe("model-resolution check", () => {
// then: Should have agent entries // then: Should have agent entries
const sisyphus = info.agents.find((a) => a.name === "sisyphus") const sisyphus = info.agents.find((a) => a.name === "sisyphus")
expect(sisyphus).toBeDefined() expect(sisyphus).toBeDefined()
expect(sisyphus!.requirement.fallbackChain[0]?.model).toBe("claude-opus-4-5") expect(sisyphus!.requirement.fallbackChain[0]?.model).toBe("claude-opus-4-6")
expect(sisyphus!.requirement.fallbackChain[0]?.providers).toContain("anthropic") expect(sisyphus!.requirement.fallbackChain[0]?.providers).toContain("anthropic")
expect(sisyphus!.requirement.fallbackChain[0]?.providers).toContain("github-copilot")
}) })
it("returns category requirements with provider chains", async () => { it("returns category requirements with provider chains", async () => {
@@ -43,7 +42,7 @@ describe("model-resolution check", () => {
// given: User has override for oracle agent // given: User has override for oracle agent
const mockConfig = { const mockConfig = {
agents: { agents: {
oracle: { model: "anthropic/claude-opus-4-5" }, oracle: { model: "anthropic/claude-opus-4-6" },
}, },
} }
@@ -52,8 +51,8 @@ describe("model-resolution check", () => {
// then: Oracle should show the override // then: Oracle should show the override
const oracle = info.agents.find((a) => a.name === "oracle") const oracle = info.agents.find((a) => a.name === "oracle")
expect(oracle).toBeDefined() expect(oracle).toBeDefined()
expect(oracle!.userOverride).toBe("anthropic/claude-opus-4-5") expect(oracle!.userOverride).toBe("anthropic/claude-opus-4-6")
expect(oracle!.effectiveResolution).toBe("User override: anthropic/claude-opus-4-5") expect(oracle!.effectiveResolution).toBe("User override: anthropic/claude-opus-4-6")
}) })
it("shows user override for category when configured", async () => { it("shows user override for category when configured", async () => {
@@ -90,6 +89,46 @@ describe("model-resolution check", () => {
expect(sisyphus!.effectiveResolution).toContain("Provider fallback:") expect(sisyphus!.effectiveResolution).toContain("Provider fallback:")
expect(sisyphus!.effectiveResolution).toContain("anthropic") expect(sisyphus!.effectiveResolution).toContain("anthropic")
}) })
it("captures user variant for agent when configured", async () => {
const { getModelResolutionInfoWithOverrides } = await import("./model-resolution")
//#given User has model with variant override for oracle agent
const mockConfig = {
agents: {
oracle: { model: "openai/gpt-5.2", variant: "xhigh" },
},
}
//#when getting resolution info with config
const info = getModelResolutionInfoWithOverrides(mockConfig)
//#then Oracle should have userVariant set
const oracle = info.agents.find((a) => a.name === "oracle")
expect(oracle).toBeDefined()
expect(oracle!.userOverride).toBe("openai/gpt-5.2")
expect(oracle!.userVariant).toBe("xhigh")
})
it("captures user variant for category when configured", async () => {
const { getModelResolutionInfoWithOverrides } = await import("./model-resolution")
//#given User has model with variant override for visual-engineering category
const mockConfig = {
categories: {
"visual-engineering": { model: "google/gemini-3-flash-preview", variant: "high" },
},
}
//#when getting resolution info with config
const info = getModelResolutionInfoWithOverrides(mockConfig)
//#then visual-engineering should have userVariant set
const visual = info.categories.find((c) => c.name === "visual-engineering")
expect(visual).toBeDefined()
expect(visual!.userOverride).toBe("google/gemini-3-flash-preview")
expect(visual!.userVariant).toBe("high")
})
}) })
describe("checkModelResolution", () => { describe("checkModelResolution", () => {

View File

@@ -51,6 +51,7 @@ export interface AgentResolutionInfo {
name: string name: string
requirement: ModelRequirement requirement: ModelRequirement
userOverride?: string userOverride?: string
userVariant?: string
effectiveModel: string effectiveModel: string
effectiveResolution: string effectiveResolution: string
} }
@@ -59,6 +60,7 @@ export interface CategoryResolutionInfo {
name: string name: string
requirement: ModelRequirement requirement: ModelRequirement
userOverride?: string userOverride?: string
userVariant?: string
effectiveModel: string effectiveModel: string
effectiveResolution: string effectiveResolution: string
} }
@@ -69,8 +71,8 @@ export interface ModelResolutionInfo {
} }
interface OmoConfig { interface OmoConfig {
agents?: Record<string, { model?: string }> agents?: Record<string, { model?: string; variant?: string; category?: string }>
categories?: Record<string, { model?: string }> categories?: Record<string, { model?: string; variant?: string }>
} }
function loadConfig(): OmoConfig | null { function loadConfig(): OmoConfig | null {
@@ -152,10 +154,12 @@ export function getModelResolutionInfoWithOverrides(config: OmoConfig): ModelRes
const agents: AgentResolutionInfo[] = Object.entries(AGENT_MODEL_REQUIREMENTS).map( const agents: AgentResolutionInfo[] = Object.entries(AGENT_MODEL_REQUIREMENTS).map(
([name, requirement]) => { ([name, requirement]) => {
const userOverride = config.agents?.[name]?.model const userOverride = config.agents?.[name]?.model
const userVariant = config.agents?.[name]?.variant
return { return {
name, name,
requirement, requirement,
userOverride, userOverride,
userVariant,
effectiveModel: getEffectiveModel(requirement, userOverride), effectiveModel: getEffectiveModel(requirement, userOverride),
effectiveResolution: buildEffectiveResolution(requirement, userOverride), effectiveResolution: buildEffectiveResolution(requirement, userOverride),
} }
@@ -165,10 +169,12 @@ export function getModelResolutionInfoWithOverrides(config: OmoConfig): ModelRes
const categories: CategoryResolutionInfo[] = Object.entries(CATEGORY_MODEL_REQUIREMENTS).map( const categories: CategoryResolutionInfo[] = Object.entries(CATEGORY_MODEL_REQUIREMENTS).map(
([name, requirement]) => { ([name, requirement]) => {
const userOverride = config.categories?.[name]?.model const userOverride = config.categories?.[name]?.model
const userVariant = config.categories?.[name]?.variant
return { return {
name, name,
requirement, requirement,
userOverride, userOverride,
userVariant,
effectiveModel: getEffectiveModel(requirement, userOverride), effectiveModel: getEffectiveModel(requirement, userOverride),
effectiveResolution: buildEffectiveResolution(requirement, userOverride), effectiveResolution: buildEffectiveResolution(requirement, userOverride),
} }
@@ -182,7 +188,44 @@ function formatModelWithVariant(model: string, variant?: string): string {
return variant ? `${model} (${variant})` : model return variant ? `${model} (${variant})` : model
} }
function getEffectiveVariant(requirement: ModelRequirement): string | undefined { function getAgentOverride(
agentName: string,
config: OmoConfig,
): { variant?: string; category?: string } | undefined {
const agentOverrides = config.agents
if (!agentOverrides) return undefined
// Direct lookup first, then case-insensitive lookup (matches agent-variant.ts)
return (
agentOverrides[agentName] ??
Object.entries(agentOverrides).find(
([key]) => key.toLowerCase() === agentName.toLowerCase()
)?.[1]
)
}
function getEffectiveVariant(
name: string,
requirement: ModelRequirement,
config: OmoConfig,
): string | undefined {
const agentOverride = getAgentOverride(name, config)
// Priority 1: Agent's direct variant override
if (agentOverride?.variant) {
return agentOverride.variant
}
// Priority 2: Agent's category -> category's variant (matches agent-variant.ts)
const categoryName = agentOverride?.category
if (categoryName) {
const categoryVariant = config.categories?.[categoryName]?.variant
if (categoryVariant) {
return categoryVariant
}
}
// Priority 3: Fall back to requirement's fallback chain
const firstEntry = requirement.fallbackChain[0] const firstEntry = requirement.fallbackChain[0]
return firstEntry?.variant ?? requirement.variant return firstEntry?.variant ?? requirement.variant
} }
@@ -193,7 +236,20 @@ interface AvailableModelsInfo {
cacheExists: boolean cacheExists: boolean
} }
function buildDetailsArray(info: ModelResolutionInfo, available: AvailableModelsInfo): string[] { function getCategoryEffectiveVariant(
categoryName: string,
requirement: ModelRequirement,
config: OmoConfig,
): string | undefined {
const categoryVariant = config.categories?.[categoryName]?.variant
if (categoryVariant) {
return categoryVariant
}
const firstEntry = requirement.fallbackChain[0]
return firstEntry?.variant ?? requirement.variant
}
function buildDetailsArray(info: ModelResolutionInfo, available: AvailableModelsInfo, config: OmoConfig): string[] {
const details: string[] = [] const details: string[] = []
details.push("═══ Available Models (from cache) ═══") details.push("═══ Available Models (from cache) ═══")
@@ -215,14 +271,17 @@ function buildDetailsArray(info: ModelResolutionInfo, available: AvailableModels
details.push("Agents:") details.push("Agents:")
for (const agent of info.agents) { for (const agent of info.agents) {
const marker = agent.userOverride ? "●" : "○" const marker = agent.userOverride ? "●" : "○"
const display = formatModelWithVariant(agent.effectiveModel, getEffectiveVariant(agent.requirement)) const display = formatModelWithVariant(agent.effectiveModel, getEffectiveVariant(agent.name, agent.requirement, config))
details.push(` ${marker} ${agent.name}: ${display}`) details.push(` ${marker} ${agent.name}: ${display}`)
} }
details.push("") details.push("")
details.push("Categories:") details.push("Categories:")
for (const category of info.categories) { for (const category of info.categories) {
const marker = category.userOverride ? "●" : "○" const marker = category.userOverride ? "●" : "○"
const display = formatModelWithVariant(category.effectiveModel, getEffectiveVariant(category.requirement)) const display = formatModelWithVariant(
category.effectiveModel,
getCategoryEffectiveVariant(category.name, category.requirement, config)
)
details.push(` ${marker} ${category.name}: ${display}`) details.push(` ${marker} ${category.name}: ${display}`)
} }
details.push("") details.push("")
@@ -249,7 +308,7 @@ export async function checkModelResolution(): Promise<CheckResult> {
name: CHECK_NAMES[CHECK_IDS.MODEL_RESOLUTION], name: CHECK_NAMES[CHECK_IDS.MODEL_RESOLUTION],
status: available.cacheExists ? "pass" : "warn", status: available.cacheExists ? "pass" : "warn",
message: `${agentCount} agents, ${categoryCount} categories${overrideNote}${cacheNote}`, message: `${agentCount} agents, ${categoryCount} categories${overrideNote}${cacheNote}`,
details: buildDetailsArray(info, available), details: buildDetailsArray(info, available, config),
} }
} }

View File

@@ -43,7 +43,7 @@ Model Providers (Priority: Native > Copilot > OpenCode Zen > Z.ai > Kimi):
OpenAI Native openai/ models (GPT-5.2 for Oracle) OpenAI Native openai/ models (GPT-5.2 for Oracle)
Gemini Native google/ models (Gemini 3 Pro, Flash) Gemini Native google/ models (Gemini 3 Pro, Flash)
Copilot github-copilot/ models (fallback) Copilot github-copilot/ models (fallback)
OpenCode Zen opencode/ models (opencode/claude-opus-4-5, etc.) OpenCode Zen opencode/ models (opencode/claude-opus-4-6, etc.)
Z.ai zai-coding-plan/glm-4.7 (Librarian priority) Z.ai zai-coding-plan/glm-4.7 (Librarian priority)
Kimi kimi-for-coding/k2p5 (Sisyphus/Prometheus fallback) Kimi kimi-for-coding/k2p5 (Sisyphus/Prometheus fallback)
`) `)

View File

@@ -243,7 +243,7 @@ async function runTuiMode(detected: DetectedConfig): Promise<InstallConfig | nul
message: "Do you have access to OpenCode Zen (opencode/ models)?", message: "Do you have access to OpenCode Zen (opencode/ models)?",
options: [ options: [
{ value: "no" as const, label: "No", hint: "Will use other configured providers" }, { value: "no" as const, label: "No", hint: "Will use other configured providers" },
{ value: "yes" as const, label: "Yes", hint: "opencode/claude-opus-4-5, opencode/gpt-5.2, etc." }, { value: "yes" as const, label: "Yes", hint: "opencode/claude-opus-4-6, opencode/gpt-5.2, etc." },
], ],
initialValue: initial.opencodeZen, initialValue: initial.opencodeZen,
}) })

View File

@@ -376,7 +376,7 @@ describe("generateModelConfig", () => {
const result = generateModelConfig(config) const result = generateModelConfig(config)
// #then // #then
expect(result.agents?.sisyphus?.model).toBe("anthropic/claude-opus-4-5") expect(result.agents?.sisyphus?.model).toBe("anthropic/claude-opus-4-6")
}) })
test("Sisyphus is created when multiple fallback providers are available", () => { test("Sisyphus is created when multiple fallback providers are available", () => {
@@ -393,7 +393,7 @@ describe("generateModelConfig", () => {
const result = generateModelConfig(config) const result = generateModelConfig(config)
// #then // #then
expect(result.agents?.sisyphus?.model).toBe("anthropic/claude-opus-4-5") expect(result.agents?.sisyphus?.model).toBe("anthropic/claude-opus-4-6")
}) })
test("Sisyphus is omitted when no fallback provider is available (OpenAI not in chain)", () => { test("Sisyphus is omitted when no fallback provider is available (OpenAI not in chain)", () => {
@@ -409,7 +409,7 @@ describe("generateModelConfig", () => {
}) })
describe("Hephaestus agent special cases", () => { describe("Hephaestus agent special cases", () => {
test("Hephaestus is created when OpenAI is available (has gpt-5.2-codex)", () => { test("Hephaestus is created when OpenAI is available (openai provider connected)", () => {
// #given // #given
const config = createConfig({ hasOpenAI: true }) const config = createConfig({ hasOpenAI: true })
@@ -417,11 +417,11 @@ describe("generateModelConfig", () => {
const result = generateModelConfig(config) const result = generateModelConfig(config)
// #then // #then
expect(result.agents?.hephaestus?.model).toBe("openai/gpt-5.2-codex") expect(result.agents?.hephaestus?.model).toBe("openai/gpt-5.3-codex")
expect(result.agents?.hephaestus?.variant).toBe("medium") expect(result.agents?.hephaestus?.variant).toBe("medium")
}) })
test("Hephaestus is created when Copilot is available (has gpt-5.2-codex)", () => { test("Hephaestus is created when Copilot is available (github-copilot provider connected)", () => {
// #given // #given
const config = createConfig({ hasCopilot: true }) const config = createConfig({ hasCopilot: true })
@@ -429,11 +429,11 @@ describe("generateModelConfig", () => {
const result = generateModelConfig(config) const result = generateModelConfig(config)
// #then // #then
expect(result.agents?.hephaestus?.model).toBe("github-copilot/gpt-5.2-codex") expect(result.agents?.hephaestus?.model).toBe("github-copilot/gpt-5.3-codex")
expect(result.agents?.hephaestus?.variant).toBe("medium") expect(result.agents?.hephaestus?.variant).toBe("medium")
}) })
test("Hephaestus is created when OpenCode Zen is available (has gpt-5.2-codex)", () => { test("Hephaestus is created when OpenCode Zen is available (opencode provider connected)", () => {
// #given // #given
const config = createConfig({ hasOpencodeZen: true }) const config = createConfig({ hasOpencodeZen: true })
@@ -441,11 +441,11 @@ describe("generateModelConfig", () => {
const result = generateModelConfig(config) const result = generateModelConfig(config)
// #then // #then
expect(result.agents?.hephaestus?.model).toBe("opencode/gpt-5.2-codex") expect(result.agents?.hephaestus?.model).toBe("opencode/gpt-5.3-codex")
expect(result.agents?.hephaestus?.variant).toBe("medium") expect(result.agents?.hephaestus?.variant).toBe("medium")
}) })
test("Hephaestus is omitted when only Claude is available (no gpt-5.2-codex)", () => { test("Hephaestus is omitted when only Claude is available (no required provider connected)", () => {
// #given // #given
const config = createConfig({ hasClaude: true }) const config = createConfig({ hasClaude: true })
@@ -456,7 +456,7 @@ describe("generateModelConfig", () => {
expect(result.agents?.hephaestus).toBeUndefined() expect(result.agents?.hephaestus).toBeUndefined()
}) })
test("Hephaestus is omitted when only Gemini is available (no gpt-5.2-codex)", () => { test("Hephaestus is omitted when only Gemini is available (no required provider connected)", () => {
// #given // #given
const config = createConfig({ hasGemini: true }) const config = createConfig({ hasGemini: true })
@@ -467,7 +467,7 @@ describe("generateModelConfig", () => {
expect(result.agents?.hephaestus).toBeUndefined() expect(result.agents?.hephaestus).toBeUndefined()
}) })
test("Hephaestus is omitted when only ZAI is available (no gpt-5.2-codex)", () => { test("Hephaestus is omitted when only ZAI is available (no required provider connected)", () => {
// #given // #given
const config = createConfig({ hasZaiCodingPlan: true }) const config = createConfig({ hasZaiCodingPlan: true })

View File

@@ -71,7 +71,7 @@ function isProviderAvailable(provider: string, avail: ProviderAvailability): boo
function transformModelForProvider(provider: string, model: string): string { function transformModelForProvider(provider: string, model: string): string {
if (provider === "github-copilot") { if (provider === "github-copilot") {
return model return model
.replace("claude-opus-4-5", "claude-opus-4.5") .replace("claude-opus-4-6", "claude-opus-4.6")
.replace("claude-sonnet-4-5", "claude-sonnet-4.5") .replace("claude-sonnet-4-5", "claude-sonnet-4.5")
.replace("claude-haiku-4-5", "claude-haiku-4.5") .replace("claude-haiku-4-5", "claude-haiku-4.5")
.replace("claude-sonnet-4", "claude-sonnet-4") .replace("claude-sonnet-4", "claude-sonnet-4")
@@ -122,6 +122,13 @@ function isRequiredModelAvailable(
return matchingEntry.providers.some((provider) => isProviderAvailable(provider, avail)) return matchingEntry.providers.some((provider) => isProviderAvailable(provider, avail))
} }
function isRequiredProviderAvailable(
requiredProviders: string[],
avail: ProviderAvailability
): boolean {
return requiredProviders.some((provider) => isProviderAvailable(provider, avail))
}
export function generateModelConfig(config: InstallConfig): GeneratedOmoConfig { export function generateModelConfig(config: InstallConfig): GeneratedOmoConfig {
const avail = toProviderAvailability(config) const avail = toProviderAvailability(config)
const hasAnyProvider = const hasAnyProvider =
@@ -185,6 +192,9 @@ export function generateModelConfig(config: InstallConfig): GeneratedOmoConfig {
if (req.requiresModel && !isRequiredModelAvailable(req.requiresModel, req.fallbackChain, avail)) { if (req.requiresModel && !isRequiredModelAvailable(req.requiresModel, req.fallbackChain, avail)) {
continue continue
} }
if (req.requiresProvider && !isRequiredProviderAvailable(req.requiresProvider, avail)) {
continue
}
const resolved = resolveModelFromChain(req.fallbackChain, avail) const resolved = resolveModelFromChain(req.fallbackChain, avail)
if (resolved) { if (resolved) {
@@ -205,6 +215,9 @@ export function generateModelConfig(config: InstallConfig): GeneratedOmoConfig {
if (req.requiresModel && !isRequiredModelAvailable(req.requiresModel, req.fallbackChain, avail)) { if (req.requiresModel && !isRequiredModelAvailable(req.requiresModel, req.fallbackChain, avail)) {
continue continue
} }
if (req.requiresProvider && !isRequiredProviderAvailable(req.requiresProvider, avail)) {
continue
}
const resolved = resolveModelFromChain(fallbackChain, avail) const resolved = resolveModelFromChain(fallbackChain, avail)
if (resolved) { if (resolved) {

View File

@@ -5,6 +5,7 @@ import { checkCompletionConditions } from "./completion"
import { createEventState, processEvents, serializeError } from "./events" import { createEventState, processEvents, serializeError } from "./events"
import type { OhMyOpenCodeConfig } from "../../config" import type { OhMyOpenCodeConfig } from "../../config"
import { loadPluginConfig } from "../../plugin-config" import { loadPluginConfig } from "../../plugin-config"
import { getAvailableServerPort, DEFAULT_SERVER_PORT } from "../../shared/port-utils"
const POLL_INTERVAL_MS = 500 const POLL_INTERVAL_MS = 500
const DEFAULT_TIMEOUT_MS = 0 const DEFAULT_TIMEOUT_MS = 0
@@ -89,7 +90,7 @@ export async function run(options: RunOptions): Promise<number> {
const pluginConfig = loadPluginConfig(directory, { command: "run" }) const pluginConfig = loadPluginConfig(directory, { command: "run" })
const resolvedAgent = resolveRunAgent(options, pluginConfig) const resolvedAgent = resolveRunAgent(options, pluginConfig)
console.log(pc.cyan("Starting opencode server...")) console.log(pc.cyan("Starting opencode server (auto port selection enabled)..."))
const abortController = new AbortController() const abortController = new AbortController()
let timeoutId: ReturnType<typeof setTimeout> | null = null let timeoutId: ReturnType<typeof setTimeout> | null = null
@@ -103,18 +104,24 @@ export async function run(options: RunOptions): Promise<number> {
} }
try { try {
// Support custom OpenCode server port via environment variable const envPort = process.env.OPENCODE_SERVER_PORT
// This allows Open Agent and other orchestrators to run multiple
// concurrent missions without port conflicts
const serverPort = process.env.OPENCODE_SERVER_PORT
? parseInt(process.env.OPENCODE_SERVER_PORT, 10) ? parseInt(process.env.OPENCODE_SERVER_PORT, 10)
: undefined : undefined
const serverHostname = process.env.OPENCODE_SERVER_HOSTNAME || undefined const serverHostname = process.env.OPENCODE_SERVER_HOSTNAME || "127.0.0.1"
const preferredPort = envPort && !isNaN(envPort) ? envPort : DEFAULT_SERVER_PORT
const { port: serverPort, wasAutoSelected } = await getAvailableServerPort(preferredPort, serverHostname)
if (wasAutoSelected) {
console.log(pc.yellow(`Port ${preferredPort} is busy, using port ${serverPort} instead`))
} else {
console.log(pc.dim(`Using port ${serverPort}`))
}
const { client, server } = await createOpencode({ const { client, server } = await createOpencode({
signal: abortController.signal, signal: abortController.signal,
...(serverPort && !isNaN(serverPort) ? { port: serverPort } : {}), port: serverPort,
...(serverHostname ? { hostname: serverHostname } : {}), hostname: serverHostname,
}) })
const cleanup = () => { const cleanup = () => {

View File

@@ -32,6 +32,7 @@ export const BuiltinAgentNameSchema = z.enum([
export const BuiltinSkillNameSchema = z.enum([ export const BuiltinSkillNameSchema = z.enum([
"playwright", "playwright",
"agent-browser", "agent-browser",
"dev-browser",
"frontend-ui-ux", "frontend-ui-ux",
"git-master", "git-master",
]) ])
@@ -63,10 +64,12 @@ export const HookNameSchema = z.enum([
"comment-checker", "comment-checker",
"grep-output-truncator", "grep-output-truncator",
"tool-output-truncator", "tool-output-truncator",
"question-label-truncator",
"directory-agents-injector", "directory-agents-injector",
"directory-readme-injector", "directory-readme-injector",
"empty-task-response-detector", "empty-task-response-detector",
"think-mode", "think-mode",
"subagent-question-blocker",
"anthropic-context-window-limit-recovery", "anthropic-context-window-limit-recovery",
"preemptive-compaction", "preemptive-compaction",
"rules-injector", "rules-injector",
@@ -92,13 +95,21 @@ export const HookNameSchema = z.enum([
"start-work", "start-work",
"atlas", "atlas",
"unstable-agent-babysitter", "unstable-agent-babysitter",
"task-reminder",
"task-resume-info",
"stop-continuation-guard", "stop-continuation-guard",
"tasks-todowrite-disabler", "tasks-todowrite-disabler",
"write-existing-file-guard",
]) ])
export const BuiltinCommandNameSchema = z.enum([ export const BuiltinCommandNameSchema = z.enum([
"init-deep", "init-deep",
"ralph-loop",
"ulw-loop",
"cancel-ralph",
"refactor",
"start-work", "start-work",
"stop-continuation",
]) ])
export const AgentOverrideConfigSchema = z.object({ export const AgentOverrideConfigSchema = z.object({
@@ -340,6 +351,17 @@ export const BrowserAutomationConfigSchema = z.object({
provider: BrowserAutomationProviderSchema.default("playwright"), provider: BrowserAutomationProviderSchema.default("playwright"),
}) })
export const WebsearchProviderSchema = z.enum(["exa", "tavily"])
export const WebsearchConfigSchema = z.object({
/**
* Websearch provider to use.
* - "exa": Uses Exa websearch (default, works without API key)
* - "tavily": Uses Tavily websearch (requires TAVILY_API_KEY)
*/
provider: WebsearchProviderSchema.optional(),
})
export const TmuxLayoutSchema = z.enum([ export const TmuxLayoutSchema = z.enum([
'main-horizontal', // main pane top, agent panes bottom stack 'main-horizontal', // main pane top, agent panes bottom stack
'main-vertical', // main pane left, agent panes right stack (default) 'main-vertical', // main pane left, agent panes right stack (default)
@@ -357,8 +379,10 @@ export const TmuxConfigSchema = z.object({
}) })
export const SisyphusTasksConfigSchema = z.object({ export const SisyphusTasksConfigSchema = z.object({
/** Storage path for tasks (default: .sisyphus/tasks) */ /** Absolute or relative storage path override. When set, bypasses global config dir. */
storage_path: z.string().default(".sisyphus/tasks"), storage_path: z.string().optional(),
/** Force task list ID (alternative to env ULTRAWORK_TASK_LIST_ID) */
task_list_id: z.string().optional(),
/** Enable Claude Code path compatibility mode */ /** Enable Claude Code path compatibility mode */
claude_code_compat: z.boolean().default(false), claude_code_compat: z.boolean().default(false),
}) })
@@ -393,6 +417,7 @@ export const OhMyOpenCodeConfigSchema = z.object({
babysitting: BabysittingConfigSchema.optional(), babysitting: BabysittingConfigSchema.optional(),
git_master: GitMasterConfigSchema.optional(), git_master: GitMasterConfigSchema.optional(),
browser_automation_engine: BrowserAutomationConfigSchema.optional(), browser_automation_engine: BrowserAutomationConfigSchema.optional(),
websearch: WebsearchConfigSchema.optional(),
tmux: TmuxConfigSchema.optional(), tmux: TmuxConfigSchema.optional(),
sisyphus: SisyphusConfigSchema.optional(), sisyphus: SisyphusConfigSchema.optional(),
}) })
@@ -420,6 +445,8 @@ export type BuiltinCategoryName = z.infer<typeof BuiltinCategoryNameSchema>
export type GitMasterConfig = z.infer<typeof GitMasterConfigSchema> export type GitMasterConfig = z.infer<typeof GitMasterConfigSchema>
export type BrowserAutomationProvider = z.infer<typeof BrowserAutomationProviderSchema> export type BrowserAutomationProvider = z.infer<typeof BrowserAutomationProviderSchema>
export type BrowserAutomationConfig = z.infer<typeof BrowserAutomationConfigSchema> export type BrowserAutomationConfig = z.infer<typeof BrowserAutomationConfigSchema>
export type WebsearchProvider = z.infer<typeof WebsearchProviderSchema>
export type WebsearchConfig = z.infer<typeof WebsearchConfigSchema>
export type TmuxConfig = z.infer<typeof TmuxConfigSchema> export type TmuxConfig = z.infer<typeof TmuxConfigSchema>
export type TmuxLayout = z.infer<typeof TmuxLayoutSchema> export type TmuxLayout = z.infer<typeof TmuxLayoutSchema>
export type SisyphusTasksConfig = z.infer<typeof SisyphusTasksConfigSchema> export type SisyphusTasksConfig = z.infer<typeof SisyphusTasksConfigSchema>

View File

@@ -2,7 +2,7 @@
## OVERVIEW ## OVERVIEW
20 feature modules: background agents, skill MCPs, builtin skills/commands, Claude Code compatibility layer. 17 feature modules: background agents, skill MCPs, builtin skills/commands, Claude Code compatibility layer, task management.
**Feature Types**: Task orchestration, Skill definitions, Command templates, Claude Code loaders, Supporting utilities **Feature Types**: Task orchestration, Skill definitions, Command templates, Claude Code loaders, Supporting utilities
@@ -10,27 +10,25 @@
``` ```
features/ features/
├── background-agent/ # Task lifecycle (1418 lines) ├── background-agent/ # Task lifecycle (1556 lines)
│ ├── manager.ts # Launch → poll → complete │ ├── manager.ts # Launch → poll → complete
│ └── concurrency.ts # Per-provider limits │ └── concurrency.ts # Per-provider limits
├── builtin-skills/ # Core skills (1729 lines) ├── builtin-skills/ # Core skills
│ └── skills.ts # playwright, dev-browser, frontend-ui-ux, git-master, typescript-programmer │ └── skills/ # playwright, agent-browser, frontend-ui-ux, git-master, dev-browser
├── builtin-commands/ # ralph-loop, refactor, ulw-loop, init-deep, start-work, cancel-ralph, stop-continuation ├── builtin-commands/ # ralph-loop, refactor, ulw-loop, init-deep, start-work, cancel-ralph, stop-continuation
├── claude-code-agent-loader/ # ~/.claude/agents/*.md ├── claude-code-agent-loader/ # ~/.claude/agents/*.md
├── claude-code-command-loader/ # ~/.claude/commands/*.md ├── claude-code-command-loader/ # ~/.claude/commands/*.md
├── claude-code-mcp-loader/ # .mcp.json with ${VAR} expansion ├── claude-code-mcp-loader/ # .mcp.json with ${VAR} expansion
├── claude-code-plugin-loader/ # installed_plugins.json ├── claude-code-plugin-loader/ # installed_plugins.json (486 lines)
├── claude-code-session-state/ # Session persistence ├── claude-code-session-state/ # Session persistence
├── opencode-skill-loader/ # Skills from 6 directories ├── opencode-skill-loader/ # Skills from 6 directories (loader.ts 311 lines)
├── context-injector/ # AGENTS.md/README.md injection ├── context-injector/ # AGENTS.md/README.md injection
├── boulder-state/ # Todo state persistence ├── boulder-state/ # Todo state persistence
├── hook-message-injector/ # Message injection ├── hook-message-injector/ # Message injection
├── task-toast-manager/ # Background task notifications ├── task-toast-manager/ # Background task notifications
├── skill-mcp-manager/ # MCP client lifecycle (617 lines) ├── skill-mcp-manager/ # MCP client lifecycle (640 lines)
├── tmux-subagent/ # Tmux session management ├── tmux-subagent/ # Tmux session management (472 lines)
├── mcp-oauth/ # MCP OAuth handling ├── mcp-oauth/ # MCP OAuth handling
├── sisyphus-swarm/ # Swarm coordination
├── sisyphus-tasks/ # Task tracking
└── claude-tasks/ # Task schema/storage - see AGENTS.md └── claude-tasks/ # Task schema/storage - see AGENTS.md
``` ```

View File

@@ -94,7 +94,7 @@ describe("ConcurrencyManager.getConcurrencyLimit", () => {
// when // when
const modelLimit = manager.getConcurrencyLimit("anthropic/claude-sonnet-4-5") const modelLimit = manager.getConcurrencyLimit("anthropic/claude-sonnet-4-5")
const providerLimit = manager.getConcurrencyLimit("anthropic/claude-opus-4-5") const providerLimit = manager.getConcurrencyLimit("anthropic/claude-opus-4-6")
const defaultLimit = manager.getConcurrencyLimit("google/gemini-3-pro") const defaultLimit = manager.getConcurrencyLimit("google/gemini-3-pro")
// then // then

View File

@@ -783,7 +783,7 @@ describe("BackgroundManager.notifyParentSession - dynamic message lookup", () =>
} }
const currentMessage: CurrentMessage = { const currentMessage: CurrentMessage = {
agent: "sisyphus", agent: "sisyphus",
model: { providerID: "anthropic", modelID: "claude-opus-4-5" }, model: { providerID: "anthropic", modelID: "claude-opus-4-6" },
} }
// when // when
@@ -791,7 +791,7 @@ describe("BackgroundManager.notifyParentSession - dynamic message lookup", () =>
// then - uses currentMessage values, not task.parentModel/parentAgent // then - uses currentMessage values, not task.parentModel/parentAgent
expect(promptBody.agent).toBe("sisyphus") expect(promptBody.agent).toBe("sisyphus")
expect(promptBody.model).toEqual({ providerID: "anthropic", modelID: "claude-opus-4-5" }) expect(promptBody.model).toEqual({ providerID: "anthropic", modelID: "claude-opus-4-6" })
}) })
test("should fallback to parentAgent when currentMessage.agent is undefined", async () => { test("should fallback to parentAgent when currentMessage.agent is undefined", async () => {
@@ -875,6 +875,90 @@ describe("BackgroundManager.notifyParentSession - dynamic message lookup", () =>
}) })
}) })
describe("BackgroundManager.notifyParentSession - aborted parent", () => {
test("should skip notification when parent session is aborted", async () => {
//#given
let promptCalled = false
const client = {
session: {
prompt: async () => {
promptCalled = true
return {}
},
abort: async () => ({}),
messages: async () => {
const error = new Error("User aborted")
error.name = "MessageAbortedError"
throw error
},
},
}
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput)
const task: BackgroundTask = {
id: "task-aborted-parent",
sessionID: "session-child",
parentSessionID: "session-parent",
parentMessageID: "msg-parent",
description: "task aborted parent",
prompt: "test",
agent: "explore",
status: "completed",
startedAt: new Date(),
completedAt: new Date(),
}
getPendingByParent(manager).set("session-parent", new Set([task.id, "task-remaining"]))
//#when
await (manager as unknown as { notifyParentSession: (task: BackgroundTask) => Promise<void> })
.notifyParentSession(task)
//#then
expect(promptCalled).toBe(false)
manager.shutdown()
})
test("should swallow aborted error from prompt", async () => {
//#given
let promptCalled = false
const client = {
session: {
prompt: async () => {
promptCalled = true
const error = new Error("User aborted")
error.name = "MessageAbortedError"
throw error
},
abort: async () => ({}),
messages: async () => ({ data: [] }),
},
}
const manager = new BackgroundManager({ client, directory: tmpdir() } as unknown as PluginInput)
const task: BackgroundTask = {
id: "task-aborted-prompt",
sessionID: "session-child",
parentSessionID: "session-parent",
parentMessageID: "msg-parent",
description: "task aborted prompt",
prompt: "test",
agent: "explore",
status: "completed",
startedAt: new Date(),
completedAt: new Date(),
}
getPendingByParent(manager).set("session-parent", new Set([task.id]))
//#when
await (manager as unknown as { notifyParentSession: (task: BackgroundTask) => Promise<void> })
.notifyParentSession(task)
//#then
expect(promptCalled).toBe(true)
manager.shutdown()
})
})
function buildNotificationPromptBody( function buildNotificationPromptBody(
task: BackgroundTask, task: BackgroundTask,
currentMessage: CurrentMessage | null currentMessage: CurrentMessage | null
@@ -913,7 +997,7 @@ describe("BackgroundManager.tryCompleteTask", () => {
test("should release concurrency and clear key on completion", async () => { test("should release concurrency and clear key on completion", async () => {
// given // given
const concurrencyKey = "anthropic/claude-opus-4-5" const concurrencyKey = "anthropic/claude-opus-4-6"
const concurrencyManager = getConcurrencyManager(manager) const concurrencyManager = getConcurrencyManager(manager)
await concurrencyManager.acquire(concurrencyKey) await concurrencyManager.acquire(concurrencyKey)
@@ -942,7 +1026,7 @@ describe("BackgroundManager.tryCompleteTask", () => {
test("should prevent double completion and double release", async () => { test("should prevent double completion and double release", async () => {
// given // given
const concurrencyKey = "anthropic/claude-opus-4-5" const concurrencyKey = "anthropic/claude-opus-4-6"
const concurrencyManager = getConcurrencyManager(manager) const concurrencyManager = getConcurrencyManager(manager)
await concurrencyManager.acquire(concurrencyKey) await concurrencyManager.acquire(concurrencyKey)
@@ -1573,7 +1657,7 @@ describe("BackgroundManager - Non-blocking Queue Integration", () => {
description: "Task 1", description: "Task 1",
prompt: "Do something", prompt: "Do something",
agent: "test-agent", agent: "test-agent",
model: { providerID: "anthropic", modelID: "claude-opus-4-5" }, model: { providerID: "anthropic", modelID: "claude-opus-4-6" },
parentSessionID: "parent-session", parentSessionID: "parent-session",
parentMessageID: "parent-message", parentMessageID: "parent-message",
} }

View File

@@ -351,6 +351,11 @@ export class BackgroundManager {
existingTask.concurrencyKey = undefined existingTask.concurrencyKey = undefined
} }
// Abort the session to prevent infinite polling hang
this.client.session.abort({
path: { id: sessionID },
}).catch(() => {})
this.markForNotification(existingTask) this.markForNotification(existingTask)
this.notifyParentSession(existingTask).catch(err => { this.notifyParentSession(existingTask).catch(err => {
log("[background-agent] Failed to notify on error:", err) log("[background-agent] Failed to notify on error:", err)
@@ -600,6 +605,14 @@ export class BackgroundManager {
this.concurrencyManager.release(existingTask.concurrencyKey) this.concurrencyManager.release(existingTask.concurrencyKey)
existingTask.concurrencyKey = undefined existingTask.concurrencyKey = undefined
} }
// Abort the session to prevent infinite polling hang
if (existingTask.sessionID) {
this.client.session.abort({
path: { id: existingTask.sessionID },
}).catch(() => {})
}
this.markForNotification(existingTask) this.markForNotification(existingTask)
this.notifyParentSession(existingTask).catch(err => { this.notifyParentSession(existingTask).catch(err => {
log("[background-agent] Failed to notify on resume error:", err) log("[background-agent] Failed to notify on resume error:", err)
@@ -1110,7 +1123,14 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
break break
} }
} }
} catch { } catch (error) {
if (this.isAbortedSessionError(error)) {
log("[background-agent] Parent session aborted, skipping notification:", {
taskId: task.id,
parentSessionID: task.parentSessionID,
})
return
}
const messageDir = getMessageDir(task.parentSessionID) const messageDir = getMessageDir(task.parentSessionID)
const currentMessage = messageDir ? findNearestMessageWithFields(messageDir) : null const currentMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
agent = currentMessage?.agent ?? task.parentAgent agent = currentMessage?.agent ?? task.parentAgent
@@ -1141,6 +1161,13 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
noReply: !allComplete, noReply: !allComplete,
}) })
} catch (error) { } catch (error) {
if (this.isAbortedSessionError(error)) {
log("[background-agent] Parent session aborted, skipping notification:", {
taskId: task.id,
parentSessionID: task.parentSessionID,
})
return
}
log("[background-agent] Failed to send notification:", error) log("[background-agent] Failed to send notification:", error)
} }
@@ -1179,6 +1206,28 @@ Use \`background_output(task_id="${task.id}")\` to retrieve this result when rea
return `${seconds}s` return `${seconds}s`
} }
private isAbortedSessionError(error: unknown): boolean {
const message = this.getErrorText(error)
return message.toLowerCase().includes("aborted")
}
private getErrorText(error: unknown): string {
if (!error) return ""
if (typeof error === "string") return error
if (error instanceof Error) {
return `${error.name}: ${error.message}`
}
if (typeof error === "object" && error !== null) {
if ("message" in error && typeof error.message === "string") {
return error.message
}
if ("name" in error && typeof error.name === "string") {
return error.name
}
}
return ""
}
private hasRunningTasks(): boolean { private hasRunningTasks(): boolean {
for (const task of this.tasks.values()) { for (const task of this.tasks.values()) {
if (task.status === "running") return true if (task.status === "running") return true

View File

@@ -246,5 +246,33 @@ describe("boulder-state", () => {
expect(state.plan_name).toBe("auth-refactor") expect(state.plan_name).toBe("auth-refactor")
expect(state.started_at).toBeDefined() expect(state.started_at).toBeDefined()
}) })
test("should include agent field when provided", () => {
//#given - plan path, session id, and agent type
const planPath = "/path/to/feature.md"
const sessionId = "ses-xyz789"
const agent = "atlas"
//#when - createBoulderState is called with agent
const state = createBoulderState(planPath, sessionId, agent)
//#then - state should include the agent field
expect(state.agent).toBe("atlas")
expect(state.active_plan).toBe(planPath)
expect(state.session_ids).toEqual([sessionId])
expect(state.plan_name).toBe("feature")
})
test("should allow agent to be undefined", () => {
//#given - plan path and session id without agent
const planPath = "/path/to/legacy.md"
const sessionId = "ses-legacy"
//#when - createBoulderState is called without agent
const state = createBoulderState(planPath, sessionId)
//#then - state should not have agent field (backward compatible)
expect(state.agent).toBeUndefined()
})
}) })
}) })

View File

@@ -139,12 +139,14 @@ export function getPlanName(planPath: string): string {
*/ */
export function createBoulderState( export function createBoulderState(
planPath: string, planPath: string,
sessionId: string sessionId: string,
agent?: string
): BoulderState { ): BoulderState {
return { return {
active_plan: planPath, active_plan: planPath,
started_at: new Date().toISOString(), started_at: new Date().toISOString(),
session_ids: [sessionId], session_ids: [sessionId],
plan_name: getPlanName(planPath), plan_name: getPlanName(planPath),
...(agent !== undefined ? { agent } : {}),
} }
} }

View File

@@ -14,6 +14,8 @@ export interface BoulderState {
session_ids: string[] session_ids: string[]
/** Plan name derived from filename */ /** Plan name derived from filename */
plan_name: string plan_name: string
/** Agent type to use when resuming (e.g., 'atlas') */
agent?: string
} }
export interface PlanProgress { export interface PlanProgress {

View File

@@ -86,4 +86,58 @@ describe("createBuiltinSkills", () => {
expect(defaultSkills).toHaveLength(4) expect(defaultSkills).toHaveLength(4)
expect(agentBrowserSkills).toHaveLength(4) expect(agentBrowserSkills).toHaveLength(4)
}) })
test("should exclude playwright when it is in disabledSkills", () => {
// #given
const options = { disabledSkills: new Set(["playwright"]) }
// #when
const skills = createBuiltinSkills(options)
// #then
expect(skills.map((s) => s.name)).not.toContain("playwright")
expect(skills.map((s) => s.name)).toContain("frontend-ui-ux")
expect(skills.map((s) => s.name)).toContain("git-master")
expect(skills.map((s) => s.name)).toContain("dev-browser")
expect(skills.length).toBe(3)
})
test("should exclude multiple skills when they are in disabledSkills", () => {
// #given
const options = { disabledSkills: new Set(["playwright", "git-master"]) }
// #when
const skills = createBuiltinSkills(options)
// #then
expect(skills.map((s) => s.name)).not.toContain("playwright")
expect(skills.map((s) => s.name)).not.toContain("git-master")
expect(skills.map((s) => s.name)).toContain("frontend-ui-ux")
expect(skills.map((s) => s.name)).toContain("dev-browser")
expect(skills.length).toBe(2)
})
test("should return an empty array when all skills are disabled", () => {
// #given
const options = {
disabledSkills: new Set(["playwright", "frontend-ui-ux", "git-master", "dev-browser"]),
}
// #when
const skills = createBuiltinSkills(options)
// #then
expect(skills.length).toBe(0)
})
test("should return all skills when disabledSkills set is empty", () => {
// #given
const options = { disabledSkills: new Set<string>() }
// #when
const skills = createBuiltinSkills(options)
// #then
expect(skills.length).toBe(4)
})
}) })

View File

@@ -11,12 +11,19 @@ import {
export interface CreateBuiltinSkillsOptions { export interface CreateBuiltinSkillsOptions {
browserProvider?: BrowserAutomationProvider browserProvider?: BrowserAutomationProvider
disabledSkills?: Set<string>
} }
export function createBuiltinSkills(options: CreateBuiltinSkillsOptions = {}): BuiltinSkill[] { export function createBuiltinSkills(options: CreateBuiltinSkillsOptions = {}): BuiltinSkill[] {
const { browserProvider = "playwright" } = options const { browserProvider = "playwright", disabledSkills } = options
const browserSkill = browserProvider === "agent-browser" ? agentBrowserSkill : playwrightSkill const browserSkill = browserProvider === "agent-browser" ? agentBrowserSkill : playwrightSkill
return [browserSkill, frontendUiUxSkill, gitMasterSkill, devBrowserSkill] const skills = [browserSkill, frontendUiUxSkill, gitMasterSkill, devBrowserSkill]
if (!disabledSkills) {
return skills
}
return skills.filter((skill) => !disabledSkills.has(skill.name))
} }

View File

@@ -12,6 +12,7 @@ claude-tasks/
├── types.test.ts # Schema validation tests (8 tests) ├── types.test.ts # Schema validation tests (8 tests)
├── storage.ts # File operations ├── storage.ts # File operations
├── storage.test.ts # Storage tests (14 tests) ├── storage.test.ts # Storage tests (14 tests)
├── todo-sync.ts # Task → Todo synchronization
└── index.ts # Barrel exports └── index.ts # Barrel exports
``` ```
@@ -44,67 +45,21 @@ interface Task {
## TODO SYNC ## TODO SYNC
The task system includes a sync layer (`todo-sync.ts`) that automatically mirrors task state to the project's Todo system. Task system includes sync layer (`todo-sync.ts`) that automatically mirrors task state to the project's Todo system.
- **Creation**: Creating a task via `task_create` adds a corresponding item to the Todo list. - **Creation**: `task_create` adds corresponding Todo item
- **Updates**: Updating a task's `status` or `subject` via `task_update` reflects in the Todo list. - **Updates**: `task_update` reflects in Todo list
- **Completion**: Marking a task as `completed` automatically marks the Todo item as done. - **Completion**: `completed` status marks Todo item done
## STORAGE UTILITIES ## STORAGE UTILITIES
### getTaskDir(config) | Function | Purpose |
|----------|---------|
Returns: `.sisyphus/tasks` (or custom path from config) | `getTaskDir(config)` | Returns task storage directory path |
| `resolveTaskListId(config)` | Resolves task list ID (env → config → cwd basename) |
### readJsonSafe(filePath, schema) | `readJsonSafe(path, schema)` | Parse + validate, returns null on failure |
| `writeJsonAtomic(path, data)` | Atomic write via temp file + rename |
- Returns parsed & validated data or `null` | `acquireLock(dirPath)` | File-based lock with 30s stale threshold |
- Safe for missing files, invalid JSON, schema violations
### writeJsonAtomic(filePath, data)
- Atomic write via temp file + rename
- Creates parent directories automatically
- Cleans up temp file on error
### acquireLock(dirPath)
- File-based lock: `.lock` file with timestamp
- 30-second stale threshold
- Returns `{ acquired: boolean, release: () => void }`
## TESTING
**types.test.ts** (8 tests):
- Valid status enum values
- Required vs optional fields
- Array validation (blocks, blockedBy)
- Schema rejection for invalid data
**storage.test.ts** (14 tests):
- Path construction
- Safe JSON reading (missing files, invalid JSON, schema failures)
- Atomic writes (directory creation, overwrites)
- Lock acquisition (fresh locks, stale locks, release)
## USAGE
```typescript
import { TaskSchema, getTaskDir, readJsonSafe, writeJsonAtomic, acquireLock } from "./features/claude-tasks"
const taskDir = getTaskDir(config)
const lock = acquireLock(taskDir)
try {
const task = readJsonSafe(join(taskDir, "1.json"), TaskSchema)
if (task) {
task.status = "completed"
writeJsonAtomic(join(taskDir, "1.json"), task)
}
} finally {
lock.release()
}
```
## ANTI-PATTERNS ## ANTI-PATTERNS

View File

@@ -1,26 +1,99 @@
import { describe, test, expect, beforeEach, afterEach } from "bun:test" import { describe, test, expect, beforeEach, afterEach } from "bun:test"
import { existsSync, mkdirSync, rmSync, writeFileSync } from "fs" import { existsSync, mkdirSync, rmSync, writeFileSync } from "fs"
import { join } from "path" import { join, basename } from "path"
import { z } from "zod" import { z } from "zod"
import { getTaskDir, readJsonSafe, writeJsonAtomic, acquireLock, generateTaskId, listTaskFiles } from "./storage" import { getOpenCodeConfigDir } from "../../shared/opencode-config-dir"
import {
getTaskDir,
readJsonSafe,
writeJsonAtomic,
acquireLock,
generateTaskId,
listTaskFiles,
resolveTaskListId,
sanitizePathSegment,
} from "./storage"
import type { OhMyOpenCodeConfig } from "../../config/schema" import type { OhMyOpenCodeConfig } from "../../config/schema"
const TEST_DIR = ".test-claude-tasks" const TEST_DIR = ".test-claude-tasks"
const TEST_DIR_ABS = join(process.cwd(), TEST_DIR) const TEST_DIR_ABS = join(process.cwd(), TEST_DIR)
describe("getTaskDir", () => { describe("getTaskDir", () => {
test("returns correct path for default config", () => { const originalTaskListId = process.env.ULTRAWORK_TASK_LIST_ID
beforeEach(() => {
if (originalTaskListId === undefined) {
delete process.env.ULTRAWORK_TASK_LIST_ID
} else {
process.env.ULTRAWORK_TASK_LIST_ID = originalTaskListId
}
})
afterEach(() => {
if (originalTaskListId === undefined) {
delete process.env.ULTRAWORK_TASK_LIST_ID
} else {
process.env.ULTRAWORK_TASK_LIST_ID = originalTaskListId
}
})
test("returns global config path for default config", () => {
//#given //#given
const config: Partial<OhMyOpenCodeConfig> = {} const config: Partial<OhMyOpenCodeConfig> = {}
const configDir = getOpenCodeConfigDir({ binary: "opencode" })
const expectedListId = sanitizePathSegment(basename(process.cwd()))
//#when //#when
const result = getTaskDir(config) const result = getTaskDir(config)
//#then //#then
expect(result).toBe(join(process.cwd(), ".sisyphus/tasks")) expect(result).toBe(join(configDir, "tasks", expectedListId))
}) })
test("returns correct path with custom storage_path", () => { test("respects ULTRAWORK_TASK_LIST_ID env var", () => {
//#given
process.env.ULTRAWORK_TASK_LIST_ID = "custom list/id"
const configDir = getOpenCodeConfigDir({ binary: "opencode" })
//#when
const result = getTaskDir()
//#then
expect(result).toBe(join(configDir, "tasks", "custom-list-id"))
})
test("falls back to sanitized cwd basename when env var not set", () => {
//#given
delete process.env.ULTRAWORK_TASK_LIST_ID
const configDir = getOpenCodeConfigDir({ binary: "opencode" })
const expectedListId = sanitizePathSegment(basename(process.cwd()))
//#when
const result = getTaskDir()
//#then
expect(result).toBe(join(configDir, "tasks", expectedListId))
})
test("returns absolute storage_path without joining cwd", () => {
//#given
const config: Partial<OhMyOpenCodeConfig> = {
sisyphus: {
tasks: {
storage_path: "/tmp/custom-task-path",
claude_code_compat: false,
},
},
}
//#when
const result = getTaskDir(config)
//#then
expect(result).toBe("/tmp/custom-task-path")
})
test("joins relative storage_path with cwd", () => {
//#given //#given
const config: Partial<OhMyOpenCodeConfig> = { const config: Partial<OhMyOpenCodeConfig> = {
sisyphus: { sisyphus: {
@@ -37,13 +110,59 @@ describe("getTaskDir", () => {
//#then //#then
expect(result).toBe(join(process.cwd(), ".custom/tasks")) expect(result).toBe(join(process.cwd(), ".custom/tasks"))
}) })
})
describe("resolveTaskListId", () => {
const originalTaskListId = process.env.ULTRAWORK_TASK_LIST_ID
beforeEach(() => {
if (originalTaskListId === undefined) {
delete process.env.ULTRAWORK_TASK_LIST_ID
} else {
process.env.ULTRAWORK_TASK_LIST_ID = originalTaskListId
}
})
afterEach(() => {
if (originalTaskListId === undefined) {
delete process.env.ULTRAWORK_TASK_LIST_ID
} else {
process.env.ULTRAWORK_TASK_LIST_ID = originalTaskListId
}
})
test("returns env var when set", () => {
//#given
process.env.ULTRAWORK_TASK_LIST_ID = "custom-list"
test("returns correct path with default config parameter", () => {
//#when //#when
const result = getTaskDir() const result = resolveTaskListId()
//#then //#then
expect(result).toBe(join(process.cwd(), ".sisyphus/tasks")) expect(result).toBe("custom-list")
})
test("sanitizes special characters", () => {
//#given
process.env.ULTRAWORK_TASK_LIST_ID = "custom list/id"
//#when
const result = resolveTaskListId()
//#then
expect(result).toBe("custom-list-id")
})
test("returns sanitized cwd basename when env var not set", () => {
//#given
delete process.env.ULTRAWORK_TASK_LIST_ID
const expected = sanitizePathSegment(basename(process.cwd()))
//#when
const result = resolveTaskListId()
//#then
expect(result).toBe(expected)
}) })
}) })

View File

@@ -1,13 +1,35 @@
import { join, dirname } from "path" import { join, dirname, basename, isAbsolute } from "path"
import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, unlinkSync, readdirSync } from "fs" import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, unlinkSync, readdirSync } from "fs"
import { randomUUID } from "crypto" import { randomUUID } from "crypto"
import { getOpenCodeConfigDir } from "../../shared/opencode-config-dir"
import type { z } from "zod" import type { z } from "zod"
import type { OhMyOpenCodeConfig } from "../../config/schema" import type { OhMyOpenCodeConfig } from "../../config/schema"
export function getTaskDir(config: Partial<OhMyOpenCodeConfig> = {}): string { export function getTaskDir(config: Partial<OhMyOpenCodeConfig> = {}): string {
const tasksConfig = config.sisyphus?.tasks const tasksConfig = config.sisyphus?.tasks
const storagePath = tasksConfig?.storage_path ?? ".sisyphus/tasks" const storagePath = tasksConfig?.storage_path
return join(process.cwd(), storagePath)
if (storagePath) {
return isAbsolute(storagePath) ? storagePath : join(process.cwd(), storagePath)
}
const configDir = getOpenCodeConfigDir({ binary: "opencode" })
const listId = resolveTaskListId(config)
return join(configDir, "tasks", listId)
}
export function sanitizePathSegment(value: string): string {
return value.replace(/[^a-zA-Z0-9_-]/g, "-") || "default"
}
export function resolveTaskListId(config: Partial<OhMyOpenCodeConfig> = {}): string {
const envId = process.env.ULTRAWORK_TASK_LIST_ID?.trim()
if (envId) return sanitizePathSegment(envId)
const configId = config.sisyphus?.tasks?.task_list_id?.trim()
if (configId) return sanitizePathSegment(configId)
return sanitizePathSegment(basename(process.cwd()))
} }
export function ensureDir(dirPath: string): void { export function ensureDir(dirPath: string): void {

View File

@@ -387,4 +387,177 @@ Skill body.
} }
}) })
}) })
describe("deduplication", () => {
it("deduplicates skills by name across scopes, keeping higher priority (opencode-project > opencode > project)", async () => {
const originalCwd = process.cwd()
const originalOpenCodeConfigDir = process.env.OPENCODE_CONFIG_DIR
const originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR
// given: same skill name in multiple scopes
const opencodeProjectSkillsDir = join(TEST_DIR, ".opencode", "skills")
const opencodeConfigDir = join(TEST_DIR, "opencode-global")
const opencodeGlobalSkillsDir = join(opencodeConfigDir, "skills")
const projectClaudeSkillsDir = join(TEST_DIR, ".claude", "skills")
process.env.OPENCODE_CONFIG_DIR = opencodeConfigDir
process.env.CLAUDE_CONFIG_DIR = join(TEST_DIR, "claude-user")
mkdirSync(join(opencodeProjectSkillsDir, "duplicate-skill"), { recursive: true })
mkdirSync(join(opencodeGlobalSkillsDir, "duplicate-skill"), { recursive: true })
mkdirSync(join(projectClaudeSkillsDir, "duplicate-skill"), { recursive: true })
writeFileSync(
join(opencodeProjectSkillsDir, "duplicate-skill", "SKILL.md"),
`---
name: duplicate-skill
description: From opencode-project (highest priority)
---
opencode-project body.
`
)
writeFileSync(
join(opencodeGlobalSkillsDir, "duplicate-skill", "SKILL.md"),
`---
name: duplicate-skill
description: From opencode-global (middle priority)
---
opencode-global body.
`
)
writeFileSync(
join(projectClaudeSkillsDir, "duplicate-skill", "SKILL.md"),
`---
name: duplicate-skill
description: From claude project (lowest priority among these)
---
claude project body.
`
)
// when
const { discoverSkills } = await import("./loader")
process.chdir(TEST_DIR)
try {
const skills = await discoverSkills()
const duplicates = skills.filter(s => s.name === "duplicate-skill")
// then
expect(duplicates).toHaveLength(1)
expect(duplicates[0]?.scope).toBe("opencode-project")
expect(duplicates[0]?.definition.description).toContain("opencode-project")
} finally {
process.chdir(originalCwd)
if (originalOpenCodeConfigDir === undefined) {
delete process.env.OPENCODE_CONFIG_DIR
} else {
process.env.OPENCODE_CONFIG_DIR = originalOpenCodeConfigDir
}
if (originalClaudeConfigDir === undefined) {
delete process.env.CLAUDE_CONFIG_DIR
} else {
process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir
}
}
})
it("prioritizes OpenCode global skills over legacy Claude project skills", async () => {
const originalCwd = process.cwd()
const originalOpenCodeConfigDir = process.env.OPENCODE_CONFIG_DIR
const originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR
const opencodeConfigDir = join(TEST_DIR, "opencode-global")
const opencodeGlobalSkillsDir = join(opencodeConfigDir, "skills")
const projectClaudeSkillsDir = join(TEST_DIR, ".claude", "skills")
process.env.OPENCODE_CONFIG_DIR = opencodeConfigDir
process.env.CLAUDE_CONFIG_DIR = join(TEST_DIR, "claude-user")
mkdirSync(join(opencodeGlobalSkillsDir, "global-over-project"), { recursive: true })
mkdirSync(join(projectClaudeSkillsDir, "global-over-project"), { recursive: true })
writeFileSync(
join(opencodeGlobalSkillsDir, "global-over-project", "SKILL.md"),
`---
name: global-over-project
description: From opencode-global (should win)
---
opencode-global body.
`
)
writeFileSync(
join(projectClaudeSkillsDir, "global-over-project", "SKILL.md"),
`---
name: global-over-project
description: From claude project (should lose)
---
claude project body.
`
)
const { discoverSkills } = await import("./loader")
process.chdir(TEST_DIR)
try {
const skills = await discoverSkills()
const matches = skills.filter(s => s.name === "global-over-project")
expect(matches).toHaveLength(1)
expect(matches[0]?.scope).toBe("opencode")
expect(matches[0]?.definition.description).toContain("opencode-global")
} finally {
process.chdir(originalCwd)
if (originalOpenCodeConfigDir === undefined) {
delete process.env.OPENCODE_CONFIG_DIR
} else {
process.env.OPENCODE_CONFIG_DIR = originalOpenCodeConfigDir
}
if (originalClaudeConfigDir === undefined) {
delete process.env.CLAUDE_CONFIG_DIR
} else {
process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir
}
}
})
it("returns no duplicates from discoverSkills", async () => {
const originalCwd = process.cwd()
const originalOpenCodeConfigDir = process.env.OPENCODE_CONFIG_DIR
process.env.OPENCODE_CONFIG_DIR = join(TEST_DIR, "opencode-global")
// given
const skillContent = `---
name: unique-test-skill
description: A unique skill for dedup test
---
Skill body.
`
createTestSkill("unique-test-skill", skillContent)
// when
const { discoverSkills } = await import("./loader")
process.chdir(TEST_DIR)
try {
const skills = await discoverSkills({ includeClaudeCodePaths: false })
// then
const names = skills.map(s => s.name)
const uniqueNames = [...new Set(names)]
expect(names.length).toBe(uniqueNames.length)
} finally {
process.chdir(originalCwd)
if (originalOpenCodeConfigDir === undefined) {
delete process.env.OPENCODE_CONFIG_DIR
} else {
process.env.OPENCODE_CONFIG_DIR = originalOpenCodeConfigDir
}
}
})
})
}) })

View File

@@ -66,7 +66,8 @@ async function loadSkillFromPath(
skillPath: string, skillPath: string,
resolvedPath: string, resolvedPath: string,
defaultName: string, defaultName: string,
scope: SkillScope scope: SkillScope,
namePrefix: string = ""
): Promise<LoadedSkill | null> { ): Promise<LoadedSkill | null> {
try { try {
const content = await fs.readFile(skillPath, "utf-8") const content = await fs.readFile(skillPath, "utf-8")
@@ -75,7 +76,10 @@ async function loadSkillFromPath(
const mcpJsonMcp = await loadMcpJsonFromDir(resolvedPath) const mcpJsonMcp = await loadMcpJsonFromDir(resolvedPath)
const mcpConfig = mcpJsonMcp || frontmatterMcp const mcpConfig = mcpJsonMcp || frontmatterMcp
const skillName = data.name || defaultName // For nested skills, use the full path as the name (e.g., "superpowers/brainstorming")
// For flat skills, use frontmatter name or directory name
const baseName = data.name || defaultName
const skillName = namePrefix ? `${namePrefix}/${baseName}` : baseName
const originalDescription = data.description || "" const originalDescription = data.description || ""
const isOpencodeSource = scope === "opencode" || scope === "opencode-project" const isOpencodeSource = scope === "opencode" || scope === "opencode-project"
const formattedDescription = `(${scope} - Skill) ${originalDescription}` const formattedDescription = `(${scope} - Skill) ${originalDescription}`
@@ -128,48 +132,67 @@ $ARGUMENTS
} }
} }
async function loadSkillsFromDir(skillsDir: string, scope: SkillScope): Promise<LoadedSkill[]> { async function loadSkillsFromDir(
skillsDir: string,
scope: SkillScope,
namePrefix: string = "",
depth: number = 0,
maxDepth: number = 2
): Promise<LoadedSkill[]> {
const entries = await fs.readdir(skillsDir, { withFileTypes: true }).catch(() => []) const entries = await fs.readdir(skillsDir, { withFileTypes: true }).catch(() => [])
const skills: LoadedSkill[] = [] const skillMap = new Map<string, LoadedSkill>()
for (const entry of entries) { const directories = entries.filter(e => !e.name.startsWith(".") && (e.isDirectory() || e.isSymbolicLink()))
if (entry.name.startsWith(".")) continue const files = entries.filter(e => !e.name.startsWith(".") && !e.isDirectory() && !e.isSymbolicLink() && isMarkdownFile(e))
for (const entry of directories) {
const entryPath = join(skillsDir, entry.name) const entryPath = join(skillsDir, entry.name)
const resolvedPath = await resolveSymlinkAsync(entryPath)
const dirName = entry.name
if (entry.isDirectory() || entry.isSymbolicLink()) { const skillMdPath = join(resolvedPath, "SKILL.md")
const resolvedPath = await resolveSymlinkAsync(entryPath) try {
const dirName = entry.name await fs.access(skillMdPath)
const skill = await loadSkillFromPath(skillMdPath, resolvedPath, dirName, scope, namePrefix)
const skillMdPath = join(resolvedPath, "SKILL.md") if (skill && !skillMap.has(skill.name)) {
try { skillMap.set(skill.name, skill)
await fs.access(skillMdPath)
const skill = await loadSkillFromPath(skillMdPath, resolvedPath, dirName, scope)
if (skill) skills.push(skill)
continue
} catch {
} }
const namedSkillMdPath = join(resolvedPath, `${dirName}.md`)
try {
await fs.access(namedSkillMdPath)
const skill = await loadSkillFromPath(namedSkillMdPath, resolvedPath, dirName, scope)
if (skill) skills.push(skill)
continue
} catch {
}
continue continue
} catch {
} }
if (isMarkdownFile(entry)) { const namedSkillMdPath = join(resolvedPath, `${dirName}.md`)
const skillName = basename(entry.name, ".md") try {
const skill = await loadSkillFromPath(entryPath, skillsDir, skillName, scope) await fs.access(namedSkillMdPath)
if (skill) skills.push(skill) const skill = await loadSkillFromPath(namedSkillMdPath, resolvedPath, dirName, scope, namePrefix)
if (skill && !skillMap.has(skill.name)) {
skillMap.set(skill.name, skill)
}
continue
} catch {
}
if (depth < maxDepth) {
const newPrefix = namePrefix ? `${namePrefix}/${dirName}` : dirName
const nestedSkills = await loadSkillsFromDir(resolvedPath, scope, newPrefix, depth + 1, maxDepth)
for (const nestedSkill of nestedSkills) {
if (!skillMap.has(nestedSkill.name)) {
skillMap.set(nestedSkill.name, nestedSkill)
}
}
} }
} }
return skills for (const entry of files) {
const entryPath = join(skillsDir, entry.name)
const baseName = basename(entry.name, ".md")
const skill = await loadSkillFromPath(entryPath, skillsDir, baseName, scope, namePrefix)
if (skill && !skillMap.has(skill.name)) {
skillMap.set(skill.name, skill)
}
}
return Array.from(skillMap.values())
} }
function skillsToRecord(skills: LoadedSkill[]): Record<string, CommandDefinition> { function skillsToRecord(skills: LoadedSkill[]): Record<string, CommandDefinition> {
@@ -210,15 +233,33 @@ export interface DiscoverSkillsOptions {
includeClaudeCodePaths?: boolean includeClaudeCodePaths?: boolean
} }
/**
* Deduplicates skills by name, keeping the first occurrence (higher priority).
* Priority order: opencode-project > opencode > project > user
* (OpenCode Global skills take precedence over legacy Claude project skills)
*/
function deduplicateSkills(skills: LoadedSkill[]): LoadedSkill[] {
const seen = new Set<string>()
const result: LoadedSkill[] = []
for (const skill of skills) {
if (!seen.has(skill.name)) {
seen.add(skill.name)
result.push(skill)
}
}
return result
}
export async function discoverAllSkills(): Promise<LoadedSkill[]> { export async function discoverAllSkills(): Promise<LoadedSkill[]> {
const [opencodeProjectSkills, projectSkills, opencodeGlobalSkills, userSkills] = await Promise.all([ const [opencodeProjectSkills, opencodeGlobalSkills, projectSkills, userSkills] = await Promise.all([
discoverOpencodeProjectSkills(), discoverOpencodeProjectSkills(),
discoverProjectClaudeSkills(),
discoverOpencodeGlobalSkills(), discoverOpencodeGlobalSkills(),
discoverProjectClaudeSkills(),
discoverUserClaudeSkills(), discoverUserClaudeSkills(),
]) ])
return [...opencodeProjectSkills, ...projectSkills, ...opencodeGlobalSkills, ...userSkills] // Priority: opencode-project > opencode > project > user
return deduplicateSkills([...opencodeProjectSkills, ...opencodeGlobalSkills, ...projectSkills, ...userSkills])
} }
export async function discoverSkills(options: DiscoverSkillsOptions = {}): Promise<LoadedSkill[]> { export async function discoverSkills(options: DiscoverSkillsOptions = {}): Promise<LoadedSkill[]> {
@@ -230,7 +271,8 @@ export async function discoverSkills(options: DiscoverSkillsOptions = {}): Promi
]) ])
if (!includeClaudeCodePaths) { if (!includeClaudeCodePaths) {
return [...opencodeProjectSkills, ...opencodeGlobalSkills] // Priority: opencode-project > opencode
return deduplicateSkills([...opencodeProjectSkills, ...opencodeGlobalSkills])
} }
const [projectSkills, userSkills] = await Promise.all([ const [projectSkills, userSkills] = await Promise.all([
@@ -238,7 +280,8 @@ export async function discoverSkills(options: DiscoverSkillsOptions = {}): Promi
discoverUserClaudeSkills(), discoverUserClaudeSkills(),
]) ])
return [...opencodeProjectSkills, ...projectSkills, ...opencodeGlobalSkills, ...userSkills] // Priority: opencode-project > opencode > project > user
return deduplicateSkills([...opencodeProjectSkills, ...opencodeGlobalSkills, ...projectSkills, ...userSkills])
} }
export async function getSkillByName(name: string, options: DiscoverSkillsOptions = {}): Promise<LoadedSkill | undefined> { export async function getSkillByName(name: string, options: DiscoverSkillsOptions = {}): Promise<LoadedSkill | undefined> {

View File

@@ -1,6 +1,34 @@
import { describe, it, expect } from "bun:test" /// <reference types="bun-types" />
import { describe, it, expect, beforeEach, afterEach } from "bun:test"
import { join } from "node:path"
import { tmpdir } from "node:os"
import { resolveSkillContent, resolveMultipleSkills, resolveSkillContentAsync, resolveMultipleSkillsAsync } from "./skill-content" import { resolveSkillContent, resolveMultipleSkills, resolveSkillContentAsync, resolveMultipleSkillsAsync } from "./skill-content"
let originalEnv: Record<string, string | undefined>
let testConfigDir: string
beforeEach(() => {
originalEnv = {
CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR,
OPENCODE_CONFIG_DIR: process.env.OPENCODE_CONFIG_DIR,
}
const unique = `skill-content-test-${Date.now()}-${Math.random().toString(16).slice(2)}`
testConfigDir = join(tmpdir(), unique)
process.env.CLAUDE_CONFIG_DIR = testConfigDir
process.env.OPENCODE_CONFIG_DIR = testConfigDir
})
afterEach(() => {
for (const [key, value] of Object.entries(originalEnv)) {
if (value !== undefined) {
process.env[key] = value
} else {
delete process.env[key]
}
}
})
describe("resolveSkillContent", () => { describe("resolveSkillContent", () => {
it("should return template for existing skill", () => { it("should return template for existing skill", () => {
// given: builtin skills with 'frontend-ui-ux' skill // given: builtin skills with 'frontend-ui-ux' skill
@@ -33,10 +61,12 @@ describe("resolveSkillContent", () => {
expect(result).toBeNull() expect(result).toBeNull()
}) })
it("should return null for empty string", () => { it("should return null for disabled skill", () => {
// given: builtin skills // given: frontend-ui-ux skill disabled
// when: resolving content for empty string const options = { disabledSkills: new Set(["frontend-ui-ux"]) }
const result = resolveSkillContent("")
// when: resolving content for disabled skill
const result = resolveSkillContent("frontend-ui-ux", options)
// then: returns null // then: returns null
expect(result).toBeNull() expect(result).toBeNull()
@@ -96,6 +126,20 @@ describe("resolveMultipleSkills", () => {
expect(result.notFound).toEqual(["skill-one", "skill-two", "skill-three"]) expect(result.notFound).toEqual(["skill-one", "skill-two", "skill-three"])
}) })
it("should treat disabled skills as not found", () => {
// #given: frontend-ui-ux disabled, playwright not disabled
const skillNames = ["frontend-ui-ux", "playwright"]
const options = { disabledSkills: new Set(["frontend-ui-ux"]) }
// #when: resolving multiple skills with disabled one
const result = resolveMultipleSkills(skillNames, options)
// #then: frontend-ui-ux in notFound, playwright resolved
expect(result.resolved.size).toBe(1)
expect(result.resolved.has("playwright")).toBe(true)
expect(result.notFound).toEqual(["frontend-ui-ux"])
})
it("should preserve skill order in resolved map", () => { it("should preserve skill order in resolved map", () => {
// given: list of skill names in specific order // given: list of skill names in specific order
const skillNames = ["playwright", "frontend-ui-ux"] const skillNames = ["playwright", "frontend-ui-ux"]
@@ -111,21 +155,24 @@ describe("resolveMultipleSkills", () => {
}) })
describe("resolveSkillContentAsync", () => { describe("resolveSkillContentAsync", () => {
it("should return template for builtin skill", async () => { it("should return template for builtin skill async", async () => {
// given: builtin skill 'frontend-ui-ux' // given: builtin skill 'frontend-ui-ux'
// when: resolving content async // when: resolving content async
const result = await resolveSkillContentAsync("frontend-ui-ux") const options = { disabledSkills: new Set(["frontend-ui-ux"]) }
const result = await resolveSkillContentAsync("git-master", options)
// then: returns template string // then: returns template string
expect(result).not.toBeNull() expect(result).not.toBeNull()
expect(typeof result).toBe("string") expect(typeof result).toBe("string")
expect(result).toContain("Role: Designer-Turned-Developer") expect(result).toContain("Git Master Agent")
}) })
it("should return null for non-existent skill", async () => { it("should return null for disabled skill async", async () => {
// given: non-existent skill name // given: frontend-ui-ux disabled
// when: resolving content async const options = { disabledSkills: new Set(["frontend-ui-ux"]) }
const result = await resolveSkillContentAsync("definitely-not-a-skill-12345")
// when: resolving content async for disabled skill
const result = await resolveSkillContentAsync("frontend-ui-ux", options)
// then: returns null // then: returns null
expect(result).toBeNull() expect(result).toBeNull()
@@ -133,9 +180,9 @@ describe("resolveSkillContentAsync", () => {
}) })
describe("resolveMultipleSkillsAsync", () => { describe("resolveMultipleSkillsAsync", () => {
it("should resolve builtin skills", async () => { it("should resolve builtin skills async", async () => {
// given: builtin skill names // given: builtin skill names
const skillNames = ["playwright", "frontend-ui-ux"] const skillNames = ["playwright", "git-master"]
// when: resolving multiple skills async // when: resolving multiple skills async
const result = await resolveMultipleSkillsAsync(skillNames) const result = await resolveMultipleSkillsAsync(skillNames)
@@ -144,10 +191,10 @@ describe("resolveMultipleSkillsAsync", () => {
expect(result.resolved.size).toBe(2) expect(result.resolved.size).toBe(2)
expect(result.notFound).toEqual([]) expect(result.notFound).toEqual([])
expect(result.resolved.get("playwright")).toContain("Playwright Browser Automation") expect(result.resolved.get("playwright")).toContain("Playwright Browser Automation")
expect(result.resolved.get("frontend-ui-ux")).toContain("Designer-Turned-Developer") expect(result.resolved.get("git-master")).toContain("Git Master Agent")
}) })
it("should handle partial success with non-existent skills", async () => { it("should handle partial success with non-existent skills async", async () => {
// given: mix of existing and non-existing skills // given: mix of existing and non-existing skills
const skillNames = ["playwright", "nonexistent-skill-12345"] const skillNames = ["playwright", "nonexistent-skill-12345"]
@@ -160,6 +207,20 @@ describe("resolveMultipleSkillsAsync", () => {
expect(result.resolved.get("playwright")).toContain("Playwright Browser Automation") expect(result.resolved.get("playwright")).toContain("Playwright Browser Automation")
}) })
it("should treat disabled skills as not found async", async () => {
// #given: frontend-ui-ux disabled
const skillNames = ["frontend-ui-ux", "playwright"]
const options = { disabledSkills: new Set(["frontend-ui-ux"]) }
// #when: resolving multiple skills async with disabled one
const result = await resolveMultipleSkillsAsync(skillNames, options)
// #then: frontend-ui-ux in notFound, playwright resolved
expect(result.resolved.size).toBe(1)
expect(result.resolved.has("playwright")).toBe(true)
expect(result.notFound).toEqual(["frontend-ui-ux"])
})
it("should NOT inject watermark when both options are disabled", async () => { it("should NOT inject watermark when both options are disabled", async () => {
// given: git-master skill with watermark disabled // given: git-master skill with watermark disabled
const skillNames = ["git-master"] const skillNames = ["git-master"]

View File

@@ -8,6 +8,7 @@ import type { GitMasterConfig, BrowserAutomationProvider } from "../../config/sc
export interface SkillResolutionOptions { export interface SkillResolutionOptions {
gitMasterConfig?: GitMasterConfig gitMasterConfig?: GitMasterConfig
browserProvider?: BrowserAutomationProvider browserProvider?: BrowserAutomationProvider
disabledSkills?: Set<string>
} }
const cachedSkillsByProvider = new Map<string, LoadedSkill[]>() const cachedSkillsByProvider = new Map<string, LoadedSkill[]>()
@@ -18,12 +19,22 @@ function clearSkillCache(): void {
async function getAllSkills(options?: SkillResolutionOptions): Promise<LoadedSkill[]> { async function getAllSkills(options?: SkillResolutionOptions): Promise<LoadedSkill[]> {
const cacheKey = options?.browserProvider ?? "playwright" const cacheKey = options?.browserProvider ?? "playwright"
const cached = cachedSkillsByProvider.get(cacheKey) const hasDisabledSkills = options?.disabledSkills && options.disabledSkills.size > 0
if (cached) return cached
// Skip cache if disabledSkills is provided (varies between calls)
if (!hasDisabledSkills) {
const cached = cachedSkillsByProvider.get(cacheKey)
if (cached) return cached
}
const [discoveredSkills, builtinSkillDefs] = await Promise.all([ const [discoveredSkills, builtinSkillDefs] = await Promise.all([
discoverSkills({ includeClaudeCodePaths: true }), discoverSkills({ includeClaudeCodePaths: true }),
Promise.resolve(createBuiltinSkills({ browserProvider: options?.browserProvider })), Promise.resolve(
createBuiltinSkills({
browserProvider: options?.browserProvider,
disabledSkills: options?.disabledSkills,
})
),
]) ])
const builtinSkillsAsLoaded: LoadedSkill[] = builtinSkillDefs.map((skill) => ({ const builtinSkillsAsLoaded: LoadedSkill[] = builtinSkillDefs.map((skill) => ({
@@ -47,8 +58,15 @@ async function getAllSkills(options?: SkillResolutionOptions): Promise<LoadedSki
const discoveredNames = new Set(discoveredSkills.map((s) => s.name)) const discoveredNames = new Set(discoveredSkills.map((s) => s.name))
const uniqueBuiltins = builtinSkillsAsLoaded.filter((s) => !discoveredNames.has(s.name)) const uniqueBuiltins = builtinSkillsAsLoaded.filter((s) => !discoveredNames.has(s.name))
const allSkills = [...discoveredSkills, ...uniqueBuiltins] let allSkills = [...discoveredSkills, ...uniqueBuiltins]
cachedSkillsByProvider.set(cacheKey, allSkills)
// Filter discovered skills by disabledSkills (builtin skills are already filtered by createBuiltinSkills)
if (hasDisabledSkills) {
allSkills = allSkills.filter((s) => !options!.disabledSkills!.has(s.name))
} else {
cachedSkillsByProvider.set(cacheKey, allSkills)
}
return allSkills return allSkills
} }
@@ -122,7 +140,10 @@ export function injectGitMasterConfig(template: string, config?: GitMasterConfig
} }
export function resolveSkillContent(skillName: string, options?: SkillResolutionOptions): string | null { export function resolveSkillContent(skillName: string, options?: SkillResolutionOptions): string | null {
const skills = createBuiltinSkills({ browserProvider: options?.browserProvider }) const skills = createBuiltinSkills({
browserProvider: options?.browserProvider,
disabledSkills: options?.disabledSkills,
})
const skill = skills.find((s) => s.name === skillName) const skill = skills.find((s) => s.name === skillName)
if (!skill) return null if (!skill) return null
@@ -137,7 +158,10 @@ export function resolveMultipleSkills(skillNames: string[], options?: SkillResol
resolved: Map<string, string> resolved: Map<string, string>
notFound: string[] notFound: string[]
} { } {
const skills = createBuiltinSkills({ browserProvider: options?.browserProvider }) const skills = createBuiltinSkills({
browserProvider: options?.browserProvider,
disabledSkills: options?.disabledSkills,
})
const skillMap = new Map(skills.map((s) => [s.name, s.template])) const skillMap = new Map(skills.map((s) => [s.name, s.template]))
const resolved = new Map<string, string>() const resolved = new Map<string, string>()

View File

@@ -192,7 +192,7 @@ describe("TaskToastManager", () => {
description: "Task with inherited model", description: "Task with inherited model",
agent: "sisyphus-junior", agent: "sisyphus-junior",
isBackground: false, isBackground: false,
modelInfo: { model: "cliproxy/claude-opus-4-5", type: "inherited" as const }, modelInfo: { model: "cliproxy/claude-opus-4-6", type: "inherited" as const },
} }
// when - addTask is called // when - addTask is called
@@ -202,7 +202,7 @@ describe("TaskToastManager", () => {
expect(mockClient.tui.showToast).toHaveBeenCalled() expect(mockClient.tui.showToast).toHaveBeenCalled()
const call = mockClient.tui.showToast.mock.calls[0][0] const call = mockClient.tui.showToast.mock.calls[0][0]
expect(call.body.message).toContain("[FALLBACK]") expect(call.body.message).toContain("[FALLBACK]")
expect(call.body.message).toContain("cliproxy/claude-opus-4-5") expect(call.body.message).toContain("cliproxy/claude-opus-4-6")
expect(call.body.message).toContain("(inherited from parent)") expect(call.body.message).toContain("(inherited from parent)")
}) })

View File

@@ -2,7 +2,7 @@
## OVERVIEW ## OVERVIEW
34 lifecycle hooks intercepting/modifying agent behavior across 5 events. 40+ lifecycle hooks intercepting/modifying agent behavior across 5 events.
**Event Types**: **Event Types**:
- `UserPromptSubmit` (`chat.message`) - Can block - `UserPromptSubmit` (`chat.message`) - Can block
@@ -14,10 +14,10 @@
## STRUCTURE ## STRUCTURE
``` ```
hooks/ hooks/
├── atlas/ # Main orchestration (757 lines) ├── atlas/ # Main orchestration (770 lines)
├── anthropic-context-window-limit-recovery/ # Auto-summarize ├── anthropic-context-window-limit-recovery/ # Auto-summarize
├── todo-continuation-enforcer.ts # Force TODO completion ├── todo-continuation-enforcer.ts # Force TODO completion (517 lines)
├── ralph-loop/ # Self-referential dev loop ├── ralph-loop/ # Self-referential dev loop (428 lines)
├── claude-code-hooks/ # settings.json compat layer - see AGENTS.md ├── claude-code-hooks/ # settings.json compat layer - see AGENTS.md
├── comment-checker/ # Prevents AI slop ├── comment-checker/ # Prevents AI slop
├── auto-slash-command/ # Detects /command patterns ├── auto-slash-command/ # Detects /command patterns
@@ -27,13 +27,14 @@ hooks/
├── edit-error-recovery/ # Recovers from failures ├── edit-error-recovery/ # Recovers from failures
├── thinking-block-validator/ # Ensures valid <thinking> ├── thinking-block-validator/ # Ensures valid <thinking>
├── context-window-monitor.ts # Reminds of headroom ├── context-window-monitor.ts # Reminds of headroom
├── session-recovery/ # Auto-recovers from crashes ├── session-recovery/ # Auto-recovers from crashes (436 lines)
├── session-notification.ts # Session event notifications (337 lines)
├── think-mode/ # Dynamic thinking budget ├── think-mode/ # Dynamic thinking budget
├── keyword-detector/ # ultrawork/search/analyze modes ├── keyword-detector/ # ultrawork/search/analyze modes
├── background-notification/ # OS notification ├── background-notification/ # OS notification
├── prometheus-md-only/ # Planner read-only mode ├── prometheus-md-only/ # Planner read-only mode
├── agent-usage-reminder/ # Specialized agent hints ├── agent-usage-reminder/ # Specialized agent hints
├── auto-update-checker/ # Plugin update check ├── auto-update-checker/ # Plugin update check (304 lines)
├── tool-output-truncator.ts # Prevents context bloat ├── tool-output-truncator.ts # Prevents context bloat
├── compaction-context-injector/ # Injects context on compaction ├── compaction-context-injector/ # Injects context on compaction
├── delegate-task-retry/ # Retries failed delegations ├── delegate-task-retry/ # Retries failed delegations
@@ -47,6 +48,11 @@ hooks/
├── sisyphus-junior-notepad/ # Sisyphus Junior notepad ├── sisyphus-junior-notepad/ # Sisyphus Junior notepad
├── stop-continuation-guard/ # Guards stop continuation ├── stop-continuation-guard/ # Guards stop continuation
├── subagent-question-blocker/ # Blocks subagent questions ├── subagent-question-blocker/ # Blocks subagent questions
├── task-reminder/ # Task progress reminders
├── tasks-todowrite-disabler/ # Disables TodoWrite when task system active
├── unstable-agent-babysitter/ # Monitors unstable agent behavior
├── write-existing-file-guard/ # Guards against overwriting existing files
├── preemptive-compaction.ts # Preemptive context compaction
└── index.ts # Hook aggregation + registration └── index.ts # Hook aggregation + registration
``` ```
@@ -61,8 +67,8 @@ hooks/
## EXECUTION ORDER ## EXECUTION ORDER
- **UserPromptSubmit**: keywordDetector → claudeCodeHooks → autoSlashCommand → startWork - **UserPromptSubmit**: keywordDetector → claudeCodeHooks → autoSlashCommand → startWork
- **PreToolUse**: subagentQuestionBlocker → questionLabelTruncator → claudeCodeHooks → nonInteractiveEnv → commentChecker → directoryAgentsInjector → directoryReadmeInjector → rulesInjector → prometheusMdOnly → sisyphusJuniorNotepad → atlasHook - **PreToolUse**: subagentQuestionBlocker → questionLabelTruncator → claudeCodeHooks → nonInteractiveEnv → commentChecker → directoryAgentsInjector → directoryReadmeInjector → rulesInjector → prometheusMdOnly → sisyphusJuniorNotepad → writeExistingFileGuard → atlasHook
- **PostToolUse**: claudeCodeHooks → toolOutputTruncator → contextWindowMonitor → commentChecker → directoryAgentsInjector → directoryReadmeInjector → rulesInjector → emptyTaskResponseDetector → agentUsageReminder → interactiveBashSession → editErrorRecovery → delegateTaskRetry → atlasHook → taskResumeInfo - **PostToolUse**: claudeCodeHooks → toolOutputTruncator → contextWindowMonitor → commentChecker → directoryAgentsInjector → directoryReadmeInjector → rulesInjector → emptyTaskResponseDetector → agentUsageReminder → interactiveBashSession → editErrorRecovery → delegateTaskRetry → atlasHook → taskResumeInfo → taskReminder
## HOW TO ADD ## HOW TO ADD
1. Create `src/hooks/name/` with `index.ts` exporting `createMyHook(ctx)` 1. Create `src/hooks/name/` with `index.ts` exporting `createMyHook(ctx)`

View File

@@ -80,7 +80,7 @@ describe("executeCompact lock management", () => {
let fakeTimeouts: FakeTimeouts let fakeTimeouts: FakeTimeouts
const sessionID = "test-session-123" const sessionID = "test-session-123"
const directory = "/test/dir" const directory = "/test/dir"
const msg = { providerID: "anthropic", modelID: "claude-opus-4-5" } const msg = { providerID: "anthropic", modelID: "claude-opus-4-6" }
beforeEach(() => { beforeEach(() => {
// given: Fresh state for each test // given: Fresh state for each test
@@ -332,7 +332,7 @@ describe("executeCompact lock management", () => {
expect(mockClient.session.summarize).toHaveBeenCalledWith( expect(mockClient.session.summarize).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
path: { id: sessionID }, path: { id: sessionID },
body: { providerID: "anthropic", modelID: "claude-opus-4-5", auto: true }, body: { providerID: "anthropic", modelID: "claude-opus-4-6", auto: true },
}), }),
) )

View File

@@ -47,7 +47,7 @@ describe("atlas hook", () => {
} }
const messageData = { const messageData = {
agent, agent,
model: { providerID: "anthropic", modelID: "claude-opus-4-5" }, model: { providerID: "anthropic", modelID: "claude-opus-4-6" },
} }
writeFileSync(join(messageDir, "msg_test001.json"), JSON.stringify(messageData)) writeFileSync(join(messageDir, "msg_test001.json"), JSON.stringify(messageData))
} }
@@ -624,6 +624,11 @@ describe("atlas hook", () => {
describe("session.idle handler (boulder continuation)", () => { describe("session.idle handler (boulder continuation)", () => {
const MAIN_SESSION_ID = "main-session-123" const MAIN_SESSION_ID = "main-session-123"
async function flushMicrotasks(): Promise<void> {
await Promise.resolve()
await Promise.resolve()
}
beforeEach(() => { beforeEach(() => {
mock.module("../../features/claude-code-session-state", () => ({ mock.module("../../features/claude-code-session-state", () => ({
getMainSessionID: () => MAIN_SESSION_ID, getMainSessionID: () => MAIN_SESSION_ID,
@@ -858,8 +863,8 @@ describe("atlas hook", () => {
expect(callArgs.body.parts[0].text).toContain("2 remaining") expect(callArgs.body.parts[0].text).toContain("2 remaining")
}) })
test("should not inject when last agent is not Atlas", async () => { test("should not inject when last agent does not match boulder agent", async () => {
// given - boulder state with incomplete plan, but last agent is NOT Atlas // given - boulder state with incomplete plan, but last agent does NOT match
const planPath = join(TEST_DIR, "test-plan.md") const planPath = join(TEST_DIR, "test-plan.md")
writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [ ] Task 2") writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [ ] Task 2")
@@ -868,10 +873,11 @@ describe("atlas hook", () => {
started_at: "2026-01-02T10:00:00Z", started_at: "2026-01-02T10:00:00Z",
session_ids: [MAIN_SESSION_ID], session_ids: [MAIN_SESSION_ID],
plan_name: "test-plan", plan_name: "test-plan",
agent: "atlas",
} }
writeBoulderState(TEST_DIR, state) writeBoulderState(TEST_DIR, state)
// given - last agent is NOT Atlas // given - last agent is NOT the boulder agent
cleanupMessageStorage(MAIN_SESSION_ID) cleanupMessageStorage(MAIN_SESSION_ID)
setupMessageStorage(MAIN_SESSION_ID, "sisyphus") setupMessageStorage(MAIN_SESSION_ID, "sisyphus")
@@ -886,10 +892,44 @@ describe("atlas hook", () => {
}, },
}) })
// then - should NOT call prompt because agent is not Atlas // then - should NOT call prompt because agent does not match
expect(mockInput._promptMock).not.toHaveBeenCalled() expect(mockInput._promptMock).not.toHaveBeenCalled()
}) })
test("should inject when last agent matches boulder agent even if non-Atlas", async () => {
// given - boulder state expects sisyphus and last agent is sisyphus
const planPath = join(TEST_DIR, "test-plan.md")
writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [ ] Task 2")
const state: BoulderState = {
active_plan: planPath,
started_at: "2026-01-02T10:00:00Z",
session_ids: [MAIN_SESSION_ID],
plan_name: "test-plan",
agent: "sisyphus",
}
writeBoulderState(TEST_DIR, state)
cleanupMessageStorage(MAIN_SESSION_ID)
setupMessageStorage(MAIN_SESSION_ID, "sisyphus")
const mockInput = createMockPluginInput()
const hook = createAtlasHook(mockInput)
// when
await hook.handler({
event: {
type: "session.idle",
properties: { sessionID: MAIN_SESSION_ID },
},
})
// then - should call prompt for sisyphus
expect(mockInput._promptMock).toHaveBeenCalled()
const callArgs = mockInput._promptMock.mock.calls[0][0]
expect(callArgs.body.agent).toBe("sisyphus")
})
test("should debounce rapid continuation injections (prevent infinite loop)", async () => { test("should debounce rapid continuation injections (prevent infinite loop)", async () => {
// given - boulder state with incomplete plan // given - boulder state with incomplete plan
const planPath = join(TEST_DIR, "test-plan.md") const planPath = join(TEST_DIR, "test-plan.md")
@@ -930,6 +970,135 @@ describe("atlas hook", () => {
expect(mockInput._promptMock).toHaveBeenCalledTimes(1) expect(mockInput._promptMock).toHaveBeenCalledTimes(1)
}) })
test("should stop continuation after 2 consecutive prompt failures (issue #1355)", async () => {
//#given - boulder state with incomplete plan and prompt always fails
const planPath = join(TEST_DIR, "test-plan.md")
writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [ ] Task 2")
const state: BoulderState = {
active_plan: planPath,
started_at: "2026-01-02T10:00:00Z",
session_ids: [MAIN_SESSION_ID],
plan_name: "test-plan",
}
writeBoulderState(TEST_DIR, state)
const promptMock = mock(() => Promise.reject(new Error("Bad Request")))
const mockInput = createMockPluginInput({ promptMock })
const hook = createAtlasHook(mockInput)
const originalDateNow = Date.now
let now = 0
Date.now = () => now
try {
//#when - idle fires repeatedly, past cooldown each time
await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } })
await flushMicrotasks()
now += 6000
await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } })
await flushMicrotasks()
now += 6000
await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } })
await flushMicrotasks()
//#then - should attempt only twice, then disable continuation
expect(promptMock).toHaveBeenCalledTimes(2)
} finally {
Date.now = originalDateNow
}
})
test("should reset prompt failure counter on success and only stop after 2 consecutive failures", async () => {
//#given - boulder state with incomplete plan
const planPath = join(TEST_DIR, "test-plan.md")
writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [ ] Task 2")
const state: BoulderState = {
active_plan: planPath,
started_at: "2026-01-02T10:00:00Z",
session_ids: [MAIN_SESSION_ID],
plan_name: "test-plan",
}
writeBoulderState(TEST_DIR, state)
const promptMock = mock(() => Promise.resolve())
promptMock.mockImplementationOnce(() => Promise.reject(new Error("Bad Request")))
promptMock.mockImplementationOnce(() => Promise.resolve())
promptMock.mockImplementationOnce(() => Promise.reject(new Error("Bad Request")))
promptMock.mockImplementationOnce(() => Promise.reject(new Error("Bad Request")))
const mockInput = createMockPluginInput({ promptMock })
const hook = createAtlasHook(mockInput)
const originalDateNow = Date.now
let now = 0
Date.now = () => now
try {
//#when - fail, succeed (reset), then fail twice (disable), then attempt again
for (let i = 0; i < 5; i++) {
await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } })
await flushMicrotasks()
now += 6000
}
//#then - 4 prompt attempts; 5th idle is skipped after 2 consecutive failures
expect(promptMock).toHaveBeenCalledTimes(4)
} finally {
Date.now = originalDateNow
}
})
test("should reset continuation failure state on session.compacted event", async () => {
//#given - boulder state with incomplete plan and prompt always fails
const planPath = join(TEST_DIR, "test-plan.md")
writeFileSync(planPath, "# Plan\n- [ ] Task 1\n- [ ] Task 2")
const state: BoulderState = {
active_plan: planPath,
started_at: "2026-01-02T10:00:00Z",
session_ids: [MAIN_SESSION_ID],
plan_name: "test-plan",
}
writeBoulderState(TEST_DIR, state)
const promptMock = mock(() => Promise.reject(new Error("Bad Request")))
const mockInput = createMockPluginInput({ promptMock })
const hook = createAtlasHook(mockInput)
const originalDateNow = Date.now
let now = 0
Date.now = () => now
try {
//#when - two failures disables continuation, then compaction resets it
await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } })
await flushMicrotasks()
now += 6000
await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } })
await flushMicrotasks()
now += 6000
await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } })
await flushMicrotasks()
await hook.handler({ event: { type: "session.compacted", properties: { sessionID: MAIN_SESSION_ID } } })
now += 6000
await hook.handler({ event: { type: "session.idle", properties: { sessionID: MAIN_SESSION_ID } } })
await flushMicrotasks()
//#then - 2 attempts + 1 after compaction (3 total)
expect(promptMock).toHaveBeenCalledTimes(3)
} finally {
Date.now = originalDateNow
}
})
test("should cleanup on session.deleted", async () => { test("should cleanup on session.deleted", async () => {
// given - boulder state // given - boulder state
const planPath = join(TEST_DIR, "test-plan.md") const planPath = join(TEST_DIR, "test-plan.md")

View File

@@ -26,6 +26,13 @@ function isSisyphusPath(filePath: string): boolean {
const WRITE_EDIT_TOOLS = ["Write", "Edit", "write", "edit"] const WRITE_EDIT_TOOLS = ["Write", "Edit", "write", "edit"]
function getLastAgentFromSession(sessionID: string): string | null {
const messageDir = getMessageDir(sessionID)
if (!messageDir) return null
const nearest = findNearestMessageWithFields(messageDir)
return nearest?.agent?.toLowerCase() ?? null
}
const DIRECT_WORK_REMINDER = ` const DIRECT_WORK_REMINDER = `
--- ---
@@ -384,6 +391,7 @@ interface ToolExecuteAfterOutput {
interface SessionState { interface SessionState {
lastEventWasAbortError?: boolean lastEventWasAbortError?: boolean
lastContinuationInjectedAt?: number lastContinuationInjectedAt?: number
promptFailureCount: number
} }
const CONTINUATION_COOLDOWN_MS = 5000 const CONTINUATION_COOLDOWN_MS = 5000
@@ -425,13 +433,14 @@ export function createAtlasHook(
function getState(sessionID: string): SessionState { function getState(sessionID: string): SessionState {
let state = sessions.get(sessionID) let state = sessions.get(sessionID)
if (!state) { if (!state) {
state = {} state = { promptFailureCount: 0 }
sessions.set(sessionID, state) sessions.set(sessionID, state)
} }
return state return state
} }
async function injectContinuation(sessionID: string, planName: string, remaining: number, total: number): Promise<void> { async function injectContinuation(sessionID: string, planName: string, remaining: number, total: number, agent?: string): Promise<void> {
const state = getState(sessionID)
const hasRunningBgTasks = backgroundManager const hasRunningBgTasks = backgroundManager
? backgroundManager.getTasksByParentSession(sessionID).some(t => t.status === "running") ? backgroundManager.getTasksByParentSession(sessionID).some(t => t.status === "running")
: false : false
@@ -474,21 +483,28 @@ export function createAtlasHook(
: undefined : undefined
} }
await ctx.client.session.prompt({ await ctx.client.session.prompt({
path: { id: sessionID }, path: { id: sessionID },
body: { body: {
agent: "atlas", agent: agent ?? "atlas",
...(model !== undefined ? { model } : {}), ...(model !== undefined ? { model } : {}),
parts: [{ type: "text", text: prompt }], parts: [{ type: "text", text: prompt }],
}, },
query: { directory: ctx.directory }, query: { directory: ctx.directory },
}) })
log(`[${HOOK_NAME}] Boulder continuation injected`, { sessionID }) state.promptFailureCount = 0
} catch (err) {
log(`[${HOOK_NAME}] Boulder continuation failed`, { sessionID, error: String(err) }) log(`[${HOOK_NAME}] Boulder continuation injected`, { sessionID })
} } catch (err) {
} state.promptFailureCount += 1
log(`[${HOOK_NAME}] Boulder continuation failed`, {
sessionID,
error: String(err),
promptFailureCount: state.promptFailureCount,
})
}
}
return { return {
handler: async ({ event }: { event: { type: string; properties?: unknown } }): Promise<void> => { handler: async ({ event }: { event: { type: string; properties?: unknown } }): Promise<void> => {
@@ -534,6 +550,14 @@ export function createAtlasHook(
return return
} }
if (state.promptFailureCount >= 2) {
log(`[${HOOK_NAME}] Skipped: continuation disabled after repeated prompt failures`, {
sessionID,
promptFailureCount: state.promptFailureCount,
})
return
}
const hasRunningBgTasks = backgroundManager const hasRunningBgTasks = backgroundManager
? backgroundManager.getTasksByParentSession(sessionID).some(t => t.status === "running") ? backgroundManager.getTasksByParentSession(sessionID).some(t => t.status === "running")
: false : false
@@ -549,8 +573,14 @@ export function createAtlasHook(
return return
} }
if (!isCallerOrchestrator(sessionID)) { const requiredAgent = (boulderState.agent ?? "atlas").toLowerCase()
log(`[${HOOK_NAME}] Skipped: last agent is not Atlas`, { sessionID }) const lastAgent = getLastAgentFromSession(sessionID)
if (!lastAgent || lastAgent !== requiredAgent) {
log(`[${HOOK_NAME}] Skipped: last agent does not match boulder agent`, {
sessionID,
lastAgent: lastAgent ?? "unknown",
requiredAgent,
})
return return
} }
@@ -568,7 +598,7 @@ export function createAtlasHook(
state.lastContinuationInjectedAt = now state.lastContinuationInjectedAt = now
const remaining = progress.total - progress.completed const remaining = progress.total - progress.completed
injectContinuation(sessionID, boulderState.plan_name, remaining, progress.total) injectContinuation(sessionID, boulderState.plan_name, remaining, progress.total, boulderState.agent)
return return
} }
@@ -618,6 +648,17 @@ export function createAtlasHook(
} }
return return
} }
if (event.type === "session.compacted") {
const sessionID = (props?.sessionID ?? (props?.info as { id?: string } | undefined)?.id) as
| string
| undefined
if (sessionID) {
sessions.delete(sessionID)
log(`[${HOOK_NAME}] Session compacted: cleaned up`, { sessionID })
}
return
}
}, },
"tool.execute.before": async ( "tool.execute.before": async (

View File

@@ -1,6 +1,6 @@
import * as fs from "node:fs" import * as fs from "node:fs"
import * as path from "node:path" import * as path from "node:path"
import { CACHE_DIR, PACKAGE_NAME } from "./constants" import { PACKAGE_NAME, USER_CONFIG_DIR } from "./constants"
import { log } from "../../shared/logger" import { log } from "../../shared/logger"
interface BunLockfile { interface BunLockfile {
@@ -17,7 +17,7 @@ function stripTrailingCommas(json: string): string {
} }
function removeFromBunLock(packageName: string): boolean { function removeFromBunLock(packageName: string): boolean {
const lockPath = path.join(CACHE_DIR, "bun.lock") const lockPath = path.join(USER_CONFIG_DIR, "bun.lock")
if (!fs.existsSync(lockPath)) return false if (!fs.existsSync(lockPath)) return false
try { try {
@@ -48,8 +48,8 @@ function removeFromBunLock(packageName: string): boolean {
export function invalidatePackage(packageName: string = PACKAGE_NAME): boolean { export function invalidatePackage(packageName: string = PACKAGE_NAME): boolean {
try { try {
const pkgDir = path.join(CACHE_DIR, "node_modules", packageName) const pkgDir = path.join(USER_CONFIG_DIR, "node_modules", packageName)
const pkgJsonPath = path.join(CACHE_DIR, "package.json") const pkgJsonPath = path.join(USER_CONFIG_DIR, "package.json")
let packageRemoved = false let packageRemoved = false
let dependencyRemoved = false let dependencyRemoved = false

View File

@@ -16,12 +16,6 @@ function getCacheDir(): string {
export const CACHE_DIR = getCacheDir() export const CACHE_DIR = getCacheDir()
export const VERSION_FILE = path.join(CACHE_DIR, "version") export const VERSION_FILE = path.join(CACHE_DIR, "version")
export const INSTALLED_PACKAGE_JSON = path.join(
CACHE_DIR,
"node_modules",
PACKAGE_NAME,
"package.json"
)
export function getWindowsAppdataDir(): string | null { export function getWindowsAppdataDir(): string | null {
if (process.platform !== "win32") return null if (process.platform !== "win32") return null
@@ -31,3 +25,10 @@ export function getWindowsAppdataDir(): string | null {
export const USER_CONFIG_DIR = getOpenCodeConfigDir({ binary: "opencode" }) export const USER_CONFIG_DIR = getOpenCodeConfigDir({ binary: "opencode" })
export const USER_OPENCODE_CONFIG = path.join(USER_CONFIG_DIR, "opencode.json") export const USER_OPENCODE_CONFIG = path.join(USER_CONFIG_DIR, "opencode.json")
export const USER_OPENCODE_CONFIG_JSONC = path.join(USER_CONFIG_DIR, "opencode.jsonc") export const USER_OPENCODE_CONFIG_JSONC = path.join(USER_CONFIG_DIR, "opencode.jsonc")
export const INSTALLED_PACKAGE_JSON = path.join(
USER_CONFIG_DIR,
"node_modules",
PACKAGE_NAME,
"package.json"
)

View File

@@ -1,6 +1,7 @@
import { describe, expect, test, beforeEach, afterEach, spyOn } from "bun:test" import { describe, expect, test, beforeEach, afterEach, spyOn } from "bun:test"
import { createCategorySkillReminderHook } from "./index" import { createCategorySkillReminderHook } from "./index"
import { updateSessionAgent, clearSessionAgent, _resetForTesting } from "../../features/claude-code-session-state" import { updateSessionAgent, clearSessionAgent, _resetForTesting } from "../../features/claude-code-session-state"
import type { AvailableSkill } from "../../agents/dynamic-agent-prompt-builder"
import * as sharedModule from "../../shared" import * as sharedModule from "../../shared"
describe("category-skill-reminder hook", () => { describe("category-skill-reminder hook", () => {
@@ -29,10 +30,14 @@ describe("category-skill-reminder hook", () => {
} as any } as any
} }
function createHook(availableSkills: AvailableSkill[] = []) {
return createCategorySkillReminderHook(createMockPluginInput(), availableSkills)
}
describe("target agent detection", () => { describe("target agent detection", () => {
test("should inject reminder for sisyphus agent after 3 tool calls", async () => { test("should inject reminder for sisyphus agent after 3 tool calls", async () => {
// given - sisyphus agent session with multiple tool calls // given - sisyphus agent session with multiple tool calls
const hook = createCategorySkillReminderHook(createMockPluginInput()) const hook = createHook()
const sessionID = "sisyphus-session" const sessionID = "sisyphus-session"
updateSessionAgent(sessionID, "Sisyphus") updateSessionAgent(sessionID, "Sisyphus")
@@ -52,7 +57,7 @@ describe("category-skill-reminder hook", () => {
test("should inject reminder for atlas agent", async () => { test("should inject reminder for atlas agent", async () => {
// given - atlas agent session // given - atlas agent session
const hook = createCategorySkillReminderHook(createMockPluginInput()) const hook = createHook()
const sessionID = "atlas-session" const sessionID = "atlas-session"
updateSessionAgent(sessionID, "Atlas") updateSessionAgent(sessionID, "Atlas")
@@ -71,7 +76,7 @@ describe("category-skill-reminder hook", () => {
test("should inject reminder for sisyphus-junior agent", async () => { test("should inject reminder for sisyphus-junior agent", async () => {
// given - sisyphus-junior agent session // given - sisyphus-junior agent session
const hook = createCategorySkillReminderHook(createMockPluginInput()) const hook = createHook()
const sessionID = "junior-session" const sessionID = "junior-session"
updateSessionAgent(sessionID, "sisyphus-junior") updateSessionAgent(sessionID, "sisyphus-junior")
@@ -90,7 +95,7 @@ describe("category-skill-reminder hook", () => {
test("should NOT inject reminder for non-target agents", async () => { test("should NOT inject reminder for non-target agents", async () => {
// given - librarian agent session (not a target) // given - librarian agent session (not a target)
const hook = createCategorySkillReminderHook(createMockPluginInput()) const hook = createHook()
const sessionID = "librarian-session" const sessionID = "librarian-session"
updateSessionAgent(sessionID, "librarian") updateSessionAgent(sessionID, "librarian")
@@ -109,7 +114,7 @@ describe("category-skill-reminder hook", () => {
test("should detect agent from input.agent when session state is empty", async () => { test("should detect agent from input.agent when session state is empty", async () => {
// given - no session state, agent provided in input // given - no session state, agent provided in input
const hook = createCategorySkillReminderHook(createMockPluginInput()) const hook = createHook()
const sessionID = "input-agent-session" const sessionID = "input-agent-session"
const output = { title: "", output: "result", metadata: {} } const output = { title: "", output: "result", metadata: {} }
@@ -127,7 +132,7 @@ describe("category-skill-reminder hook", () => {
describe("delegation tool tracking", () => { describe("delegation tool tracking", () => {
test("should NOT inject reminder if delegate_task is used", async () => { test("should NOT inject reminder if delegate_task is used", async () => {
// given - sisyphus agent that uses delegate_task // given - sisyphus agent that uses delegate_task
const hook = createCategorySkillReminderHook(createMockPluginInput()) const hook = createHook()
const sessionID = "delegation-session" const sessionID = "delegation-session"
updateSessionAgent(sessionID, "Sisyphus") updateSessionAgent(sessionID, "Sisyphus")
@@ -147,7 +152,7 @@ describe("category-skill-reminder hook", () => {
test("should NOT inject reminder if call_omo_agent is used", async () => { test("should NOT inject reminder if call_omo_agent is used", async () => {
// given - sisyphus agent that uses call_omo_agent // given - sisyphus agent that uses call_omo_agent
const hook = createCategorySkillReminderHook(createMockPluginInput()) const hook = createHook()
const sessionID = "omo-agent-session" const sessionID = "omo-agent-session"
updateSessionAgent(sessionID, "Sisyphus") updateSessionAgent(sessionID, "Sisyphus")
@@ -167,7 +172,7 @@ describe("category-skill-reminder hook", () => {
test("should NOT inject reminder if task tool is used", async () => { test("should NOT inject reminder if task tool is used", async () => {
// given - sisyphus agent that uses task tool // given - sisyphus agent that uses task tool
const hook = createCategorySkillReminderHook(createMockPluginInput()) const hook = createHook()
const sessionID = "task-session" const sessionID = "task-session"
updateSessionAgent(sessionID, "Sisyphus") updateSessionAgent(sessionID, "Sisyphus")
@@ -189,7 +194,7 @@ describe("category-skill-reminder hook", () => {
describe("tool call counting", () => { describe("tool call counting", () => {
test("should NOT inject reminder before 3 tool calls", async () => { test("should NOT inject reminder before 3 tool calls", async () => {
// given - sisyphus agent with only 2 tool calls // given - sisyphus agent with only 2 tool calls
const hook = createCategorySkillReminderHook(createMockPluginInput()) const hook = createHook()
const sessionID = "few-calls-session" const sessionID = "few-calls-session"
updateSessionAgent(sessionID, "Sisyphus") updateSessionAgent(sessionID, "Sisyphus")
@@ -207,7 +212,7 @@ describe("category-skill-reminder hook", () => {
test("should only inject reminder once per session", async () => { test("should only inject reminder once per session", async () => {
// given - sisyphus agent session // given - sisyphus agent session
const hook = createCategorySkillReminderHook(createMockPluginInput()) const hook = createHook()
const sessionID = "once-session" const sessionID = "once-session"
updateSessionAgent(sessionID, "Sisyphus") updateSessionAgent(sessionID, "Sisyphus")
@@ -231,7 +236,7 @@ describe("category-skill-reminder hook", () => {
test("should only count delegatable work tools", async () => { test("should only count delegatable work tools", async () => {
// given - sisyphus agent with mixed tool calls // given - sisyphus agent with mixed tool calls
const hook = createCategorySkillReminderHook(createMockPluginInput()) const hook = createHook()
const sessionID = "mixed-tools-session" const sessionID = "mixed-tools-session"
updateSessionAgent(sessionID, "Sisyphus") updateSessionAgent(sessionID, "Sisyphus")
@@ -252,7 +257,7 @@ describe("category-skill-reminder hook", () => {
describe("event handling", () => { describe("event handling", () => {
test("should reset state on session.deleted event", async () => { test("should reset state on session.deleted event", async () => {
// given - sisyphus agent with reminder already shown // given - sisyphus agent with reminder already shown
const hook = createCategorySkillReminderHook(createMockPluginInput()) const hook = createHook()
const sessionID = "delete-session" const sessionID = "delete-session"
updateSessionAgent(sessionID, "Sisyphus") updateSessionAgent(sessionID, "Sisyphus")
@@ -278,7 +283,7 @@ describe("category-skill-reminder hook", () => {
test("should reset state on session.compacted event", async () => { test("should reset state on session.compacted event", async () => {
// given - sisyphus agent with reminder already shown // given - sisyphus agent with reminder already shown
const hook = createCategorySkillReminderHook(createMockPluginInput()) const hook = createHook()
const sessionID = "compact-session" const sessionID = "compact-session"
updateSessionAgent(sessionID, "Sisyphus") updateSessionAgent(sessionID, "Sisyphus")
@@ -306,7 +311,7 @@ describe("category-skill-reminder hook", () => {
describe("case insensitivity", () => { describe("case insensitivity", () => {
test("should handle tool names case-insensitively", async () => { test("should handle tool names case-insensitively", async () => {
// given - sisyphus agent with mixed case tool names // given - sisyphus agent with mixed case tool names
const hook = createCategorySkillReminderHook(createMockPluginInput()) const hook = createHook()
const sessionID = "case-session" const sessionID = "case-session"
updateSessionAgent(sessionID, "Sisyphus") updateSessionAgent(sessionID, "Sisyphus")
@@ -325,7 +330,7 @@ describe("category-skill-reminder hook", () => {
test("should handle delegation tool names case-insensitively", async () => { test("should handle delegation tool names case-insensitively", async () => {
// given - sisyphus agent using DELEGATE_TASK in uppercase // given - sisyphus agent using DELEGATE_TASK in uppercase
const hook = createCategorySkillReminderHook(createMockPluginInput()) const hook = createHook()
const sessionID = "case-delegate-session" const sessionID = "case-delegate-session"
updateSessionAgent(sessionID, "Sisyphus") updateSessionAgent(sessionID, "Sisyphus")
@@ -343,4 +348,71 @@ describe("category-skill-reminder hook", () => {
clearSessionAgent(sessionID) clearSessionAgent(sessionID)
}) })
}) })
describe("dynamic skills reminder message", () => {
test("shows built-in skills when only built-in skills are available", async () => {
// given
const availableSkills: AvailableSkill[] = [
{ name: "frontend-ui-ux", description: "Frontend UI/UX work", location: "plugin" },
{ name: "git-master", description: "Git operations", location: "plugin" },
{ name: "playwright", description: "Browser automation", location: "plugin" },
]
const hook = createHook(availableSkills)
const sessionID = "builtins-only"
updateSessionAgent(sessionID, "Sisyphus")
const output = { title: "", output: "result", metadata: {} }
// when
await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "1" }, output)
await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "2" }, output)
await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "3" }, output)
// then
expect(output.output).toContain("**Built-in**:")
expect(output.output).toContain("frontend-ui-ux")
expect(output.output).toContain("**⚡ YOUR SKILLS (PRIORITY)**")
expect(output.output).toContain("load_skills=[\"frontend-ui-ux\"")
})
test("emphasizes user skills with PRIORITY and uses first user skill in example", async () => {
// given
const availableSkills: AvailableSkill[] = [
{ name: "frontend-ui-ux", description: "Frontend UI/UX work", location: "plugin" },
{ name: "react-19", description: "React 19 expertise", location: "user" },
{ name: "web-designer", description: "Visual design", location: "user" },
]
const hook = createHook(availableSkills)
const sessionID = "user-skills"
updateSessionAgent(sessionID, "Atlas")
const output = { title: "", output: "result", metadata: {} }
// when
await hook["tool.execute.after"]({ tool: "bash", sessionID, callID: "1" }, output)
await hook["tool.execute.after"]({ tool: "bash", sessionID, callID: "2" }, output)
await hook["tool.execute.after"]({ tool: "bash", sessionID, callID: "3" }, output)
// then
expect(output.output).toContain("**⚡ YOUR SKILLS (PRIORITY)**")
expect(output.output).toContain("react-19")
expect(output.output).toContain("> User-installed skills OVERRIDE")
expect(output.output).toContain("load_skills=[\"react-19\"")
})
test("still injects a generic reminder when no skills are provided", async () => {
// given
const hook = createHook([])
const sessionID = "no-skills"
updateSessionAgent(sessionID, "Sisyphus")
const output = { title: "", output: "result", metadata: {} }
// when
await hook["tool.execute.after"]({ tool: "read", sessionID, callID: "1" }, output)
await hook["tool.execute.after"]({ tool: "read", sessionID, callID: "2" }, output)
await hook["tool.execute.after"]({ tool: "read", sessionID, callID: "3" }, output)
// then
expect(output.output).toContain("[Category+Skill Reminder]")
expect(output.output).toContain("load_skills=[]")
})
})
}) })

View File

@@ -1,4 +1,5 @@
import type { PluginInput } from "@opencode-ai/plugin" import type { PluginInput } from "@opencode-ai/plugin"
import type { AvailableSkill } from "../../agents/dynamic-agent-prompt-builder"
import { getSessionAgent } from "../../features/claude-code-session-state" import { getSessionAgent } from "../../features/claude-code-session-state"
import { log } from "../../shared" import { log } from "../../shared"
@@ -34,33 +35,41 @@ const DELEGATION_TOOLS = new Set([
"task", "task",
]) ])
const REMINDER_MESSAGE = ` function formatSkillNames(skills: AvailableSkill[], limit: number): string {
[Category+Skill Reminder] if (skills.length === 0) return "(none)"
const shown = skills.slice(0, limit).map((s) => s.name)
const remaining = skills.length - shown.length
const suffix = remaining > 0 ? ` (+${remaining} more)` : ""
return shown.join(", ") + suffix
}
You are an orchestrator agent. Consider whether this work should be delegated: function buildReminderMessage(availableSkills: AvailableSkill[]): string {
const builtinSkills = availableSkills.filter((s) => s.location === "plugin")
const customSkills = availableSkills.filter((s) => s.location !== "plugin")
**DELEGATE when:** const builtinText = formatSkillNames(builtinSkills, 8)
- UI/Frontend work → category: "visual-engineering", skills: ["frontend-ui-ux"] const customText = formatSkillNames(customSkills, 8)
- Complex logic/architecture → category: "ultrabrain"
- Quick/trivial tasks → category: "quick"
- Git operations → skills: ["git-master"]
- Browser automation → skills: ["playwright"] or ["agent-browser"]
**DO IT YOURSELF when:** const exampleSkillName = customSkills[0]?.name ?? builtinSkills[0]?.name
- Gathering context/exploring codebase const loadSkills = exampleSkillName ? `["${exampleSkillName}"]` : "[]"
- Simple edits that are part of a larger task you're coordinating
- Tasks requiring your full context understanding
Example delegation: const lines = [
\`\`\` "",
delegate_task( "[Category+Skill Reminder]",
category="visual-engineering", "",
load_skills=["frontend-ui-ux"], `**Built-in**: ${builtinText}`,
description="Implement responsive navbar with animations", `**⚡ YOUR SKILLS (PRIORITY)**: ${customText}`,
run_in_background=true "",
) "> User-installed skills OVERRIDE built-in defaults. ALWAYS prefer YOUR SKILLS when domain matches.",
\`\`\` "",
` "```typescript",
`delegate_task(category=\"visual-engineering\", load_skills=${loadSkills}, run_in_background=true)`,
"```",
"",
]
return lines.join("\n")
}
interface ToolExecuteInput { interface ToolExecuteInput {
tool: string tool: string
@@ -81,8 +90,12 @@ interface SessionState {
toolCallCount: number toolCallCount: number
} }
export function createCategorySkillReminderHook(_ctx: PluginInput) { export function createCategorySkillReminderHook(
_ctx: PluginInput,
availableSkills: AvailableSkill[] = []
) {
const sessionStates = new Map<string, SessionState>() const sessionStates = new Map<string, SessionState>()
const reminderMessage = buildReminderMessage(availableSkills)
function getOrCreateState(sessionID: string): SessionState { function getOrCreateState(sessionID: string): SessionState {
if (!sessionStates.has(sessionID)) { if (!sessionStates.has(sessionID)) {
@@ -130,7 +143,7 @@ export function createCategorySkillReminderHook(_ctx: PluginInput) {
state.toolCallCount++ state.toolCallCount++
if (state.toolCallCount >= 3 && !state.delegationUsed && !state.reminderShown) { if (state.toolCallCount >= 3 && !state.delegationUsed && !state.reminderShown) {
output.output += REMINDER_MESSAGE output.output += reminderMessage
state.reminderShown = true state.reminderShown = true
log("[category-skill-reminder] Reminder injected", { log("[category-skill-reminder] Reminder injected", {
sessionID, sessionID,

View File

@@ -4,12 +4,12 @@
Full Claude Code `settings.json` hook compatibility layer. Intercepts OpenCode events to execute external scripts/commands. Full Claude Code `settings.json` hook compatibility layer. Intercepts OpenCode events to execute external scripts/commands.
**Config Sources** (priority): `.claude/settings.json` (project) > `~/.claude/settings.json` (global) **Config Sources** (priority): `.claude/settings.local.json` > `.claude/settings.json` (project) > `~/.claude/settings.json` (global)
## STRUCTURE ## STRUCTURE
``` ```
claude-code-hooks/ claude-code-hooks/
├── index.ts # Main factory (401 lines) ├── index.ts # Main factory (421 lines)
├── config.ts # Loads ~/.claude/settings.json ├── config.ts # Loads ~/.claude/settings.json
├── config-loader.ts # Extended config (disabledHooks) ├── config-loader.ts # Extended config (disabledHooks)
├── pre-tool-use.ts # PreToolUse executor ├── pre-tool-use.ts # PreToolUse executor
@@ -19,6 +19,7 @@ claude-code-hooks/
├── pre-compact.ts # PreCompact executor ├── pre-compact.ts # PreCompact executor
├── transcript.ts # Tool use recording ├── transcript.ts # Tool use recording
├── tool-input-cache.ts # Pre→post input caching ├── tool-input-cache.ts # Pre→post input caching
├── todo.ts # Todo integration
└── types.ts # Hook & IO type definitions └── types.ts # Hook & IO type definitions
``` ```
@@ -31,22 +32,16 @@ claude-code-hooks/
| Stop | Session idle/end | Inject | sessionId, parentSessionId, cwd | | Stop | Session idle/end | Inject | sessionId, parentSessionId, cwd |
| PreCompact | Before summarize | No | sessionId, cwd | | PreCompact | Before summarize | No | sessionId, cwd |
## CONFIG SOURCES
Priority (highest first):
1. `.claude/settings.local.json` (Project-local, git-ignored)
2. `.claude/settings.json` (Project)
3. `~/.claude/settings.json` (Global user)
## HOOK EXECUTION ## HOOK EXECUTION
- **Matchers**: Hooks filter by tool name or event type via regex/glob. - **Matchers**: Hooks filter by tool name or event type via regex/glob
- **Commands**: Executed via subprocess with env vars (`$SESSION_ID`, `$TOOL_NAME`). - **Commands**: Executed via subprocess with env vars (`$SESSION_ID`, `$TOOL_NAME`)
- **Exit Codes**: - **Exit Codes**:
- `0`: Pass (Success) - `0`: Pass (Success)
- `1`: Warn (Continue with system message) - `1`: Warn (Continue with system message)
- `2`: Block (Abort operation/prompt) - `2`: Block (Abort operation/prompt)
## ANTI-PATTERNS ## ANTI-PATTERNS
- **Heavy PreToolUse**: Runs before EVERY tool; keep logic light to avoid latency. - **Heavy PreToolUse**: Runs before EVERY tool; keep logic light to avoid latency
- **Blocking non-critical**: Prefer PostToolUse warnings for non-fatal issues. - **Blocking non-critical**: Prefer PostToolUse warnings for non-fatal issues
- **Direct state mutation**: Use `updatedInput` in PreToolUse instead of side effects. - **Direct state mutation**: Use `updatedInput` in PreToolUse instead of side effects
- **Ignoring Exit Codes**: Ensure scripts return `2` to properly block sensitive tools. - **Ignoring Exit Codes**: Ensure scripts return `2` to properly block sensitive tools

View File

@@ -55,7 +55,9 @@ export function getClaudeSettingsPaths(customPath?: string): string[] {
paths.unshift(customPath) paths.unshift(customPath)
} }
return paths // Deduplicate paths to prevent loading the same file multiple times
// (e.g., when cwd is the home directory)
return [...new Set(paths)]
} }
function mergeHooksConfig( function mergeHooksConfig(

View File

@@ -1,14 +1,4 @@
import { describe, expect, it, mock, beforeEach } from "bun:test" import { describe, expect, it, mock } from "bun:test"
// Mock dependencies before importing
const mockInjectHookMessage = mock(() => true)
mock.module("../../features/hook-message-injector", () => ({
injectHookMessage: mockInjectHookMessage,
}))
mock.module("../../shared/logger", () => ({
log: () => {},
}))
mock.module("../../shared/system-directive", () => ({ mock.module("../../shared/system-directive", () => ({
createSystemDirective: (type: string) => `[DIRECTIVE:${type}]`, createSystemDirective: (type: string) => `[DIRECTIVE:${type}]`,
@@ -25,78 +15,45 @@ mock.module("../../shared/system-directive", () => ({
})) }))
import { createCompactionContextInjector } from "./index" import { createCompactionContextInjector } from "./index"
import type { SummarizeContext } from "./index"
describe("createCompactionContextInjector", () => { describe("createCompactionContextInjector", () => {
beforeEach(() => {
mockInjectHookMessage.mockClear()
})
describe("Agent Verification State preservation", () => { describe("Agent Verification State preservation", () => {
it("includes Agent Verification State section in compaction prompt", async () => { it("includes Agent Verification State section in compaction prompt", async () => {
// given //#given
const injector = createCompactionContextInjector() const injector = createCompactionContextInjector()
const context: SummarizeContext = {
sessionID: "test-session",
providerID: "anthropic",
modelID: "claude-sonnet-4-5",
usageRatio: 0.85,
directory: "/test/dir",
}
// when //#when
await injector(context) const prompt = injector()
// then //#then
expect(mockInjectHookMessage).toHaveBeenCalledTimes(1) expect(prompt).toContain("Agent Verification State")
const calls = mockInjectHookMessage.mock.calls as unknown as [string, string, unknown][] expect(prompt).toContain("Current Agent")
const injectedPrompt = calls[0]?.[1] ?? "" expect(prompt).toContain("Verification Progress")
expect(injectedPrompt).toContain("Agent Verification State")
expect(injectedPrompt).toContain("Current Agent")
expect(injectedPrompt).toContain("Verification Progress")
}) })
it("includes Momus-specific context for reviewer agents", async () => { it("includes reviewer-agent continuity fields", async () => {
// given //#given
const injector = createCompactionContextInjector() const injector = createCompactionContextInjector()
const context: SummarizeContext = {
sessionID: "test-session",
providerID: "anthropic",
modelID: "claude-sonnet-4-5",
usageRatio: 0.9,
directory: "/test/dir",
}
// when //#when
await injector(context) const prompt = injector()
// then //#then
const calls = mockInjectHookMessage.mock.calls as unknown as [string, string, unknown][] expect(prompt).toContain("Previous Rejections")
const injectedPrompt = calls[0]?.[1] ?? "" expect(prompt).toContain("Acceptance Status")
expect(injectedPrompt).toContain("Previous Rejections") expect(prompt).toContain("reviewer agents")
expect(injectedPrompt).toContain("Acceptance Status")
expect(injectedPrompt).toContain("reviewer agents")
}) })
it("preserves file verification progress in compaction prompt", async () => { it("preserves file verification progress fields", async () => {
// given //#given
const injector = createCompactionContextInjector() const injector = createCompactionContextInjector()
const context: SummarizeContext = {
sessionID: "test-session",
providerID: "anthropic",
modelID: "claude-sonnet-4-5",
usageRatio: 0.95,
directory: "/test/dir",
}
// when //#when
await injector(context) const prompt = injector()
// then //#then
const calls = mockInjectHookMessage.mock.calls as unknown as [string, string, unknown][] expect(prompt).toContain("Pending Verifications")
const injectedPrompt = calls[0]?.[1] ?? "" expect(prompt).toContain("Files already verified")
expect(injectedPrompt).toContain("Pending Verifications")
expect(injectedPrompt).toContain("Files already verified")
}) })
}) })
}) })

View File

@@ -1,16 +1,6 @@
import { injectHookMessage } from "../../features/hook-message-injector"
import { log } from "../../shared/logger"
import { createSystemDirective, SystemDirectiveTypes } from "../../shared/system-directive" import { createSystemDirective, SystemDirectiveTypes } from "../../shared/system-directive"
export interface SummarizeContext { const COMPACTION_CONTEXT_PROMPT = `${createSystemDirective(SystemDirectiveTypes.COMPACTION_CONTEXT)}
sessionID: string
providerID: string
modelID: string
usageRatio: number
directory: string
}
const SUMMARIZE_CONTEXT_PROMPT = `${createSystemDirective(SystemDirectiveTypes.COMPACTION_CONTEXT)}
When summarizing this session, you MUST include the following sections in your summary: When summarizing this session, you MUST include the following sections in your summary:
@@ -58,19 +48,5 @@ This context is critical for maintaining continuity after compaction.
` `
export function createCompactionContextInjector() { export function createCompactionContextInjector() {
return async (ctx: SummarizeContext): Promise<void> => { return (): string => COMPACTION_CONTEXT_PROMPT
log("[compaction-context-injector] injecting context", { sessionID: ctx.sessionID })
const success = injectHookMessage(ctx.sessionID, SUMMARIZE_CONTEXT_PROMPT, {
agent: "general",
model: { providerID: ctx.providerID, modelID: ctx.modelID },
path: { cwd: ctx.directory },
})
if (success) {
log("[compaction-context-injector] context injected", { sessionID: ctx.sessionID })
} else {
log("[compaction-context-injector] injection failed", { sessionID: ctx.sessionID })
}
}
} }

View File

@@ -34,7 +34,8 @@ export { createDelegateTaskRetryHook } from "./delegate-task-retry";
export { createQuestionLabelTruncatorHook } from "./question-label-truncator"; export { createQuestionLabelTruncatorHook } from "./question-label-truncator";
export { createSubagentQuestionBlockerHook } from "./subagent-question-blocker"; export { createSubagentQuestionBlockerHook } from "./subagent-question-blocker";
export { createStopContinuationGuardHook, type StopContinuationGuard } from "./stop-continuation-guard"; export { createStopContinuationGuardHook, type StopContinuationGuard } from "./stop-continuation-guard";
export { createCompactionContextInjector, type SummarizeContext } from "./compaction-context-injector"; export { createCompactionContextInjector } from "./compaction-context-injector";
export { createUnstableAgentBabysitterHook } from "./unstable-agent-babysitter"; export { createUnstableAgentBabysitterHook } from "./unstable-agent-babysitter";
export { createPreemptiveCompactionHook } from "./preemptive-compaction"; export { createPreemptiveCompactionHook } from "./preemptive-compaction";
export { createTasksTodowriteDisablerHook } from "./tasks-todowrite-disabler"; export { createTasksTodowriteDisablerHook } from "./tasks-todowrite-disabler";
export { createWriteExistingFileGuardHook } from "./write-existing-file-guard";

View File

@@ -36,7 +36,7 @@ export function createKeywordDetectorHook(ctx: PluginInput, collector?: ContextC
// Remove system-reminder content to prevent automated system messages from triggering mode keywords // Remove system-reminder content to prevent automated system messages from triggering mode keywords
const cleanText = removeSystemReminders(promptText) const cleanText = removeSystemReminders(promptText)
const modelID = input.model?.modelID const modelID = input.model?.modelID
let detectedKeywords = detectKeywordsWithType(removeCodeBlocks(cleanText), currentAgent, modelID) let detectedKeywords = detectKeywordsWithType(cleanText, currentAgent, modelID)
if (isPlannerAgent(currentAgent)) { if (isPlannerAgent(currentAgent)) {
detectedKeywords = detectedKeywords.filter((k) => k.type !== "ultrawork") detectedKeywords = detectedKeywords.filter((k) => k.type !== "ultrawork")

View File

@@ -1,5 +1,4 @@
import type { PluginInput } from "@opencode-ai/plugin" import type { PluginInput } from "@opencode-ai/plugin"
import type { ShellType } from "../../shared"
import { HOOK_NAME, NON_INTERACTIVE_ENV, SHELL_COMMAND_PATTERNS } from "./constants" import { HOOK_NAME, NON_INTERACTIVE_ENV, SHELL_COMMAND_PATTERNS } from "./constants"
import { log, buildEnvPrefix } from "../../shared" import { log, buildEnvPrefix } from "../../shared"
@@ -54,10 +53,8 @@ export function createNonInteractiveEnvHook(_ctx: PluginInput) {
// for git commands to prevent interactive prompts. // for git commands to prevent interactive prompts.
// The bash tool always runs in a Unix-like shell (bash/sh), even on Windows // The bash tool always runs in a Unix-like shell (bash/sh), even on Windows
// (via Git Bash, WSL, etc.), so we always use unix export syntax. // (via Git Bash, WSL, etc.), so always use unix export syntax.
// This fixes GitHub issues #983 and #889. const envPrefix = buildEnvPrefix(NON_INTERACTIVE_ENV, "unix")
const shellType: ShellType = "unix"
const envPrefix = buildEnvPrefix(NON_INTERACTIVE_ENV, shellType)
output.args.command = `${envPrefix} ${command}` output.args.command = `${envPrefix} ${command}`
log(`[${HOOK_NAME}] Prepended non-interactive env vars to git command`, { log(`[${HOOK_NAME}] Prepended non-interactive env vars to git command`, {

View File

@@ -34,7 +34,7 @@ describe("preemptive-compaction", () => {
info: { info: {
role: "assistant", role: "assistant",
providerID: "anthropic", providerID: "anthropic",
modelID: "claude-opus-4-5", modelID: "claude-opus-4-6",
tokens: { tokens: {
input: 180000, input: 180000,
output: 0, output: 0,
@@ -60,6 +60,41 @@ describe("preemptive-compaction", () => {
expect(summarize).toHaveBeenCalled() expect(summarize).toHaveBeenCalled()
}) })
test("triggers summarize for non-anthropic providers when usage exceeds threshold", async () => {
//#given
const messages = mock(() =>
Promise.resolve({
data: [
{
info: {
role: "assistant",
providerID: "openai",
modelID: "gpt-5.2",
tokens: {
input: 180000,
output: 0,
reasoning: 0,
cache: { read: 0, write: 0 },
},
},
},
],
})
)
const summarize = mock(() => Promise.resolve())
const hook = createPreemptiveCompactionHook(createMockCtx({ messages, summarize }))
const output = { title: "", output: "", metadata: {} }
//#when
await hook["tool.execute.after"](
{ tool: "Read", sessionID, callID: "call-3" },
output
)
//#then
expect(summarize).toHaveBeenCalled()
})
test("does not summarize when usage is below threshold", async () => { test("does not summarize when usage is below threshold", async () => {
// #given // #given
const messages = mock(() => const messages = mock(() =>
@@ -69,7 +104,7 @@ describe("preemptive-compaction", () => {
info: { info: {
role: "assistant", role: "assistant",
providerID: "anthropic", providerID: "anthropic",
modelID: "claude-opus-4-5", modelID: "claude-opus-4-6",
tokens: { tokens: {
input: 100000, input: 100000,
output: 0, output: 0,

View File

@@ -1,8 +1,10 @@
const DEFAULT_ACTUAL_LIMIT = 200_000
const ANTHROPIC_ACTUAL_LIMIT = const ANTHROPIC_ACTUAL_LIMIT =
process.env.ANTHROPIC_1M_CONTEXT === "true" || process.env.ANTHROPIC_1M_CONTEXT === "true" ||
process.env.VERTEX_ANTHROPIC_1M_CONTEXT === "true" process.env.VERTEX_ANTHROPIC_1M_CONTEXT === "true"
? 1_000_000 ? 1_000_000
: 200_000 : DEFAULT_ACTUAL_LIMIT
const PREEMPTIVE_COMPACTION_THRESHOLD = 0.78 const PREEMPTIVE_COMPACTION_THRESHOLD = 0.78
@@ -59,11 +61,14 @@ export function createPreemptiveCompactionHook(ctx: PluginInput) {
if (assistantMessages.length === 0) return if (assistantMessages.length === 0) return
const lastAssistant = assistantMessages[assistantMessages.length - 1] const lastAssistant = assistantMessages[assistantMessages.length - 1]
if (lastAssistant.providerID !== "anthropic") return const actualLimit =
lastAssistant.providerID === "anthropic"
? ANTHROPIC_ACTUAL_LIMIT
: DEFAULT_ACTUAL_LIMIT
const lastTokens = lastAssistant.tokens const lastTokens = lastAssistant.tokens
const totalInputTokens = (lastTokens?.input ?? 0) + (lastTokens?.cache?.read ?? 0) const totalInputTokens = (lastTokens?.input ?? 0) + (lastTokens?.cache?.read ?? 0)
const usageRatio = totalInputTokens / ANTHROPIC_ACTUAL_LIMIT const usageRatio = totalInputTokens / actualLimit
if (usageRatio < PREEMPTIVE_COMPACTION_THRESHOLD) return if (usageRatio < PREEMPTIVE_COMPACTION_THRESHOLD) return

View File

@@ -9,7 +9,7 @@ export const ALLOWED_EXTENSIONS = [".md"]
export const ALLOWED_PATH_PREFIX = ".sisyphus" export const ALLOWED_PATH_PREFIX = ".sisyphus"
export const BLOCKED_TOOLS = ["Write", "Edit", "write", "edit"] export const BLOCKED_TOOLS = ["Write", "Edit", "write", "edit", "bash"]
export const PLANNING_CONSULT_WARNING = ` export const PLANNING_CONSULT_WARNING = `

View File

@@ -173,7 +173,25 @@ describe("prometheus-md-only", () => {
).rejects.toThrow("can only write/edit .md files") ).rejects.toThrow("can only write/edit .md files")
}) })
test("should not affect non-Write/Edit tools", async () => { test("should block bash commands from Prometheus", async () => {
// given
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
const input = {
tool: "bash",
sessionID: TEST_SESSION_ID,
callID: "call-1",
}
const output = {
args: { command: "echo test" },
}
// when / #then
await expect(
hook["tool.execute.before"](input, output)
).rejects.toThrow("cannot execute bash commands")
})
test("should not affect non-blocked tools", async () => {
// given // given
const hook = createPrometheusMdOnlyHook(createMockPluginInput()) const hook = createPrometheusMdOnlyHook(createMockPluginInput())
const input = { const input = {
@@ -334,6 +352,121 @@ describe("prometheus-md-only", () => {
}) })
}) })
describe("boulder state priority over message files (fixes #927)", () => {
const BOULDER_DIR = join(tmpdir(), `boulder-test-${randomUUID()}`)
const BOULDER_FILE = join(BOULDER_DIR, ".sisyphus", "boulder.json")
beforeEach(() => {
mkdirSync(join(BOULDER_DIR, ".sisyphus"), { recursive: true })
})
afterEach(() => {
rmSync(BOULDER_DIR, { recursive: true, force: true })
})
//#given session was started with prometheus (first message), but /start-work set boulder agent to atlas
//#when user types "continue" after interruption (memory cleared, falls back to message files)
//#then should use boulder state agent (atlas), not message file agent (prometheus)
test("should prioritize boulder agent over message file agent", async () => {
// given - prometheus in message files (from /plan)
setupMessageStorage(TEST_SESSION_ID, "prometheus")
// given - atlas in boulder state (from /start-work)
writeFileSync(BOULDER_FILE, JSON.stringify({
active_plan: "/test/plan.md",
started_at: new Date().toISOString(),
session_ids: [TEST_SESSION_ID],
plan_name: "test-plan",
agent: "atlas"
}))
const hook = createPrometheusMdOnlyHook({
client: {},
directory: BOULDER_DIR,
} as never)
const input = {
tool: "Write",
sessionID: TEST_SESSION_ID,
callID: "call-1",
}
const output = {
args: { filePath: "/path/to/code.ts" },
}
// when / then - should NOT block because boulder says atlas, not prometheus
await expect(
hook["tool.execute.before"](input, output)
).resolves.toBeUndefined()
})
test("should use prometheus from boulder state when set", async () => {
// given - atlas in message files (from some other agent)
setupMessageStorage(TEST_SESSION_ID, "atlas")
// given - prometheus in boulder state (edge case, but should honor it)
writeFileSync(BOULDER_FILE, JSON.stringify({
active_plan: "/test/plan.md",
started_at: new Date().toISOString(),
session_ids: [TEST_SESSION_ID],
plan_name: "test-plan",
agent: "prometheus"
}))
const hook = createPrometheusMdOnlyHook({
client: {},
directory: BOULDER_DIR,
} as never)
const input = {
tool: "Write",
sessionID: TEST_SESSION_ID,
callID: "call-1",
}
const output = {
args: { filePath: "/path/to/code.ts" },
}
// when / then - should block because boulder says prometheus
await expect(
hook["tool.execute.before"](input, output)
).rejects.toThrow("can only write/edit .md files")
})
test("should fall back to message files when session not in boulder", async () => {
// given - prometheus in message files
setupMessageStorage(TEST_SESSION_ID, "prometheus")
// given - boulder state exists but for different session
writeFileSync(BOULDER_FILE, JSON.stringify({
active_plan: "/test/plan.md",
started_at: new Date().toISOString(),
session_ids: ["other-session-id"],
plan_name: "test-plan",
agent: "atlas"
}))
const hook = createPrometheusMdOnlyHook({
client: {},
directory: BOULDER_DIR,
} as never)
const input = {
tool: "Write",
sessionID: TEST_SESSION_ID,
callID: "call-1",
}
const output = {
args: { filePath: "/path/to/code.ts" },
}
// when / then - should block because falls back to message files (prometheus)
await expect(
hook["tool.execute.before"](input, output)
).rejects.toThrow("can only write/edit .md files")
})
})
describe("without message storage", () => { describe("without message storage", () => {
test("should handle missing session gracefully (no agent found)", async () => { test("should handle missing session gracefully (no agent found)", async () => {
// given // given

View File

@@ -4,6 +4,7 @@ import { join, resolve, relative, isAbsolute } from "node:path"
import { HOOK_NAME, PROMETHEUS_AGENT, ALLOWED_EXTENSIONS, ALLOWED_PATH_PREFIX, BLOCKED_TOOLS, PLANNING_CONSULT_WARNING, PROMETHEUS_WORKFLOW_REMINDER } from "./constants" import { HOOK_NAME, PROMETHEUS_AGENT, ALLOWED_EXTENSIONS, ALLOWED_PATH_PREFIX, BLOCKED_TOOLS, PLANNING_CONSULT_WARNING, PROMETHEUS_WORKFLOW_REMINDER } from "./constants"
import { findNearestMessageWithFields, findFirstMessageWithAgent, MESSAGE_STORAGE } from "../../features/hook-message-injector" import { findNearestMessageWithFields, findFirstMessageWithAgent, MESSAGE_STORAGE } from "../../features/hook-message-injector"
import { getSessionAgent } from "../../features/claude-code-session-state" import { getSessionAgent } from "../../features/claude-code-session-state"
import { readBoulderState } from "../../features/boulder-state"
import { log } from "../../shared/logger" import { log } from "../../shared/logger"
import { SYSTEM_DIRECTIVE_PREFIX } from "../../shared/system-directive" import { SYSTEM_DIRECTIVE_PREFIX } from "../../shared/system-directive"
import { getAgentDisplayName } from "../../shared/agent-display-names" import { getAgentDisplayName } from "../../shared/agent-display-names"
@@ -70,8 +71,31 @@ function getAgentFromMessageFiles(sessionID: string): string | undefined {
return findFirstMessageWithAgent(messageDir) ?? findNearestMessageWithFields(messageDir)?.agent return findFirstMessageWithAgent(messageDir) ?? findNearestMessageWithFields(messageDir)?.agent
} }
function getAgentFromSession(sessionID: string): string | undefined { /**
return getSessionAgent(sessionID) ?? getAgentFromMessageFiles(sessionID) * Get the effective agent for the session.
* Priority order:
* 1. In-memory session agent (most recent, set by /start-work)
* 2. Boulder state agent (persisted across restarts, fixes #927)
* 3. Message files (fallback for sessions without boulder state)
*
* This fixes issue #927 where after interruption:
* - In-memory map is cleared (process restart)
* - Message files return "prometheus" (oldest message from /plan)
* - But boulder.json has agent: "atlas" (set by /start-work)
*/
function getAgentFromSession(sessionID: string, directory: string): string | undefined {
// Check in-memory first (current session)
const memoryAgent = getSessionAgent(sessionID)
if (memoryAgent) return memoryAgent
// Check boulder state (persisted across restarts) - fixes #927
const boulderState = readBoulderState(directory)
if (boulderState?.session_ids.includes(sessionID) && boulderState.agent) {
return boulderState.agent
}
// Fallback to message files
return getAgentFromMessageFiles(sessionID)
} }
export function createPrometheusMdOnlyHook(ctx: PluginInput) { export function createPrometheusMdOnlyHook(ctx: PluginInput) {
@@ -80,7 +104,7 @@ export function createPrometheusMdOnlyHook(ctx: PluginInput) {
input: { tool: string; sessionID: string; callID: string }, input: { tool: string; sessionID: string; callID: string },
output: { args: Record<string, unknown>; message?: string } output: { args: Record<string, unknown>; message?: string }
): Promise<void> => { ): Promise<void> => {
const agentName = getAgentFromSession(input.sessionID) const agentName = getAgentFromSession(input.sessionID, ctx.directory)
if (agentName !== PROMETHEUS_AGENT) { if (agentName !== PROMETHEUS_AGENT) {
return return
@@ -106,6 +130,20 @@ export function createPrometheusMdOnlyHook(ctx: PluginInput) {
return return
} }
// Block bash commands completely - Prometheus is read-only
if (toolName === "bash") {
log(`[${HOOK_NAME}] Blocked: Prometheus cannot execute bash commands`, {
sessionID: input.sessionID,
tool: toolName,
agent: agentName,
})
throw new Error(
`[${HOOK_NAME}] ${getAgentDisplayName("prometheus")} cannot execute bash commands. ` +
`${getAgentDisplayName("prometheus")} is a READ-ONLY planner. Use /start-work to execute the plan. ` +
`APOLOGIZE TO THE USER, REMIND OF YOUR PLAN WRITING PROCESSES, TELL USER WHAT YOU WILL GOING TO DO AS THE PROCESS, WRITE THE PLAN`
)
}
const filePath = (output.args.filePath ?? output.args.path ?? output.args.file) as string | undefined const filePath = (output.args.filePath ?? output.args.path ?? output.args.file) as string | undefined
if (!filePath) { if (!filePath) {
return return

View File

@@ -102,7 +102,7 @@ All ${progress.total} tasks are done. Create a new plan with: /plan "your task"`
if (existingState) { if (existingState) {
clearBoulderState(ctx.directory) clearBoulderState(ctx.directory)
} }
const newState = createBoulderState(matchedPlan, sessionId) const newState = createBoulderState(matchedPlan, sessionId, "atlas")
writeBoulderState(ctx.directory, newState) writeBoulderState(ctx.directory, newState)
contextInfo = ` contextInfo = `
@@ -187,7 +187,7 @@ All ${plans.length} plan(s) are complete. Create a new plan with: /plan "your ta
} else if (incompletePlans.length === 1) { } else if (incompletePlans.length === 1) {
const planPath = incompletePlans[0] const planPath = incompletePlans[0]
const progress = getPlanProgress(planPath) const progress = getPlanProgress(planPath)
const newState = createBoulderState(planPath, sessionId) const newState = createBoulderState(planPath, sessionId, "atlas")
writeBoulderState(ctx.directory, newState) writeBoulderState(ctx.directory, newState)
contextInfo += ` contextInfo += `

View File

@@ -41,7 +41,7 @@ describe("createThinkModeHook integration", () => {
const hook = createThinkModeHook() const hook = createThinkModeHook()
const input = createMockInput( const input = createMockInput(
"github-copilot", "github-copilot",
"claude-opus-4-5", "claude-opus-4-6",
"Please think deeply about this problem" "Please think deeply about this problem"
) )
@@ -50,7 +50,7 @@ describe("createThinkModeHook integration", () => {
// then should upgrade to high variant and inject thinking config // then should upgrade to high variant and inject thinking config
const message = input.message as MessageWithInjectedProps const message = input.message as MessageWithInjectedProps
expect(input.message.model?.modelID).toBe("claude-opus-4-5-high") expect(input.message.model?.modelID).toBe("claude-opus-4-6-high")
expect(message.thinking).toBeDefined() expect(message.thinking).toBeDefined()
expect((message.thinking as Record<string, unknown>)?.type).toBe( expect((message.thinking as Record<string, unknown>)?.type).toBe(
"enabled" "enabled"
@@ -61,11 +61,11 @@ describe("createThinkModeHook integration", () => {
}) })
it("should handle github-copilot Claude with dots in version", async () => { it("should handle github-copilot Claude with dots in version", async () => {
// given a github-copilot Claude model with dot format (claude-opus-4.5) // given a github-copilot Claude model with dot format (claude-opus-4.6)
const hook = createThinkModeHook() const hook = createThinkModeHook()
const input = createMockInput( const input = createMockInput(
"github-copilot", "github-copilot",
"claude-opus-4.5", "claude-opus-4.6",
"ultrathink mode" "ultrathink mode"
) )
@@ -74,7 +74,7 @@ describe("createThinkModeHook integration", () => {
// then should upgrade to high variant (hyphen format) // then should upgrade to high variant (hyphen format)
const message = input.message as MessageWithInjectedProps const message = input.message as MessageWithInjectedProps
expect(input.message.model?.modelID).toBe("claude-opus-4-5-high") expect(input.message.model?.modelID).toBe("claude-opus-4-6-high")
expect(message.thinking).toBeDefined() expect(message.thinking).toBeDefined()
}) })
@@ -179,7 +179,7 @@ describe("createThinkModeHook integration", () => {
const hook = createThinkModeHook() const hook = createThinkModeHook()
const input = createMockInput( const input = createMockInput(
"github-copilot", "github-copilot",
"claude-opus-4-5", "claude-opus-4-6",
"Just do this task" "Just do this task"
) )
const originalModelID = input.message.model?.modelID const originalModelID = input.message.model?.modelID
@@ -271,7 +271,7 @@ describe("createThinkModeHook integration", () => {
const hook = createThinkModeHook() const hook = createThinkModeHook()
const input = createMockInput( const input = createMockInput(
"github-copilot", "github-copilot",
"claude-opus-4-5-high", "claude-opus-4-6-high",
"think deeply" "think deeply"
) )
@@ -280,7 +280,7 @@ describe("createThinkModeHook integration", () => {
// then should NOT modify the model (already high) // then should NOT modify the model (already high)
const message = input.message as MessageWithInjectedProps const message = input.message as MessageWithInjectedProps
expect(input.message.model?.modelID).toBe("claude-opus-4-5-high") expect(input.message.model?.modelID).toBe("claude-opus-4-6-high")
// No additional thinking config should be injected // No additional thinking config should be injected
expect(message.thinking).toBeUndefined() expect(message.thinking).toBeUndefined()
}) })
@@ -341,13 +341,13 @@ describe("createThinkModeHook integration", () => {
it("should handle empty prompt gracefully", async () => { it("should handle empty prompt gracefully", async () => {
// given empty prompt // given empty prompt
const hook = createThinkModeHook() const hook = createThinkModeHook()
const input = createMockInput("github-copilot", "claude-opus-4-5", "") const input = createMockInput("github-copilot", "claude-opus-4-6", "")
// when the chat.params hook is called // when the chat.params hook is called
await hook["chat.params"](input, sessionID) await hook["chat.params"](input, sessionID)
// then should not upgrade (no think keyword) // then should not upgrade (no think keyword)
expect(input.message.model?.modelID).toBe("claude-opus-4-5") expect(input.message.model?.modelID).toBe("claude-opus-4-6")
}) })
}) })

View File

@@ -12,7 +12,7 @@ describe("think-mode switcher", () => {
it("should resolve github-copilot Claude Opus to anthropic config", () => { it("should resolve github-copilot Claude Opus to anthropic config", () => {
// given a github-copilot provider with Claude Opus model // given a github-copilot provider with Claude Opus model
const providerID = "github-copilot" const providerID = "github-copilot"
const modelID = "claude-opus-4-5" const modelID = "claude-opus-4-6"
// when getting thinking config // when getting thinking config
const config = getThinkingConfig(providerID, modelID) const config = getThinkingConfig(providerID, modelID)
@@ -38,8 +38,8 @@ describe("think-mode switcher", () => {
}) })
it("should handle Claude with dots in version number", () => { it("should handle Claude with dots in version number", () => {
// given a model ID with dots (claude-opus-4.5) // given a model ID with dots (claude-opus-4.6)
const config = getThinkingConfig("github-copilot", "claude-opus-4.5") const config = getThinkingConfig("github-copilot", "claude-opus-4.6")
// then should still return anthropic thinking config // then should still return anthropic thinking config
expect(config).not.toBeNull() expect(config).not.toBeNull()
@@ -127,18 +127,26 @@ describe("think-mode switcher", () => {
describe("getHighVariant with dots vs hyphens", () => { describe("getHighVariant with dots vs hyphens", () => {
it("should handle dots in Claude version numbers", () => { it("should handle dots in Claude version numbers", () => {
// given a Claude model ID with dot format // given a Claude model ID with dot format
const variant = getHighVariant("claude-opus-4.5") const variant = getHighVariant("claude-opus-4.6")
// then should return high variant with hyphen format // then should return high variant with hyphen format
expect(variant).toBe("claude-opus-4-5-high") expect(variant).toBe("claude-opus-4-6-high")
}) })
it("should handle hyphens in Claude version numbers", () => { it("should handle hyphens in Claude version numbers", () => {
// given a Claude model ID with hyphen format // given a Claude model ID with hyphen format
const variant = getHighVariant("claude-opus-4-5") const variant = getHighVariant("claude-opus-4-6")
// then should return high variant // then should return high variant
expect(variant).toBe("claude-opus-4-5-high") expect(variant).toBe("claude-opus-4-6-high")
})
it("should handle claude-opus-4-6 high variant", () => {
// given a Claude Opus 4.6 model ID
const variant = getHighVariant("claude-opus-4-6")
// then should return high variant
expect(variant).toBe("claude-opus-4-6-high")
}) })
it("should handle dots in GPT version numbers", () => { it("should handle dots in GPT version numbers", () => {
@@ -169,7 +177,7 @@ describe("think-mode switcher", () => {
it("should return null for already-high variants", () => { it("should return null for already-high variants", () => {
// given model IDs that are already high variants // given model IDs that are already high variants
expect(getHighVariant("claude-opus-4-5-high")).toBeNull() expect(getHighVariant("claude-opus-4-6-high")).toBeNull()
expect(getHighVariant("gpt-5-2-high")).toBeNull() expect(getHighVariant("gpt-5-2-high")).toBeNull()
expect(getHighVariant("gemini-3-pro-high")).toBeNull() expect(getHighVariant("gemini-3-pro-high")).toBeNull()
}) })
@@ -185,7 +193,7 @@ describe("think-mode switcher", () => {
describe("isAlreadyHighVariant", () => { describe("isAlreadyHighVariant", () => {
it("should detect -high suffix", () => { it("should detect -high suffix", () => {
// given model IDs with -high suffix // given model IDs with -high suffix
expect(isAlreadyHighVariant("claude-opus-4-5-high")).toBe(true) expect(isAlreadyHighVariant("claude-opus-4-6-high")).toBe(true)
expect(isAlreadyHighVariant("gpt-5-2-high")).toBe(true) expect(isAlreadyHighVariant("gpt-5-2-high")).toBe(true)
expect(isAlreadyHighVariant("gemini-3-pro-high")).toBe(true) expect(isAlreadyHighVariant("gemini-3-pro-high")).toBe(true)
}) })
@@ -197,8 +205,8 @@ describe("think-mode switcher", () => {
it("should return false for base models", () => { it("should return false for base models", () => {
// given base model IDs without -high suffix // given base model IDs without -high suffix
expect(isAlreadyHighVariant("claude-opus-4-5")).toBe(false) expect(isAlreadyHighVariant("claude-opus-4-6")).toBe(false)
expect(isAlreadyHighVariant("claude-opus-4.5")).toBe(false) expect(isAlreadyHighVariant("claude-opus-4.6")).toBe(false)
expect(isAlreadyHighVariant("gpt-5.2")).toBe(false) expect(isAlreadyHighVariant("gpt-5.2")).toBe(false)
expect(isAlreadyHighVariant("gemini-3-pro")).toBe(false) expect(isAlreadyHighVariant("gemini-3-pro")).toBe(false)
}) })
@@ -214,7 +222,7 @@ describe("think-mode switcher", () => {
it("should return null for already-high variants", () => { it("should return null for already-high variants", () => {
// given already-high model variants // given already-high model variants
expect( expect(
getThinkingConfig("anthropic", "claude-opus-4-5-high") getThinkingConfig("anthropic", "claude-opus-4-6-high")
).toBeNull() ).toBeNull()
expect(getThinkingConfig("openai", "gpt-5-2-high")).toBeNull() expect(getThinkingConfig("openai", "gpt-5-2-high")).toBeNull()
expect(getThinkingConfig("google", "gemini-3-pro-high")).toBeNull() expect(getThinkingConfig("google", "gemini-3-pro-high")).toBeNull()
@@ -223,7 +231,7 @@ describe("think-mode switcher", () => {
it("should return null for already-high variants via github-copilot", () => { it("should return null for already-high variants via github-copilot", () => {
// given already-high model variants via github-copilot // given already-high model variants via github-copilot
expect( expect(
getThinkingConfig("github-copilot", "claude-opus-4-5-high") getThinkingConfig("github-copilot", "claude-opus-4-6-high")
).toBeNull() ).toBeNull()
expect(getThinkingConfig("github-copilot", "gpt-5.2-high")).toBeNull() expect(getThinkingConfig("github-copilot", "gpt-5.2-high")).toBeNull()
}) })
@@ -250,7 +258,7 @@ describe("think-mode switcher", () => {
describe("Direct provider configs (backwards compatibility)", () => { describe("Direct provider configs (backwards compatibility)", () => {
it("should still work for direct anthropic provider", () => { it("should still work for direct anthropic provider", () => {
// given direct anthropic provider // given direct anthropic provider
const config = getThinkingConfig("anthropic", "claude-opus-4-5") const config = getThinkingConfig("anthropic", "claude-opus-4-6")
// then should return anthropic thinking config // then should return anthropic thinking config
expect(config).not.toBeNull() expect(config).not.toBeNull()
@@ -343,10 +351,10 @@ describe("think-mode switcher", () => {
it("should handle prefixes with dots in version numbers", () => { it("should handle prefixes with dots in version numbers", () => {
// given a model ID with prefix and dots // given a model ID with prefix and dots
const variant = getHighVariant("vertex_ai/claude-opus-4.5") const variant = getHighVariant("vertex_ai/claude-opus-4.6")
// then should normalize dots and preserve prefix // then should normalize dots and preserve prefix
expect(variant).toBe("vertex_ai/claude-opus-4-5-high") expect(variant).toBe("vertex_ai/claude-opus-4-6-high")
}) })
it("should handle multiple different prefixes", () => { it("should handle multiple different prefixes", () => {
@@ -364,7 +372,7 @@ describe("think-mode switcher", () => {
it("should return null for already-high prefixed models", () => { it("should return null for already-high prefixed models", () => {
// given prefixed model IDs that are already high // given prefixed model IDs that are already high
expect(getHighVariant("vertex_ai/claude-opus-4-5-high")).toBeNull() expect(getHighVariant("vertex_ai/claude-opus-4-6-high")).toBeNull()
expect(getHighVariant("openai/gpt-5-2-high")).toBeNull() expect(getHighVariant("openai/gpt-5-2-high")).toBeNull()
}) })
}) })
@@ -372,14 +380,14 @@ describe("think-mode switcher", () => {
describe("isAlreadyHighVariant with prefixes", () => { describe("isAlreadyHighVariant with prefixes", () => {
it("should detect -high suffix in prefixed models", () => { it("should detect -high suffix in prefixed models", () => {
// given prefixed model IDs with -high suffix // given prefixed model IDs with -high suffix
expect(isAlreadyHighVariant("vertex_ai/claude-opus-4-5-high")).toBe(true) expect(isAlreadyHighVariant("vertex_ai/claude-opus-4-6-high")).toBe(true)
expect(isAlreadyHighVariant("openai/gpt-5-2-high")).toBe(true) expect(isAlreadyHighVariant("openai/gpt-5-2-high")).toBe(true)
expect(isAlreadyHighVariant("custom/gemini-3-pro-high")).toBe(true) expect(isAlreadyHighVariant("custom/gemini-3-pro-high")).toBe(true)
}) })
it("should return false for prefixed base models", () => { it("should return false for prefixed base models", () => {
// given prefixed base model IDs without -high suffix // given prefixed base model IDs without -high suffix
expect(isAlreadyHighVariant("vertex_ai/claude-opus-4-5")).toBe(false) expect(isAlreadyHighVariant("vertex_ai/claude-opus-4-6")).toBe(false)
expect(isAlreadyHighVariant("openai/gpt-5-2")).toBe(false) expect(isAlreadyHighVariant("openai/gpt-5-2")).toBe(false)
}) })
@@ -402,7 +410,7 @@ describe("think-mode switcher", () => {
it("should work with prefixed models on known providers", () => { it("should work with prefixed models on known providers", () => {
// given known provider (anthropic) with prefixed model // given known provider (anthropic) with prefixed model
// This tests that the base model name is correctly extracted for capability check // This tests that the base model name is correctly extracted for capability check
const config = getThinkingConfig("anthropic", "custom-prefix/claude-opus-4-5") const config = getThinkingConfig("anthropic", "custom-prefix/claude-opus-4-6")
// then should return thinking config (base model is capable) // then should return thinking config (base model is capable)
expect(config).not.toBeNull() expect(config).not.toBeNull()
@@ -411,7 +419,7 @@ describe("think-mode switcher", () => {
it("should return null for prefixed models that are already high", () => { it("should return null for prefixed models that are already high", () => {
// given prefixed already-high model // given prefixed already-high model
const config = getThinkingConfig("anthropic", "vertex_ai/claude-opus-4-5-high") const config = getThinkingConfig("anthropic", "vertex_ai/claude-opus-4-6-high")
// then should return null // then should return null
expect(config).toBeNull() expect(config).toBeNull()
@@ -444,11 +452,11 @@ describe("think-mode switcher", () => {
it("should not break when switching to high variant in think mode", () => { it("should not break when switching to high variant in think mode", () => {
// given think mode switching vertex_ai/claude model to high variant // given think mode switching vertex_ai/claude model to high variant
const original = "vertex_ai/claude-opus-4-5" const original = "vertex_ai/claude-opus-4-6"
const high = getHighVariant(original) const high = getHighVariant(original)
// then the high variant should be valid // then the high variant should be valid
expect(high).toBe("vertex_ai/claude-opus-4-5-high") expect(high).toBe("vertex_ai/claude-opus-4-6-high")
// #and should be recognized as already high // #and should be recognized as already high
expect(isAlreadyHighVariant(high!)).toBe(true) expect(isAlreadyHighVariant(high!)).toBe(true)

View File

@@ -38,14 +38,14 @@ function extractModelPrefix(modelID: string): { prefix: string; base: string } {
/** /**
* Normalizes model IDs to use consistent hyphen formatting. * Normalizes model IDs to use consistent hyphen formatting.
* GitHub Copilot may use dots (claude-opus-4.5) but our maps use hyphens (claude-opus-4-5). * GitHub Copilot may use dots (claude-opus-4.6) but our maps use hyphens (claude-opus-4-6).
* This ensures lookups work regardless of format. * This ensures lookups work regardless of format.
* *
* @example * @example
* normalizeModelID("claude-opus-4.5") // "claude-opus-4-5" * normalizeModelID("claude-opus-4.6") // "claude-opus-4-6"
* normalizeModelID("gemini-3.5-pro") // "gemini-3-5-pro" * normalizeModelID("gemini-3.5-pro") // "gemini-3-5-pro"
* normalizeModelID("gpt-5.2") // "gpt-5-2" * normalizeModelID("gpt-5.2") // "gpt-5-2"
* normalizeModelID("vertex_ai/claude-opus-4.5") // "vertex_ai/claude-opus-4-5" * normalizeModelID("vertex_ai/claude-opus-4.6") // "vertex_ai/claude-opus-4-6"
*/ */
function normalizeModelID(modelID: string): string { function normalizeModelID(modelID: string): string {
// Replace dots with hyphens when followed by a digit // Replace dots with hyphens when followed by a digit
@@ -59,10 +59,10 @@ function normalizeModelID(modelID: string): string {
* model provider (Anthropic, Google, OpenAI). * model provider (Anthropic, Google, OpenAI).
* *
* @example * @example
* resolveProvider("github-copilot", "claude-opus-4-5") // "anthropic" * resolveProvider("github-copilot", "claude-opus-4-6") // "anthropic"
* resolveProvider("github-copilot", "gemini-3-pro") // "google" * resolveProvider("github-copilot", "gemini-3-pro") // "google"
* resolveProvider("github-copilot", "gpt-5.2") // "openai" * resolveProvider("github-copilot", "gpt-5.2") // "openai"
* resolveProvider("anthropic", "claude-opus-4-5") // "anthropic" (unchanged) * resolveProvider("anthropic", "claude-opus-4-6") // "anthropic" (unchanged)
*/ */
function resolveProvider(providerID: string, modelID: string): string { function resolveProvider(providerID: string, modelID: string): string {
// GitHub Copilot is a proxy - infer actual provider from model name // GitHub Copilot is a proxy - infer actual provider from model name
@@ -88,7 +88,7 @@ function resolveProvider(providerID: string, modelID: string): string {
const HIGH_VARIANT_MAP: Record<string, string> = { const HIGH_VARIANT_MAP: Record<string, string> = {
// Claude // Claude
"claude-sonnet-4-5": "claude-sonnet-4-5-high", "claude-sonnet-4-5": "claude-sonnet-4-5-high",
"claude-opus-4-5": "claude-opus-4-5-high", "claude-opus-4-6": "claude-opus-4-6-high",
// Gemini // Gemini
"gemini-3-pro": "gemini-3-pro-high", "gemini-3-pro": "gemini-3-pro-high",
"gemini-3-pro-low": "gemini-3-pro-high", "gemini-3-pro-low": "gemini-3-pro-high",

View File

@@ -0,0 +1,206 @@
import { describe, test, expect, beforeEach, afterEach } from "bun:test"
import { createWriteExistingFileGuardHook } from "./index"
import * as fs from "fs"
import * as path from "path"
import * as os from "os"
describe("createWriteExistingFileGuardHook", () => {
let tempDir: string
let ctx: { directory: string }
let hook: ReturnType<typeof createWriteExistingFileGuardHook>
beforeEach(() => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "write-guard-test-"))
ctx = { directory: tempDir }
hook = createWriteExistingFileGuardHook(ctx as any)
})
afterEach(() => {
fs.rmSync(tempDir, { recursive: true, force: true })
})
describe("tool.execute.before", () => {
test("allows write to non-existing file", async () => {
//#given
const nonExistingFile = path.join(tempDir, "new-file.txt")
const input = { tool: "Write", sessionID: "ses_1", callID: "call_1" }
const output = { args: { filePath: nonExistingFile, content: "hello" } }
//#when
const result = hook["tool.execute.before"]?.(input as any, output as any)
//#then
await expect(result).resolves.toBeUndefined()
})
test("blocks write to existing file", async () => {
//#given
const existingFile = path.join(tempDir, "existing-file.txt")
fs.writeFileSync(existingFile, "existing content")
const input = { tool: "Write", sessionID: "ses_1", callID: "call_1" }
const output = { args: { filePath: existingFile, content: "new content" } }
//#when
const result = hook["tool.execute.before"]?.(input as any, output as any)
//#then
await expect(result).rejects.toThrow("File already exists. Use edit tool instead.")
})
test("blocks write tool (lowercase) to existing file", async () => {
//#given
const existingFile = path.join(tempDir, "existing-file.txt")
fs.writeFileSync(existingFile, "existing content")
const input = { tool: "write", sessionID: "ses_1", callID: "call_1" }
const output = { args: { filePath: existingFile, content: "new content" } }
//#when
const result = hook["tool.execute.before"]?.(input as any, output as any)
//#then
await expect(result).rejects.toThrow("File already exists. Use edit tool instead.")
})
test("ignores non-write tools", async () => {
//#given
const existingFile = path.join(tempDir, "existing-file.txt")
fs.writeFileSync(existingFile, "existing content")
const input = { tool: "Edit", sessionID: "ses_1", callID: "call_1" }
const output = { args: { filePath: existingFile, content: "new content" } }
//#when
const result = hook["tool.execute.before"]?.(input as any, output as any)
//#then
await expect(result).resolves.toBeUndefined()
})
test("ignores tools without any file path arg", async () => {
//#given
const input = { tool: "Write", sessionID: "ses_1", callID: "call_1" }
const output = { args: { command: "ls" } }
//#when
const result = hook["tool.execute.before"]?.(input as any, output as any)
//#then
await expect(result).resolves.toBeUndefined()
})
describe("alternative arg names", () => {
test("blocks write using 'path' arg to existing file", async () => {
//#given
const existingFile = path.join(tempDir, "existing-file.txt")
fs.writeFileSync(existingFile, "existing content")
const input = { tool: "Write", sessionID: "ses_1", callID: "call_1" }
const output = { args: { path: existingFile, content: "new content" } }
//#when
const result = hook["tool.execute.before"]?.(input as any, output as any)
//#then
await expect(result).rejects.toThrow("File already exists. Use edit tool instead.")
})
test("blocks write using 'file_path' arg to existing file", async () => {
//#given
const existingFile = path.join(tempDir, "existing-file.txt")
fs.writeFileSync(existingFile, "existing content")
const input = { tool: "Write", sessionID: "ses_1", callID: "call_1" }
const output = { args: { file_path: existingFile, content: "new content" } }
//#when
const result = hook["tool.execute.before"]?.(input as any, output as any)
//#then
await expect(result).rejects.toThrow("File already exists. Use edit tool instead.")
})
test("allows write using 'path' arg to non-existing file", async () => {
//#given
const nonExistingFile = path.join(tempDir, "new-file.txt")
const input = { tool: "Write", sessionID: "ses_1", callID: "call_1" }
const output = { args: { path: nonExistingFile, content: "hello" } }
//#when
const result = hook["tool.execute.before"]?.(input as any, output as any)
//#then
await expect(result).resolves.toBeUndefined()
})
test("allows write using 'file_path' arg to non-existing file", async () => {
//#given
const nonExistingFile = path.join(tempDir, "new-file.txt")
const input = { tool: "Write", sessionID: "ses_1", callID: "call_1" }
const output = { args: { file_path: nonExistingFile, content: "hello" } }
//#when
const result = hook["tool.execute.before"]?.(input as any, output as any)
//#then
await expect(result).resolves.toBeUndefined()
})
})
describe("relative path resolution using ctx.directory", () => {
test("blocks write to existing file using relative path", async () => {
//#given
const existingFile = path.join(tempDir, "existing-file.txt")
fs.writeFileSync(existingFile, "existing content")
const input = { tool: "Write", sessionID: "ses_1", callID: "call_1" }
const output = { args: { filePath: "existing-file.txt", content: "new content" } }
//#when
const result = hook["tool.execute.before"]?.(input as any, output as any)
//#then
await expect(result).rejects.toThrow("File already exists. Use edit tool instead.")
})
test("allows write to non-existing file using relative path", async () => {
//#given
const input = { tool: "Write", sessionID: "ses_1", callID: "call_1" }
const output = { args: { filePath: "new-file.txt", content: "hello" } }
//#when
const result = hook["tool.execute.before"]?.(input as any, output as any)
//#then
await expect(result).resolves.toBeUndefined()
})
test("blocks write to nested relative path when file exists", async () => {
//#given
const subDir = path.join(tempDir, "subdir")
fs.mkdirSync(subDir)
const existingFile = path.join(subDir, "existing.txt")
fs.writeFileSync(existingFile, "existing content")
const input = { tool: "Write", sessionID: "ses_1", callID: "call_1" }
const output = { args: { filePath: "subdir/existing.txt", content: "new content" } }
//#when
const result = hook["tool.execute.before"]?.(input as any, output as any)
//#then
await expect(result).rejects.toThrow("File already exists. Use edit tool instead.")
})
test("uses ctx.directory not process.cwd for relative path resolution", async () => {
//#given
const existingFile = path.join(tempDir, "test-file.txt")
fs.writeFileSync(existingFile, "content")
const differentCtx = { directory: tempDir }
const differentHook = createWriteExistingFileGuardHook(differentCtx as any)
const input = { tool: "Write", sessionID: "ses_1", callID: "call_1" }
const output = { args: { filePath: "test-file.txt", content: "new" } }
//#when
const result = differentHook["tool.execute.before"]?.(input as any, output as any)
//#then
await expect(result).rejects.toThrow("File already exists. Use edit tool instead.")
})
})
})
})

View File

@@ -0,0 +1,33 @@
import type { Hooks, PluginInput } from "@opencode-ai/plugin"
import { existsSync } from "fs"
import { resolve, isAbsolute } from "path"
import { log } from "../../shared"
export function createWriteExistingFileGuardHook(ctx: PluginInput): Hooks {
return {
"tool.execute.before": async (input, output) => {
const toolName = input.tool?.toLowerCase()
if (toolName !== "write") {
return
}
const args = output.args as { filePath?: string; path?: string; file_path?: string } | undefined
const filePath = args?.filePath ?? args?.path ?? args?.file_path
if (!filePath) {
return
}
const resolvedPath = isAbsolute(filePath) ? filePath : resolve(ctx.directory, filePath)
if (existsSync(resolvedPath)) {
log("[write-existing-file-guard] Blocking write to existing file", {
sessionID: input.sessionID,
filePath,
resolvedPath,
})
throw new Error("File already exists. Use edit tool instead.")
}
},
}
}

View File

@@ -0,0 +1,21 @@
import { describe, expect, test } from "bun:test"
import { readFileSync } from "node:fs"
describe("experimental.session.compacting", () => {
test("does not hardcode a model and uses output.context", () => {
//#given
const indexUrl = new URL("./index.ts", import.meta.url)
const content = readFileSync(indexUrl, "utf-8")
const hookIndex = content.indexOf('"experimental.session.compacting"')
//#when
const hookSlice = hookIndex >= 0 ? content.slice(hookIndex, hookIndex + 1200) : ""
//#then
expect(hookIndex).toBeGreaterThanOrEqual(0)
expect(content.includes('modelID: "claude-opus-4-6"')).toBe(false)
expect(hookSlice.includes("output.context.push")).toBe(true)
expect(hookSlice.includes("providerID:")).toBe(false)
expect(hookSlice.includes("modelID:")).toBe(false)
})
})

View File

@@ -1,4 +1,5 @@
import type { Plugin, ToolDefinition } from "@opencode-ai/plugin"; import type { Plugin, ToolDefinition } from "@opencode-ai/plugin";
import type { AvailableSkill } from "./agents/dynamic-agent-prompt-builder";
import { import {
createTodoContinuationEnforcer, createTodoContinuationEnforcer,
createContextWindowMonitorHook, createContextWindowMonitorHook,
@@ -37,6 +38,7 @@ import {
createUnstableAgentBabysitterHook, createUnstableAgentBabysitterHook,
createPreemptiveCompactionHook, createPreemptiveCompactionHook,
createTasksTodowriteDisablerHook, createTasksTodowriteDisablerHook,
createWriteExistingFileGuardHook,
} from "./hooks"; } from "./hooks";
import { import {
contextCollector, contextCollector,
@@ -55,6 +57,7 @@ import {
discoverOpencodeProjectSkills, discoverOpencodeProjectSkills,
mergeSkills, mergeSkills,
} from "./features/opencode-skill-loader"; } from "./features/opencode-skill-loader";
import type { SkillScope } from "./features/opencode-skill-loader/types";
import { createBuiltinSkills } from "./features/builtin-skills"; import { createBuiltinSkills } from "./features/builtin-skills";
import { getSystemMcpServerNames } from "./features/claude-code-mcp-loader"; import { getSystemMcpServerNames } from "./features/claude-code-mcp-loader";
import { import {
@@ -83,6 +86,10 @@ import {
createTaskList, createTaskList,
createTaskUpdateTool, createTaskUpdateTool,
} from "./tools"; } from "./tools";
import {
CATEGORY_DESCRIPTIONS,
DEFAULT_CATEGORIES,
} from "./tools/delegate-task/constants";
import { BackgroundManager } from "./features/background-agent"; import { BackgroundManager } from "./features/background-agent";
import { SkillMcpManager } from "./features/skill-mcp-manager"; import { SkillMcpManager } from "./features/skill-mcp-manager";
import { initTaskToastManager } from "./features/task-toast-manager"; import { initTaskToastManager } from "./features/task-toast-manager";
@@ -98,7 +105,9 @@ import {
getOpenCodeVersion, getOpenCodeVersion,
isOpenCodeVersionAtLeast, isOpenCodeVersionAtLeast,
OPENCODE_NATIVE_AGENTS_INJECTION_VERSION, OPENCODE_NATIVE_AGENTS_INJECTION_VERSION,
injectServerAuthIntoClient,
} from "./shared"; } from "./shared";
import { filterDisabledTools } from "./shared/disabled-tools";
import { loadPluginConfig } from "./plugin-config"; import { loadPluginConfig } from "./plugin-config";
import { createModelCacheState } from "./plugin-state"; import { createModelCacheState } from "./plugin-state";
import { createConfigHandler } from "./plugin-handlers"; import { createConfigHandler } from "./plugin-handlers";
@@ -107,11 +116,13 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
log("[OhMyOpenCodePlugin] ENTRY - plugin loading", { log("[OhMyOpenCodePlugin] ENTRY - plugin loading", {
directory: ctx.directory, directory: ctx.directory,
}); });
injectServerAuthIntoClient(ctx.client);
// Start background tmux check immediately // Start background tmux check immediately
startTmuxCheck(); startTmuxCheck();
const pluginConfig = loadPluginConfig(ctx.directory, ctx); const pluginConfig = loadPluginConfig(ctx.directory, ctx);
const disabledHooks = new Set(pluginConfig.disabled_hooks ?? []); const disabledHooks = new Set(pluginConfig.disabled_hooks ?? []);
const firstMessageVariantGate = createFirstMessageVariantGate(); const firstMessageVariantGate = createFirstMessageVariantGate();
const tmuxConfig = { const tmuxConfig = {
@@ -239,9 +250,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
? createThinkingBlockValidatorHook() ? createThinkingBlockValidatorHook()
: null; : null;
const categorySkillReminder = isHookEnabled("category-skill-reminder") let categorySkillReminder: ReturnType<typeof createCategorySkillReminderHook> | null = null;
? createCategorySkillReminderHook(ctx)
: null;
const ralphLoop = isHookEnabled("ralph-loop") const ralphLoop = isHookEnabled("ralph-loop")
? createRalphLoopHook(ctx, { ? createRalphLoopHook(ctx, {
@@ -278,6 +287,9 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
const questionLabelTruncator = createQuestionLabelTruncatorHook(); const questionLabelTruncator = createQuestionLabelTruncatorHook();
const subagentQuestionBlocker = createSubagentQuestionBlockerHook(); const subagentQuestionBlocker = createSubagentQuestionBlockerHook();
const writeExistingFileGuard = isHookEnabled("write-existing-file-guard")
? createWriteExistingFileGuardHook(ctx)
: null;
const taskResumeInfo = createTaskResumeInfoHook(); const taskResumeInfo = createTaskResumeInfoHook();
@@ -386,37 +398,9 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
const lookAt = isMultimodalLookerEnabled ? createLookAt(ctx) : null; const lookAt = isMultimodalLookerEnabled ? createLookAt(ctx) : null;
const browserProvider = const browserProvider =
pluginConfig.browser_automation_engine?.provider ?? "playwright"; pluginConfig.browser_automation_engine?.provider ?? "playwright";
const delegateTask = createDelegateTask({ const disabledSkills = new Set<string>(pluginConfig.disabled_skills ?? []);
manager: backgroundManager,
client: ctx.client,
directory: ctx.directory,
userCategories: pluginConfig.categories,
gitMasterConfig: pluginConfig.git_master,
sisyphusJuniorModel: pluginConfig.agents?.["sisyphus-junior"]?.model,
browserProvider,
onSyncSessionCreated: async (event) => {
log("[index] onSyncSessionCreated callback", {
sessionID: event.sessionID,
parentID: event.parentID,
title: event.title,
});
await tmuxSessionManager.onSessionCreated({
type: "session.created",
properties: {
info: {
id: event.sessionID,
parentID: event.parentID,
title: event.title,
},
},
});
},
});
const disabledSkills = new Set(pluginConfig.disabled_skills ?? []);
const systemMcpNames = getSystemMcpServerNames(); const systemMcpNames = getSystemMcpServerNames();
const builtinSkills = createBuiltinSkills({ browserProvider }).filter( const builtinSkills = createBuiltinSkills({ browserProvider, disabledSkills }).filter((skill) => {
(skill) => {
if (disabledSkills.has(skill.name as never)) return false;
if (skill.mcpConfig) { if (skill.mcpConfig) {
for (const mcpName of Object.keys(skill.mcpConfig)) { for (const mcpName of Object.keys(skill.mcpConfig)) {
if (systemMcpNames.has(mcpName)) return false; if (systemMcpNames.has(mcpName)) return false;
@@ -441,6 +425,68 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
projectSkills, projectSkills,
opencodeProjectSkills, opencodeProjectSkills,
); );
function mapScopeToLocation(scope: SkillScope): AvailableSkill["location"] {
if (scope === "user" || scope === "opencode") return "user";
if (scope === "project" || scope === "opencode-project") return "project";
return "plugin";
}
const availableSkills: AvailableSkill[] = mergedSkills.map((skill) => ({
name: skill.name,
description: skill.definition.description ?? "",
location: mapScopeToLocation(skill.scope),
}));
const mergedCategories = pluginConfig.categories
? { ...DEFAULT_CATEGORIES, ...pluginConfig.categories }
: DEFAULT_CATEGORIES;
const availableCategories = Object.entries(mergedCategories).map(
([name, categoryConfig]) => ({
name,
description:
pluginConfig.categories?.[name]?.description
?? CATEGORY_DESCRIPTIONS[name]
?? "General tasks",
model: categoryConfig.model,
}),
);
const delegateTask = createDelegateTask({
manager: backgroundManager,
client: ctx.client,
directory: ctx.directory,
userCategories: pluginConfig.categories,
gitMasterConfig: pluginConfig.git_master,
sisyphusJuniorModel: pluginConfig.agents?.["sisyphus-junior"]?.model,
browserProvider,
disabledSkills,
availableCategories,
availableSkills,
onSyncSessionCreated: async (event) => {
log("[index] onSyncSessionCreated callback", {
sessionID: event.sessionID,
parentID: event.parentID,
title: event.title,
});
await tmuxSessionManager.onSessionCreated({
type: "session.created",
properties: {
info: {
id: event.sessionID,
parentID: event.parentID,
title: event.title,
},
},
});
},
});
categorySkillReminder = isHookEnabled("category-skill-reminder")
? createCategorySkillReminderHook(ctx, availableSkills)
: null;
const skillMcpManager = new SkillMcpManager(); const skillMcpManager = new SkillMcpManager();
const getSessionIDForMcp = () => getMainSessionID() || ""; const getSessionIDForMcp = () => getMainSessionID() || "";
const skillTool = createSkillTool({ const skillTool = createSkillTool({
@@ -448,6 +494,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
mcpManager: skillMcpManager, mcpManager: skillMcpManager,
getSessionID: getSessionIDForMcp, getSessionID: getSessionIDForMcp,
gitMasterConfig: pluginConfig.git_master, gitMasterConfig: pluginConfig.git_master,
disabledSkills
}); });
const skillMcpTool = createSkillMcpTool({ const skillMcpTool = createSkillMcpTool({
manager: skillMcpManager, manager: skillMcpManager,
@@ -481,19 +528,26 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
} }
: {}; : {};
const allTools: Record<string, ToolDefinition> = {
...builtinTools,
...backgroundTools,
call_omo_agent: callOmoAgent,
...(lookAt ? { look_at: lookAt } : {}),
delegate_task: delegateTask,
skill: skillTool,
skill_mcp: skillMcpTool,
slashcommand: slashcommandTool,
interactive_bash,
...taskToolsRecord,
};
const filteredTools: Record<string, ToolDefinition> = filterDisabledTools(
allTools,
pluginConfig.disabled_tools,
);
return { return {
tool: { tool: filteredTools,
...builtinTools,
...backgroundTools,
call_omo_agent: callOmoAgent,
...(lookAt ? { look_at: lookAt } : {}),
delegate_task: delegateTask,
skill: skillTool,
skill_mcp: skillMcpTool,
slashcommand: slashcommandTool,
interactive_bash,
...taskToolsRecord,
},
"chat.message": async (input, output) => { "chat.message": async (input, output) => {
if (input.agent) { if (input.agent) {
@@ -718,6 +772,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
"tool.execute.before": async (input, output) => { "tool.execute.before": async (input, output) => {
await subagentQuestionBlocker["tool.execute.before"]?.(input, output); await subagentQuestionBlocker["tool.execute.before"]?.(input, output);
await writeExistingFileGuard?.["tool.execute.before"]?.(input, output);
await questionLabelTruncator["tool.execute.before"]?.(input, output); await questionLabelTruncator["tool.execute.before"]?.(input, output);
await claudeCodeHooks["tool.execute.before"](input, output); await claudeCodeHooks["tool.execute.before"](input, output);
await nonInteractiveEnv?.["tool.execute.before"](input, output); await nonInteractiveEnv?.["tool.execute.before"](input, output);
@@ -835,17 +890,14 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
await taskResumeInfo["tool.execute.after"](input, output); await taskResumeInfo["tool.execute.after"](input, output);
}, },
"experimental.session.compacting": async (input: { sessionID: string }) => { "experimental.session.compacting": async (
_input: { sessionID: string },
output: { context: string[] },
): Promise<void> => {
if (!compactionContextInjector) { if (!compactionContextInjector) {
return; return;
} }
await compactionContextInjector({ output.context.push(compactionContextInjector());
sessionID: input.sessionID,
providerID: "anthropic",
modelID: "claude-opus-4-5",
usageRatio: 0.8,
directory: ctx.directory,
});
}, },
}; };
}; };

View File

@@ -14,7 +14,7 @@ Tier 1 of three-tier MCP system: 3 built-in remote HTTP MCPs.
``` ```
mcp/ mcp/
├── index.ts # createBuiltinMcps() factory ├── index.ts # createBuiltinMcps() factory
├── websearch.ts # Exa AI web search ├── websearch.ts # Exa AI / Tavily web search
├── context7.ts # Library documentation ├── context7.ts # Library documentation
├── grep-app.ts # GitHub code search ├── grep-app.ts # GitHub code search
├── types.ts # McpNameSchema ├── types.ts # McpNameSchema
@@ -25,15 +25,24 @@ mcp/
| Name | URL | Purpose | Auth | | Name | URL | Purpose | Auth |
|------|-----|---------|------| |------|-----|---------|------|
| websearch | mcp.exa.ai/mcp?tools=web_search_exa | Real-time web search | EXA_API_KEY | | websearch | mcp.exa.ai / mcp.tavily.com | Real-time web search | EXA_API_KEY / TAVILY_API_KEY |
| context7 | mcp.context7.com/mcp | Library docs | CONTEXT7_API_KEY | | context7 | mcp.context7.com/mcp | Library docs | CONTEXT7_API_KEY (optional) |
| grep_app | mcp.grep.app | GitHub code search | None | | grep_app | mcp.grep.app | GitHub code search | None |
## THREE-TIER MCP SYSTEM ## Websearch Provider Configuration
1. **Built-in** (this directory): websearch, context7, grep_app | Provider | URL | Auth | API Key Required |
2. **Claude Code compat**: `.mcp.json` with `${VAR}` expansion |----------|-----|------|------------------|
3. **Skill-embedded**: YAML frontmatter in skills (handled by skill-mcp-manager) | exa (default) | mcp.exa.ai | x-api-key header | No (optional) |
| tavily | mcp.tavily.com | Authorization Bearer | Yes |
```jsonc
{
"websearch": {
"provider": "tavily" // or "exa" (default)
}
}
```
## CONFIG PATTERN ## CONFIG PATTERN
@@ -47,15 +56,6 @@ export const mcp_name = {
} }
``` ```
## USAGE
```typescript
import { createBuiltinMcps } from "./mcp"
const mcps = createBuiltinMcps() // Enable all
const mcps = createBuiltinMcps(["websearch"]) // Disable specific
```
## HOW TO ADD ## HOW TO ADD
1. Create `src/mcp/my-mcp.ts` 1. Create `src/mcp/my-mcp.ts`
@@ -66,5 +66,5 @@ const mcps = createBuiltinMcps(["websearch"]) // Disable specific
- **Remote only**: HTTP/SSE, no stdio - **Remote only**: HTTP/SSE, no stdio
- **Disable**: User can set `disabled_mcps: ["name"]` in config - **Disable**: User can set `disabled_mcps: ["name"]` in config
- **Context7**: Optional auth using `CONTEXT7_API_KEY` env var - **Exa**: Default provider, works without API key
- **Exa**: Optional auth using `EXA_API_KEY` env var - **Tavily**: Requires `TAVILY_API_KEY` env var

View File

@@ -83,4 +83,24 @@ describe("createBuiltinMcps", () => {
expect(result).toHaveProperty("grep_app") expect(result).toHaveProperty("grep_app")
expect(Object.keys(result)).toHaveLength(3) expect(Object.keys(result)).toHaveLength(3)
}) })
test("should not throw when websearch disabled even if tavily configured without API key", () => {
// given
const originalTavilyKey = process.env.TAVILY_API_KEY
delete process.env.TAVILY_API_KEY
const disabledMcps = ["websearch"]
const config = { websearch: { provider: "tavily" as const } }
try {
// when
const createMcps = () => createBuiltinMcps(disabledMcps, config)
// then
expect(createMcps).not.toThrow()
const result = createMcps()
expect(result).not.toHaveProperty("websearch")
} finally {
if (originalTavilyKey) process.env.TAVILY_API_KEY = originalTavilyKey
}
})
}) })

View File

@@ -1,7 +1,8 @@
import { websearch } from "./websearch" import { createWebsearchConfig } from "./websearch"
import { context7 } from "./context7" import { context7 } from "./context7"
import { grep_app } from "./grep-app" import { grep_app } from "./grep-app"
import type { McpName } from "./types" import type { McpName } from "./types"
import type { OhMyOpenCodeConfig } from "../config/schema"
export { McpNameSchema, type McpName } from "./types" export { McpNameSchema, type McpName } from "./types"
@@ -13,19 +14,19 @@ type RemoteMcpConfig = {
oauth?: false oauth?: false
} }
const allBuiltinMcps: Record<McpName, RemoteMcpConfig> = { export function createBuiltinMcps(disabledMcps: string[] = [], config?: OhMyOpenCodeConfig) {
websearch,
context7,
grep_app,
}
export function createBuiltinMcps(disabledMcps: string[] = []) {
const mcps: Record<string, RemoteMcpConfig> = {} const mcps: Record<string, RemoteMcpConfig> = {}
for (const [name, config] of Object.entries(allBuiltinMcps)) { if (!disabledMcps.includes("websearch")) {
if (!disabledMcps.includes(name)) { mcps.websearch = createWebsearchConfig(config?.websearch)
mcps[name] = config }
}
if (!disabledMcps.includes("context7")) {
mcps.context7 = context7
}
if (!disabledMcps.includes("grep_app")) {
mcps.grep_app = grep_app
} }
return mcps return mcps

116
src/mcp/websearch.test.ts Normal file
View File

@@ -0,0 +1,116 @@
import { describe, expect, test, beforeEach, afterEach } from "bun:test"
import { createWebsearchConfig } from "./websearch"
describe("websearch MCP provider configuration", () => {
const originalEnv = { ...process.env }
beforeEach(() => {
delete process.env.EXA_API_KEY
delete process.env.TAVILY_API_KEY
})
afterEach(() => {
process.env = { ...originalEnv }
})
test("returns Exa config when no config provided", () => {
//#given - no config
//#when
const result = createWebsearchConfig()
//#then
expect(result.url).toContain("mcp.exa.ai")
expect(result.type).toBe("remote")
expect(result.enabled).toBe(true)
})
test("returns Exa config when provider is 'exa'", () => {
//#given
const config = { provider: "exa" as const }
//#when
const result = createWebsearchConfig(config)
//#then
expect(result.url).toContain("mcp.exa.ai")
expect(result.type).toBe("remote")
})
test("includes x-api-key header when EXA_API_KEY is set", () => {
//#given
const apiKey = "test-exa-key-12345"
process.env.EXA_API_KEY = apiKey
//#when
const result = createWebsearchConfig()
//#then
expect(result.headers).toEqual({ "x-api-key": apiKey })
})
test("returns Tavily config when provider is 'tavily' and TAVILY_API_KEY set", () => {
//#given
const tavilyKey = "test-tavily-key-67890"
process.env.TAVILY_API_KEY = tavilyKey
const config = { provider: "tavily" as const }
//#when
const result = createWebsearchConfig(config)
//#then
expect(result.url).toContain("mcp.tavily.com")
expect(result.headers).toEqual({ Authorization: `Bearer ${tavilyKey}` })
})
test("throws error when provider is 'tavily' but TAVILY_API_KEY missing", () => {
//#given
delete process.env.TAVILY_API_KEY
const config = { provider: "tavily" as const }
//#when
const createTavilyConfig = () => createWebsearchConfig(config)
//#then
expect(createTavilyConfig).toThrow("TAVILY_API_KEY environment variable is required")
})
test("returns Exa when both keys present but no explicit provider", () => {
//#given
process.env.EXA_API_KEY = "test-exa-key"
process.env.TAVILY_API_KEY = "test-tavily-key"
//#when
const result = createWebsearchConfig()
//#then
expect(result.url).toContain("mcp.exa.ai")
expect(result.headers).toEqual({ "x-api-key": "test-exa-key" })
})
test("Tavily config uses Authorization Bearer header format", () => {
//#given
const tavilyKey = "tavily-secret-key-xyz"
process.env.TAVILY_API_KEY = tavilyKey
const config = { provider: "tavily" as const }
//#when
const result = createWebsearchConfig(config)
//#then
expect(result.headers?.Authorization).toMatch(/^Bearer /)
expect(result.headers?.Authorization).toBe(`Bearer ${tavilyKey}`)
})
test("Exa config has no headers when EXA_API_KEY not set", () => {
//#given
delete process.env.EXA_API_KEY
//#when
const result = createWebsearchConfig()
//#then
expect(result.url).toContain("mcp.exa.ai")
expect(result.headers).toBeUndefined()
})
})

View File

@@ -1,10 +1,44 @@
export const websearch = { import type { WebsearchConfig } from "../config/schema"
type: "remote" as const,
url: "https://mcp.exa.ai/mcp?tools=web_search_exa", type RemoteMcpConfig = {
enabled: true, type: "remote"
headers: process.env.EXA_API_KEY url: string
? { "x-api-key": process.env.EXA_API_KEY } enabled: boolean
: undefined, headers?: Record<string, string>
// Disable OAuth auto-detection - Exa uses API key header, not OAuth oauth?: false
oauth: false as const,
} }
export function createWebsearchConfig(config?: WebsearchConfig): RemoteMcpConfig {
const provider = config?.provider || "exa"
if (provider === "tavily") {
const tavilyKey = process.env.TAVILY_API_KEY
if (!tavilyKey) {
throw new Error("TAVILY_API_KEY environment variable is required for Tavily provider")
}
return {
type: "remote" as const,
url: "https://mcp.tavily.com/mcp/",
enabled: true,
headers: {
Authorization: `Bearer ${tavilyKey}`,
},
oauth: false as const,
}
}
// Default to Exa
return {
type: "remote" as const,
url: "https://mcp.exa.ai/mcp?tools=web_search_exa",
enabled: true,
headers: process.env.EXA_API_KEY
? { "x-api-key": process.env.EXA_API_KEY }
: undefined,
oauth: false as const,
}
}
// Backward compatibility: export static instance using default config
export const websearch = createWebsearchConfig()

View File

@@ -23,12 +23,6 @@ beforeEach(() => {
oracle: { name: "oracle", prompt: "test", mode: "subagent" }, oracle: { name: "oracle", prompt: "test", mode: "subagent" },
}) })
spyOn(sisyphusJunior, "createSisyphusJuniorAgentWithOverrides" as any).mockReturnValue({
name: "sisyphus-junior",
prompt: "test",
mode: "subagent",
})
spyOn(commandLoader, "loadUserCommands" as any).mockResolvedValue({}) spyOn(commandLoader, "loadUserCommands" as any).mockResolvedValue({})
spyOn(commandLoader, "loadProjectCommands" as any).mockResolvedValue({}) spyOn(commandLoader, "loadProjectCommands" as any).mockResolvedValue({})
spyOn(commandLoader, "loadOpencodeGlobalCommands" as any).mockResolvedValue({}) spyOn(commandLoader, "loadOpencodeGlobalCommands" as any).mockResolvedValue({})
@@ -63,7 +57,7 @@ beforeEach(() => {
spyOn(mcpModule, "createBuiltinMcps" as any).mockReturnValue({}) spyOn(mcpModule, "createBuiltinMcps" as any).mockReturnValue({})
spyOn(shared, "log" as any).mockImplementation(() => {}) spyOn(shared, "log" as any).mockImplementation(() => {})
spyOn(shared, "fetchAvailableModels" as any).mockResolvedValue(new Set(["anthropic/claude-opus-4-5"])) spyOn(shared, "fetchAvailableModels" as any).mockResolvedValue(new Set(["anthropic/claude-opus-4-6"]))
spyOn(shared, "readConnectedProvidersCache" as any).mockReturnValue(null) spyOn(shared, "readConnectedProvidersCache" as any).mockReturnValue(null)
spyOn(configDir, "getOpenCodeConfigPaths" as any).mockReturnValue({ spyOn(configDir, "getOpenCodeConfigPaths" as any).mockReturnValue({
@@ -73,7 +67,7 @@ beforeEach(() => {
spyOn(permissionCompat, "migrateAgentConfig" as any).mockImplementation((config: Record<string, unknown>) => config) spyOn(permissionCompat, "migrateAgentConfig" as any).mockImplementation((config: Record<string, unknown>) => config)
spyOn(modelResolver, "resolveModelWithFallback" as any).mockReturnValue({ model: "anthropic/claude-opus-4-5" }) spyOn(modelResolver, "resolveModelWithFallback" as any).mockReturnValue({ model: "anthropic/claude-opus-4-6" })
}) })
afterEach(() => { afterEach(() => {
@@ -105,6 +99,66 @@ afterEach(() => {
;(modelResolver.resolveModelWithFallback as any)?.mockRestore?.() ;(modelResolver.resolveModelWithFallback as any)?.mockRestore?.()
}) })
describe("Sisyphus-Junior model inheritance", () => {
test("does not inherit UI-selected model as system default", async () => {
// #given
const pluginConfig: OhMyOpenCodeConfig = {}
const config: Record<string, unknown> = {
model: "opencode/kimi-k2.5-free",
agent: {},
}
const handler = createConfigHandler({
ctx: { directory: "/tmp" },
pluginConfig,
modelCacheState: {
anthropicContext1MEnabled: false,
modelContextLimitsCache: new Map(),
},
})
// #when
await handler(config)
// #then
const agentConfig = config.agent as Record<string, { model?: string }>
expect(agentConfig["sisyphus-junior"]?.model).toBe(
sisyphusJunior.SISYPHUS_JUNIOR_DEFAULTS.model
)
})
test("uses explicitly configured sisyphus-junior model", async () => {
// #given
const pluginConfig: OhMyOpenCodeConfig = {
agents: {
"sisyphus-junior": {
model: "openai/gpt-5.3-codex",
},
},
}
const config: Record<string, unknown> = {
model: "opencode/kimi-k2.5-free",
agent: {},
}
const handler = createConfigHandler({
ctx: { directory: "/tmp" },
pluginConfig,
modelCacheState: {
anthropicContext1MEnabled: false,
modelContextLimitsCache: new Map(),
},
})
// #when
await handler(config)
// #then
const agentConfig = config.agent as Record<string, { model?: string }>
expect(agentConfig["sisyphus-junior"]?.model).toBe(
"openai/gpt-5.3-codex"
)
})
})
describe("Plan agent demote behavior", () => { describe("Plan agent demote behavior", () => {
test("orders core agents as sisyphus -> hephaestus -> prometheus -> atlas", async () => { test("orders core agents as sisyphus -> hephaestus -> prometheus -> atlas", async () => {
// #given // #given
@@ -123,7 +177,7 @@ describe("Plan agent demote behavior", () => {
}, },
} }
const config: Record<string, unknown> = { const config: Record<string, unknown> = {
model: "anthropic/claude-opus-4-5", model: "anthropic/claude-opus-4-6",
agent: {}, agent: {},
} }
const handler = createConfigHandler({ const handler = createConfigHandler({
@@ -154,7 +208,7 @@ describe("Plan agent demote behavior", () => {
}, },
} }
const config: Record<string, unknown> = { const config: Record<string, unknown> = {
model: "anthropic/claude-opus-4-5", model: "anthropic/claude-opus-4-6",
agent: { agent: {
plan: { plan: {
name: "plan", name: "plan",
@@ -191,7 +245,7 @@ describe("Plan agent demote behavior", () => {
}, },
} }
const config: Record<string, unknown> = { const config: Record<string, unknown> = {
model: "anthropic/claude-opus-4-5", model: "anthropic/claude-opus-4-6",
agent: { agent: {
plan: { plan: {
name: "plan", name: "plan",
@@ -228,7 +282,7 @@ describe("Plan agent demote behavior", () => {
}, },
} }
const config: Record<string, unknown> = { const config: Record<string, unknown> = {
model: "anthropic/claude-opus-4-5", model: "anthropic/claude-opus-4-6",
agent: {}, agent: {},
} }
const handler = createConfigHandler({ const handler = createConfigHandler({
@@ -263,7 +317,7 @@ describe("Agent permission defaults", () => {
}) })
const pluginConfig: OhMyOpenCodeConfig = {} const pluginConfig: OhMyOpenCodeConfig = {}
const config: Record<string, unknown> = { const config: Record<string, unknown> = {
model: "anthropic/claude-opus-4-5", model: "anthropic/claude-opus-4-6",
agent: {}, agent: {},
} }
const handler = createConfigHandler({ const handler = createConfigHandler({
@@ -295,7 +349,7 @@ describe("Prometheus category config resolution", () => {
// then // then
expect(config).toBeDefined() expect(config).toBeDefined()
expect(config?.model).toBe("openai/gpt-5.2-codex") expect(config?.model).toBe("openai/gpt-5.3-codex")
expect(config?.variant).toBe("xhigh") expect(config?.variant).toBe("xhigh")
}) })
@@ -355,7 +409,7 @@ describe("Prometheus category config resolution", () => {
// then - falls back to DEFAULT_CATEGORIES // then - falls back to DEFAULT_CATEGORIES
expect(config).toBeDefined() expect(config).toBeDefined()
expect(config?.model).toBe("openai/gpt-5.2-codex") expect(config?.model).toBe("openai/gpt-5.3-codex")
expect(config?.variant).toBe("xhigh") expect(config?.variant).toBe("xhigh")
}) })
@@ -406,7 +460,7 @@ describe("Prometheus direct override priority over category", () => {
}, },
} }
const config: Record<string, unknown> = { const config: Record<string, unknown> = {
model: "anthropic/claude-opus-4-5", model: "anthropic/claude-opus-4-6",
agent: {}, agent: {},
} }
const handler = createConfigHandler({ const handler = createConfigHandler({
@@ -446,7 +500,7 @@ describe("Prometheus direct override priority over category", () => {
}, },
} }
const config: Record<string, unknown> = { const config: Record<string, unknown> = {
model: "anthropic/claude-opus-4-5", model: "anthropic/claude-opus-4-6",
agent: {}, agent: {},
} }
const handler = createConfigHandler({ const handler = createConfigHandler({
@@ -487,7 +541,7 @@ describe("Prometheus direct override priority over category", () => {
}, },
} }
const config: Record<string, unknown> = { const config: Record<string, unknown> = {
model: "anthropic/claude-opus-4-5", model: "anthropic/claude-opus-4-6",
agent: {}, agent: {},
} }
const handler = createConfigHandler({ const handler = createConfigHandler({
@@ -522,7 +576,7 @@ describe("Prometheus direct override priority over category", () => {
}, },
} }
const config: Record<string, unknown> = { const config: Record<string, unknown> = {
model: "anthropic/claude-opus-4-5", model: "anthropic/claude-opus-4-6",
agent: {}, agent: {},
} }
const handler = createConfigHandler({ const handler = createConfigHandler({
@@ -560,7 +614,7 @@ describe("Deadlock prevention - fetchAvailableModels must not receive client", (
}, },
} }
const config: Record<string, unknown> = { const config: Record<string, unknown> = {
model: "anthropic/claude-opus-4-5", model: "anthropic/claude-opus-4-6",
agent: {}, agent: {},
} }
const mockClient = { const mockClient = {

View File

@@ -157,6 +157,7 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
// config.model represents the currently active model in OpenCode (including UI selection) // config.model represents the currently active model in OpenCode (including UI selection)
// Pass it as uiSelectedModel so it takes highest priority in model resolution // Pass it as uiSelectedModel so it takes highest priority in model resolution
const currentModel = config.model as string | undefined; const currentModel = config.model as string | undefined;
const disabledSkills = new Set<string>(pluginConfig.disabled_skills ?? []);
const builtinAgents = await createBuiltinAgents( const builtinAgents = await createBuiltinAgents(
migratedDisabledAgents, migratedDisabledAgents,
pluginConfig.agents, pluginConfig.agents,
@@ -167,7 +168,8 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
allDiscoveredSkills, allDiscoveredSkills,
ctx.client, ctx.client,
browserProvider, browserProvider,
currentModel // uiSelectedModel - takes highest priority currentModel, // uiSelectedModel - takes highest priority
disabledSkills
); );
// Claude Code agents: Do NOT apply permission migration // Claude Code agents: Do NOT apply permission migration
@@ -220,7 +222,7 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
agentConfig["sisyphus-junior"] = createSisyphusJuniorAgentWithOverrides( agentConfig["sisyphus-junior"] = createSisyphusJuniorAgentWithOverrides(
pluginConfig.agents?.["sisyphus-junior"], pluginConfig.agents?.["sisyphus-junior"],
config.model as string | undefined undefined
); );
if (builderEnabled) { if (builderEnabled) {
@@ -305,7 +307,7 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
prompt: PROMETHEUS_SYSTEM_PROMPT, prompt: PROMETHEUS_SYSTEM_PROMPT,
permission: PROMETHEUS_PERMISSION, permission: PROMETHEUS_PERMISSION,
description: `${configAgent?.plan?.description ?? "Plan agent"} (Prometheus - OhMyOpenCode)`, description: `${configAgent?.plan?.description ?? "Plan agent"} (Prometheus - OhMyOpenCode)`,
color: (configAgent?.plan?.color as string) ?? "#9D4EDD", // Amethyst Purple - wisdom/foresight color: (configAgent?.plan?.color as string) ?? "#FF5722", // Deep Orange - Fire/Flame theme
...(temperatureToUse !== undefined ? { temperature: temperatureToUse } : {}), ...(temperatureToUse !== undefined ? { temperature: temperatureToUse } : {}),
...(topPToUse !== undefined ? { top_p: topPToUse } : {}), ...(topPToUse !== undefined ? { top_p: topPToUse } : {}),
...(maxTokensToUse !== undefined ? { maxTokens: maxTokensToUse } : {}), ...(maxTokensToUse !== undefined ? { maxTokens: maxTokensToUse } : {}),
@@ -358,7 +360,8 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
: {}; : {};
const planDemoteConfig = shouldDemotePlan const planDemoteConfig = shouldDemotePlan
? { mode: "subagent" as const } ? { mode: "subagent" as const
}
: undefined; : undefined;
config.agent = { config.agent = {
@@ -447,7 +450,7 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
: { servers: {} }; : { servers: {} };
config.mcp = { config.mcp = {
...createBuiltinMcps(pluginConfig.disabled_mcps), ...createBuiltinMcps(pluginConfig.disabled_mcps, pluginConfig),
...(config.mcp as Record<string, unknown>), ...(config.mcp as Record<string, unknown>),
...mcpResult.servers, ...mcpResult.servers,
...pluginComponents.mcpServers, ...pluginComponents.mcpServers,

View File

@@ -9,22 +9,3 @@ export function createModelCacheState(): ModelCacheState {
anthropicContext1MEnabled: false, anthropicContext1MEnabled: false,
}; };
} }
export function getModelLimit(
state: ModelCacheState,
providerID: string,
modelID: string
): number | undefined {
const key = `${providerID}/${modelID}`;
const cached = state.modelContextLimitsCache.get(key);
if (cached) return cached;
if (
providerID === "anthropic" &&
state.anthropicContext1MEnabled &&
modelID.includes("sonnet")
) {
return 1_000_000;
}
return undefined;
}

View File

@@ -9,12 +9,14 @@
## STRUCTURE ## STRUCTURE
``` ```
shared/ shared/
├── tmux/ # Tmux TUI integration (types, utils, constants) ├── tmux/ # Tmux TUI integration (types, utils 312 lines, constants)
├── logger.ts # File-based logging (/tmp/oh-my-opencode.log) - 53 imports ├── logger.ts # File-based logging (/tmp/oh-my-opencode.log) - 53 imports
├── dynamic-truncator.ts # Token-aware context window management (194 lines) ├── dynamic-truncator.ts # Token-aware context window management (194 lines)
├── model-resolver.ts # 3-step resolution (Override → Fallback → Default) ├── model-resolver.ts # 3-step resolution (Override → Fallback → Default)
├── model-requirements.ts # Agent/category model fallback chains (162 lines) ├── model-requirements.ts # Agent/category model fallback chains (162 lines)
├── model-availability.ts # Provider model fetching & fuzzy matching (154 lines) ├── model-availability.ts # Provider model fetching & fuzzy matching (357 lines)
├── model-sanitizer.ts # Model name sanitization
├── model-suggestion-retry.ts # Model suggestion on failure
├── jsonc-parser.ts # JSONC parsing with comment support ├── jsonc-parser.ts # JSONC parsing with comment support
├── frontmatter.ts # YAML frontmatter extraction (JSON_SCHEMA only) - 9 imports ├── frontmatter.ts # YAML frontmatter extraction (JSON_SCHEMA only) - 9 imports
├── data-path.ts # XDG-compliant storage resolution ├── data-path.ts # XDG-compliant storage resolution
@@ -22,16 +24,26 @@ shared/
├── claude-config-dir.ts # ~/.claude resolution - 9 imports ├── claude-config-dir.ts # ~/.claude resolution - 9 imports
├── migration.ts # Legacy config migration logic (231 lines) ├── migration.ts # Legacy config migration logic (231 lines)
├── opencode-version.ts # Semantic version comparison ├── opencode-version.ts # Semantic version comparison
├── permission-compat.ts # Agent tool restriction enforcement ├── permission-compat.ts # Agent tool restriction enforcement - 6 imports
├── system-directive.ts # Unified system message prefix & types ├── system-directive.ts # Unified system message prefix & types - 8 imports
├── session-utils.ts # Session cursor, orchestrator detection ├── session-utils.ts # Session cursor, orchestrator detection
├── session-cursor.ts # Session message cursor tracking
├── shell-env.ts # Cross-platform shell environment ├── shell-env.ts # Cross-platform shell environment
├── agent-variant.ts # Agent variant from config ├── agent-variant.ts # Agent variant from config
├── zip-extractor.ts # Binary/Resource ZIP extraction ├── zip-extractor.ts # Binary/Resource ZIP extraction
├── deep-merge.ts # Recursive object merging (proto-pollution safe, MAX_DEPTH=50) ├── deep-merge.ts # Recursive object merging (proto-pollution safe, MAX_DEPTH=50)
├── case-insensitive.ts # Case-insensitive object lookups ├── case-insensitive.ts # Case-insensitive object lookups
├── session-cursor.ts # Session message cursor tracking
├── command-executor.ts # Shell command execution (225 lines) ├── command-executor.ts # Shell command execution (225 lines)
├── snake-case.ts # Case conversion utilities
├── tool-name.ts # Tool naming conventions
├── pattern-matcher.ts # Pattern matching utilities
├── port-utils.ts # Port management
├── file-utils.ts # File operation utilities
├── file-reference-resolver.ts # File reference resolution
├── connected-providers-cache.ts # Provider caching
├── external-plugin-detector.ts # Plugin detection
├── first-message-variant.ts # Message variant types
├── opencode-server-auth.ts # Authentication utilities
└── index.ts # Barrel export for all utilities └── index.ts # Barrel export for all utilities
``` ```

View File

@@ -8,9 +8,9 @@ describe("Agent Config Integration", () => {
test("migrates old format agent keys to lowercase", () => { test("migrates old format agent keys to lowercase", () => {
// given - config with old format keys // given - config with old format keys
const oldConfig = { const oldConfig = {
Sisyphus: { model: "anthropic/claude-opus-4-5" }, Sisyphus: { model: "anthropic/claude-opus-4-6" },
Atlas: { model: "anthropic/claude-opus-4-5" }, Atlas: { model: "anthropic/claude-opus-4-6" },
"Prometheus (Planner)": { model: "anthropic/claude-opus-4-5" }, "Prometheus (Planner)": { model: "anthropic/claude-opus-4-6" },
"Metis (Plan Consultant)": { model: "anthropic/claude-sonnet-4-5" }, "Metis (Plan Consultant)": { model: "anthropic/claude-sonnet-4-5" },
"Momus (Plan Reviewer)": { model: "anthropic/claude-sonnet-4-5" }, "Momus (Plan Reviewer)": { model: "anthropic/claude-sonnet-4-5" },
} }
@@ -33,9 +33,9 @@ describe("Agent Config Integration", () => {
expect(result.migrated).not.toHaveProperty("Momus (Plan Reviewer)") expect(result.migrated).not.toHaveProperty("Momus (Plan Reviewer)")
// then - values are preserved // then - values are preserved
expect(result.migrated.sisyphus).toEqual({ model: "anthropic/claude-opus-4-5" }) expect(result.migrated.sisyphus).toEqual({ model: "anthropic/claude-opus-4-6" })
expect(result.migrated.atlas).toEqual({ model: "anthropic/claude-opus-4-5" }) expect(result.migrated.atlas).toEqual({ model: "anthropic/claude-opus-4-6" })
expect(result.migrated.prometheus).toEqual({ model: "anthropic/claude-opus-4-5" }) expect(result.migrated.prometheus).toEqual({ model: "anthropic/claude-opus-4-6" })
// then - changed flag is true // then - changed flag is true
expect(result.changed).toBe(true) expect(result.changed).toBe(true)
@@ -44,7 +44,7 @@ describe("Agent Config Integration", () => {
test("preserves already lowercase keys", () => { test("preserves already lowercase keys", () => {
// given - config with lowercase keys // given - config with lowercase keys
const config = { const config = {
sisyphus: { model: "anthropic/claude-opus-4-5" }, sisyphus: { model: "anthropic/claude-opus-4-6" },
oracle: { model: "openai/gpt-5.2" }, oracle: { model: "openai/gpt-5.2" },
librarian: { model: "opencode/glm-4.7-free" }, librarian: { model: "opencode/glm-4.7-free" },
} }
@@ -62,9 +62,9 @@ describe("Agent Config Integration", () => {
test("handles mixed case config", () => { test("handles mixed case config", () => {
// given - config with mixed old and new format // given - config with mixed old and new format
const mixedConfig = { const mixedConfig = {
Sisyphus: { model: "anthropic/claude-opus-4-5" }, Sisyphus: { model: "anthropic/claude-opus-4-6" },
oracle: { model: "openai/gpt-5.2" }, oracle: { model: "openai/gpt-5.2" },
"Prometheus (Planner)": { model: "anthropic/claude-opus-4-5" }, "Prometheus (Planner)": { model: "anthropic/claude-opus-4-6" },
librarian: { model: "opencode/glm-4.7-free" }, librarian: { model: "opencode/glm-4.7-free" },
} }
@@ -172,8 +172,8 @@ describe("Agent Config Integration", () => {
test("old config migrates and displays correctly", () => { test("old config migrates and displays correctly", () => {
// given - old format config // given - old format config
const oldConfig = { const oldConfig = {
Sisyphus: { model: "anthropic/claude-opus-4-5", temperature: 0.1 }, Sisyphus: { model: "anthropic/claude-opus-4-6", temperature: 0.1 },
"Prometheus (Planner)": { model: "anthropic/claude-opus-4-5" }, "Prometheus (Planner)": { model: "anthropic/claude-opus-4-6" },
} }
// when - config is migrated // when - config is migrated
@@ -192,15 +192,15 @@ describe("Agent Config Integration", () => {
expect(prometheusDisplay).toBe("Prometheus (Plan Builder)") expect(prometheusDisplay).toBe("Prometheus (Plan Builder)")
// then - config values are preserved // then - config values are preserved
expect(result.migrated.sisyphus).toEqual({ model: "anthropic/claude-opus-4-5", temperature: 0.1 }) expect(result.migrated.sisyphus).toEqual({ model: "anthropic/claude-opus-4-6", temperature: 0.1 })
expect(result.migrated.prometheus).toEqual({ model: "anthropic/claude-opus-4-5" }) expect(result.migrated.prometheus).toEqual({ model: "anthropic/claude-opus-4-6" })
}) })
test("new config works without migration", () => { test("new config works without migration", () => {
// given - new format config (already lowercase) // given - new format config (already lowercase)
const newConfig = { const newConfig = {
sisyphus: { model: "anthropic/claude-opus-4-5" }, sisyphus: { model: "anthropic/claude-opus-4-6" },
atlas: { model: "anthropic/claude-opus-4-5" }, atlas: { model: "anthropic/claude-opus-4-6" },
} }
// when - migration is applied (should be no-op) // when - migration is applied (should be no-op)

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