Compare commits

..

111 Commits

Author SHA1 Message Date
github-actions[bot]
dc5a24ac3e release: v2.13.1 2026-01-05 17:16:18 +00:00
YeonGyu-Kim
9d13c6cff1 fix(config): skip permission migration for Claude Code agents
Claude Code uses whitelist-based tools format which is semantically
different from OpenCode's denylist-based permission system. Apply
migration only to plugin agents for compatibility.

🤖 Generated with [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode) assistance
2026-01-06 02:10:34 +09:00
YeonGyu-Kim
b78e564872 feat(builtin-commands): add /refactor command for intelligent LSP/AST-based refactoring
Ports the refactor command from ~/.config/opencode/command/refactor.md to the project as a builtin command.

The /refactor command provides deterministic, LSP/AST-aware refactoring with:
- Automatic intent analysis and codebase mapping
- Risk assessment with test coverage verification
- Detailed planning via Plan agent
- Step-by-step execution with continuous verification
- Zero-regression guarantees via comprehensive testing

Supports multiple refactoring scopes (file/module/project) and strategies (safe/aggressive).

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-06 01:49:53 +09:00
YeonGyu-Kim
c709fafa25 docs: update 'Just Install It' section with detailed Sisyphus workflow across all languages
🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-06 01:42:59 +09:00
github-actions[bot]
5914a393ad release: v2.13.0 2026-01-05 15:03:55 +00:00
YeonGyu-Kim
4e5b3566a2 feat(tools): refactor slashcommand to support options and caching
- Extract createSlashcommandTool factory with SlashcommandToolOptions
- Export discoverCommandsSync for external use
- Move description building to lazy evaluation with caching
- Support pre-warming cache with provided commands and skills
- Simplify tool initialization in plugin with new factory approach

This allows the slashcommand tool to be instantiated with custom options
while maintaining backward compatibility through lazy loading.

🤖 Generated with assistance of [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-05 23:45:01 +09:00
YeonGyu-Kim
898d3e6175 fix(cli): migrate Gemini models to explicit antigravity- prefix for quota routing
All Gemini model references now use the `antigravity-` prefix to ensure explicit
routing to Antigravity quota pools instead of relying on legacy `-preview` suffix
disambiguation. This approach prevents potential breakage if Google removes the
`-preview` suffix in future versions.

Updates include:
- config-manager: Updated Gemini model assignments with antigravity- prefix
- config-manager.test.ts: Updated test assertions to match new naming convention
- install.ts: Updated config summary display to show antigravity-prefixed model name

Migration follows opencode-antigravity-auth plugin v1.2.7+ guidance for explicit
quota routing configuration.

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-05 23:36:10 +09:00
YeonGyu-Kim
21236d88a7 chore(keyword-detector): add verification guarantee section to ultrawork prompt
Added comprehensive VERIFICATION GUARANTEE section to ultrawork prompt to enforce proof-based task completion. Includes:
- Pre-implementation success criteria definition (Functional, Observable, Pass/Fail)
- Mandatory Test Plan template for non-trivial tasks
- Execution & Evidence requirements table (Build, Test, Manual Verify, Regression)
- TDD workflow with evidence requirements
- Verification anti-patterns and blocking violations

This enhancement ensures agents must provide PROOF that something works before claiming completion - eliminating vague "it should work now" claims without evidence.

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-05 23:21:25 +09:00
YeonGyu-Kim
ea8ca1a100 docs: update model names and auth versions for latest releases
- Update to antigravity v1.2.7 model naming conventions
- Update Codex auth version from 4.2.0 to 4.3.0
- Remove hotfix documentation (resolved in v4.3.0)
- Document available models and variants

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-05 23:03:30 +09:00
YeonGyu-Kim
66acb0e444 chore(auth): remove deprecated models and ChatGPT hotfix
- Remove gemini-3-pro-medium and gemini-3-flash-lite (deprecated in antigravity v1.2.7)
- Remove CHATGPT_HOTFIX_REPO variable and setupChatGPTHotfix() function
- Update CODEX_PROVIDER_CONFIG to modern variants system

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-05 23:03:11 +09:00
YeonGyu-Kim
f7c8763462 chore(keyword-detector): revert ultrawork to stronger agent utilization instructions
- Restore [CODE RED] maximum precision requirement
- Restore YOU MUST LEVERAGE ALL AVAILABLE AGENTS directive
- Restore TELL THE USER WHAT AGENTS YOU WILL LEVERAGE NOW
- Restore explicit parallel exploration/librarian spawn workflow
- Keep mandatory ULTRAWORK MODE ENABLED! message
- Simplify constants structure for better maintainability

This addresses the issue where explore/librarian agents were being called less frequently after recent prompt changes.

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-05 22:44:06 +09:00
YeonGyu-Kim
ee2f390bf6 chore(keyword-detector): add mandatory ultrawork mode message
🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-05 22:44:06 +09:00
YeonGyu-Kim
ae6495dc17 config(fallback): use opencode/glm-4.7-free as default fallback model
🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-05 22:44:06 +09:00
popododo0720
b8b8d14b1c docs: update auth plugin versions to latest releases (#477)
- opencode-antigravity-auth: 1.1.2 → 1.2.7
- opencode-openai-codex-auth: 4.1.1 → 4.2.0

Fixes #463
2026-01-05 22:41:44 +09:00
Sisyphus
7a10b24bbd feat: allow disabled_mcps to accept any MCP name (#513) 2026-01-05 21:12:05 +09:00
github-actions[bot]
258463a146 @luosky has signed the CLA in code-yeongyu/oh-my-opencode#512 2026-01-05 11:49:53 +00:00
Sisyphus
0f890c11c2 fix(test): increase timeout in duration test to prevent flakiness (#508) 2026-01-05 20:20:46 +09:00
YeonGyu-Kim
e81002ba43 docs: remove websearch_exa from feature documentation
🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-05 20:09:34 +09:00
YeonGyu-Kim
a20f011014 docs(librarian): make web search conditional in agent prompt
🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-05 20:09:27 +09:00
YeonGyu-Kim
48174ec25a chore(config): update schema after websearch_exa removal
🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-05 20:09:20 +09:00
YeonGyu-Kim
26e77a0a89 test(doctor): update MCP checks for websearch_exa removal
🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-05 20:09:15 +09:00
YeonGyu-Kim
a5c71473a5 refactor(mcp): remove websearch_exa as built-in MCP server
🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-05 20:09:09 +09:00
github-actions[bot]
aecfc77fb6 @raydocs has signed the CLA in code-yeongyu/oh-my-opencode#499 2026-01-05 07:39:55 +00:00
YeonGyu-Kim
5a4261a607 fix(hooks): pass input.agent parameter to keyword detector
Wire agent information through keyword detector hooks:
- Pass input.agent to detectKeywordsWithType in keyword-detector hook
- Pass input.agent to detectKeywordsWithType in claude-code-hooks
- Enables agent-aware ultrawork message generation

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-05 16:26:35 +09:00
YeonGyu-Kim
6913613398 fix(keyword-detector): implement agent-aware ultrawork message generation
Restructure ultrawork message generation to support agent-specific instructions.
- Extract ultrawork message components into modular constants
- Add getUltraworkMessage(agentName) function that adapts instructions based on agent type
- Support planner-specific vs default agent execution patterns
- Pass agentName parameter through detector.ts for message resolution

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-05 16:26:29 +09:00
YeonGyu-Kim
d27a1efd94 feat(keyword-detector): enable variant='max' for ultrawork mode
🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-05 13:44:07 +09:00
YeonGyu-Kim
bc05fb6671 feat(sisyphus): enable variant='max' for maximum reasoning effort
🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-05 13:44:07 +09:00
YeonGyu-Kim
7937d72cbf refactor(loaders): migrate to async-first pattern for commands and skills
- Remove all sync functions from command loader (async now default)
- Remove sync load functions from skill loader (async now default)
- Add resolveSymlinkAsync to file-utils.ts
- Update all callers to use async versions:
  - config-handler.ts
  - index.ts
  - tools/slashcommand/tools.ts
  - tools/skill/tools.ts
  - hooks/auto-slash-command/executor.ts
  - loader.test.ts
- All 607 tests pass, build succeeds

Generated with assistance of 🤖 [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-05 13:44:07 +09:00
YeonGyu-Kim
fe11ba294c perf(startup): parallelize command and skill loading in config-handler
- Add async versions of skill loader functions (loadUserSkillsAsync, loadProjectSkillsAsync, loadOpencodeGlobalSkillsAsync, loadOpencodeProjectSkillsAsync)
- Use Promise.all to load 8 loaders concurrently instead of sequentially
- Improves startup performance by eliminating serial I/O bottlenecks

Generated with assistance of OhMyOpenCode
2026-01-05 13:44:07 +09:00
github-actions[bot]
6b5a8263f9 @popododo0720 has signed the CLA in code-yeongyu/oh-my-opencode#477 2026-01-05 04:07:46 +00:00
YeonGyu-Kim
65b00c9720 fix: fix Planner-Sisyphus visibility for OpenCode 1.1.1
- Change Planner-Sisyphus mode from "all" to "primary" for proper Tab selector visibility
- Fixes agent visibility breaking changes introduced in OpenCode 1.1.1
- 🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-05 09:45:05 +09:00
github-actions[bot]
5ed031db63 release: v2.12.4 2026-01-05 00:30:37 +00:00
YeonGyu-Kim
0553676ab0 fix: use mode 'all' for Planner-Sisyphus agent and inherit default model
🤖 Generated with assistance of OhMyOpenCode

- Fix Planner-Sisyphus agent config: use `mode: 'all'` instead of `mode: 'primary'` to show in Tab selector
- Use model fallback to default config model when plan agent model not specified
- Demote original plan agent to `subagent` mode with `hidden: true` instead of `disable: true`
- Destructure `mode` from plan config to avoid passing it to migratedPlanConfig
2026-01-05 09:26:42 +09:00
github-actions[bot]
b80b373230 release: v2.12.3 2026-01-04 20:44:49 +00:00
YeonGyu-Kim
f55046228f Merge branch 'fix/v1.1.1-permission-migration' into dev
- Add mode: primary to Planner-Sisyphus for Tab selector visibility
- Skip skills with invalid YAML frontmatter
- Add parseError/hadFrontmatter to FrontmatterResult

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-05 05:41:33 +09:00
YeonGyu-Kim
2992902283 fix: skip invalid YAML skills and enable Planner-Sisyphus in Tab selector
- Skip skills with invalid YAML frontmatter using new parseError flag
- Add mode: "primary" to Planner-Sisyphus agent config for visibility
- Prevents silent failures when loading skills with malformed YAML

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-05 05:38:28 +09:00
YeonGyu-Kim
b66c8dc1d1 feat(frontmatter): track parsing errors and frontmatter existence in result type
Add hadFrontmatter and parseError flags to FrontmatterResult interface to enable error handling in skill loading.

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-05 05:38:17 +09:00
YeonGyu-Kim
8f2209a138 fix: proper OpenCode v1.1.1 permission migration (#490)
* fix: implement proper version-aware permission format for OpenCode v1.1.1

- Rewrite permission-compat.ts with runtime version detection
- createAgentToolRestrictions() returns correct format per version
- v1.1.1+ uses permission format, older uses tools format
- Add migrateToolsToPermission/migratePermissionToTools helpers
- Update test suite for new API

🤖 Generated with assistance of OhMyOpenCode
https://github.com/code-yeongyu/oh-my-opencode

* fix: update all agents to use createAgentToolRestrictions()

- Replace hardcoded tools: { X: false } format with version-aware utility
- All agents now use createAgentToolRestrictions([...])
- Ensures compatibility with both old and new OpenCode versions

🤖 Generated with assistance of OhMyOpenCode
https://github.com/code-yeongyu/oh-my-opencode

* fix: add runtime migration for user agent configs in config-handler

Migrate tools/permission format in user/project/plugin agent configs
based on detected OpenCode version at load time.

🤖 Generated with [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-05 05:28:25 +09:00
YeonGyu-Kim
6c3ef65aed fix: add runtime migration for user agent configs in config-handler
Migrate tools/permission format in user/project/plugin agent configs
based on detected OpenCode version at load time.

🤖 Generated with [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-05 04:56:47 +09:00
YeonGyu-Kim
e1e8b24941 fix: update all agents to use createAgentToolRestrictions()
- Replace hardcoded tools: { X: false } format with version-aware utility
- All agents now use createAgentToolRestrictions([...])
- Ensures compatibility with both old and new OpenCode versions

🤖 Generated with assistance of OhMyOpenCode
https://github.com/code-yeongyu/oh-my-opencode
2026-01-05 04:50:13 +09:00
YeonGyu-Kim
0d0ddefbfe fix: implement proper version-aware permission format for OpenCode v1.1.1
- Rewrite permission-compat.ts with runtime version detection
- createAgentToolRestrictions() returns correct format per version
- v1.1.1+ uses permission format, older uses tools format
- Add migrateToolsToPermission/migratePermissionToTools helpers
- Update test suite for new API

🤖 Generated with assistance of OhMyOpenCode
https://github.com/code-yeongyu/oh-my-opencode
2026-01-05 04:50:01 +09:00
YeonGyu-Kim
09f72e2902 feat: OpenCode v1.1.1 permission system compatibility (#489)
* feat: add OpenCode v1.1.1 version detection and permission compatibility utilities

- Add opencode-version.ts: Detect installed OpenCode version and support API
- Add permission-compat.ts: Compatibility layer for permission system migration
- Add comprehensive tests (418 lines total)
- Export new utilities from shared/index.ts

🤖 Generated with assistance of OhMyOpenCode (https://github.com/code-yeongyu/oh-my-opencode)

* fix: update agent permission configs for OpenCode v1.1.1 compatibility

- Fix document-writer: change invalid 'permission: { background_task: deny }' to 'tools: { background_task: false }'
- Fix explore: split 'permission' and 'tools' config, move tool-level denials to 'tools' key
- Fix oracle: split 'permission' and 'tools' config, move tool-level denials to 'tools' key
- Align all agents with v1.1.1 permission system structure

🤖 Generated with assistance of OhMyOpenCode (https://github.com/code-yeongyu/oh-my-opencode)
2026-01-05 04:26:26 +09:00
sisyphus-dev-ai
5f63aff01d chore: changes by sisyphus-dev-ai 2026-01-04 18:43:15 +00:00
YeonGyu-Kim
6fd9734337 fix(keyword-detector): show ultrawork toast on every activation
🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-05 02:46:31 +09:00
YeonGyu-Kim
4bf853fc91 test(context-injector): remove keyword-specific test cases
Keyword detection is now handled by claude-code-hooks, not by context-injector
messages.transform hook. Remove tests for keyword injection that are no longer
applicable.

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-05 02:46:31 +09:00
YeonGyu-Kim
87134d3390 refactor(keyword): unify keyword injection into UserPromptSubmit pipeline
Move keyword detection from experimental.chat.messages.transform to claude-code-hooks
chat.message handler. Uses proven injectHookMessage file system approach for reliable
context injection.

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-05 02:46:31 +09:00
YeonGyu-Kim
36c42ac92f fix(context-injector): inline keyword detection in messages transform hook
Fixes race condition where chat.message runs after experimental.chat.messages.transform,
preventing keyword-detected context from being injected. Moves detection logic inline
into the transform hook for atomic detection and injection.

Changes:
- Add detectKeywordsWithType and extractPromptText utilities to injector
- Detect keywords inline within messages transform hook
- Create synthetic message with merged context before last user message
- Add 4 comprehensive test cases for keyword detection scenarios

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-05 02:46:31 +09:00
github-actions[bot]
56fe32caab @Skyline-23 has signed the CLA in code-yeongyu/oh-my-opencode#484 2026-01-04 17:42:11 +00:00
github-actions[bot]
09756b8ffc @RhysSullivan has signed the CLA in code-yeongyu/oh-my-opencode#482 2026-01-04 17:19:54 +00:00
YeonGyu-Kim
9ba9f906c5 feat(context-injector): implement messages transform hook for context injection
- Implement `createContextInjectorMessagesTransformHook` for messages transform hook
- Refactor existing `chat.message` handler to be a no-op (context injection moved to transform)
- Add comprehensive test suite for the new hook (4 test cases)
- Update exports to expose new hook function

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-05 01:55:42 +09:00
YeonGyu-Kim
ce69007fde perf(skill-loader): add blocking discovery API with worker threads
Implement synchronous skill discovery using Node.js Worker Threads and Atomics.wait for blocking operations. Allows synchronous API access while leveraging async operations internally via dedicated worker thread.

Changes:
- blocking.ts: Main blocking discovery function using SharedArrayBuffer and MessagePort
- discover-worker.ts: Worker thread implementation for async skill discovery
- blocking.test.ts: Comprehensive test suite with BDD comments

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-05 01:55:42 +09:00
YeonGyu-Kim
b1f36d61a8 perf(skill): implement lazy content loading
- Add LazyContentLoader interface to LoadedSkill type
- Defer skill body loading until first use
- Cache loaded content for subsequent calls
- Reduce startup time by not reading full file contents

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-05 01:55:42 +09:00
YeonGyu-Kim
97e51c42dc perf(init): integrate async skill/command loaders
🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

- Replace synchronous skill discovery calls in src/index.ts with async versions
- Use Promise.all to parallelize 4 skill directory scans
- Wrap conditional calls in Promise.resolve for consistent Promise types
- Imports: discoverUserClaudeSkillsAsync, discoverProjectClaudeSkillsAsync, discoverOpencodeGlobalSkillsAsync, discoverOpencodeProjectSkillsAsync
- Verification: bun test passes (571 pass, 1 pre-existing failure), bun run typecheck passes
2026-01-05 01:55:42 +09:00
YeonGyu-Kim
91d2705804 perf(plugin-loader): parallelize component loading
- Convert sequential plugin component loading to Promise.all
- Wrap sync functions in Promise.resolve() for parallel execution
- commands, skills, agents, mcpServers, hooksConfigs now load concurrently

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-05 01:55:42 +09:00
YeonGyu-Kim
6575dfcbc4 perf(skill-loader): parallelize directory scanning
- Add async versions of skill discovery functions
- Create discoverAllSkillsAsync() with Promise.all parallelization
- Use fs.promises for async file operations
- Keep sync versions for backward compatibility

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-05 01:55:42 +09:00
YeonGyu-Kim
59b0e6943d perf(command-loader): parallelize directory scanning
- Add async loadCommandsFromDirAsync using fs.promises API
- Add 4 async load functions for user/project/global/project-opencode commands
- Add loadAllCommandsAsync with Promise.all parallelization
- Sync versions preserved for backward compatibility

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-05 01:55:42 +09:00
YeonGyu-Kim
9d64f213ee perf(init): use background tmux path check
- Call startTmuxCheck() at plugin initialization instead of blocking await
- Remove tmuxAvailable check, always include interactive_bash tool
- Tool gracefully handles missing tmux via getCachedTmuxPath() ?? "tmux"

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-05 01:55:42 +09:00
YeonGyu-Kim
e572c7c321 perf(init): parallelize googleAuth and tmuxPath initialization
🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-05 01:55:42 +09:00
YeonGyu-Kim
be2adff3ef feat(skill-loader): add async directory scanner
Add async versions of skill loading functions with concurrency control:
- mapWithConcurrency: Generic concurrent mapper with limit (16)
- loadSkillFromPathAsync: Async skill file parsing
- loadMcpJsonFromDirAsync: Async mcp.json loading
- discoverSkillsInDirAsync: Async directory scanner

Tests: 20 new tests covering all async functions

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-05 01:55:42 +09:00
github-actions[bot]
37f4c48183 @geq1fan has signed the CLA in code-yeongyu/oh-my-opencode#481 2026-01-04 14:31:25 +00:00
YeonGyu-Kim
a49fbeec5f refactor(todo-continuation-enforcer): update message mock structure and remove unreliable abort error handling tests
- Add MockMessage interface to match new message structure
- Update client mock to include messages() method
- Remove abort error detection tests that were unreliable
- Simplify error handling logic for better testability

🤖 Generated with assistance of OhMyOpenCode (https://github.com/code-yeongyu/oh-my-opencode)
2026-01-04 18:12:59 +09:00
YeonGyu-Kim
7a7b16fb62 feat(context-injector): introduce centralized context collection and integrate with keyword-detector
- Add ContextCollector class for managing and merging context entries across sessions
- Add types and interfaces for context management (ContextEntry, ContextPriority, PendingContext)
- Create context-injector hook for injection coordination
- Refactor keyword-detector to use context-injector instead of hook-message-injector
- Update src/index.ts to initialize context-injector infrastructure

🤖 Generated with assistance of OhMyOpenCode (https://github.com/code-yeongyu/oh-my-opencode)
2026-01-04 18:12:48 +09:00
github-actions[bot]
ae781f1e14 @ChiR24 has signed the CLA in code-yeongyu/oh-my-opencode#473 2026-01-04 06:14:48 +00:00
YeonGyu-Kim
d7645a4058 docs: remove sponsor request row from README header tables
This commit is 🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-04 12:08:57 +09:00
github-actions[bot]
16927729c7 @fparrav has signed the CLA in code-yeongyu/oh-my-opencode#469 2026-01-03 23:51:41 +00:00
YeonGyu-Kim
a4ba63cd1c docs: add sponsors Suyeol Jeon (devxoul) and Daewoong An (devwon) to README files (#460)
🤖 Generated with assistance of OhMyOpenCode
2026-01-04 00:10:16 +09:00
Sisyphus
063db0d390 fix(skill-mcp-manager): filter npm/pnpm/yarn env vars that break MCP servers (#459)
When running in pnpm projects, the .npmrc configuration propagates as
NPM_CONFIG_* environment variables to child processes. This can cause
MCP servers to fail due to registry/proxy conflicts or case sensitivity
issues between uppercase and lowercase variants.

This fix adds a createCleanMcpEnvironment function that filters out:
- NPM_CONFIG_* and npm_config_* (npm/pnpm config)
- YARN_* (yarn config)
- PNPM_* (pnpm config)
- NO_UPDATE_NOTIFIER

Fixes #456

Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
2026-01-03 23:48:17 +09:00
Sisyphus
dc52395ead feat(lsp): sync LSP catalog with OpenCode (#455)
Add new language servers from OpenCode's server.ts:
- prisma: Prisma schema support (.prisma)
- ocaml-lsp: OCaml language support (.ml, .mli)
- texlab: LaTeX support (.tex, .bib)
- dockerfile: Dockerfile support (.dockerfile)
- gleam: Gleam language support (.gleam)
- clojure-lsp: Clojure support (.clj, .cljs, .cljc, .edn)
- nixd: Nix language support (.nix)
- tinymist: Typst support (.typ, .typc)
- haskell-language-server: Haskell support (.hs, .lhs)

Add new language extensions from OpenCode's language.ts:
- .ets -> typescript
- .lhs -> haskell
- .kt, .kts -> kotlin
- .nix -> nix
- .typ, .typc -> typst
- .prisma -> prisma

Update server IDs to match OpenCode convention:
- Add 'bash' as primary ID (keep bash-ls as legacy alias)
- Add 'terraform' as primary ID (keep terraform-ls as legacy alias)

Closes #454

Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
2026-01-03 23:48:00 +09:00
Sisyphus
c8e9f90900 docs: add missing LLM Agent installation step links to translated READMEs ToC (#458)
- Update README.ko.md ToC with Korean step links
- Update README.ja.md ToC with Japanese step links
- Update README.zh-cn.md ToC with Chinese step links

These ToC links were present in README.md (English) but missing
from the translated versions, making navigation inconsistent.

Fixes #457

Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
2026-01-03 23:47:46 +09:00
hqone
6fbc5ba582 fix: preserve custom provider prefixes in think mode model switching (#451)
When using custom providers with model ID prefixes (e.g., vertex_ai/claude-sonnet-4-5),
the think mode switcher was stripping the prefix when mapping to high variants,
causing routing failures in custom LLM proxies.

Changes:
- Add extractModelPrefix() to parse and preserve prefixes like vertex_ai/, openai/, etc.
- Update getHighVariant() to preserve prefix when mapping to -high variants
- Update isAlreadyHighVariant() to check base model name (without prefix)
- Update getThinkingConfig() to check capability using base model name
- Add comprehensive tests for custom provider prefix scenarios

This fix ensures backward compatibility while supporting custom providers
that use prefixed model IDs for routing.

Fixes issue where think mode would break custom providers with prefixed models
by stripping the routing prefix during model variant switching.
2026-01-03 23:21:44 +09:00
YeonGyu-Kim
fc76ea9d93 fix(skill-mcp-manager): prevent memory leaks from orphaned MCP processes (#453)
* fix(skill-mcp-manager): prevent memory leaks from orphaned MCP processes

- Close transport on connection failure to prevent zombie processes
- Add process exit handlers (SIGINT/SIGTERM) for graceful cleanup
- Use pendingConnections Map to prevent duplicate client spawns

Fixes #361

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

* fix(ci): replace deprecated rhysd/actionlint-action with direct installation

rhysd/actionlint-action repository was removed/archived.
Use official actionlint download script instead.

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

* fix(skill-mcp-manager): add transport.close() and idle timeout to fix memory leaks

Previously, disconnectSession() and disconnectAll() only called client.close() but NOT transport.close().
StdioClientTransport spawns child processes for MCP servers, and without transport.close(), these
processes remained orphaned and accumulated memory (6GB leak reported).

Changes:
- Added missing transport.close() calls in disconnectSession() and disconnectAll()
- Added idle timeout mechanism (5-minute timeout) with lastUsedAt tracking
- Added cleanup timer that runs every 60 seconds to remove idle clients
- Made signal handlers (SIGINT, SIGTERM, SIGBREAK) async to properly await cleanup
- Ensure proper cleanup order: clear from map first, then close client, then close transport

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

* fix(ci): pin actionlint download script to v1.7.10 for supply chain security

- Pin to specific release tag instead of 'main' branch
- Prevents potential supply chain attacks from upstream compromises

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-03 22:15:23 +09:00
YeonGyu-Kim
2a3b45bea5 docs: update Discord invite link across all README files
- Update Discord invite from discord.gg/aSfGzWtYxM to discord.gg/PUwSMR9XNk
- Synchronized across all language variants:
  - README.md (English)
  - README.ko.md (Korean)
  - README.ja.md (Japanese)
  - README.zh-cn.md (Simplified Chinese)

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-03 21:27:40 +09:00
YeonGyu-Kim
79b80e5a2f docs: sync README reviews and orchestrator banner across languages
- Add Sigrid's Sisyphus quote to English README
- Add orchestrator coming banner to Korean, Japanese, and Chinese READMEs
- Synchronize content across all language versions

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-03 21:27:40 +09:00
github-actions[bot]
e2cbe8c29b @hqone has signed the CLA in code-yeongyu/oh-my-opencode#451 2026-01-03 12:22:03 +00:00
Steven Vo
99c7df5640 fix: respect ANTHROPIC_1M_CONTEXT and VERTEX_ANTHROPIC_1M_CONTEXT env vars (#450)
- Update preemptive-compaction hook to use 1M limit when env vars set
- Update dynamic-truncator to use 1M limit for output truncation
- Update context-window-monitor to use 1M limit for usage tracking

Previously hardcoded 200k limits caused compaction at 140k tokens even
with 1M context enabled. Now respects env vars consistently with base
opencode implementation.

Fixes compaction triggering too early with Claude Sonnet 4.5 1M context.

Related to anomalyco/opencode#6660
2026-01-03 21:06:06 +09:00
YeonGyu-Kim
f61e1a5f2b fix(non-interactive-env): use export for env vars to apply to all chained commands
Previous `VAR=val cmd` format only applied to first command in chains.
New `export VAR=val; cmd` format ensures variables persist for all commands.

Also increased test timeouts for todo-continuation-enforcer stability.

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-03 15:36:01 +09:00
YeonGyu-Kim
03c51c9321 update readme 2026-01-03 15:13:48 +09:00
YeonGyu-Kim
c10994563b add .sisyphus 2026-01-03 15:13:40 +09:00
YeonGyu-Kim
d188688dd8 feat(keyword-detector): enhance ultrawork mode with zero-tolerance execution rules
Add 'ZERO TOLERANCE FOR SHORTCUTS' section consolidating rigorous execution requirements:
- Explicit prohibitions table: mocking/stubbing, scope reduction, partial completion
- Lazy placeholder elimination (no TODO comments, ..., etc in code)
- Rigorous execution mandate: original intent parsing, no task too large, honest assessment
- Failure recovery protocol: stop, identify, create TODOs, complete 100%
- Strengthens commitment to honest, complete delivery over shortcuts

Expands from previous prove-yourself mode rules into comprehensive execution framework.

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-03 14:33:26 +09:00
Victor Jaepyo Jo
95645effd7 fix(ralph-loop): clear orphaned state when original session no longer exists (#446)
* fix(ralph-loop): clear orphaned state when original session no longer exists

When a session with an active ralph-loop terminates abnormally (abort, window close),
the state file remains with active: true. Previously, when a new session started,
the hook would skip the orphaned state without cleaning it up.

This fix adds session existence validation:
- Before skipping a loop owned by a different session, check if that session still exists
- If the original session no longer exists, clear the orphan state and log
- If the original session still exists, skip as before (it's another active session's loop)

Changes:
- Add checkSessionExists option to RalphLoopOptions for dependency injection
- Wire up sessionExists from session-manager as the default implementation
- Add tests for orphan state cleanup and active session preservation

* fix(ralph-loop): add error handling around checkSessionExists call

Wraps the async checkSessionExists call in try/catch for consistency
with other async operations in this file. If the check throws, logs
the error and falls back to the original behavior (not clearing state).

---------

Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
2026-01-03 14:11:59 +09:00
Sisyphus
00b8f622d5 feat(installer): add opencode-desktop compatibility with dynamic config paths (#442)
The installer now dynamically detects and uses the appropriate config
directory based on whether opencode CLI or opencode-desktop (Tauri) is
being used:

- opencode CLI: ~/.config/opencode/ (all platforms)
- opencode-desktop on Linux: ~/.config/ai.opencode.desktop/
- opencode-desktop on macOS: ~/Library/Application Support/ai.opencode.desktop/
- opencode-desktop on Windows: %APPDATA%/ai.opencode.desktop/

Key changes:
- Add new opencode-config-dir.ts module with platform-specific path resolution
- Support dev builds with ai.opencode.desktop.dev identifier
- Backward compatibility: checks legacy ~/.config/opencode/ first
- Refactor config-manager.ts to use dynamic paths via config context
- Update doctor plugin check to use shared path utilities

Fixes #440

Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
2026-01-03 14:00:36 +09:00
Sisyphus
967e53258c docs: fix outdated Builder-Sisyphus references to OpenCode-Builder (#444)
The README files documented 'Builder-Sisyphus' as the agent name, but
the actual implementation uses 'OpenCode-Builder' as defined in:
- src/config/schema.ts (OverridableAgentNameSchema)
- src/plugin-handlers/config-handler.ts (agentConfig creation)

Updated all 4 README files (EN, KO, JA, ZH-CN) to use the correct agent
name that matches the codebase.

Fixes #436

Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
2026-01-03 13:54:55 +09:00
github-actions[bot]
c40f562434 @changeroa has signed the CLA in code-yeongyu/oh-my-opencode#446 2026-01-03 04:51:22 +00:00
YeonGyu-Kim
a9523bc607 feat(ci): enhance sisyphus-agent workflow with PR/issue title and mandatory context reading guidelines
- Extract issue/PR title in Collect Context step
- Add CONTEXT_TITLE environment variable for Sisyphus prompt
- Include TITLE_PLACEHOLDER in dynamic prompt injection
- Enhance 'Read Full Conversation' section with ultrawork-style strict guidance:
  * [CODE RED] MANDATORY CONTEXT READING header with zero tolerance policy
  * Explicit list of what to extract from conversation (original description, attempts, decisions, feedback, references)
  * 'FAILURE TO READ EVERYTHING = GUARANTEED FAILURE' warning to emphasize importance
  * Clearer TODO creation instructions with requirement to summarize context first

This ensures Sisyphus agent has complete contextual information and explicitly emphasizes the critical importance of full conversation reading before any action.

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-03 12:58:13 +09:00
YeonGyu-Kim
f26bf24c33 feat(keyword-detector): enhance ultrawork mode instructions with TODO emphasis
- Restructure ultrawork mode message with clearer priorities
- Add TODO IS YOUR LIFELINE section emphasizing TodoWrite usage
- Enhance agent utilization principles and execution rules
- Improve clarity of zero tolerance failure policies

🤖 Generated with assistance of [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-03 12:51:09 +09:00
YeonGyu-Kim
bc65fcea7e refactor(sisyphus-prompt-builder): rename buildUltraworkAgentTable to buildUltraworkAgentSection
- Rename function to better reflect output format (bullet points, not table)
- Change output from markdown table to bullet point list
- Update sorting logic while maintaining agent priority

🤖 Generated with assistance of [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-03 12:51:04 +09:00
YeonGyu-Kim
3a8eac751e make tables 2026-01-03 12:45:18 +09:00
Sisyphus
48dc8298dd fix(webfetch): apply aggressive truncation for webfetch outputs (#434)
Root cause: DEFAULT_TARGET_MAX_TOKENS (50k tokens ~200k chars) was too high
for webfetch outputs. Web pages can be large but most content doesn't exceed
this limit, so truncation rarely triggered.

Changes:
- Add WEBFETCH_MAX_TOKENS = 10k tokens (~40k chars) for web content
- Introduce TOOL_SPECIFIC_MAX_TOKENS map for per-tool limits
- webfetch/WebFetch now use aggressive 10k token limit
- Other tools continue using default 50k token limit
- Add comprehensive tests for truncation behavior

Fixes #195

Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
2026-01-03 12:09:34 +09:00
YeonGyu-Kim
8bc9d6a540 fix(ci): fix YAML indentation in sisyphus-agent workflow heredoc
PR #439 added ultrawork-mode content without proper YAML indentation.
In GitHub Actions run: | blocks, all lines must be indented at least
as much as the first content line. The unindented heredoc content
broke YAML parsing, causing 'workflow file issue' failures.

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-03 11:49:16 +09:00
YeonGyu-Kim
6a6e20cf5d feat(ci): add actionlint workflow linter
- New workflow that runs actionlint on GitHub workflow file changes
- Runs on push and pull_request events to .github/workflows/**

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-03 11:41:11 +09:00
YeonGyu-Kim
3a5aea7f4b fix(ci): harden sisyphus-agent workflow condition for push event safety
- Add explicit `github.event_name == 'issue_comment'` check
- Add null coalescing (`|| ''`) for safe property access
- Use `>-` folded block scalar for better YAML parsing

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-03 11:41:11 +09:00
YeonGyu-Kim
a4812801b4 fix(non-interactive-env): add line continuation for command display
Improves readability by placing the git command on its own line instead of concatenating it directly after environment variables. The VAR=value prefix now continues to the next line with proper shell escaping.

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-03 11:41:11 +09:00
Sisyphus
6422ff270b feat(workflow): restore sisyphus agent enhancements with ultrawork/analyze-mode (#439)
Re-implements the workflow enhancements that were reverted in #437.

Changes:
- Add ultrawork-mode section with agent utilization principles, execution rules,
  TDD workflow, and zero-tolerance failure guidelines
- Add analyze-mode section for context gathering before execution
- Add CRITICAL: First Steps section requiring full conversation reading and
  immediate todo creation
- Includes PR inline review comments and review bodies in context gathering

This restores the functionality from #430 to ensure the agent:
- Reads full issue/PR context before taking action
- Creates todos immediately after reading context
- Follows structured parallel execution patterns

Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
2026-01-03 11:29:33 +09:00
YeonGyu-Kim
3c27206777 Revert "feat(workflow): enhance sisyphus agent with mandatory context reading…" (#437)
This reverts commit 8510a2273d.
2026-01-03 11:03:27 +09:00
Sisyphus
8510a2273d feat(workflow): enhance sisyphus agent with mandatory context reading and todo creation (#430) 2026-01-03 10:17:23 +09:00
Sisyphus
a8ca3ad5fb docs: add TDD section with RED-GREEN-REFACTOR cycle to AGENTS.md (#433) 2026-01-03 10:05:03 +09:00
github-actions[bot]
30e0cc6ef1 release: v2.12.2 2026-01-03 01:02:03 +00:00
Sisyphus
f345101f91 fix(ralph-loop): adopt OContinue patterns for better performance and abort handling (#431) 2026-01-03 09:55:12 +09:00
Sisyphus
d09c994b91 fix(session-recovery): detect 'final block cannot be thinking' error pattern (#420) 2026-01-03 09:46:45 +09:00
sisyphus-dev-ai
8c30974c18 fix: address review feedback - fix typos and wording consistency 2026-01-03 00:43:09 +00:00
Sisyphus
c341c156ec docs: update Discord invite link in all README files (#429) 2026-01-03 09:35:35 +09:00
github-actions[bot]
b1528c590d release: v2.12.1 2026-01-02 17:42:01 +00:00
Jeon Suyeol
8b9913345b fix(todo-continuation-enforcer): add 500ms grace period to prevent false countdown cancellation (#424) 2026-01-03 02:22:55 +09:00
YeonGyu-Kim
fa204d8af0 chore: remove dead code - unused imports and variables
- Remove unused import OhMyOpenCodeConfig from src/index.ts
- Remove unused import dirname from src/features/opencode-skill-loader/loader.ts
- Remove unused import detectKeywords from src/hooks/keyword-detector/index.ts
- Remove unused import CliMatch from src/tools/ast-grep/utils.ts
- Prefix unused parameter _original in src/tools/ast-grep/utils.ts

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-02 23:03:16 +09:00
YeonGyu-Kim
924fa79bd3 style: improve git command env prefix readability with line continuation
🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-02 22:52:39 +09:00
YeonGyu-Kim
c78241e78e docs(agents): regenerate AGENTS.md with updated commit reference (d0694e5) and corrected line counts
- Updated timestamp: 2026-01-02T22:41:22+09:00
- src/index.ts: 723→464 lines
- executor.ts: 554→564 lines

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-02 22:43:37 +09:00
YeonGyu-Kim
d0694e5aa4 fix(background-agent): prevent memory leaks by cleaning notifications in finally block and add TTL-based task pruning
- Move clearNotificationsForTask() to finally block to ensure cleanup even on success
- Add TASK_TTL_MS (30 min) constant for stale task detection
- Implement pruneStaleTasksAndNotifications() to remove expired tasks and notifications
- Add comprehensive tests for pruning functionality (fresh tasks, stale tasks, notifications)
- Prevents indefinite Map growth when tasks complete without errors

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-02 22:30:55 +09:00
YeonGyu-Kim
4a9bdc89aa fix(non-interactive-env): prepend env vars directly to git command string
OpenCode's bash tool ignores args.env and uses hardcoded process.env in spawn().
Work around this by prepending GIT_EDITOR, EDITOR, VISUAL, and PAGER env vars
directly to the command string. Only applies to git commands to avoid bloating
non-git commands.

Added shellEscape() and buildEnvPrefix() helper functions to properly escape
env var values and construct the prefix string.

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-02 22:30:55 +09:00
sisyphus-dev-ai
50afbf7c37 chore: changes by sisyphus-dev-ai 2026-01-02 12:54:32 +00:00
YeonGyu-Kim
b64b3f96e6 fix(recovery): correct prompt_async API path parameter from sessionID to id
The prompt_async method expects path parameter named 'id' (not 'sessionID').
This bug prevented the 'Continue' message from being sent after compaction,
causing the recovery process to fail silently due to silent error swallowing
in the empty catch block.

Fixes:
- Type definition: changed path: { sessionID: string } to path: { id: string }
- Implementation: changed path: { sessionID } to path: { id: sessionID } at 2 locations (lines 411, 509)

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-02 21:16:23 +09:00
YeonGyu-Kim
e3ad790185 feat(hooks): add edit-error-recovery hook for handling Edit tool errors (opencode#4718)
- Detects Edit tool errors (oldString/newString mismatches)
- Injects system reminder forcing AI to read file, verify state, apologize
- Includes comprehensive test suite (8 tests)
- Integrates with hook system and configuration schema

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-02 21:16:23 +09:00
101 changed files with 7126 additions and 1037 deletions

22
.github/workflows/lint-workflows.yml vendored Normal file
View File

@@ -0,0 +1,22 @@
name: Lint Workflows
on:
push:
paths:
- '.github/workflows/**'
pull_request:
paths:
- '.github/workflows/**'
jobs:
actionlint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- name: Install actionlint
run: |
bash <(curl -sSL https://raw.githubusercontent.com/rhysd/actionlint/v1.7.10/scripts/download-actionlint.bash)
- name: Run actionlint
run: ./actionlint -color -shellcheck=""

View File

@@ -15,13 +15,13 @@ jobs:
agent:
runs-on: ubuntu-latest
# @sisyphus-dev-ai mention only (maintainers, exclude self)
if: |
if: >-
github.event_name == 'workflow_dispatch' ||
(contains(github.event.comment.body, '@sisyphus-dev-ai') &&
github.event.comment.user.login != 'sisyphus-dev-ai' &&
contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association))
(github.event_name == 'issue_comment' &&
contains(github.event.comment.body || '', '@sisyphus-dev-ai') &&
(github.event.comment.user.login || '') != 'sisyphus-dev-ai' &&
contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association || ''))
# Minimal default GITHUB_TOKEN permissions
permissions:
contents: read
@@ -156,6 +156,71 @@ jobs:
OMO_JSON=~/.config/opencode/oh-my-opencode.json
PROMPT_APPEND=$(cat << 'PROMPT_EOF'
<ultrawork-mode>
[CODE RED] Maximum precision required. Ultrathink before acting.
YOU MUST LEVERAGE ALL AVAILABLE AGENTS TO THEIR FULLEST POTENTIAL.
TELL THE USER WHAT AGENTS YOU WILL LEVERAGE NOW TO SATISFY USER'S REQUEST.
## AGENT UTILIZATION PRINCIPLES (by capability, not by name)
- **Codebase Exploration**: Spawn exploration agents using BACKGROUND TASKS for file patterns, internal implementations, project structure
- **Documentation & References**: Use librarian-type agents via BACKGROUND TASKS for API references, examples, external library docs
- **Planning & Strategy**: For implementation tasks, spawn a dedicated planning agent for work breakdown (not needed for simple questions/investigations)
- **High-IQ Reasoning**: Leverage specialized agents for architecture decisions, code review, strategic planning
- **Frontend/UI Tasks**: Delegate to UI-specialized agents for design and implementation
## EXECUTION RULES
- **TODO**: Track EVERY step. Mark complete IMMEDIATELY after each.
- **PARALLEL**: Fire independent agent calls simultaneously via background_task - NEVER wait sequentially.
- **BACKGROUND FIRST**: Use background_task for exploration/research agents (10+ concurrent if needed).
- **VERIFY**: Re-read request after completion. Check ALL requirements met before reporting done.
- **DELEGATE**: Don't do everything yourself - orchestrate specialized agents for their strengths.
## WORKFLOW
1. Analyze the request and identify required capabilities
2. Spawn exploration/librarian agents via background_task in PARALLEL (10+ if needed)
3. Always Use Plan agent with gathered context to create detailed work breakdown
4. Execute with continuous verification against original requirements
## TDD (if test infrastructure exists)
1. Write spec (requirements)
2. Write tests (failing)
3. RED: tests fail
4. Implement minimal code
5. GREEN: tests pass
6. Refactor if needed (must stay green)
7. Next feature, repeat
## ZERO TOLERANCE FAILURES
- **NO Scope Reduction**: Never make "demo", "skeleton", "simplified", "basic" versions - deliver FULL implementation
- **NO MockUp Work**: When user asked you to do "port A", you must "port A", fully, 100%. No Extra feature, No reduced feature, no mock data, fully working 100% port.
- **NO Partial Completion**: Never stop at 60-80% saying "you can extend this..." - finish 100%
- **NO Assumed Shortcuts**: Never skip requirements you deem "optional" or "can be added later"
- **NO Premature Stopping**: Never declare done until ALL TODOs are completed and verified
- **NO TEST DELETION**: Never delete or skip failing tests to make the build pass. Fix the code, not the tests.
THE USER ASKED FOR X. DELIVER EXACTLY X. NOT A SUBSET. NOT A DEMO. NOT A STARTING POINT.
</ultrawork-mode>
---
[analyze-mode]
ANALYSIS MODE. Gather context before diving deep:
CONTEXT GATHERING (parallel):
- 1-2 explore agents (codebase patterns, implementations)
- 1-2 librarian agents (if external library involved)
- Direct tools: Grep, AST-grep, LSP for targeted searches
IF COMPLEX (architecture, multi-system, debugging after 2+ failures):
- Consult oracle for strategic guidance
SYNTHESIZE findings before proceeding.
---
## GitHub Actions Environment
@@ -244,14 +309,17 @@ jobs:
AUTHOR="$COMMENT_AUTHOR"
COMMENT_ID="$COMMENT_ID_VAL"
# Check if PR or Issue
if gh api "repos/$REPO/issues/${ISSUE_NUM}" | jq -e '.pull_request' > /dev/null; then
# Check if PR or Issue and get title
ISSUE_DATA=$(gh api "repos/$REPO/issues/${ISSUE_NUM}")
TITLE=$(echo "$ISSUE_DATA" | jq -r '.title')
if echo "$ISSUE_DATA" | jq -e '.pull_request' > /dev/null; then
echo "type=pr" >> $GITHUB_OUTPUT
echo "number=${ISSUE_NUM}" >> $GITHUB_OUTPUT
else
echo "type=issue" >> $GITHUB_OUTPUT
echo "number=${ISSUE_NUM}" >> $GITHUB_OUTPUT
fi
echo "title=${TITLE}" >> $GITHUB_OUTPUT
fi
echo "comment<<EOF" >> $GITHUB_OUTPUT
@@ -297,15 +365,32 @@ jobs:
COMMENT_AUTHOR: ${{ steps.context.outputs.author }}
CONTEXT_TYPE: ${{ steps.context.outputs.type }}
CONTEXT_NUMBER: ${{ steps.context.outputs.number }}
CONTEXT_TITLE: ${{ steps.context.outputs.title }}
REPO_NAME: ${{ github.repository }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
run: |
export PATH="$HOME/.opencode/bin:$PATH"
PROMPT=$(cat <<'PROMPT_EOF'
[analyze-mode]
ANALYSIS MODE. Gather context before diving deep:
CONTEXT GATHERING (parallel):
- 1-2 explore agents (codebase patterns, implementations)
- 1-2 librarian agents (if external library involved)
- Direct tools: Grep, AST-grep, LSP for targeted searches
IF COMPLEX (architecture, multi-system, debugging after 2+ failures):
- Consult oracle for strategic guidance
SYNTHESIZE findings before proceeding.
---
Your username is @sisyphus-dev-ai, mentioned by @AUTHOR_PLACEHOLDER in REPO_PLACEHOLDER.
## Context
- Title: TITLE_PLACEHOLDER
- Type: TYPE_PLACEHOLDER
- Number: #NUMBER_PLACEHOLDER
- Repository: REPO_PLACEHOLDER
@@ -316,8 +401,42 @@ jobs:
---
Write everything using the todo tools.
Then investigate and satisfy the request. Only if user requested to you to work explicitely, then use plan agent to plan, todo obsessivley then create a PR to `BRANCH_PLACEHOLDER` branch.
## CRITICAL: First Steps (MUST DO BEFORE ANYTHING ELSE)
### [CODE RED] MANDATORY CONTEXT READING - ZERO EXCEPTIONS
**YOU MUST READ ALL CONTENT. NOT SOME. NOT MOST. ALL.**
1. **READ FULL CONVERSATION** - Execute ALL commands below before ANY other action:
- **Issues**: `gh issue view NUMBER_PLACEHOLDER --comments`
- **PRs**: Use ALL THREE commands to get COMPLETE context:
```bash
gh pr view NUMBER_PLACEHOLDER --comments
gh api repos/REPO_PLACEHOLDER/pulls/NUMBER_PLACEHOLDER/comments
gh api repos/REPO_PLACEHOLDER/pulls/NUMBER_PLACEHOLDER/reviews
```
**WHAT TO EXTRACT FROM THE CONVERSATION:**
- The ORIGINAL issue/PR description (first message) - this is often the TRUE requirement
- ALL previous attempts and their outcomes
- ALL decisions made and their reasoning
- ALL feedback, criticism, and rejection reasons
- ANY linked issues, PRs, or external references
- The EXACT ask from the user who mentioned you
**FAILURE TO READ EVERYTHING = GUARANTEED FAILURE**
You WILL make wrong assumptions. You WILL repeat past mistakes. You WILL miss critical context.
2. **CREATE TODOS IMMEDIATELY**: Right after reading, create your todo list using todo tools.
- First todo: "Summarize issue/PR context and requirements"
- Break down ALL work into atomic, verifiable steps
- Plan everything BEFORE starting any work
---
Plan everything using todo tools.
Then investigate and satisfy the request. Only if user requested to you to work explicitly, then use plan agent to plan, todo obsessively then create a PR to `BRANCH_PLACEHOLDER` branch.
When done, report the result to the issue/PR with `gh issue comment NUMBER_PLACEHOLDER` or `gh pr comment NUMBER_PLACEHOLDER`.
PROMPT_EOF
)
@@ -326,6 +445,7 @@ jobs:
PROMPT="${PROMPT//REPO_PLACEHOLDER/$REPO_NAME}"
PROMPT="${PROMPT//TYPE_PLACEHOLDER/$CONTEXT_TYPE}"
PROMPT="${PROMPT//NUMBER_PLACEHOLDER/$CONTEXT_NUMBER}"
PROMPT="${PROMPT//TITLE_PLACEHOLDER/$CONTEXT_TITLE}"
PROMPT="${PROMPT//BRANCH_PLACEHOLDER/$DEFAULT_BRANCH}"
PROMPT="${PROMPT//COMMENT_PLACEHOLDER/$USER_COMMENT}"

1
.gitignore vendored
View File

@@ -1,4 +1,5 @@
# Dependencies
.sisyphus/
node_modules/
# Build output

View File

@@ -1,7 +1,7 @@
# PROJECT KNOWLEDGE BASE
**Generated:** 2026-01-02T10:35:00+09:00
**Commit:** bebe660
**Generated:** 2026-01-02T22:41:22+09:00
**Commit:** d0694e5
**Branch:** dev
## OVERVIEW
@@ -20,9 +20,9 @@ oh-my-opencode/
│ ├── auth/ # Google Antigravity OAuth - see src/auth/AGENTS.md
│ ├── shared/ # Cross-cutting utilities - see src/shared/AGENTS.md
│ ├── cli/ # CLI installer, doctor - see src/cli/AGENTS.md
│ ├── mcp/ # MCP configs: context7, websearch_exa, grep_app
│ ├── mcp/ # MCP configs: context7, grep_app
│ ├── config/ # Zod schema, TypeScript types
│ └── index.ts # Main plugin entry (723 lines)
│ └── index.ts # Main plugin entry (464 lines)
├── script/ # build-schema.ts, publish.ts, generate-changelog.ts
└── dist/ # Build output (ESM + .d.ts)
```
@@ -39,6 +39,29 @@ oh-my-opencode/
| Config schema | `src/config/schema.ts` | Run `bun run build:schema` after |
| Claude Code compat | `src/features/claude-code-*-loader/` | Command, skill, agent, mcp loaders |
## TDD (Test-Driven Development)
**MANDATORY for new features and bug fixes.** Follow RED-GREEN-REFACTOR:
```
1. RED - Write failing test first (test MUST fail)
2. GREEN - Write MINIMAL code to pass (nothing more)
3. REFACTOR - Clean up while tests stay GREEN
4. REPEAT - Next test case
```
| Phase | Action | Verification |
|-------|--------|--------------|
| **RED** | Write test describing expected behavior | `bun test` → FAIL (expected) |
| **GREEN** | Implement minimum code to pass | `bun test` → PASS |
| **REFACTOR** | Improve code quality, remove duplication | `bun test` → PASS (must stay green) |
**Rules:**
- NEVER write implementation before test
- NEVER delete failing tests to "pass" - fix the code
- One test at a time - don't batch
- Test file naming: `*.test.ts` alongside source
## CONVENTIONS
- **Bun only**: `bun run`, `bun test`, `bunx` (NEVER npm/npx)
@@ -46,7 +69,7 @@ oh-my-opencode/
- **Build**: `bun build` (ESM) + `tsc --emitDeclarationOnly`
- **Exports**: Barrel pattern in index.ts; explicit named exports for tools/hooks
- **Naming**: kebab-case directories, createXXXHook/createXXXTool factories
- **Testing**: BDD comments `#given`, `#when`, `#then` (same as AAA)
- **Testing**: BDD comments `#given`, `#when`, `#then` (same as AAA); TDD workflow (RED-GREEN-REFACTOR)
- **Temperature**: 0.1 for code agents, max 0.3
## ANTI-PATTERNS
@@ -96,11 +119,11 @@ CI auto-commits schema changes on master, maintains rolling `next` draft release
| File | Lines | Description |
|------|-------|-------------|
| `src/index.ts` | 723 | Main plugin, all hook/tool init |
| `src/index.ts` | 464 | Main plugin, all hook/tool init |
| `src/cli/config-manager.ts` | 669 | JSONC parsing, env detection |
| `src/auth/antigravity/fetch.ts` | 621 | Token refresh, URL rewriting |
| `src/tools/lsp/client.ts` | 611 | LSP protocol, JSON-RPC |
| `src/hooks/anthropic-context-window-limit-recovery/executor.ts` | 554 | Multi-stage recovery |
| `src/hooks/anthropic-context-window-limit-recovery/executor.ts` | 564 | Multi-stage recovery |
| `src/agents/sisyphus.ts` | 504 | Orchestrator prompt |
## NOTES

View File

@@ -89,7 +89,7 @@ oh-my-opencode/
│ ├── agents/ # AI agents (OmO, oracle, librarian, explore, etc.)
│ ├── hooks/ # 21 lifecycle hooks
│ ├── tools/ # LSP (11), AST-Grep, Grep, Glob, etc.
│ ├── mcp/ # MCP server integrations (context7, websearch_exa, grep_app)
│ ├── mcp/ # MCP server integrations (context7, grep_app)
│ ├── features/ # Claude Code compatibility layers
│ ├── config/ # Zod schemas and TypeScript types
│ ├── auth/ # Google Antigravity OAuth

View File

@@ -2,12 +2,14 @@
>
> *「私はエージェントが生成したコードと人間が書いたコードを区別できない、しかしはるかに多くのことを達成できる世界を作り、ソフトウェア革命を起こすことを目指しています。私はこの旅に個人的な時間、情熱、そして資金を注ぎ込んできましたし、これからもそうし続けます。」*
>
> [![The Orchestrator is coming](./.github/assets/orchestrator-sisyphus.png)](https://x.com/justsisyphus/status/2006250634354548963)
> > **オーケストレーターが来ます。今週中に。[Xで通知を受け取る](https://x.com/justsisyphus/status/2006250634354548963)**
>
> 一緒に歩みましょう!
>
> | [<img alt="Discord link" src="https://img.shields.io/discord/1452487457085063218?color=5865F2&label=discord&labelColor=black&logo=discord&logoColor=white&style=flat-square" width="156px" />](https://discord.gg/PWpXmbhF) | [Discordコミュニティ](https://discord.gg/PWpXmbhF)に参加して、コントリビューターや`oh-my-opencode`仲間とつながりましょう。 |
> | [<img alt="Discord link" src="https://img.shields.io/discord/1452487457085063218?color=5865F2&label=discord&labelColor=black&logo=discord&logoColor=white&style=flat-square" width="156px" />](https://discord.gg/PUwSMR9XNk) | [Discordコミュニティ](https://discord.gg/PUwSMR9XNk)に参加して、コントリビューターや`oh-my-opencode`仲間とつながりましょう。 |
> | :-----| :----- |
> | [<img alt="X link" src="https://img.shields.io/badge/Follow-%40justsisyphus-00CED1?style=flat-square&logo=x&labelColor=black" width="156px" />](https://x.com/justsisyphus) | `oh-my-opencode`に関するニュースは私のXアカウントで投稿していましたが、無実の罪で凍結されたため、<br />[@justsisyphus](https://x.com/justsisyphus)が代わりに更新を投稿しています。 |
> | [<img alt="Sponsor" src="https://img.shields.io/badge/Sponsor-❤-ff69b4?style=flat-square&logo=github-sponsors&labelColor=black" width="156px" />](https://github.com/sponsors/code-yeongyu) | [スポンサーになって](https://github.com/sponsors/code-yeongyu) `oh-my-opencode` の開発を応援してください。皆さまのご支援がこのプロジェクトを成長させます。 |
<!-- <CENTERED SECTION FOR GITHUB DISPLAY> -->
@@ -46,6 +48,11 @@
> "Oh My Opencodeを使って、たった1日で8000個のeslint警告を解消しました" — [Jacob Ferrari](https://x.com/jacobferrari_/status/2003258761952289061)
> "Ohmyopencodeとralph loopを使って、一晩で45,000行のtauriアプリをSaaSウェブアプリに変換しました。インタビュープロンプトから始めて、質問に対する評価と推奨を求めました。作業する様子を見ているのは驚きでしたし、朝起きたらほぼ完成したウェブサイトがありました" - [James Hargis](https://x.com/hargabyte/status/2007299688261882202)
> "今週末はopen code、oh my opencode、supermemoryでマインクラフト/ソウルライクな何かを作る実験をしています。"
> "昼食後の散歩に行く間に、しゃがみアニメーションを追加するよう頼みました。[動画]" - [MagiMetal](https://x.com/MagiMetal/status/2005374704178373023)
> "これをコアに取り入れて彼を採用すべきです。マジで。本当に、本当に、本当に良いです" — Henning Kilset
> "@yeon_gyu_kimを説得できるなら雇うべきです。彼はopencodeに革命を起こしました" — [mysticaltech](https://x.com/mysticaltech/status/2001858758608376079)
@@ -56,6 +63,8 @@
> "Oh My Opencodeは頂点に立っています、敵はいません" — [RyanOnThePath](https://x.com/RyanOnThePath/status/2001438321252118548)
> "Oh My OpenCode Is Actually Insane" - [YouTube - Darren Builds AI](https://www.youtube.com/watch?v=G_Snfh2M41M)
> "シジフォスという名前自体が美しいじゃないですか?" — Sigrid ([@sigridjin_eth](https://x.com/sigridjin_eth))
---
@@ -70,6 +79,19 @@
- [インストール](#インストール)
- [人間の方へ](#人間の方へ)
- [LLM エージェントの方へ](#llm-エージェントの方へ)
- [ステップ 0: サブスクリプション情報を確認](#ステップ-0-サブスクリプション情報を確認)
- [ステップ 1: OpenCode インストール確認](#ステップ-1-opencode-インストール確認)
- [ステップ 2: インストーラーを実行](#ステップ-2-インストーラーを実行)
- [ステップ 3: 設定確認](#ステップ-3-設定確認)
- [ステップ 4: 認証情報の設定](#ステップ-4-認証情報の設定)
- [4.1 Anthropic (Claude)](#41-anthropic-claude)
- [4.2 Google Gemini (Antigravity OAuth)](#42-google-gemini-antigravity-oauth)
- [4.3 OpenAI (ChatGPT Plus/Pro)](#43-openai-chatgpt-pluspro)
- [⚠️ 注意](#-注意)
- [セットアップの確認](#セットアップの確認)
- [ユーザーに「おめでとうございます!🎉」と伝える](#ユーザーにおめでとうございますと伝える)
- [複雑すぎますか?](#複雑すぎますか)
- [アンインストール](#アンインストール)
- [機能](#機能)
- [Agents: あなたの新しいチームメイト](#agents-あなたの新しいチームメイト)
- [バックグラウンドエージェント: 本当のチームのように働く](#バックグラウンドエージェント-本当のチームのように働く)
@@ -167,13 +189,15 @@ Windows から Linux に初めて乗り換えた時のこと、自分の思い
インストールするだけで、エージェントは以下のようなワークフローで働けるようになります:
1. バックグラウンドタスクとして Gemini 3 Pro にフロントエンドを書かせている間に、Claude Opus 4.5 がバックエンドを作成し、デバッグで詰まったら GPT 5.2 に助けを求めます。フロントエンドの実装完了報告が来たら、それを検証して出荷します。
2. 何か調べる必要があれば、公式ドキュメント、コードベースの全履歴、GitHub に公開されている実装例まで徹底的に調査します。単なる grep だけでなく、内蔵された LSP ツールや AST-Grep まで駆使します。
3. LLM に仕事を任せる際、コンテキスト管理の心配はもう不要です。私がやります。
- OhMyOpenCode は複数のエージェントを積極的に活用し、コンテキストの負荷を軽減します。
- **あなたのエージェントは今や開発チームのリードです。あなたは AI マネージャーです。**
4. 頼んだ仕事が完了するまで止まりません
5. このプロジェクトについて深く知りたくない?大丈夫です。ただ 'ultrathink' と入力してください
1. Sisyphusは自分自身でファイルを探し回るような時間の無駄はしません。メインエージェントのコンテキストを軽量に保つため、より高速で安価なモデルへ並列でバックグラウンドタスクを飛ばし、自身の代わりに領域の調査を完了させます。
1. SisyphusはリファクタリングにLSPを活用します。その方が確実で、安全、かつ的確だからです。
1. UIに関わる重い作業が必要な場合、SisyphusはフロントエンドのタスクをGemini 3 Proに直接デリゲートします。
1. もしSisyphusがループに陥ったり壁にぶつかったりしても、無駄に悩み続けることはありません。高IQな戦略的バックアップとしてGPT 5.2を呼び出します。
1. 複雑なオープンソースフレームワークを扱っていますかSisyphusはサブエージェントを生成し、生のソースコードやドキュメントをリアルタイムで消化します。彼は完全なコンテキスト認識を持って動作します。
1. Sisyphusがコメントに触れるとき、その存在意義を証明するか、さもなくば削除します。あなたのコードベースを常にクリーンに保ちます
1. Sisyphusは自身のTODOリストに縛られています。もし始めたことを終わらせられなければ、システムは彼を強制的に「bouldering」モードに戻します。あなたのタスクは、何があろうと完了します
1. 正直、ドキュメントなんて読む必要はありません。ただプロンプトを書いてください。「ultrawork」というキーワードを含めるだけで十分です。Sisyphusが構造を分析し、コンテキストを集め、外部のソースコードまで掘り下げ、仕事が100%完了するまでboulderingを続けます。
1. ぶっちゃけ、「ultrawork」と打つのすら面倒ですよね。それなら「ulw」だけでOKです。ただulwと打ち、コーヒーでも飲んでいてください。仕事は終わっています。
このような機能が不要であれば、前述の通り、特定の機能だけを選んで使うことができます。
@@ -275,7 +299,7 @@ opencode auth login
{
"plugin": [
"oh-my-opencode",
"opencode-antigravity-auth@1.1.2"
"opencode-antigravity-auth@1.2.7"
]
}
```
@@ -300,7 +324,7 @@ opencode auth login
}
```
**利用可能なモデル名**: `google/gemini-3-pro-high`, `google/gemini-3-pro-medium`, `google/gemini-3-pro-low`, `google/gemini-3-flash`, `google/gemini-3-flash`, `google/gemini-3-flash-lite`, `google/claude-sonnet-4-5`, `google/claude-sonnet-4-5-thinking`, `google/claude-opus-4-5-thinking`, `google/gpt-oss-120b-medium`
**利用可能なモデル名**: `google/antigravity-gemini-3-pro-high`, `google/antigravity-gemini-3-pro-low`, `google/antigravity-gemini-3-flash`, `google/antigravity-claude-sonnet-4-5`, `google/antigravity-claude-sonnet-4-5-thinking-low`, `google/antigravity-claude-sonnet-4-5-thinking-medium`, `google/antigravity-claude-sonnet-4-5-thinking-high`, `google/antigravity-claude-opus-4-5-thinking-low`, `google/antigravity-claude-opus-4-5-thinking-medium`, `google/antigravity-claude-opus-4-5-thinking-high`, `google/gemini-3-pro-preview`, `google/gemini-3-flash-preview`, `google/gemini-2.5-pro`, `google/gemini-2.5-flash`
その後、認証を行います:
@@ -323,26 +347,19 @@ opencode auth login
{
"plugin": [
"oh-my-opencode",
"opencode-openai-codex-auth@4.1.1"
"opencode-openai-codex-auth@4.3.0"
]
}
```
**重要**: 現在、公式 npm パッケージに 400 エラー (`"No tool call found for function call output with call_id"`) を引き起こすバグがあります。修正版がリリースされるまでは、**ホットフィックスブランチの使用を推奨します**。`~/.config/opencode/package.json` を修正してください:
##### モデル設定
```json
{
"dependencies": {
"opencode-openai-codex-auth": "code-yeongyu/opencode-openai-codex-auth#fix/orphaned-function-call-output-with-tools"
}
}
```
その後、`cd ~/.config/opencode && bun i` を実行してください。`opencode.json` ではバージョン指定なしで `"opencode-openai-codex-auth"` として使用します(`@4.1.0` は除外)。
#### 4.3.1 モデル設定
`opencode.json` に完全なモデル設定も構成する必要があります。
[opencode-openai-codex-auth ドキュメント](https://github.com/numman-ali/opencode-openai-codex-auth)を読み、[`config/full-opencode.json`](https://github.com/numman-ali/opencode-openai-codex-auth/blob/main/config/full-opencode.json) から provider/models 設定をコピーして、ユーザーの既存のセットアップを壊さないよう慎重にマージしてください。
[opencode-openai-codex-auth ドキュメント](https://github.com/numman-ali/opencode-openai-codex-auth)を読み、[`config/opencode-modern.json`](https://github.com/numman-ali/opencode-openai-codex-auth/blob/main/config/opencode-modern.json)OpenCode v1.0.210+)または [`config/opencode-legacy.json`](https://github.com/numman-ali/opencode-openai-codex-auth/blob/main/config/opencode-legacy.json)(旧バージョン)から provider/models 設定をコピーして、ユーザーの既存のセットアップを壊さないよう慎重にマージしてください。
**利用可能なモデル**: `openai/gpt-5.2`, `openai/gpt-5.2-codex`, `openai/gpt-5.1-codex-max`, `openai/gpt-5.1-codex`, `openai/gpt-5.1-codex-mini`, `openai/gpt-5.1`
**Variants** (OpenCode v1.0.210+): `--variant=<none|low|medium|high|xhigh>` オプションで推論強度を制御できます。
その後、認証を行います:
@@ -541,7 +558,6 @@ OpenCode セッション履歴をナビゲートおよび検索するための
```
- **Online**: プロジェクトのルールがすべてではありません。拡張機能のための内蔵 MCP を提供します:
- **context7**: ライブラリの最新公式ドキュメントを取得
- **websearch_exa**: Exa AI を活用したリアルタイムウェブ検索
- **grep_app**: 数百万の公開 GitHub リポジトリから超高速コード検索(実装例を探すのに最適)
#### マルチモーダルを活用し、トークンは節約する
@@ -633,7 +649,7 @@ Oh My OpenCode は以下の場所からフックを読み込んで実行しま
| トグル | `false` の場合、ロードが無効になるパス | 影響を受けないもの |
| ---------- | ------------------------------------------------------------------------------------- | ----------------------------------------------------- |
| `mcp` | `~/.claude/.mcp.json`, `./.mcp.json`, `./.claude/.mcp.json` | 内蔵 MCP (context7, websearch_exa) |
| `mcp` | `~/.claude/.mcp.json`, `./.mcp.json`, `./.claude/.mcp.json` | 内蔵 MCP (context7, grep_app) |
| `commands` | `~/.claude/commands/*.md`, `./.claude/commands/*.md` | `~/.config/opencode/command/`, `./.opencode/command/` |
| `skills` | `~/.claude/skills/*/SKILL.md`, `./.claude/skills/*/SKILL.md` | - |
| `agents` | `~/.claude/agents/*.md`, `./.claude/agents/*.md` | 内蔵エージェント (oracle, librarian 等) |
@@ -826,7 +842,7 @@ Oh My OpenCode は以下の場所からフックを読み込んで実行しま
有効時デフォルト、Sisyphus はオプションの特殊エージェントを備えた強力なオーケストレーターを提供します:
- **Sisyphus**: プライマリオーケストレーターエージェント (Claude Opus 4.5)
- **Builder-Sisyphus**: OpenCode のデフォルトビルドエージェントSDK 制限により名前変更、デフォルトで無効)
- **OpenCode-Builder**: OpenCode のデフォルトビルドエージェントSDK 制限により名前変更、デフォルトで無効)
- **Planner-Sisyphus**: OpenCode のデフォルトプランエージェントSDK 制限により名前変更、デフォルトで有効)
**設定オプション:**
@@ -842,7 +858,7 @@ Oh My OpenCode は以下の場所からフックを読み込んで実行しま
}
```
**例:Builder-Sisyphus を有効化:**
**例:OpenCode-Builder を有効化:**
```json
{
@@ -852,7 +868,7 @@ Oh My OpenCode は以下の場所からフックを読み込んで実行しま
}
```
これにより、Sisyphus と並行して Builder-Sisyphus エージェントを有効化できます。Sisyphus が有効な場合、デフォルトのビルドエージェントは常にサブエージェントモードに降格されます。
これにより、Sisyphus と並行して OpenCode-Builder エージェントを有効化できます。Sisyphus が有効な場合、デフォルトのビルドエージェントは常にサブエージェントモードに降格されます。
**例:すべての Sisyphus オーケストレーションを無効化:**
@@ -873,7 +889,7 @@ Oh My OpenCode は以下の場所からフックを読み込んで実行しま
"model": "anthropic/claude-sonnet-4",
"temperature": 0.3
},
"Builder-Sisyphus": {
"OpenCode-Builder": {
"model": "anthropic/claude-opus-4"
},
"Planner-Sisyphus": {
@@ -886,7 +902,7 @@ Oh My OpenCode は以下の場所からフックを読み込んで実行しま
| オプション | デフォルト | 説明 |
| --------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `disabled` | `false` | `true` の場合、すべての Sisyphus オーケストレーションを無効化し、元の build/plan をプライマリとして復元します。 |
| `default_builder_enabled` | `false` | `true` の場合、Builder-Sisyphus エージェントを有効化しますOpenCode build と同じ、SDK 制限により名前変更)。デフォルトでは無効です。 |
| `default_builder_enabled` | `false` | `true` の場合、OpenCode-Builder エージェントを有効化しますOpenCode build と同じ、SDK 制限により名前変更)。デフォルトでは無効です。 |
| `planner_enabled` | `true` | `true` の場合、Planner-Sisyphus エージェントを有効化しますOpenCode plan と同じ、SDK 制限により名前変更)。デフォルトで有効です。 |
| `replace_plan` | `true` | `true` の場合、デフォルトのプランエージェントをサブエージェントモードに降格させます。`false` に設定すると、Planner-Sisyphus とデフォルトのプランの両方を利用できます。 |
@@ -906,17 +922,16 @@ Oh My OpenCode は以下の場所からフックを読み込んで実行しま
### MCPs
コンテキスト7、Exa、grep.app MCP がデフォルトで有効になっています。
Context7、grep.app MCP がデフォルトで有効になっています。
- **context7**: ライブラリの最新公式ドキュメントを取得
- **websearch_exa**: Exa AI を活用したリアルタイムウェブ検索
- **grep_app**: [grep.app](https://grep.app) を通じて数百万の公開 GitHub リポジトリから超高速コード検索
不要であれば、`~/.config/opencode/oh-my-opencode.json` または `.opencode/oh-my-opencode.json` の `disabled_mcps` を使用して無効化できます:
```json
{
"disabled_mcps": ["context7", "websearch_exa", "grep_app"]
"disabled_mcps": ["context7", "grep_app"]
}
```
@@ -1025,5 +1040,8 @@ OpenCode が Debian / ArchLinux だとしたら、Oh My OpenCode は Ubuntu / [O
- **Numman Ali** [GitHub](https://github.com/numman-ali) [X](https://x.com/nummanali)
- 最初のスポンサー
- **Aaron Iker** [GitHub](https://github.com/aaroniker) [X](https://x.com/aaroniker)
- **Suyeol Jeon (devxoul)** [GitHub](https://github.com/devxoul)
- 私のキャリアをスタートさせてくださった方であり、優れたエージェンティックワークフローをどのように構築できるかについて多大なインスピレーションを与えてくださった方です。優れたチームを作るために優れたシステムをどう設計すべきか多くのことを学び、その学びがこのharnessを作る上で大きな助けとなりました。
- **Hyerin Won (devwon)** [GitHub](https://github.com/devwon)
*素晴らしいヒーロー画像を作成してくれた [@junhoyeo](https://github.com/junhoyeo) に感謝します*

View File

@@ -2,12 +2,14 @@
>
> *"저는 에이전트가 생성한 코드와 인간이 작성한 코드를 구분할 수 없으면서도, 훨씬 더 많은 것을 달성할 수 있는 세상을 만들어 소프트웨어 혁명을 일으키고자 합니다. 저는 이 여정에 개인적인 시간, 열정, 그리고 자금을 쏟아부었고, 앞으로도 계속 그렇게 할 것입니다."*
>
> [![The Orchestrator is coming](./.github/assets/orchestrator-sisyphus.png)](https://x.com/justsisyphus/status/2006250634354548963)
> > **오케스트레이터가 옵니다. 이번 주에요. [X에서 알림받기](https://x.com/justsisyphus/status/2006250634354548963)**
>
> 함께해주세요!
>
> | [<img alt="Discord link" src="https://img.shields.io/discord/1452487457085063218?color=5865F2&label=discord&labelColor=black&logo=discord&logoColor=white&style=flat-square" width="156px" />](https://discord.gg/PWpXmbhF) | [Discord 커뮤니티](https://discord.gg/PWpXmbhF)에서 기여자들과 `oh-my-opencode` 사용자들을 만나보세요. |
> | [<img alt="Discord link" src="https://img.shields.io/discord/1452487457085063218?color=5865F2&label=discord&labelColor=black&logo=discord&logoColor=white&style=flat-square" width="156px" />](https://discord.gg/PUwSMR9XNk) | [Discord 커뮤니티](https://discord.gg/PUwSMR9XNk)에서 기여자들과 `oh-my-opencode` 사용자들을 만나보세요. |
> | :-----| :----- |
> | [<img alt="X link" src="https://img.shields.io/badge/Follow-%40justsisyphus-00CED1?style=flat-square&logo=x&labelColor=black" width="156px" />](https://x.com/justsisyphus) | `oh-my-opencode` 관련 소식은 제 X 계정에서 올렸었는데, 억울하게 정지당해서 <br />[@justsisyphus](https://x.com/justsisyphus)가 대신 소식을 전하고 있습니다. |
> | [<img alt="Sponsor" src="https://img.shields.io/badge/Sponsor-❤-ff69b4?style=flat-square&logo=github-sponsors&labelColor=black" width="156px" />](https://github.com/sponsors/code-yeongyu) | [스폰서가 되어](https://github.com/sponsors/code-yeongyu) `oh-my-opencode` 개발을 응원해주세요. 여러분의 후원이 이 프로젝트를 계속 성장시킵니다. |
<!-- <CENTERED SECTION FOR GITHUB DISPLAY> -->
@@ -43,6 +45,11 @@
> "Oh My Opencode를 사용해서, 단 하루만에 8000개의 eslint 경고를 해결했습니다" — [Jacob Ferrari](https://x.com/jacobferrari_/status/2003258761952289061)
> "Ohmyopencode와 ralph loop을 사용해서, 하룻밤 만에 45,000줄짜리 tauri 앱을 SaaS 웹앱으로 전환했습니다. 인터뷰 프롬프트로 시작해서, 질문에 대한 평점과 추천을 요청했습니다. 일하는 모습을 지켜보는 것도 놀라웠고, 아침에 일어나니 거의 완성된 웹사이트가 있었습니다!" - [James Hargis](https://x.com/hargabyte/status/2007299688261882202)
> "이번 주말에 open code, oh my opencode와 supermemory로 마인크래프트/소울라이크 같은 괴물을 만들어보고 있습니다."
> "점심 먹고 산책하러 가는 동안 웅크리기 애니메이션 추가해달라고 시켰습니다. [영상]" - [MagiMetal](https://x.com/MagiMetal/status/2005374704178373023)
> "이걸 코어에 넣고 그를 채용해야 합니다. 진심으로요. 이건 정말, 정말, 정말 좋습니다." — Henning Kilset
> "@yeon_gyu_kim 을 설득할 수 있다면 고용하세요, 이 사람은 opencode를 혁신했습니다." — [mysticaltech](https://x.com/mysticaltech/status/2001858758608376079)
@@ -53,6 +60,8 @@
> "Oh My Opencode는 독보적입니다, 경쟁자가 없습니다" — [RyanOnThePath](https://x.com/RyanOnThePath/status/2001438321252118548)
> "Oh My OpenCode Is Actually Insane" - [YouTube - Darren Builds AI](https://www.youtube.com/watch?v=G_Snfh2M41M)
> "시지푸스 이름 자체가 이쁘잖아요?" — Sigrid ([@sigridjin_eth](https://x.com/sigridjin_eth))
---
@@ -67,6 +76,19 @@
- [설치](#설치)
- [인간인 당신을 위한 설치 가이드](#인간인-당신을-위한-설치-가이드)
- [LLM Agent 를 위한 설치 가이드](#llm-agent-를-위한-설치-가이드)
- [0단계: 구독 정보 확인](#0단계-구독-정보-확인)
- [1단계: OpenCode 설치 확인](#1단계-opencode-설치-확인)
- [2단계: 설치 프로그램 실행](#2단계-설치-프로그램-실행)
- [3단계: 설정 확인](#3단계-설정-확인)
- [4단계: 인증정보 설정](#4단계-인증정보-설정)
- [4.1 Anthropic (Claude)](#41-anthropic-claude)
- [4.2 Google Gemini (Antigravity OAuth)](#42-google-gemini-antigravity-oauth)
- [4.3 OpenAI (ChatGPT Plus/Pro)](#43-openai-chatgpt-pluspro)
- [⚠️ 주의](#-주의)
- [설정 확인](#설정-확인)
- [사용자에게 '축하합니다! 🎉'라고 말하세요](#사용자에게-축하합니다-라고-말하세요)
- [너무 복잡한가요?](#너무-복잡한가요)
- [언인스톨](#언인스톨)
- [기능](#기능)
- [Agents: 당신의 새로운 팀원들](#agents-당신의-새로운-팀원들)
- [백그라운드 에이전트: 진짜 팀 처럼 일 하도록](#백그라운드-에이전트-진짜-팀-처럼-일-하도록)
@@ -164,13 +186,15 @@ OpenCode 가 낭만이 사라진것같은 오늘날의 시대에, 당신에게
#### 그저 설치하면 되는 것.
1. 백그라운드 태스크로 Gemini 3 Pro 가 프론트엔드를 작성하게 시켜두는 동안, Claude Opus 4.5 가 백엔드를 작성하고, 디버깅하다 막히면 GPT 5.2 에게 도움을 받습니다. 프론트엔드 구현이 완료되었다고 보고받으면, 이를 다시 확인하고 일하게 만들 수 있습니다.
2. 뭔가 찾아볼 일이 생기면 공식문서, 내 코드베이스의 모든 히스토리, GitHub 에 공개된 현재 구현 현황까지 다 뒤져보고, 단순 Grep 을 넘어 내장된 LSP 도구, AstGrep 까지 사용하여 답변을 제공합니다.
3. LLM 에게 일을 맡길때에 큰 컨텍스트에 대한 걱정은 더 이상 하지마세요. 제가 하겠습니다.
- OhMyOpenCode 가 여러 에이전트를 적극 활용하도록 하여 컨텍스트 관리에 관한 부담을 줄입니다.
- **당신의 에이전트는 이제 개발팀 리드입니다. 당신은 이제 AI Manager 입니다.**
4. 하기로 약속 한 일을 완수 할 때 까지 멈추지 않습니다.
5. 이 프로젝트에 자세히 알기 싫다고요? 괜찮습니다. 그냥 'ultrawork' 라고 치세요.
1. 시지푸스는 직접 파일을 찾아다니며 시간을 낭비하지 않습니다. 메인 에이전트의 컨텍스트를 가볍게 유지하기 위해, 더 빠르고 저렴한 모델들에게 병렬로 백그라운드 태스크를 실행시켜 지형지물을 파악하게 합니다.
1. 시지푸스는 LSP를 활용해 리팩토링을 수행합니다. 이는 훨씬 더 결정론적이고 안전하며 정교합니다.
1. UI 작업이 필요한 고난도 태스크를 마주하면, 시지푸스는 프론트엔드 작업을 Gemini 3 Pro에게 직접 위임합니다.
1. 루프에 갇히거나 한계에 부딪히면 시지푸스는 무의미하게 반복하지 않습니다. High-IQ 전략적 백업을 위해 GPT 5.2를 호출합니다.
1. 복잡한 오픈소스 프레임워크를 다루시나요? 시지푸스는 서브에이전트들을 생성해 소스 코드와 문서를 실시간으로 파악합니다. 완벽한 컨텍스트 인지를 바탕으로 작동합니다.
1. 시지푸스는 주석을 건드릴 때 그 존재 이유를 증명하거나, 아니면 그냥 날려버립니다. 당신의 코드베이스를 깨끗하게 유지합니다.
1. 시지푸스는 본인의 TODO 리스트에 귀속됩니다. 시작한 일을 끝내지 못하면 시스템이 그를 다시 "bouldering" 모드로 강제 소환합니다. 작업은 무조건 완료됩니다.
1. 솔직히 문서 읽을 필요도 없습니다. 그냥 프롬프트를 작성하세요. 'ultrawork' 키워드를 포함하기만 하면 됩니다. 시지푸스가 구조를 분석하고, 컨텍스트를 수집하고, 외부 소스 코드를 파헤치며 작업이 100% 완료될 때까지 계속 bouldering을 이어갈 것입니다.
1. 사실 'ultrawork'라고 타이핑하는 것도 일입니다. 그냥 'ulw'라고 치세요. 딱 ulw 세 글자면 됩니다. 그리고 커피나 한잔하세요. 작업은 이미 끝났습니다.
그러나 이러한 작업이 싫다면, 말했듯 특정한 기능만 가져가 사용 할 수 있습니다.
@@ -272,7 +296,7 @@ opencode auth login
{
"plugin": [
"oh-my-opencode",
"opencode-antigravity-auth@1.1.2"
"opencode-antigravity-auth@1.2.7"
]
}
```
@@ -297,7 +321,7 @@ opencode auth login
}
```
**사용 가능한 모델 이름**: `google/gemini-3-pro-high`, `google/gemini-3-pro-medium`, `google/gemini-3-pro-low`, `google/gemini-3-flash`, `google/gemini-3-flash`, `google/gemini-3-flash-lite`, `google/claude-sonnet-4-5`, `google/claude-sonnet-4-5-thinking`, `google/claude-opus-4-5-thinking`, `google/gpt-oss-120b-medium`
**사용 가능한 모델 이름**: `google/antigravity-gemini-3-pro-high`, `google/antigravity-gemini-3-pro-low`, `google/antigravity-gemini-3-flash`, `google/antigravity-claude-sonnet-4-5`, `google/antigravity-claude-sonnet-4-5-thinking-low`, `google/antigravity-claude-sonnet-4-5-thinking-medium`, `google/antigravity-claude-sonnet-4-5-thinking-high`, `google/antigravity-claude-opus-4-5-thinking-low`, `google/antigravity-claude-opus-4-5-thinking-medium`, `google/antigravity-claude-opus-4-5-thinking-high`, `google/gemini-3-pro-preview`, `google/gemini-3-flash-preview`, `google/gemini-2.5-pro`, `google/gemini-2.5-flash`
그 후 인증:
@@ -320,26 +344,19 @@ opencode auth login
{
"plugin": [
"oh-my-opencode",
"opencode-openai-codex-auth@4.1.1"
"opencode-openai-codex-auth@4.3.0"
]
}
```
**중요**: 현재 공식 npm 패키지에 400 에러(`"No tool call found for function call output with call_id"`)를 유발하는 버그가 있습니다. 수정 버전이 배포될 때까지 **핫픽스 브랜치 사용을 권장합니다**. `~/.config/opencode/package.json`을 수정하세요:
##### 모델 설정
```json
{
"dependencies": {
"opencode-openai-codex-auth": "code-yeongyu/opencode-openai-codex-auth#fix/orphaned-function-call-output-with-tools"
}
}
```
그 후 `cd ~/.config/opencode && bun i`를 실행하세요. `opencode.json`에서는 버전 없이 `"opencode-openai-codex-auth"`로 사용합니다 (`@4.1.0` 제외).
#### 4.3.1 모델 설정
`opencode.json`에 전체 모델 설정도 구성해야 합니다.
[opencode-openai-codex-auth 문서](https://github.com/numman-ali/opencode-openai-codex-auth)를 읽고, [`config/full-opencode.json`](https://github.com/numman-ali/opencode-openai-codex-auth/blob/main/config/full-opencode.json)에서 provider/models 설정을 복사하여, 사용자의 기존 셋업에 영향을 주지 않도록 깊게 고민하여 적절히 통합하세요.
[opencode-openai-codex-auth 문서](https://github.com/numman-ali/opencode-openai-codex-auth)를 읽고, [`config/opencode-modern.json`](https://github.com/numman-ali/opencode-openai-codex-auth/blob/main/config/opencode-modern.json) (OpenCode v1.0.210+) 또는 [`config/opencode-legacy.json`](https://github.com/numman-ali/opencode-openai-codex-auth/blob/main/config/opencode-legacy.json) (구버전)에서 provider/models 설정을 복사하여, 사용자의 기존 셋업에 영향을 주지 않도록 깊게 고민하여 적절히 통합하세요.
**사용 가능한 모델**: `openai/gpt-5.2`, `openai/gpt-5.2-codex`, `openai/gpt-5.1-codex-max`, `openai/gpt-5.1-codex`, `openai/gpt-5.1-codex-mini`, `openai/gpt-5.1`
**Variants** (OpenCode v1.0.210+): `--variant=<none|low|medium|high|xhigh>` 옵션으로 추론 강도를 조절할 수 있습니다.
그 후 인증:
@@ -534,7 +551,6 @@ OpenCode 세션 히스토리를 탐색하고 검색하기 위한 도구들입니
```
- **Online**: 프로젝트 규칙이 전부는 아니겠죠. 확장 기능을 위한 내장 MCP를 제공합니다:
- **context7**: 공식 문서 조회
- **websearch_exa**: 실시간 웹 검색
- **grep_app**: 공개 GitHub 저장소에서 초고속 코드 검색 (구현 예제 찾기에 최적)
#### 멀티모달을 다 활용하면서, 토큰은 덜 쓰도록.
@@ -626,7 +642,7 @@ Oh My OpenCode는 다음 위치의 훅을 읽고 실행합니다:
| 토글 | `false`일 때 로딩 비활성화 경로 | 영향 받지 않음 |
| ---------- | ------------------------------------------------------------------------------------- | ----------------------------------------------------- |
| `mcp` | `~/.claude/.mcp.json`, `./.mcp.json`, `./.claude/.mcp.json` | 내장 MCP (context7, websearch_exa) |
| `mcp` | `~/.claude/.mcp.json`, `./.mcp.json`, `./.claude/.mcp.json` | 내장 MCP (context7, grep_app) |
| `commands` | `~/.claude/commands/*.md`, `./.claude/commands/*.md` | `~/.config/opencode/command/`, `./.opencode/command/` |
| `skills` | `~/.claude/skills/*/SKILL.md`, `./.claude/skills/*/SKILL.md` | - |
| `agents` | `~/.claude/agents/*.md`, `./.claude/agents/*.md` | 내장 에이전트 (oracle, librarian 등) |
@@ -819,7 +835,7 @@ Schema 자동 완성이 지원됩니다:
활성화 시 (기본값), Sisyphus는 옵션으로 선택 가능한 특화 에이전트들과 함께 강력한 오케스트레이터를 제공합니다:
- **Sisyphus**: Primary 오케스트레이터 에이전트 (Claude Opus 4.5)
- **Builder-Sisyphus**: OpenCode 기본 빌드 에이전트 (SDK 제한으로 이름만 변경, 기본적으로 비활성화)
- **OpenCode-Builder**: OpenCode 기본 빌드 에이전트 (SDK 제한으로 이름만 변경, 기본적으로 비활성화)
- **Planner-Sisyphus**: OpenCode 기본 플랜 에이전트 (SDK 제한으로 이름만 변경, 기본적으로 활성화)
**설정 옵션:**
@@ -835,7 +851,7 @@ Schema 자동 완성이 지원됩니다:
}
```
**예시: Builder-Sisyphus 활성화하기:**
**예시: OpenCode-Builder 활성화하기:**
```json
{
@@ -845,7 +861,7 @@ Schema 자동 완성이 지원됩니다:
}
```
이렇게 하면 Sisyphus와 함께 Builder-Sisyphus 에이전트를 활성화할 수 있습니다. Sisyphus가 활성화되면 기본 빌드 에이전트는 항상 subagent 모드로 강등됩니다.
이렇게 하면 Sisyphus와 함께 OpenCode-Builder 에이전트를 활성화할 수 있습니다. Sisyphus가 활성화되면 기본 빌드 에이전트는 항상 subagent 모드로 강등됩니다.
**예시: 모든 Sisyphus 오케스트레이션 비활성화:**
@@ -866,7 +882,7 @@ Schema 자동 완성이 지원됩니다:
"model": "anthropic/claude-sonnet-4",
"temperature": 0.3
},
"Builder-Sisyphus": {
"OpenCode-Builder": {
"model": "anthropic/claude-opus-4"
},
"Planner-Sisyphus": {
@@ -879,7 +895,7 @@ Schema 자동 완성이 지원됩니다:
| 옵션 | 기본값 | 설명 |
| --------------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
| `disabled` | `false` | `true`면 모든 Sisyphus 오케스트레이션을 비활성화하고 원래 build/plan을 primary로 복원합니다. |
| `default_builder_enabled` | `false` | `true`면 Builder-Sisyphus 에이전트를 활성화합니다 (OpenCode build와 동일, SDK 제한으로 이름만 변경). 기본적으로 비활성화되어 있습니다. |
| `default_builder_enabled` | `false` | `true`면 OpenCode-Builder 에이전트를 활성화합니다 (OpenCode build와 동일, SDK 제한으로 이름만 변경). 기본적으로 비활성화되어 있습니다. |
| `planner_enabled` | `true` | `true`면 Planner-Sisyphus 에이전트를 활성화합니다 (OpenCode plan과 동일, SDK 제한으로 이름만 변경). 기본적으로 활성화되어 있습니다. |
| `replace_plan` | `true` | `true`면 기본 플랜 에이전트를 subagent 모드로 강등시킵니다. `false`로 설정하면 Planner-Sisyphus와 기본 플랜을 모두 사용할 수 있습니다. |
@@ -899,17 +915,16 @@ Schema 자동 완성이 지원됩니다:
### MCPs
기본적으로 Context7, Exa, grep.app MCP 를 지원합니다.
기본적으로 Context7, grep.app MCP 를 지원합니다.
- **context7**: 라이브러리의 최신 공식 문서를 가져옵니다
- **websearch_exa**: Exa AI 기반 실시간 웹 검색
- **grep_app**: [grep.app](https://grep.app)을 통해 수백만 개의 공개 GitHub 저장소에서 초고속 코드 검색
이것이 마음에 들지 않는다면, ~/.config/opencode/oh-my-opencode.json 혹은 .opencode/oh-my-opencode.json 의 `disabled_mcps` 를 사용하여 비활성화할 수 있습니다:
```json
{
"disabled_mcps": ["context7", "websearch_exa", "grep_app"]
"disabled_mcps": ["context7", "grep_app"]
}
```
@@ -1018,5 +1033,8 @@ OpenCode 를 사용하여 이 프로젝트의 99% 를 작성했습니다. 기능
- **Numman Ali** [GitHub](https://github.com/numman-ali) [X](https://x.com/nummanali)
- 첫 번째 스폰서
- **Aaron Iker** [GitHub](https://github.com/aaroniker) [X](https://x.com/aaroniker)
- **전수열 (devxoul)** [GitHub](https://github.com/devxoul)
- 저의 커리어 시작을 만들어주신분이며, 좋은 에이전틱 워크플로우를 어떻게 만들 수 있을까에 대해 많은 영감을 주신 분입니다. 좋은 팀을 만들기 위해 좋은 시스템을 어떻게 설계 할 수 있을지 많은 배움을 얻었고 그러한 내용들이 이 harness를 만드는데에 큰 도움이 되었습니다.
- **원혜린 (devwon)** [GitHub](https://github.com/devwon)
*멋진 히어로 이미지를 만들어주신 히어로 [@junhoyeo](https://github.com/junhoyeo) 께 감사드립니다*

View File

@@ -7,10 +7,9 @@
>
> Be with us!
>
> | [<img alt="Discord link" src="https://img.shields.io/discord/1452487457085063218?color=5865F2&label=discord&labelColor=black&logo=discord&logoColor=white&style=flat-square" width="156px" />](https://discord.gg/PWpXmbhF) | Join our [Discord community](https://discord.gg/PWpXmbhF) to connect with contributors and fellow `oh-my-opencode` users. |
> | [<img alt="Discord link" src="https://img.shields.io/discord/1452487457085063218?color=5865F2&label=discord&labelColor=black&logo=discord&logoColor=white&style=flat-square" width="156px" />](https://discord.gg/PUwSMR9XNk) | Join our [Discord community](https://discord.gg/PUwSMR9XNk) to connect with contributors and fellow `oh-my-opencode` users. |
> | :-----| :----- |
> | [<img alt="X link" src="https://img.shields.io/badge/Follow-%40justsisyphus-00CED1?style=flat-square&logo=x&labelColor=black" width="156px" />](https://x.com/justsisyphus) | News and updates for `oh-my-opencode` used to be posted on my X account. <br /> Since it was suspended mistakenly, [@justsisyphus](https://x.com/justsisyphus) now posts updates on my behalf. |
> | [<img alt="Sponsor" src="https://img.shields.io/badge/Sponsor-❤-ff69b4?style=flat-square&logo=github-sponsors&labelColor=black" width="156px" />](https://github.com/sponsors/code-yeongyu) | Support the development of `oh-my-opencode` by [becoming a sponsor](https://github.com/sponsors/code-yeongyu). Your contribution helps keep this project alive and growing. |
<!-- <CENTERED SECTION FOR GITHUB DISPLAY> -->
@@ -54,6 +53,11 @@ No stupid token consumption massive subagents here. No bloat tools here.
> "Knocked out 8000 eslint warnings with Oh My Opencode, just in a day" — [Jacob Ferrari](https://x.com/jacobferrari_/status/2003258761952289061)
> "I converted a 45k line tauri app into a SaaS web app overnight using Ohmyopencode and ralph loop. Started with interview me prompt, asked it for ratings and recommendations on the questions. It was amazing to watch it work and to wake up this morning to a mostly working website!" - [James Hargis](https://x.com/hargabyte/status/2007299688261882202)
> "Experimenting with open code, oh my opencode and supermemory this weekend to build some minecraft/souls-like abomination."
> "Asking it to add crouch animations while I go take my post-lunch walk. [Video]" - [MagiMetal](https://x.com/MagiMetal/status/2005374704178373023)
> "You guys should pull this into core and recruit him. Seriously. It's really, really, really good." — Henning Kilset
> "Hire @yeon_gyu_kim if you can convince him, this dude has revolutionized opencode." — [mysticaltech](https://x.com/mysticaltech/status/2001858758608376079)
@@ -64,7 +68,9 @@ No stupid token consumption massive subagents here. No bloat tools here.
> "Oh My Opencode is king of the hill and has no contenders" — [RyanOnThePath](https://x.com/RyanOnThePath/status/2001438321252118548)
> "Isn't the name Sisyphus itself beautiful?" — Sigrid ([@sigridjin_eth](https://x.com/sigridjin_eth))
> "Oh My OpenCode Is Actually Insane" - [YouTube - Darren Builds AI](https://www.youtube.com/watch?v=G_Snfh2M41M)
>
> "Isn't the name Sisyphus beautiful by itself?" — Sigrid ([@sigridjin_eth](https://x.com/sigridjin_eth))
---
@@ -122,8 +128,7 @@ No stupid token consumption massive subagents here. No bloat tools here.
# Oh My OpenCode
oMoMoMoMoMo···
Meet Sisyphus: The Batteries-Included Agent that codes like you.
[Claude Code](https://www.claude.com/product/claude-code) is great.
But if you're a hacker, you'll fall head over heels for [OpenCode](https://github.com/sst/opencode).
@@ -191,8 +196,17 @@ Meet our main agent: Sisyphus (Opus 4.5 High). Below are the tools Sisyphus uses
Just by installing this, you make your agents to work like:
1. While Gemini 3 Pro writes the frontend as a background task, Claude Opus 4.5 handles the backend. Stuck debugging? Call GPT 5.2 for help. When the frontend reports done, verify and ship.
2. Need to look something up? It scours official docs, your entire codebase history, and public GitHub implementations—using not just grep but built-in LSP tools and AST-Grep.
1. Sisyphus doesn't waste time hunting for files himself; he keeps the main agent's context lean. Instead, he fires off background tasks to faster, cheaper models in parallel to map the territory for him.
1. Sisyphus leverages LSP for refactoring; it's more deterministic, safer, and surgical.
1. When the heavy lifting requires a UI touch, Sisyphus delegates frontend tasks directly to Gemini 3 Pro.
1. If Sisyphus gets stuck in a loop or hits a wall, he doesn't keep banging his head—he calls GPT 5.2 for high-IQ strategic backup.
1. Working with a complex open-source framework? Sisyphus spawns subagents to digest the raw source code and documentation in real-time. He operates with total contextual awareness.
1. When Sisyphus touches comments, he either justifies their existence or nukes them. He keeps your codebase clean.
1. Sisyphus is bound by his TODO list. If he doesn't finish what he started, the system forces him back into "bouldering" mode. Your task gets done, period.
1. Honestly, don't even bother reading the docs. Just write your prompt. Include the 'ultrawork' keyword. Sisyphus will analyze the structure, gather the context, dig through external source code, and just keep bouldering until the job is 100% complete.
1. Actually, typing 'ultrawork' is too much effort. Just type 'ulw'. Just ulw. Sip your coffee. Your work is done.
Need to look something up? It scours official docs, your entire codebase history, and public GitHub implementations—using not just grep but built-in LSP tools and AST-Grep.
3. Stop worrying about context management when delegating to LLMs. I've got it covered.
- OhMyOpenCode aggressively leverages multiple agents to lighten the context load.
- **Your agent is now the dev team lead. You're the AI Manager.**
@@ -315,7 +329,7 @@ First, add the opencode-antigravity-auth plugin:
{
"plugin": [
"oh-my-opencode",
"opencode-antigravity-auth@1.1.2"
"opencode-antigravity-auth@1.2.7"
]
}
```
@@ -340,7 +354,7 @@ The `opencode-antigravity-auth` plugin uses different model names than the built
}
```
**Available model names**: `google/gemini-3-pro-high`, `google/gemini-3-pro-medium`, `google/gemini-3-pro-low`, `google/gemini-3-flash`, `google/gemini-3-flash`, `google/gemini-3-flash-lite`, `google/claude-sonnet-4-5`, `google/claude-sonnet-4-5-thinking`, `google/claude-opus-4-5-thinking`, `google/gpt-oss-120b-medium`
**Available model names**: `google/antigravity-gemini-3-pro-high`, `google/antigravity-gemini-3-pro-low`, `google/antigravity-gemini-3-flash`, `google/antigravity-claude-sonnet-4-5`, `google/antigravity-claude-sonnet-4-5-thinking-low`, `google/antigravity-claude-sonnet-4-5-thinking-medium`, `google/antigravity-claude-sonnet-4-5-thinking-high`, `google/antigravity-claude-opus-4-5-thinking-low`, `google/antigravity-claude-opus-4-5-thinking-medium`, `google/antigravity-claude-opus-4-5-thinking-high`, `google/gemini-3-pro-preview`, `google/gemini-3-flash-preview`, `google/gemini-2.5-pro`, `google/gemini-2.5-flash`
Then authenticate:
@@ -363,26 +377,19 @@ First, add the opencode-openai-codex-auth plugin:
{
"plugin": [
"oh-my-opencode",
"opencode-openai-codex-auth@4.1.1"
"opencode-openai-codex-auth@4.3.0"
]
}
```
**Important**: The official npm package currently has a bug causing 400 errors (`"No tool call found for function call output with call_id"`). **Use the hotfix branch** until fixed. Edit `~/.config/opencode/package.json`:
```json
{
"dependencies": {
"opencode-openai-codex-auth": "code-yeongyu/opencode-openai-codex-auth#fix/orphaned-function-call-output-with-tools"
}
}
```
Then run `cd ~/.config/opencode && bun i`. In `opencode.json`, use `"opencode-openai-codex-auth"` without the version suffix.
##### Model Configuration
You'll also need full model settings in `opencode.json`.
Read the [opencode-openai-codex-auth documentation](https://github.com/numman-ali/opencode-openai-codex-auth), copy provider/models config from [`config/full-opencode.json`](https://github.com/numman-ali/opencode-openai-codex-auth/blob/main/config/full-opencode.json), and merge carefully to avoid breaking the user's existing setup.
Read the [opencode-openai-codex-auth documentation](https://github.com/numman-ali/opencode-openai-codex-auth), copy provider/models config from [`config/opencode-modern.json`](https://github.com/numman-ali/opencode-openai-codex-auth/blob/main/config/opencode-modern.json) (for OpenCode v1.0.210+) or [`config/opencode-legacy.json`](https://github.com/numman-ali/opencode-openai-codex-auth/blob/main/config/opencode-legacy.json) (for older versions), and merge carefully to avoid breaking the user's existing setup.
**Available models**: `openai/gpt-5.2`, `openai/gpt-5.2-codex`, `openai/gpt-5.1-codex-max`, `openai/gpt-5.1-codex`, `openai/gpt-5.1-codex-mini`, `openai/gpt-5.1`
**Variants** (OpenCode v1.0.210+): Use `--variant=<none|low|medium|high|xhigh>` for reasoning effort control.
Then authenticate:
@@ -576,7 +583,6 @@ These tools enable agents to reference previous conversations and maintain conti
```
- **Online**: Project rules aren't everything. Built-in MCPs for extended capabilities:
- **context7**: Official documentation lookup
- **websearch_exa**: Real-time web search
- **grep_app**: Ultra-fast code search across public GitHub repos (great for finding implementation examples)
#### Be Multimodal. Save Tokens.
@@ -688,7 +694,7 @@ Disable specific Claude Code compatibility features with the `claude_code` confi
| Toggle | When `false`, stops loading from... | Unaffected |
| ---------- | ------------------------------------------------------------------------------------- | ----------------------------------------------------- |
| `mcp` | `~/.claude/.mcp.json`, `./.mcp.json`, `./.claude/.mcp.json` | Built-in MCP (context7, websearch_exa) |
| `mcp` | `~/.claude/.mcp.json`, `./.mcp.json`, `./.claude/.mcp.json` | Built-in MCP (context7, grep_app) |
| `commands` | `~/.claude/commands/*.md`, `./.claude/commands/*.md` | `~/.config/opencode/command/`, `./.opencode/command/` |
| `skills` | `~/.claude/skills/*/SKILL.md`, `./.claude/skills/*/SKILL.md` | - |
| `agents` | `~/.claude/agents/*.md`, `./.claude/agents/*.md` | Built-in agents (oracle, librarian, etc.) |
@@ -897,7 +903,7 @@ Available built-in skills: `playwright`
When enabled (default), Sisyphus provides a powerful orchestrator with optional specialized agents:
- **Sisyphus**: Primary orchestrator agent (Claude Opus 4.5)
- **Builder-Sisyphus**: OpenCode's default build agent, renamed due to SDK limitations (disabled by default)
- **OpenCode-Builder**: OpenCode's default build agent, renamed due to SDK limitations (disabled by default)
- **Planner-Sisyphus**: OpenCode's default plan agent, renamed due to SDK limitations (enabled by default)
**Configuration Options:**
@@ -913,7 +919,7 @@ When enabled (default), Sisyphus provides a powerful orchestrator with optional
}
```
**Example: Enable Builder-Sisyphus:**
**Example: Enable OpenCode-Builder:**
```json
{
@@ -923,7 +929,7 @@ When enabled (default), Sisyphus provides a powerful orchestrator with optional
}
```
This enables Builder-Sisyphus agent alongside Sisyphus. The default build agent is always demoted to subagent mode when Sisyphus is enabled.
This enables OpenCode-Builder agent alongside Sisyphus. The default build agent is always demoted to subagent mode when Sisyphus is enabled.
**Example: Disable all Sisyphus orchestration:**
@@ -944,7 +950,7 @@ You can also customize Sisyphus agents like other agents:
"model": "anthropic/claude-sonnet-4",
"temperature": 0.3
},
"Builder-Sisyphus": {
"OpenCode-Builder": {
"model": "anthropic/claude-opus-4"
},
"Planner-Sisyphus": {
@@ -957,7 +963,7 @@ You can also customize Sisyphus agents like other agents:
| Option | Default | Description |
| --------------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| `disabled` | `false` | When `true`, disables all Sisyphus orchestration and restores original build/plan as primary. |
| `default_builder_enabled` | `false` | When `true`, enables Builder-Sisyphus agent (same as OpenCode build, renamed due to SDK limitations). Disabled by default. |
| `default_builder_enabled` | `false` | When `true`, enables OpenCode-Builder agent (same as OpenCode build, renamed due to SDK limitations). Disabled by default. |
| `planner_enabled` | `true` | When `true`, enables Planner-Sisyphus agent (same as OpenCode plan, renamed due to SDK limitations). Enabled by default. |
| `replace_plan` | `true` | When `true`, demotes default plan agent to subagent mode. Set to `false` to keep both Planner-Sisyphus and default plan available. |
@@ -977,17 +983,16 @@ Available hooks: `todo-continuation-enforcer`, `context-window-monitor`, `sessio
### MCPs
Context7, Exa, and grep.app MCP enabled by default.
Context7 and grep.app MCP enabled by default.
- **context7**: Fetches up-to-date official documentation for libraries
- **websearch_exa**: Real-time web search powered by Exa AI
- **grep_app**: Ultra-fast code search across millions of public GitHub repositories via [grep.app](https://grep.app)
Don't want them? Disable via `disabled_mcps` in `~/.config/opencode/oh-my-opencode.json` or `.opencode/oh-my-opencode.json`:
```json
{
"disabled_mcps": ["context7", "websearch_exa", "grep_app"]
"disabled_mcps": ["context7", "grep_app"]
}
```
@@ -1096,5 +1101,8 @@ I have no affiliation with any project or model mentioned here. This is purely p
- **Numman Ali** [GitHub](https://github.com/numman-ali) [X](https://x.com/nummanali)
- The first sponsor
- **Aaron Iker** [GitHub](https://github.com/aaroniker) [X](https://x.com/aaroniker)
- **Suyeol Jeon (devxoul)** [GitHub](https://github.com/devxoul)
- The person who launched my career and inspired me deeply on how to build great agentic workflows. I learned so much about designing great systems to build great teams, and those lessons were instrumental in creating this harness.
- **Hyerin Won (devwon)** [GitHub](https://github.com/devwon)
*Special thanks to [@junhoyeo](https://github.com/junhoyeo) for this amazing hero image.*

View File

@@ -2,12 +2,14 @@
>
> *"我致力于引发一场软件革命创造一个AI生成的代码与人类代码无法区分、却能实现更多的世界。我已经在这段旅程中投入了个人时间、热情和资金并将继续这样做。"*
>
> [![The Orchestrator is coming](./.github/assets/orchestrator-sisyphus.png)](https://x.com/justsisyphus/status/2006250634354548963)
> > **编排器即将到来。就在本周。[在X上获取通知](https://x.com/justsisyphus/status/2006250634354548963)**
>
> 与我们同行!
>
> | [<img alt="Discord link" src="https://img.shields.io/discord/1452487457085063218?color=5865F2&label=discord&labelColor=black&logo=discord&logoColor=white&style=flat-square" width="156px" />](https://discord.gg/PWpXmbhF) | 加入我们的 [Discord 社区](https://discord.gg/PWpXmbhF),和贡献者们、`oh-my-opencode` 用户们一起交流。 |
> | [<img alt="Discord link" src="https://img.shields.io/discord/1452487457085063218?color=5865F2&label=discord&labelColor=black&logo=discord&logoColor=white&style=flat-square" width="156px" />](https://discord.gg/PUwSMR9XNk) | 加入我们的 [Discord 社区](https://discord.gg/PUwSMR9XNk),和贡献者们、`oh-my-opencode` 用户们一起交流。 |
> | :-----| :----- |
> | [<img alt="X link" src="https://img.shields.io/badge/Follow-%40justsisyphus-00CED1?style=flat-square&logo=x&labelColor=black" width="156px" />](https://x.com/justsisyphus) | `oh-my-opencode` 的消息之前在我的 X 账号发,但账号被无辜封了,<br />现在 [@justsisyphus](https://x.com/justsisyphus) 替我发更新。 |
> | [<img alt="Sponsor" src="https://img.shields.io/badge/Sponsor-❤-ff69b4?style=flat-square&logo=github-sponsors&labelColor=black" width="156px" />](https://github.com/sponsors/code-yeongyu) | [成为赞助者](https://github.com/sponsors/code-yeongyu),支持 `oh-my-opencode` 的开发。您的支持让这个项目持续成长。 |
<!-- <CENTERED SECTION FOR GITHUB DISPLAY> -->
@@ -48,6 +50,11 @@
> "只用了一天,就用 Oh My Opencode 干掉了 8000 个 eslint 警告" — [Jacob Ferrari](https://x.com/jacobferrari_/status/2003258761952289061)
> "用Ohmyopencode和ralph loop一夜之间把45,000行的tauri应用转成了SaaS网页应用。从面试提示开始让它对问题进行评分和推荐。看着它工作真是太神奇了早上醒来一个基本能用的网站就搞定了" - [James Hargis](https://x.com/hargabyte/status/2007299688261882202)
> "这个周末在用open code、oh my opencode和supermemory做一个我的世界/魂类的怪物项目。"
> "吃完午饭去散步的时候让它加蹲下动画。[视频]" - [MagiMetal](https://x.com/MagiMetal/status/2005374704178373023)
> "你们应该把它合并到核心代码里并聘用他。认真的。这真的、真的、真的很好" — Henning Kilset
> "如果你能说服 @yeon_gyu_kim就雇佣他吧这家伙彻底改变了 opencode" — [mysticaltech](https://x.com/mysticaltech/status/2001858758608376079)
@@ -58,6 +65,8 @@
> "Oh My Opencode 独孤求败,没有对手" — [RyanOnThePath](https://x.com/RyanOnThePath/status/2001438321252118548)
> "Oh My OpenCode Is Actually Insane" - [YouTube - Darren Builds AI](https://www.youtube.com/watch?v=G_Snfh2M41M)
> "西西弗斯这个名字本身不就很美吗?" — Sigrid ([@sigridjin_eth](https://x.com/sigridjin_eth))
---
@@ -72,6 +81,19 @@
- [安装](#安装)
- [人类专用](#人类专用)
- [给 LLM Agent 看的](#给-llm-agent-看的)
- [步骤 0确认订阅情况](#步骤-0确认订阅情况)
- [步骤 1确认 OpenCode 装没装](#步骤-1确认-opencode-装没装)
- [步骤 2跑安装程序](#步骤-2跑安装程序)
- [步骤 3验货](#步骤-3验货)
- [步骤 4搞定认证](#步骤-4搞定认证)
- [4.1 Anthropic (Claude)](#41-anthropic-claude)
- [4.2 Google Gemini (Antigravity OAuth)](#42-google-gemini-antigravity-oauth)
- [4.3 OpenAI (ChatGPT Plus/Pro)](#43-openai-chatgpt-pluspro)
- [⚠️ 注意](#-注意)
- [检查作业](#检查作业)
- [跟用户说"恭喜!🎉"](#跟用户说恭喜)
- [太麻烦了?](#太麻烦了)
- [卸载](#卸载)
- [功能](#功能)
- [Agents你的神队友](#agents你的神队友)
- [后台 Agent像真正的团队一样干活](#后台-agent像真正的团队一样干活)
@@ -169,13 +191,15 @@ oMoMoMoMoMo···
装完之后,你的 Agent 画风是这样的:
1. 后台让 Gemini 3 Pro 写前端Claude Opus 4.5 同时在写后端。调试卡住了?喊 GPT 5.2 过来救场。前端说搞定了,你验货,上线
2. 要查资料它会把官方文档、整个代码历史、GitHub 上的公开实现翻个底朝天——靠的不只是 grep还有内置 LSP 和 AST-Grep
3. 别再操心什么上下文管理了。我包了
- OhMyOpenCode 疯狂压榨多个 Agent把上下文负担降到最低
- **现在的 Agent 才是开发组长,你?你是 AI 经理。**
4. 活儿没干完,绝对不收工
5. 不想研究这么深?没事。输入 "ultrathink" 就完事了
1. Sisyphus 从不把时间浪费在苦哈哈地找文件上,他时刻保持主 Agent 的 Context 精简干练。相反,他会并行启动一堆又快又便宜的背景任务模型,帮他先探路,摸清代码全貌
1. Sisyphus 善用 LSP 进行重构;这种方式更具确定性,更安全,且手术刀般精准
1. 遇到需要 UI 润色的重活儿时Sisyphus 会直接把前端任务甩给 Gemini 3 Pro 处理
1. 如果 Sisyphus 陷入死循环或碰了壁,他绝不会在那儿死磕——他会呼叫 GPT 5.2 提供高智商的战略支援
1. 在处理复杂的开源框架Sisyphus 会派生出 Subagents 实时消化源码和文档。他是在拥有全局 Context 意识的情况下进行操作的。
1. 当 Sisyphus 动到注释时,他要么证明其存在的价值,要么直接干掉。他只负责保持你的代码库干净整洁
1. Sisyphus 受 TODO 列表的绝对约束。如果活儿没干完,系统会强行把他踢回"推石头bouldering"模式。一句话,任务必须搞定
1. 说实话,连文档都别费劲读了。直接写你的 Prompt带上 'ultrawork' 关键字。Sisyphus 会自动分析结构、抓取 Context、深度挖掘外部源码然后就这么一直"推石头",直到任务 100% 彻底完成。
1. 其实,输入 'ultrawork' 都挺费劲的。直接打 'ulw' 就行。就打 ulw。喝你的咖啡去吧活儿已经帮你干完了。
如果你不需要这全套服务,前面说了,挑你喜欢的用。
@@ -283,7 +307,7 @@ opencode auth login
{
"plugin": [
"oh-my-opencode",
"opencode-antigravity-auth@1.1.2"
"opencode-antigravity-auth@1.2.7"
]
}
```
@@ -308,7 +332,7 @@ opencode auth login
}
```
**可用模型名**`google/gemini-3-pro-high`, `google/gemini-3-pro-medium`, `google/gemini-3-pro-low`, `google/gemini-3-flash`, `google/gemini-3-flash`, `google/gemini-3-flash-lite`, `google/claude-sonnet-4-5`, `google/claude-sonnet-4-5-thinking`, `google/claude-opus-4-5-thinking`, `google/gpt-oss-120b-medium`
**可用模型名**`google/antigravity-gemini-3-pro-high`, `google/antigravity-gemini-3-pro-low`, `google/antigravity-gemini-3-flash`, `google/antigravity-claude-sonnet-4-5`, `google/antigravity-claude-sonnet-4-5-thinking-low`, `google/antigravity-claude-sonnet-4-5-thinking-medium`, `google/antigravity-claude-sonnet-4-5-thinking-high`, `google/antigravity-claude-opus-4-5-thinking-low`, `google/antigravity-claude-opus-4-5-thinking-medium`, `google/antigravity-claude-opus-4-5-thinking-high`, `google/gemini-3-pro-preview`, `google/gemini-3-flash-preview`, `google/gemini-2.5-pro`, `google/gemini-2.5-flash`
然后认证:
@@ -331,26 +355,19 @@ opencode auth login
{
"plugin": [
"oh-my-opencode",
"opencode-openai-codex-auth@4.1.1"
"opencode-openai-codex-auth@4.3.0"
]
}
```
**重要**:现在官方 npm 包有个 bug 会报 400 错(`"No tool call found for function call output with call_id"`)。修复版出来前,**一定要用 hotfix 分支**。改一下 `~/.config/opencode/package.json`
```json
{
"dependencies": {
"opencode-openai-codex-auth": "code-yeongyu/opencode-openai-codex-auth#fix/orphaned-function-call-output-with-tools"
}
}
```
然后跑 `cd ~/.config/opencode && bun i`。在 `opencode.json` 里用 `"opencode-openai-codex-auth"`(别带版本号)。
##### 模型配置
要在 `opencode.json` 里配完整的模型设置。
去读 [opencode-openai-codex-auth 文档](https://github.com/numman-ali/opencode-openai-codex-auth),从 [`config/full-opencode.json`](https://github.com/numman-ali/opencode-openai-codex-auth/blob/main/config/full-opencode.json) 抄 provider/models 配置,动脑子合并,别搞炸了。
去读 [opencode-openai-codex-auth 文档](https://github.com/numman-ali/opencode-openai-codex-auth),从 [`config/opencode-modern.json`](https://github.com/numman-ali/opencode-openai-codex-auth/blob/main/config/opencode-modern.json)OpenCode v1.0.210+)或 [`config/opencode-legacy.json`](https://github.com/numman-ali/opencode-openai-codex-auth/blob/main/config/opencode-legacy.json)(旧版本)抄 provider/models 配置,动脑子合并,别搞炸了。
**可用模型**`openai/gpt-5.2`, `openai/gpt-5.2-codex`, `openai/gpt-5.1-codex-max`, `openai/gpt-5.1-codex`, `openai/gpt-5.1-codex-mini`, `openai/gpt-5.1`
**Variants**OpenCode v1.0.210+):用 `--variant=<none|low|medium|high|xhigh>` 控制推理强度。
然后认证:
@@ -545,7 +562,6 @@ OhMyOpenCode 让这些成为可能。
```
- **在线资源**:项目里的规矩不够用?内置 MCP 来凑:
- **context7**:查最新的官方文档
- **websearch_exa**Exa AI 实时搜网
- **grep_app**:用 [grep.app](https://grep.app) 在几百万个 GitHub 仓库里秒搜代码(找抄作业的例子神器)
#### 多模态全开Token 省着用
@@ -637,7 +653,7 @@ Oh My OpenCode 会扫这些地方:
| 开关 | 设为 `false` 就停用的路径 | 不受影响的 |
| ---------- | ------------------------------------------------------------------------------------- | ----------------------------------------------------- |
| `mcp` | `~/.claude/.mcp.json`, `./.mcp.json`, `./.claude/.mcp.json` | 内置 MCPcontext7、websearch_exa |
| `mcp` | `~/.claude/.mcp.json`, `./.mcp.json`, `./.claude/.mcp.json` | 内置 MCPcontext7、grep_app |
| `commands` | `~/.claude/commands/*.md`, `./.claude/commands/*.md` | `~/.config/opencode/command/`, `./.opencode/command/` |
| `skills` | `~/.claude/skills/*/SKILL.md`, `./.claude/skills/*/SKILL.md` | - |
| `agents` | `~/.claude/agents/*.md`, `./.claude/agents/*.md` | 内置 Agentoracle、librarian 等) |
@@ -830,7 +846,7 @@ Agent 爽了,你自然也爽。但我还想直接让你爽。
默认开启。Sisyphus 提供一个强力的编排器,带可选的专门 Agent
- **Sisyphus**:主编排 AgentClaude Opus 4.5
- **Builder-Sisyphus**OpenCode 默认构建 Agent因 SDK 限制仅改名,默认禁用)
- **OpenCode-Builder**OpenCode 默认构建 Agent因 SDK 限制仅改名,默认禁用)
- **Planner-Sisyphus**OpenCode 默认计划 Agent因 SDK 限制仅改名,默认启用)
**配置选项:**
@@ -846,7 +862,7 @@ Agent 爽了,你自然也爽。但我还想直接让你爽。
}
```
**示例:启用 Builder-Sisyphus**
**示例:启用 OpenCode-Builder**
```json
{
@@ -856,7 +872,7 @@ Agent 爽了,你自然也爽。但我还想直接让你爽。
}
```
这样能和 Sisyphus 一起启用 Builder-Sisyphus Agent。启用 Sisyphus 后,默认构建 Agent 总会降级为子 Agent 模式。
这样能和 Sisyphus 一起启用 OpenCode-Builder Agent。启用 Sisyphus 后,默认构建 Agent 总会降级为子 Agent 模式。
**示例:禁用所有 Sisyphus 编排:**
@@ -877,7 +893,7 @@ Sisyphus Agent 也能自定义:
"model": "anthropic/claude-sonnet-4",
"temperature": 0.3
},
"Builder-Sisyphus": {
"OpenCode-Builder": {
"model": "anthropic/claude-opus-4"
},
"Planner-Sisyphus": {
@@ -890,7 +906,7 @@ Sisyphus Agent 也能自定义:
| 选项 | 默认值 | 说明 |
| --------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| `disabled` | `false` | 设为 `true` 就禁用所有 Sisyphus 编排,恢复原来的 build/plan。 |
| `default_builder_enabled` | `false` | 设为 `true` 就启用 Builder-Sisyphus Agent与 OpenCode build 相同,因 SDK 限制仅改名)。默认禁用。 |
| `default_builder_enabled` | `false` | 设为 `true` 就启用 OpenCode-Builder Agent与 OpenCode build 相同,因 SDK 限制仅改名)。默认禁用。 |
| `planner_enabled` | `true` | 设为 `true` 就启用 Planner-Sisyphus Agent与 OpenCode plan 相同,因 SDK 限制仅改名)。默认启用。 |
| `replace_plan` | `true` | 设为 `true` 就把默认计划 Agent 降级为子 Agent 模式。设为 `false` 可以同时保留 Planner-Sisyphus 和默认计划。 |
@@ -910,17 +926,16 @@ Sisyphus Agent 也能自定义:
### MCPs
默认送你 Context7、Exa 和 grep.app MCP。
默认送你 Context7 和 grep.app MCP。
- **context7**:查最新的官方文档
- **websearch_exa**Exa AI 实时搜网
- **grep_app**[grep.app](https://grep.app) 极速搜 GitHub 代码
不想要?在 `~/.config/opencode/oh-my-opencode.json` 或 `.opencode/oh-my-opencode.json` 的 `disabled_mcps` 里关掉:
```json
{
"disabled_mcps": ["context7", "websearch_exa", "grep_app"]
"disabled_mcps": ["context7", "grep_app"]
}
```
@@ -1028,5 +1043,8 @@ Oh My OpenCode 送你重构工具(重命名、代码操作)。
- **Numman Ali** [GitHub](https://github.com/numman-ali) [X](https://x.com/nummanali)
- 第一位赞助者
- **Aaron Iker** [GitHub](https://github.com/aaroniker) [X](https://x.com/aaroniker)
- **Suyeol Jeon (devxoul)** [GitHub](https://github.com/devxoul)
- 他是开启我职业生涯的人也是在如何构建优秀的代理工作流方面给了我很多启发的人。我从他那里学到了很多关于如何设计好的系统来打造优秀团队的知识这些经验对开发这个harness起到了巨大的帮助作用。
- **Hyerin Won (devwon)** [GitHub](https://github.com/devwon)
*感谢 [@junhoyeo](https://github.com/junhoyeo) 制作了这张超帅的 hero 图。*

View File

@@ -12,11 +12,7 @@
"type": "array",
"items": {
"type": "string",
"enum": [
"websearch_exa",
"context7",
"grep_app"
]
"minLength": 1
}
},
"disabled_agents": {
@@ -74,7 +70,8 @@
"preemptive-compaction",
"compaction-context-injector",
"claude-code-hooks",
"auto-slash-command"
"auto-slash-command",
"edit-error-recovery"
]
}
},

View File

@@ -11,8 +11,8 @@
"@code-yeongyu/comment-checker": "^0.6.1",
"@modelcontextprotocol/sdk": "^1.25.1",
"@openauthjs/openauth": "^0.4.3",
"@opencode-ai/plugin": "^1.0.162",
"@opencode-ai/sdk": "^1.0.162",
"@opencode-ai/plugin": "^1.1.1",
"@opencode-ai/sdk": "^1.1.1",
"commander": "^14.0.2",
"hono": "^4.10.4",
"js-yaml": "^4.1.1",
@@ -84,9 +84,9 @@
"@openauthjs/openauth": ["@openauthjs/openauth@0.4.3", "", { "dependencies": { "@standard-schema/spec": "1.0.0-beta.3", "aws4fetch": "1.0.20", "jose": "5.9.6" }, "peerDependencies": { "arctic": "^2.2.2", "hono": "^4.0.0" } }, "sha512-RlnjqvHzqcbFVymEwhlUEuac4utA5h4nhSK/i2szZuQmxTIqbGUxZ+nM+avM+VV4Ing+/ZaNLKILoXS3yrkOOw=="],
"@opencode-ai/plugin": ["@opencode-ai/plugin@1.0.162", "", { "dependencies": { "@opencode-ai/sdk": "1.0.162", "zod": "4.1.8" } }, "sha512-tiJw7SCfSlG/3tY2O0J2UT06OLuazOzsv1zYlFbLxLy/EVedtW0pzxYalO20a4e//vInvOXFkhd2jLyB5vNEVA=="],
"@opencode-ai/plugin": ["@opencode-ai/plugin@1.1.1", "", { "dependencies": { "@opencode-ai/sdk": "1.1.1", "zod": "4.1.8" } }, "sha512-OZGvpDal8YsSo6dnatHfwviSToGZ6mJJyEKZGxUyWDuGCP7VhcoPkoM16ktl7TCVHkDK+TdwY9tKzkzFqQNc5w=="],
"@opencode-ai/sdk": ["@opencode-ai/sdk@1.0.162", "", {}, "sha512-+XqRErBUt9eb1m3i/7WkZc/QCKCCjTaGV3MvhLhs/CUwbUn767D/ugzcG/i2ec8j/4nQmjJbjPDRmrQfvF1Qjw=="],
"@opencode-ai/sdk": ["@opencode-ai/sdk@1.1.1", "", {}, "sha512-PfXujMrHGeMnpS8Gd2BXSY+zZajlztcAvcokf06NtAhd0Mbo/hCLXgW0NBCQ+3FX3e/G2PNwz2DqMdtzyIZaCQ=="],
"@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode",
"version": "2.12.0",
"version": "2.13.1",
"description": "OpenCode plugin - custom agents (oracle, librarian) and enhanced features",
"main": "dist/index.js",
"types": "dist/index.d.ts",
@@ -56,8 +56,8 @@
"@code-yeongyu/comment-checker": "^0.6.1",
"@modelcontextprotocol/sdk": "^1.25.1",
"@openauthjs/openauth": "^0.4.3",
"@opencode-ai/plugin": "^1.0.162",
"@opencode-ai/sdk": "^1.0.162",
"@opencode-ai/plugin": "^1.1.1",
"@opencode-ai/sdk": "^1.1.1",
"commander": "^14.0.2",
"hono": "^4.10.4",
"js-yaml": "^4.1.1",

View File

@@ -143,6 +143,86 @@
"created_at": "2025-12-31T20:40:20Z",
"repoId": 1108837393,
"pullRequestNo": 388
},
{
"name": "changeroa",
"id": 65930387,
"comment_id": 3706697910,
"created_at": "2026-01-03T04:51:11Z",
"repoId": 1108837393,
"pullRequestNo": 446
},
{
"name": "hqone",
"id": 13660872,
"comment_id": 3707019551,
"created_at": "2026-01-03T12:21:52Z",
"repoId": 1108837393,
"pullRequestNo": 451
},
{
"name": "fparrav",
"id": 9319430,
"comment_id": 3707456044,
"created_at": "2026-01-03T23:51:28Z",
"repoId": 1108837393,
"pullRequestNo": 469
},
{
"name": "ChiR24",
"id": 125826529,
"comment_id": 3707776762,
"created_at": "2026-01-04T06:14:36Z",
"repoId": 1108837393,
"pullRequestNo": 473
},
{
"name": "geq1fan",
"id": 29982379,
"comment_id": 3708136393,
"created_at": "2026-01-04T14:31:14Z",
"repoId": 1108837393,
"pullRequestNo": 481
},
{
"name": "RhysSullivan",
"id": 39114868,
"comment_id": 3708266434,
"created_at": "2026-01-04T17:19:44Z",
"repoId": 1108837393,
"pullRequestNo": 482
},
{
"name": "Skyline-23",
"id": 62983047,
"comment_id": 3708282461,
"created_at": "2026-01-04T17:42:02Z",
"repoId": 1108837393,
"pullRequestNo": 484
},
{
"name": "popododo0720",
"id": 78542988,
"comment_id": 3708870772,
"created_at": "2026-01-05T04:07:35Z",
"repoId": 1108837393,
"pullRequestNo": 477
},
{
"name": "raydocs",
"id": 139067258,
"comment_id": 3709269581,
"created_at": "2026-01-05T07:39:43Z",
"repoId": 1108837393,
"pullRequestNo": 499
},
{
"name": "luosky",
"id": 307601,
"comment_id": 3710103143,
"created_at": "2026-01-05T11:46:40Z",
"repoId": 1108837393,
"pullRequestNo": 512
}
]
}

View File

@@ -1,5 +1,6 @@
import type { AgentConfig } from "@opencode-ai/sdk"
import type { AgentPromptMetadata } from "./types"
import { createAgentToolRestrictions } from "../shared/permission-compat"
const DEFAULT_MODEL = "google/gemini-3-flash-preview"
@@ -15,12 +16,14 @@ export const DOCUMENT_WRITER_PROMPT_METADATA: AgentPromptMetadata = {
export function createDocumentWriterAgent(
model: string = DEFAULT_MODEL
): AgentConfig {
const restrictions = createAgentToolRestrictions(["background_task"])
return {
description:
"A technical writer who crafts clear, comprehensive documentation. Specializes in README files, API docs, architecture docs, and user guides. MUST BE USED when executing documentation tasks from ai-todo list plans.",
mode: "subagent" as const,
model,
tools: { background_task: false },
...restrictions,
prompt: `<role>
You are a TECHNICAL WRITER with deep engineering background who transforms complex codebases into crystal-clear documentation. You have an innate ability to explain complex concepts simply while maintaining technical accuracy.

View File

@@ -1,5 +1,6 @@
import type { AgentConfig } from "@opencode-ai/sdk"
import type { AgentPromptMetadata } from "./types"
import { createAgentToolRestrictions } from "../shared/permission-compat"
const DEFAULT_MODEL = "opencode/grok-code"
@@ -24,13 +25,19 @@ export const EXPLORE_PROMPT_METADATA: AgentPromptMetadata = {
}
export function createExploreAgent(model: string = DEFAULT_MODEL): AgentConfig {
const restrictions = createAgentToolRestrictions([
"write",
"edit",
"background_task",
])
return {
description:
'Contextual grep for codebases. Answers "Where is X?", "Which file has Y?", "Find the code that does Z". Fire multiple in parallel for broad searches. Specify thoroughness: "quick" for basic, "medium" for moderate, "very thorough" for comprehensive analysis.',
mode: "subagent" as const,
model,
temperature: 0.1,
tools: { write: false, edit: false, background_task: false },
...restrictions,
prompt: `You are a codebase search specialist. Your job: find files and code, return actionable results.
## Your Mission

View File

@@ -1,5 +1,6 @@
import type { AgentConfig } from "@opencode-ai/sdk"
import type { AgentPromptMetadata } from "./types"
import { createAgentToolRestrictions } from "../shared/permission-compat"
const DEFAULT_MODEL = "google/gemini-3-pro-preview"
@@ -21,12 +22,14 @@ export const FRONTEND_PROMPT_METADATA: AgentPromptMetadata = {
export function createFrontendUiUxEngineerAgent(
model: string = DEFAULT_MODEL
): AgentConfig {
const restrictions = createAgentToolRestrictions(["background_task"])
return {
description:
"A designer-turned-developer who crafts stunning UI/UX even without design mockups. Code may be a bit messy, but the visual output is always fire.",
mode: "subagent" as const,
model,
tools: { background_task: false },
...restrictions,
prompt: `# Role: Designer-Turned-Developer
You are a designer who learned to code. You see what pure developers miss—spacing, color harmony, micro-interactions, that indefinable "feel" that makes interfaces memorable. Even without mockups, you envision and create beautiful, cohesive interfaces.

View File

@@ -1,5 +1,6 @@
import type { AgentConfig } from "@opencode-ai/sdk"
import type { AgentPromptMetadata } from "./types"
import { createAgentToolRestrictions } from "../shared/permission-compat"
const DEFAULT_MODEL = "anthropic/claude-sonnet-4-5"
@@ -21,13 +22,19 @@ export const LIBRARIAN_PROMPT_METADATA: AgentPromptMetadata = {
}
export function createLibrarianAgent(model: string = DEFAULT_MODEL): AgentConfig {
const restrictions = createAgentToolRestrictions([
"write",
"edit",
"background_task",
])
return {
description:
"Specialized codebase understanding agent for multi-repository analysis, searching remote codebases, retrieving official documentation, and finding implementation examples using GitHub CLI, Context7, and Web Search. MUST BE USED when users ask to look up code in remote repositories, explain library internals, or find usage examples in open source.",
mode: "subagent" as const,
model,
temperature: 0.1,
tools: { write: false, edit: false, background_task: false },
...restrictions,
prompt: `# THE LIBRARIAN
You are **THE LIBRARIAN**, a specialized open-source codebase understanding agent.
@@ -50,10 +57,10 @@ Classify EVERY request into one of these categories before taking action:
| Type | Trigger Examples | Tools |
|------|------------------|-------|
| **TYPE A: CONCEPTUAL** | "How do I use X?", "Best practice for Y?" | context7 + websearch_exa (parallel) |
| **TYPE A: CONCEPTUAL** | "How do I use X?", "Best practice for Y?" | context7 + web search (if available) in parallel |
| **TYPE B: IMPLEMENTATION** | "How does X implement Y?", "Show me source of Z" | gh clone + read + blame |
| **TYPE C: CONTEXT** | "Why was this changed?", "History of X?" | gh issues/prs + git log/blame |
| **TYPE D: COMPREHENSIVE** | Complex/ambiguous requests | ALL tools in parallel |
| **TYPE C: CONTEXT** | "Why was this changed?", "What's the history?", "Related issues/PRs?" | gh issues/prs + git log/blame |
| **TYPE D: COMPREHENSIVE** | Complex/ambiguous requests | ALL available tools in parallel |
---
@@ -62,12 +69,12 @@ Classify EVERY request into one of these categories before taking action:
### TYPE A: CONCEPTUAL QUESTION
**Trigger**: "How do I...", "What is...", "Best practice for...", rough/general questions
**Execute in parallel (3+ calls)**:
**Execute in parallel (2+ calls)**:
\`\`\`
Tool 1: context7_resolve-library-id("library-name")
→ then context7_get-library-docs(id, topic: "specific-topic")
Tool 2: websearch_exa_web_search_exa("library-name topic 2025")
Tool 3: grep_app_searchGitHub(query: "usage pattern", language: ["TypeScript"])
Tool 2: grep_app_searchGitHub(query: "usage pattern", language: ["TypeScript"])
Tool 3 (optional): If web search is available, search "library-name topic 2025"
\`\`\`
**Output**: Summarize findings with links to official docs and real-world examples.
@@ -129,21 +136,22 @@ gh api repos/owner/repo/pulls/<number>/files
### TYPE D: COMPREHENSIVE RESEARCH
**Trigger**: Complex questions, ambiguous requests, "deep dive into..."
**Execute ALL in parallel (6+ calls)**:
**Execute ALL available tools in parallel (5+ calls)**:
\`\`\`
// Documentation & Web
// Documentation
Tool 1: context7_resolve-library-id → context7_get-library-docs
Tool 2: websearch_exa_web_search_exa("topic recent updates")
// Code Search
Tool 3: grep_app_searchGitHub(query: "pattern1", language: [...])
Tool 4: grep_app_searchGitHub(query: "pattern2", useRegexp: true)
Tool 2: grep_app_searchGitHub(query: "pattern1", language: [...])
Tool 3: grep_app_searchGitHub(query: "pattern2", useRegexp: true)
// Source Analysis
Tool 5: gh repo clone owner/repo \${TMPDIR:-/tmp}/repo -- --depth 1
Tool 4: gh repo clone owner/repo \${TMPDIR:-/tmp}/repo -- --depth 1
// Context
Tool 6: gh search issues "topic" --repo owner/repo
Tool 5: gh search issues "topic" --repo owner/repo
// Optional: If web search is available, search for recent updates
\`\`\`
---
@@ -189,7 +197,6 @@ https://github.com/tanstack/query/blob/abc123def/packages/react-query/src/useQue
| Purpose | Tool | Command/Usage |
|---------|------|---------------|
| **Official Docs** | context7 | \`context7_resolve-library-id\`\`context7_get-library-docs\` |
| **Latest Info** | websearch_exa | \`websearch_exa_web_search_exa("query 2025")\` |
| **Fast Code Search** | grep_app | \`grep_app_searchGitHub(query, language, useRegexp)\` |
| **Deep Code Search** | gh CLI | \`gh search code "query" --repo owner/repo\` |
| **Clone Repo** | gh CLI | \`gh repo clone owner/repo \${TMPDIR:-/tmp}/name -- --depth 1\` |
@@ -198,6 +205,7 @@ https://github.com/tanstack/query/blob/abc123def/packages/react-query/src/useQue
| **Release Info** | gh CLI | \`gh api repos/owner/repo/releases/latest\` |
| **Git History** | git | \`git log\`, \`git blame\`, \`git show\` |
| **Read URL** | webfetch | \`webfetch(url)\` for blog posts, SO threads |
| **Web Search** | (if available) | Use any available web search tool for latest info |
### Temp Directory

View File

@@ -1,5 +1,6 @@
import type { AgentConfig } from "@opencode-ai/sdk"
import type { AgentPromptMetadata } from "./types"
import { createAgentToolRestrictions } from "../shared/permission-compat"
const DEFAULT_MODEL = "google/gemini-3-flash"
@@ -13,13 +14,20 @@ export const MULTIMODAL_LOOKER_PROMPT_METADATA: AgentPromptMetadata = {
export function createMultimodalLookerAgent(
model: string = DEFAULT_MODEL
): AgentConfig {
const restrictions = createAgentToolRestrictions([
"write",
"edit",
"bash",
"background_task",
])
return {
description:
"Analyze media files (PDFs, images, diagrams) that require interpretation beyond raw text. Extracts specific information or summaries from documents, describes visual content. Use when you need analyzed/extracted data rather than literal file contents.",
mode: "subagent" as const,
model,
temperature: 0.1,
tools: { write: false, edit: false, bash: false, background_task: false },
...restrictions,
prompt: `You interpret media files that cannot be read as plain text.
Your job: examine the attached file and extract ONLY what was requested.

View File

@@ -1,6 +1,7 @@
import type { AgentConfig } from "@opencode-ai/sdk"
import type { AgentPromptMetadata } from "./types"
import { isGptModel } from "./types"
import { createAgentToolRestrictions } from "../shared/permission-compat"
const DEFAULT_MODEL = "openai/gpt-5.2"
@@ -97,21 +98,28 @@ Organize your final answer in three tiers:
Your response goes directly to the user with no intermediate processing. Make your final message self-contained: a clear recommendation they can act on immediately, covering both what to do and why.`
export function createOracleAgent(model: string = DEFAULT_MODEL): AgentConfig {
const restrictions = createAgentToolRestrictions([
"write",
"edit",
"task",
"background_task",
])
const base = {
description:
"Expert technical advisor with deep reasoning for architecture decisions, code analysis, and engineering guidance.",
mode: "subagent" as const,
model,
temperature: 0.1,
tools: { write: false, edit: false, task: false, background_task: false },
...restrictions,
prompt: ORACLE_SYSTEM_PROMPT,
}
} as AgentConfig
if (isGptModel(model)) {
return { ...base, reasoningEffort: "medium", textVerbosity: "high" }
return { ...base, reasoningEffort: "medium", textVerbosity: "high" } as AgentConfig
}
return { ...base, thinking: { type: "enabled", budgetTokens: 32000 } }
return { ...base, thinking: { type: "enabled", budgetTokens: 32000 } } as AgentConfig
}
export const oracleAgent = createOracleAgent()

View File

@@ -307,3 +307,26 @@ export function buildAntiPatternsSection(agents: AvailableAgent[]): string {
|----------|-----------|
${patterns.join("\n")}`
}
export function buildUltraworkAgentSection(agents: AvailableAgent[]): string {
if (agents.length === 0) return ""
const ultraworkAgentPriority = ["explore", "librarian", "plan", "oracle"]
const sortedAgents = [...agents].sort((a, b) => {
const aIdx = ultraworkAgentPriority.indexOf(a.name)
const bIdx = ultraworkAgentPriority.indexOf(b.name)
if (aIdx === -1 && bIdx === -1) return 0
if (aIdx === -1) return 1
if (bIdx === -1) return -1
return aIdx - bIdx
})
const lines: string[] = []
for (const agent of sortedAgents) {
const shortDesc = agent.description.split(".")[0] || agent.description
const suffix = (agent.name === "explore" || agent.name === "librarian") ? " (multiple)" : ""
lines.push(`- **${agent.name}${suffix}**: ${shortDesc}`)
}
return lines.join("\n")
}

View File

@@ -11,11 +11,9 @@ describe("config-manager ANTIGRAVITY_PROVIDER_CONFIG", () => {
expect(models).toBeTruthy()
const required = [
"gemini-3-pro-high",
"gemini-3-pro-medium",
"gemini-3-pro-low",
"gemini-3-flash",
"gemini-3-flash-lite",
"antigravity-gemini-3-pro-high",
"antigravity-gemini-3-pro-low",
"antigravity-gemini-3-flash",
]
for (const key of required) {

View File

@@ -1,18 +1,59 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync, statSync } from "node:fs"
import { homedir } from "node:os"
import { join } from "node:path"
import { parseJsonc } from "../shared"
import {
parseJsonc,
getOpenCodeConfigPaths,
type OpenCodeBinaryType,
type OpenCodeConfigPaths,
} from "../shared"
import type { ConfigMergeResult, DetectedConfig, InstallConfig } from "./types"
const OPENCODE_CONFIG_DIR = join(homedir(), ".config", "opencode")
const OPENCODE_JSON = join(OPENCODE_CONFIG_DIR, "opencode.json")
const OPENCODE_JSONC = join(OPENCODE_CONFIG_DIR, "opencode.jsonc")
const OPENCODE_PACKAGE_JSON = join(OPENCODE_CONFIG_DIR, "package.json")
const OMO_CONFIG = join(OPENCODE_CONFIG_DIR, "oh-my-opencode.json")
const OPENCODE_BINARIES = ["opencode", "opencode-desktop"] as const
const CHATGPT_HOTFIX_REPO = "code-yeongyu/opencode-openai-codex-auth#fix/orphaned-function-call-output-with-tools"
interface ConfigContext {
binary: OpenCodeBinaryType
version: string | null
paths: OpenCodeConfigPaths
}
let configContext: ConfigContext | null = null
export function initConfigContext(binary: OpenCodeBinaryType, version: string | null): void {
const paths = getOpenCodeConfigPaths({ binary, version })
configContext = { binary, version, paths }
}
export function getConfigContext(): ConfigContext {
if (!configContext) {
const paths = getOpenCodeConfigPaths({ binary: "opencode", version: null })
configContext = { binary: "opencode", version: null, paths }
}
return configContext
}
export function resetConfigContext(): void {
configContext = null
}
function getConfigDir(): string {
return getConfigContext().paths.configDir
}
function getConfigJson(): string {
return getConfigContext().paths.configJson
}
function getConfigJsonc(): string {
return getConfigContext().paths.configJsonc
}
function getPackageJson(): string {
return getConfigContext().paths.packageJson
}
function getOmoConfig(): string {
return getConfigContext().paths.omoConfig
}
const BUN_INSTALL_TIMEOUT_SECONDS = 60
const BUN_INSTALL_TIMEOUT_MS = BUN_INSTALL_TIMEOUT_SECONDS * 1000
@@ -76,13 +117,16 @@ interface OpenCodeConfig {
}
export function detectConfigFormat(): { format: ConfigFormat; path: string } {
if (existsSync(OPENCODE_JSONC)) {
return { format: "jsonc", path: OPENCODE_JSONC }
const configJsonc = getConfigJsonc()
const configJson = getConfigJson()
if (existsSync(configJsonc)) {
return { format: "jsonc", path: configJsonc }
}
if (existsSync(OPENCODE_JSON)) {
return { format: "json", path: OPENCODE_JSON }
if (existsSync(configJson)) {
return { format: "json", path: configJson }
}
return { format: "none", path: OPENCODE_JSON }
return { format: "none", path: configJson }
}
interface ParseConfigResult {
@@ -129,8 +173,9 @@ function parseConfigWithError(path: string): ParseConfigResult {
}
function ensureConfigDir(): void {
if (!existsSync(OPENCODE_CONFIG_DIR)) {
mkdirSync(OPENCODE_CONFIG_DIR, { recursive: true })
const configDir = getConfigDir()
if (!existsSync(configDir)) {
mkdirSync(configDir, { recursive: true })
}
}
@@ -138,7 +183,7 @@ export function addPluginToOpenCodeConfig(): ConfigMergeResult {
try {
ensureConfigDir()
} catch (err) {
return { success: false, configPath: OPENCODE_CONFIG_DIR, error: formatErrorWithSuggestion(err, "create config directory") }
return { success: false, configPath: getConfigDir(), error: formatErrorWithSuggestion(err, "create config directory") }
}
const { format, path } = detectConfigFormat()
@@ -229,31 +274,33 @@ export function generateOmoConfig(installConfig: InstallConfig): Record<string,
const agents: Record<string, Record<string, unknown>> = {}
if (!installConfig.hasClaude) {
agents["Sisyphus"] = { model: "opencode/big-pickle" }
agents["Sisyphus"] = { model: "opencode/glm-4.7-free" }
}
agents["librarian"] = { model: "opencode/glm-4.7-free" }
// Gemini models use `antigravity-` prefix for explicit Antigravity quota routing
// @see ANTIGRAVITY_PROVIDER_CONFIG comments for rationale
if (installConfig.hasGemini) {
agents["librarian"] = { model: "google/gemini-3-flash" }
agents["explore"] = { model: "google/gemini-3-flash" }
agents["explore"] = { model: "google/antigravity-gemini-3-flash" }
} else if (installConfig.hasClaude && installConfig.isMax20) {
agents["explore"] = { model: "anthropic/claude-haiku-4-5" }
} else {
agents["librarian"] = { model: "opencode/big-pickle" }
agents["explore"] = { model: "opencode/big-pickle" }
agents["explore"] = { model: "opencode/glm-4.7-free" }
}
if (!installConfig.hasChatGPT) {
agents["oracle"] = {
model: installConfig.hasClaude ? "anthropic/claude-opus-4-5" : "opencode/big-pickle",
model: installConfig.hasClaude ? "anthropic/claude-opus-4-5" : "opencode/glm-4.7-free",
}
}
if (installConfig.hasGemini) {
agents["frontend-ui-ux-engineer"] = { model: "google/gemini-3-pro-high" }
agents["document-writer"] = { model: "google/gemini-3-flash" }
agents["multimodal-looker"] = { model: "google/gemini-3-flash" }
agents["frontend-ui-ux-engineer"] = { model: "google/antigravity-gemini-3-pro-high" }
agents["document-writer"] = { model: "google/antigravity-gemini-3-flash" }
agents["multimodal-looker"] = { model: "google/antigravity-gemini-3-flash" }
} else {
const fallbackModel = installConfig.hasClaude ? "anthropic/claude-opus-4-5" : "opencode/big-pickle"
const fallbackModel = installConfig.hasClaude ? "anthropic/claude-opus-4-5" : "opencode/glm-4.7-free"
agents["frontend-ui-ux-engineer"] = { model: fallbackModel }
agents["document-writer"] = { model: fallbackModel }
agents["multimodal-looker"] = { model: fallbackModel }
@@ -270,50 +317,52 @@ export function writeOmoConfig(installConfig: InstallConfig): ConfigMergeResult
try {
ensureConfigDir()
} catch (err) {
return { success: false, configPath: OPENCODE_CONFIG_DIR, error: formatErrorWithSuggestion(err, "create config directory") }
return { success: false, configPath: getConfigDir(), error: formatErrorWithSuggestion(err, "create config directory") }
}
const omoConfigPath = getOmoConfig()
try {
const newConfig = generateOmoConfig(installConfig)
if (existsSync(OMO_CONFIG)) {
if (existsSync(omoConfigPath)) {
try {
const stat = statSync(OMO_CONFIG)
const content = readFileSync(OMO_CONFIG, "utf-8")
const stat = statSync(omoConfigPath)
const content = readFileSync(omoConfigPath, "utf-8")
if (stat.size === 0 || isEmptyOrWhitespace(content)) {
writeFileSync(OMO_CONFIG, JSON.stringify(newConfig, null, 2) + "\n")
return { success: true, configPath: OMO_CONFIG }
writeFileSync(omoConfigPath, JSON.stringify(newConfig, null, 2) + "\n")
return { success: true, configPath: omoConfigPath }
}
const existing = parseJsonc<Record<string, unknown>>(content)
if (!existing || typeof existing !== "object" || Array.isArray(existing)) {
writeFileSync(OMO_CONFIG, JSON.stringify(newConfig, null, 2) + "\n")
return { success: true, configPath: OMO_CONFIG }
writeFileSync(omoConfigPath, JSON.stringify(newConfig, null, 2) + "\n")
return { success: true, configPath: omoConfigPath }
}
delete existing.agents
const merged = deepMerge(existing, newConfig)
writeFileSync(OMO_CONFIG, JSON.stringify(merged, null, 2) + "\n")
writeFileSync(omoConfigPath, JSON.stringify(merged, null, 2) + "\n")
} catch (parseErr) {
if (parseErr instanceof SyntaxError) {
writeFileSync(OMO_CONFIG, JSON.stringify(newConfig, null, 2) + "\n")
return { success: true, configPath: OMO_CONFIG }
writeFileSync(omoConfigPath, JSON.stringify(newConfig, null, 2) + "\n")
return { success: true, configPath: omoConfigPath }
}
throw parseErr
}
} else {
writeFileSync(OMO_CONFIG, JSON.stringify(newConfig, null, 2) + "\n")
writeFileSync(omoConfigPath, JSON.stringify(newConfig, null, 2) + "\n")
}
return { success: true, configPath: OMO_CONFIG }
return { success: true, configPath: omoConfigPath }
} catch (err) {
return { success: false, configPath: OMO_CONFIG, error: formatErrorWithSuggestion(err, "write oh-my-opencode config") }
return { success: false, configPath: omoConfigPath, error: formatErrorWithSuggestion(err, "write oh-my-opencode config") }
}
}
interface OpenCodeBinaryResult {
binary: string
binary: OpenCodeBinaryType
version: string
}
@@ -327,7 +376,9 @@ async function findOpenCodeBinaryWithVersion(): Promise<OpenCodeBinaryResult | n
const output = await new Response(proc.stdout).text()
await proc.exited
if (proc.exitCode === 0) {
return { binary, version: output.trim() }
const version = output.trim()
initConfigContext(binary, version)
return { binary, version }
}
} catch {
continue
@@ -350,7 +401,7 @@ export async function addAuthPlugins(config: InstallConfig): Promise<ConfigMerge
try {
ensureConfigDir()
} catch (err) {
return { success: false, configPath: OPENCODE_CONFIG_DIR, error: formatErrorWithSuggestion(err, "create config directory") }
return { success: false, configPath: getConfigDir(), error: formatErrorWithSuggestion(err, "create config directory") }
}
const { format, path } = detectConfigFormat()
@@ -390,46 +441,6 @@ export async function addAuthPlugins(config: InstallConfig): Promise<ConfigMerge
}
}
export function setupChatGPTHotfix(): ConfigMergeResult {
try {
ensureConfigDir()
} catch (err) {
return { success: false, configPath: OPENCODE_CONFIG_DIR, error: formatErrorWithSuggestion(err, "create config directory") }
}
try {
let packageJson: Record<string, unknown> = {}
if (existsSync(OPENCODE_PACKAGE_JSON)) {
try {
const stat = statSync(OPENCODE_PACKAGE_JSON)
const content = readFileSync(OPENCODE_PACKAGE_JSON, "utf-8")
if (stat.size > 0 && !isEmptyOrWhitespace(content)) {
packageJson = JSON.parse(content)
if (typeof packageJson !== "object" || packageJson === null || Array.isArray(packageJson)) {
packageJson = {}
}
}
} catch (parseErr) {
if (parseErr instanceof SyntaxError) {
packageJson = {}
} else {
throw parseErr
}
}
}
const deps = (packageJson.dependencies ?? {}) as Record<string, string>
deps["opencode-openai-codex-auth"] = CHATGPT_HOTFIX_REPO
packageJson.dependencies = deps
writeFileSync(OPENCODE_PACKAGE_JSON, JSON.stringify(packageJson, null, 2) + "\n")
return { success: true, configPath: OPENCODE_PACKAGE_JSON }
} catch (err) {
return { success: false, configPath: OPENCODE_PACKAGE_JSON, error: formatErrorWithSuggestion(err, "setup ChatGPT hotfix in package.json") }
}
}
export interface BunInstallResult {
success: boolean
timedOut?: boolean
@@ -444,7 +455,7 @@ export async function runBunInstall(): Promise<boolean> {
export async function runBunInstallWithDetails(): Promise<BunInstallResult> {
try {
const proc = Bun.spawn(["bun", "install"], {
cwd: OPENCODE_CONFIG_DIR,
cwd: getConfigDir(),
stdout: "pipe",
stderr: "pipe",
})
@@ -488,45 +499,44 @@ export async function runBunInstallWithDetails(): Promise<BunInstallResult> {
}
}
/**
* Antigravity Provider Configuration
*
* IMPORTANT: Model names MUST use `antigravity-` prefix for stability.
*
* The opencode-antigravity-auth plugin supports two naming conventions:
* - `antigravity-gemini-3-pro-high` (RECOMMENDED, explicit Antigravity quota routing)
* - `gemini-3-pro-high` (LEGACY, backward compatible but may break in future)
*
* Legacy names rely on Gemini CLI using `-preview` suffix for disambiguation.
* If Google removes `-preview`, legacy names may route to wrong quota.
*
* @see https://github.com/NoeFabris/opencode-antigravity-auth#migration-guide-v127
*/
export const ANTIGRAVITY_PROVIDER_CONFIG = {
google: {
name: "Google",
// NOTE: opencode-antigravity-auth expects full model specs (name/limit/modalities).
// If these are incomplete, models may appear but fail at runtime (e.g. 404).
models: {
"gemini-3-pro-high": {
"antigravity-gemini-3-pro-high": {
name: "Gemini 3 Pro High (Antigravity)",
thinking: true,
attachment: true,
limit: { context: 1048576, output: 65535 },
modalities: { input: ["text", "image", "pdf"], output: ["text"] },
},
"gemini-3-pro-medium": {
name: "Gemini 3 Pro Medium (Antigravity)",
thinking: true,
attachment: true,
limit: { context: 1048576, output: 65535 },
modalities: { input: ["text", "image", "pdf"], output: ["text"] },
},
"gemini-3-pro-low": {
"antigravity-gemini-3-pro-low": {
name: "Gemini 3 Pro Low (Antigravity)",
thinking: true,
attachment: true,
limit: { context: 1048576, output: 65535 },
modalities: { input: ["text", "image", "pdf"], output: ["text"] },
},
"gemini-3-flash": {
"antigravity-gemini-3-flash": {
name: "Gemini 3 Flash (Antigravity)",
attachment: true,
limit: { context: 1048576, output: 65536 },
modalities: { input: ["text", "image", "pdf"], output: ["text"] },
},
"gemini-3-flash-lite": {
name: "Gemini 3 Flash Lite (Antigravity)",
attachment: true,
limit: { context: 1048576, output: 65536 },
modalities: { input: ["text", "image", "pdf"], output: ["text"] },
},
},
},
}
@@ -534,12 +544,48 @@ export const ANTIGRAVITY_PROVIDER_CONFIG = {
const CODEX_PROVIDER_CONFIG = {
openai: {
name: "OpenAI",
api: "codex",
options: {
reasoningEffort: "medium",
reasoningSummary: "auto",
textVerbosity: "medium",
include: ["reasoning.encrypted_content"],
store: false,
},
models: {
"gpt-5.2": { name: "GPT-5.2" },
"o3": { name: "o3", thinking: true },
"o4-mini": { name: "o4-mini", thinking: true },
"codex-1": { name: "Codex-1" },
"gpt-5.2": {
name: "GPT 5.2 (OAuth)",
limit: { context: 272000, output: 128000 },
modalities: { input: ["text", "image"], output: ["text"] },
variants: {
none: { reasoningEffort: "none", reasoningSummary: "auto", textVerbosity: "medium" },
low: { reasoningEffort: "low", reasoningSummary: "auto", textVerbosity: "medium" },
medium: { reasoningEffort: "medium", reasoningSummary: "auto", textVerbosity: "medium" },
high: { reasoningEffort: "high", reasoningSummary: "detailed", textVerbosity: "medium" },
xhigh: { reasoningEffort: "xhigh", reasoningSummary: "detailed", textVerbosity: "medium" },
},
},
"gpt-5.2-codex": {
name: "GPT 5.2 Codex (OAuth)",
limit: { context: 272000, output: 128000 },
modalities: { input: ["text", "image"], output: ["text"] },
variants: {
low: { reasoningEffort: "low", reasoningSummary: "auto", textVerbosity: "medium" },
medium: { reasoningEffort: "medium", reasoningSummary: "auto", textVerbosity: "medium" },
high: { reasoningEffort: "high", reasoningSummary: "detailed", textVerbosity: "medium" },
xhigh: { reasoningEffort: "xhigh", reasoningSummary: "detailed", textVerbosity: "medium" },
},
},
"gpt-5.1-codex-max": {
name: "GPT 5.1 Codex Max (OAuth)",
limit: { context: 272000, output: 128000 },
modalities: { input: ["text", "image"], output: ["text"] },
variants: {
low: { reasoningEffort: "low", reasoningSummary: "detailed", textVerbosity: "medium" },
medium: { reasoningEffort: "medium", reasoningSummary: "detailed", textVerbosity: "medium" },
high: { reasoningEffort: "high", reasoningSummary: "detailed", textVerbosity: "medium" },
xhigh: { reasoningEffort: "xhigh", reasoningSummary: "detailed", textVerbosity: "medium" },
},
},
},
},
}
@@ -548,7 +594,7 @@ export function addProviderConfig(config: InstallConfig): ConfigMergeResult {
try {
ensureConfigDir()
} catch (err) {
return { success: false, configPath: OPENCODE_CONFIG_DIR, error: formatErrorWithSuggestion(err, "create config directory") }
return { success: false, configPath: getConfigDir(), error: formatErrorWithSuggestion(err, "create config directory") }
}
const { format, path } = detectConfigFormat()
@@ -622,17 +668,18 @@ export function detectCurrentConfig(): DetectedConfig {
result.hasGemini = plugins.some((p) => p.startsWith("opencode-antigravity-auth"))
result.hasChatGPT = plugins.some((p) => p.startsWith("opencode-openai-codex-auth"))
if (!existsSync(OMO_CONFIG)) {
const omoConfigPath = getOmoConfig()
if (!existsSync(omoConfigPath)) {
return result
}
try {
const stat = statSync(OMO_CONFIG)
const stat = statSync(omoConfigPath)
if (stat.size === 0) {
return result
}
const content = readFileSync(OMO_CONFIG, "utf-8")
const content = readFileSync(omoConfigPath, "utf-8")
if (isEmptyOrWhitespace(content)) {
return result
}
@@ -644,17 +691,17 @@ export function detectCurrentConfig(): DetectedConfig {
const agents = omoConfig.agents ?? {}
if (agents["Sisyphus"]?.model === "opencode/big-pickle") {
if (agents["Sisyphus"]?.model === "opencode/glm-4.7-free") {
result.hasClaude = false
result.isMax20 = false
} else if (agents["librarian"]?.model === "opencode/big-pickle") {
} else if (agents["librarian"]?.model === "opencode/glm-4.7-free") {
result.hasClaude = true
result.isMax20 = false
}
if (agents["oracle"]?.model?.startsWith("anthropic/")) {
result.hasChatGPT = false
} else if (agents["oracle"]?.model === "opencode/big-pickle") {
} else if (agents["oracle"]?.model === "opencode/glm-4.7-free") {
result.hasChatGPT = false
}

View File

@@ -9,11 +9,10 @@ describe("mcp check", () => {
const servers = mcp.getBuiltinMcpInfo()
// #then should include expected servers
expect(servers.length).toBe(3)
expect(servers.length).toBe(2)
expect(servers.every((s) => s.type === "builtin")).toBe(true)
expect(servers.every((s) => s.enabled === true)).toBe(true)
expect(servers.map((s) => s.id)).toContain("context7")
expect(servers.map((s) => s.id)).toContain("websearch_exa")
expect(servers.map((s) => s.id)).toContain("grep_app")
})
})
@@ -37,7 +36,7 @@ describe("mcp check", () => {
// #then should pass
expect(result.status).toBe("pass")
expect(result.message).toContain("3")
expect(result.message).toContain("2")
expect(result.message).toContain("enabled")
})
@@ -48,7 +47,6 @@ describe("mcp check", () => {
// #then should list servers
expect(result.details?.some((d) => d.includes("context7"))).toBe(true)
expect(result.details?.some((d) => d.includes("websearch_exa"))).toBe(true)
expect(result.details?.some((d) => d.includes("grep_app"))).toBe(true)
})
})

View File

@@ -5,7 +5,7 @@ import type { CheckResult, CheckDefinition, McpServerInfo } from "../types"
import { CHECK_IDS, CHECK_NAMES } from "../constants"
import { parseJsonc } from "../../../shared"
const BUILTIN_MCP_SERVERS = ["context7", "websearch_exa", "grep_app"]
const BUILTIN_MCP_SERVERS = ["context7", "grep_app"]
const MCP_CONFIG_PATHS = [
join(homedir(), ".claude", ".mcp.json"),

View File

@@ -1,20 +1,16 @@
import { existsSync, readFileSync } from "node:fs"
import { homedir } from "node:os"
import { join } from "node:path"
import type { CheckResult, CheckDefinition, PluginInfo } from "../types"
import { CHECK_IDS, CHECK_NAMES, PACKAGE_NAME } from "../constants"
import { parseJsonc } from "../../../shared"
const OPENCODE_CONFIG_DIR = join(homedir(), ".config", "opencode")
const OPENCODE_JSON = join(OPENCODE_CONFIG_DIR, "opencode.json")
const OPENCODE_JSONC = join(OPENCODE_CONFIG_DIR, "opencode.jsonc")
import { parseJsonc, getOpenCodeConfigPaths } from "../../../shared"
function detectConfigPath(): { path: string; format: "json" | "jsonc" } | null {
if (existsSync(OPENCODE_JSONC)) {
return { path: OPENCODE_JSONC, format: "jsonc" }
const paths = getOpenCodeConfigPaths({ binary: "opencode", version: null })
if (existsSync(paths.configJsonc)) {
return { path: paths.configJsonc, format: "jsonc" }
}
if (existsSync(OPENCODE_JSON)) {
return { path: OPENCODE_JSON, format: "json" }
if (existsSync(paths.configJson)) {
return { path: paths.configJson, format: "json" }
}
return null
}
@@ -81,13 +77,14 @@ export async function checkPluginRegistration(): Promise<CheckResult> {
const info = getPluginInfo()
if (!info.configPath) {
const expectedPaths = getOpenCodeConfigPaths({ binary: "opencode", version: null })
return {
name: CHECK_NAMES[CHECK_IDS.PLUGIN_REGISTRATION],
status: "fail",
message: "OpenCode config file not found",
details: [
"Run: bunx oh-my-opencode install",
`Expected: ${OPENCODE_JSON} or ${OPENCODE_JSONC}`,
`Expected: ${expectedPaths.configJson} or ${expectedPaths.configJsonc}`,
],
}
}

View File

@@ -30,7 +30,7 @@ describe("runner", () => {
name: "Test Check",
category: "installation",
check: async () => {
await new Promise((r) => setTimeout(r, 10))
await new Promise((r) => setTimeout(r, 50))
return { name: "Test", status: "pass", message: "OK" }
},
}

View File

@@ -7,8 +7,6 @@ import {
isOpenCodeInstalled,
getOpenCodeVersion,
addAuthPlugins,
setupChatGPTHotfix,
runBunInstall,
addProviderConfig,
detectCurrentConfig,
} from "./config-manager"
@@ -48,10 +46,10 @@ function formatConfigSummary(config: InstallConfig): string {
lines.push(color.bold(color.white("Agent Configuration")))
lines.push("")
const sisyphusModel = config.hasClaude ? "claude-opus-4-5" : "big-pickle"
const oracleModel = config.hasChatGPT ? "gpt-5.2" : (config.hasClaude ? "claude-opus-4-5" : "big-pickle")
const librarianModel = config.hasClaude && config.isMax20 ? "claude-sonnet-4-5" : "big-pickle"
const frontendModel = config.hasGemini ? "gemini-3-pro-high" : (config.hasClaude ? "claude-opus-4-5" : "big-pickle")
const sisyphusModel = config.hasClaude ? "claude-opus-4-5" : "glm-4.7-free"
const oracleModel = config.hasChatGPT ? "gpt-5.2" : (config.hasClaude ? "claude-opus-4-5" : "glm-4.7-free")
const librarianModel = "glm-4.7-free"
const frontendModel = config.hasGemini ? "antigravity-gemini-3-pro-high" : (config.hasClaude ? "claude-opus-4-5" : "glm-4.7-free")
lines.push(` ${SYMBOLS.bullet} Sisyphus ${SYMBOLS.arrow} ${color.cyan(sisyphusModel)}`)
lines.push(` ${SYMBOLS.bullet} Oracle ${SYMBOLS.arrow} ${color.cyan(oracleModel)}`)
@@ -163,7 +161,7 @@ async function runTuiMode(detected: DetectedConfig): Promise<InstallConfig | nul
const claude = await p.select({
message: "Do you have a Claude Pro/Max subscription?",
options: [
{ value: "no" as const, label: "No", hint: "Will use opencode/big-pickle as fallback" },
{ value: "no" as const, label: "No", hint: "Will use opencode/glm-4.7-free as fallback" },
{ value: "yes" as const, label: "Yes (standard)", hint: "Claude Opus 4.5 for orchestration" },
{ value: "max20" as const, label: "Yes (max20 mode)", hint: "Full power with Claude Sonnet 4.5 for Librarian" },
],
@@ -279,26 +277,6 @@ async function runNonTuiInstall(args: InstallArgs): Promise<number> {
step += 2
}
if (config.hasChatGPT) {
printStep(step++, totalSteps, "Setting up ChatGPT hotfix...")
const hotfixResult = setupChatGPTHotfix()
if (!hotfixResult.success) {
printError(`Failed: ${hotfixResult.error}`)
return 1
}
printSuccess(`Hotfix configured ${SYMBOLS.arrow} ${color.dim(hotfixResult.configPath)}`)
printInfo("Installing dependencies with bun...")
const bunSuccess = await runBunInstall()
if (bunSuccess) {
printSuccess("Dependencies installed")
} else {
printWarning("bun install failed - run manually: cd ~/.config/opencode && bun i")
}
} else {
step++
}
printStep(step++, totalSteps, "Writing oh-my-opencode configuration...")
const omoResult = writeOmoConfig(config)
if (!omoResult.success) {
@@ -310,7 +288,7 @@ async function runNonTuiInstall(args: InstallArgs): Promise<number> {
printBox(formatConfigSummary(config), isUpdate ? "Updated Configuration" : "Installation Complete")
if (!config.hasClaude && !config.hasChatGPT && !config.hasGemini) {
printWarning("No model providers configured. Using opencode/big-pickle as fallback.")
printWarning("No model providers configured. Using opencode/glm-4.7-free as fallback.")
}
if ((config.hasClaude || config.hasChatGPT || config.hasGemini) && !args.skipAuth) {
@@ -410,25 +388,6 @@ export async function install(args: InstallArgs): Promise<number> {
s.stop(`Provider config added to ${color.cyan(providerResult.configPath)}`)
}
if (config.hasChatGPT) {
s.start("Setting up ChatGPT hotfix")
const hotfixResult = setupChatGPTHotfix()
if (!hotfixResult.success) {
s.stop(`Failed to setup hotfix: ${hotfixResult.error}`)
p.outro(color.red("Installation failed."))
return 1
}
s.stop(`Hotfix configured in ${color.cyan(hotfixResult.configPath)}`)
s.start("Installing dependencies with bun")
const bunSuccess = await runBunInstall()
if (bunSuccess) {
s.stop("Dependencies installed")
} else {
s.stop(color.yellow("bun install failed - run manually: cd ~/.config/opencode && bun i"))
}
}
s.start("Writing oh-my-opencode configuration")
const omoResult = writeOmoConfig(config)
if (!omoResult.success) {
@@ -439,7 +398,7 @@ export async function install(args: InstallArgs): Promise<number> {
s.stop(`Config written to ${color.cyan(omoResult.configPath)}`)
if (!config.hasClaude && !config.hasChatGPT && !config.hasGemini) {
p.log.warn("No model providers configured. Using opencode/big-pickle as fallback.")
p.log.warn("No model providers configured. Using opencode/glm-4.7-free as fallback.")
}
p.note(formatConfigSummary(config), isUpdate ? "Updated Configuration" : "Installation Complete")

136
src/config/schema.test.ts Normal file
View File

@@ -0,0 +1,136 @@
import { describe, expect, test } from "bun:test"
import { OhMyOpenCodeConfigSchema } from "./schema"
describe("disabled_mcps schema", () => {
test("should accept built-in MCP names", () => {
//#given
const config = {
disabled_mcps: ["context7", "grep_app"],
}
//#when
const result = OhMyOpenCodeConfigSchema.safeParse(config)
//#then
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.disabled_mcps).toEqual(["context7", "grep_app"])
}
})
test("should accept custom MCP names", () => {
//#given
const config = {
disabled_mcps: ["playwright", "sqlite", "custom-mcp"],
}
//#when
const result = OhMyOpenCodeConfigSchema.safeParse(config)
//#then
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.disabled_mcps).toEqual(["playwright", "sqlite", "custom-mcp"])
}
})
test("should accept mixed built-in and custom names", () => {
//#given
const config = {
disabled_mcps: ["context7", "playwright", "custom-server"],
}
//#when
const result = OhMyOpenCodeConfigSchema.safeParse(config)
//#then
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.disabled_mcps).toEqual(["context7", "playwright", "custom-server"])
}
})
test("should accept empty array", () => {
//#given
const config = {
disabled_mcps: [],
}
//#when
const result = OhMyOpenCodeConfigSchema.safeParse(config)
//#then
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.disabled_mcps).toEqual([])
}
})
test("should reject non-string values", () => {
//#given
const config = {
disabled_mcps: [123, true, null],
}
//#when
const result = OhMyOpenCodeConfigSchema.safeParse(config)
//#then
expect(result.success).toBe(false)
})
test("should accept undefined (optional field)", () => {
//#given
const config = {}
//#when
const result = OhMyOpenCodeConfigSchema.safeParse(config)
//#then
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.disabled_mcps).toBeUndefined()
}
})
test("should reject empty strings", () => {
//#given
const config = {
disabled_mcps: [""],
}
//#when
const result = OhMyOpenCodeConfigSchema.safeParse(config)
//#then
expect(result.success).toBe(false)
})
test("should accept MCP names with various naming patterns", () => {
//#given
const config = {
disabled_mcps: [
"my-custom-mcp",
"my_custom_mcp",
"myCustomMcp",
"my.custom.mcp",
"my-custom-mcp-123",
],
}
//#when
const result = OhMyOpenCodeConfigSchema.safeParse(config)
//#then
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.disabled_mcps).toEqual([
"my-custom-mcp",
"my_custom_mcp",
"myCustomMcp",
"my.custom.mcp",
"my-custom-mcp-123",
])
}
})
})

View File

@@ -1,5 +1,5 @@
import { z } from "zod"
import { McpNameSchema } from "../mcp/types"
import { AnyMcpNameSchema, McpNameSchema } from "../mcp/types"
const PermissionValue = z.enum(["ask", "allow", "deny"])
@@ -74,6 +74,7 @@ export const HookNameSchema = z.enum([
"compaction-context-injector",
"claude-code-hooks",
"auto-slash-command",
"edit-error-recovery",
])
export const BuiltinCommandNameSchema = z.enum([
@@ -233,7 +234,7 @@ export const RalphLoopConfigSchema = z.object({
export const OhMyOpenCodeConfigSchema = z.object({
$schema: z.string().optional(),
disabled_mcps: z.array(McpNameSchema).optional(),
disabled_mcps: z.array(AnyMcpNameSchema).optional(),
disabled_agents: z.array(BuiltinAgentNameSchema).optional(),
disabled_skills: z.array(BuiltinSkillNameSchema).optional(),
disabled_hooks: z.array(HookNameSchema).optional(),
@@ -264,4 +265,4 @@ export type SkillsConfig = z.infer<typeof SkillsConfigSchema>
export type SkillDefinition = z.infer<typeof SkillDefinitionSchema>
export type RalphLoopConfig = z.infer<typeof RalphLoopConfigSchema>
export { McpNameSchema, type McpName } from "../mcp/types"
export { AnyMcpNameSchema, type AnyMcpName, McpNameSchema, type McpName } from "../mcp/types"

View File

@@ -1,8 +1,11 @@
import { describe, test, expect, beforeEach } from "bun:test"
import type { BackgroundTask } from "./types"
const TASK_TTL_MS = 30 * 60 * 1000
class MockBackgroundManager {
private tasks: Map<string, BackgroundTask> = new Map()
private notifications: Map<string, BackgroundTask[]> = new Map()
addTask(task: BackgroundTask): void {
this.tasks.set(task.id, task)
@@ -34,6 +37,74 @@ class MockBackgroundManager {
return result
}
markForNotification(task: BackgroundTask): void {
const queue = this.notifications.get(task.parentSessionID) ?? []
queue.push(task)
this.notifications.set(task.parentSessionID, queue)
}
getPendingNotifications(sessionID: string): BackgroundTask[] {
return this.notifications.get(sessionID) ?? []
}
private clearNotificationsForTask(taskId: string): void {
for (const [sessionID, tasks] of this.notifications.entries()) {
const filtered = tasks.filter((t) => t.id !== taskId)
if (filtered.length === 0) {
this.notifications.delete(sessionID)
} else {
this.notifications.set(sessionID, filtered)
}
}
}
pruneStaleTasksAndNotifications(): { prunedTasks: string[]; prunedNotifications: number } {
const now = Date.now()
const prunedTasks: string[] = []
let prunedNotifications = 0
for (const [taskId, task] of this.tasks.entries()) {
const age = now - task.startedAt.getTime()
if (age > TASK_TTL_MS) {
prunedTasks.push(taskId)
this.clearNotificationsForTask(taskId)
this.tasks.delete(taskId)
}
}
for (const [sessionID, notifications] of this.notifications.entries()) {
if (notifications.length === 0) {
this.notifications.delete(sessionID)
continue
}
const validNotifications = notifications.filter((task) => {
const age = now - task.startedAt.getTime()
return age <= TASK_TTL_MS
})
const removed = notifications.length - validNotifications.length
prunedNotifications += removed
if (validNotifications.length === 0) {
this.notifications.delete(sessionID)
} else if (validNotifications.length !== notifications.length) {
this.notifications.set(sessionID, validNotifications)
}
}
return { prunedTasks, prunedNotifications }
}
getTaskCount(): number {
return this.tasks.size
}
getNotificationCount(): number {
let count = 0
for (const notifications of this.notifications.values()) {
count += notifications.length
}
return count
}
}
function createMockTask(overrides: Partial<BackgroundTask> & { id: string; sessionID: string; parentSessionID: string }): BackgroundTask {
@@ -230,3 +301,116 @@ describe("BackgroundManager.getAllDescendantTasks", () => {
expect(result[0].id).toBe("task-b")
})
})
describe("BackgroundManager.pruneStaleTasksAndNotifications", () => {
let manager: MockBackgroundManager
beforeEach(() => {
// #given
manager = new MockBackgroundManager()
})
test("should not prune fresh tasks", () => {
// #given
const task = createMockTask({
id: "task-fresh",
sessionID: "session-fresh",
parentSessionID: "session-parent",
startedAt: new Date(),
})
manager.addTask(task)
// #when
const result = manager.pruneStaleTasksAndNotifications()
// #then
expect(result.prunedTasks).toHaveLength(0)
expect(manager.getTaskCount()).toBe(1)
})
test("should prune tasks older than 30 minutes", () => {
// #given
const staleDate = new Date(Date.now() - 31 * 60 * 1000)
const task = createMockTask({
id: "task-stale",
sessionID: "session-stale",
parentSessionID: "session-parent",
startedAt: staleDate,
})
manager.addTask(task)
// #when
const result = manager.pruneStaleTasksAndNotifications()
// #then
expect(result.prunedTasks).toContain("task-stale")
expect(manager.getTaskCount()).toBe(0)
})
test("should prune stale notifications", () => {
// #given
const staleDate = new Date(Date.now() - 31 * 60 * 1000)
const task = createMockTask({
id: "task-stale",
sessionID: "session-stale",
parentSessionID: "session-parent",
startedAt: staleDate,
})
manager.markForNotification(task)
// #when
const result = manager.pruneStaleTasksAndNotifications()
// #then
expect(result.prunedNotifications).toBe(1)
expect(manager.getNotificationCount()).toBe(0)
})
test("should clean up notifications when task is pruned", () => {
// #given
const staleDate = new Date(Date.now() - 31 * 60 * 1000)
const task = createMockTask({
id: "task-stale",
sessionID: "session-stale",
parentSessionID: "session-parent",
startedAt: staleDate,
})
manager.addTask(task)
manager.markForNotification(task)
// #when
manager.pruneStaleTasksAndNotifications()
// #then
expect(manager.getTaskCount()).toBe(0)
expect(manager.getNotificationCount()).toBe(0)
})
test("should keep fresh tasks while pruning stale ones", () => {
// #given
const staleDate = new Date(Date.now() - 31 * 60 * 1000)
const staleTask = createMockTask({
id: "task-stale",
sessionID: "session-stale",
parentSessionID: "session-parent",
startedAt: staleDate,
})
const freshTask = createMockTask({
id: "task-fresh",
sessionID: "session-fresh",
parentSessionID: "session-parent",
startedAt: new Date(),
})
manager.addTask(staleTask)
manager.addTask(freshTask)
// #when
const result = manager.pruneStaleTasksAndNotifications()
// #then
expect(result.prunedTasks).toHaveLength(1)
expect(result.prunedTasks).toContain("task-stale")
expect(manager.getTaskCount()).toBe(1)
expect(manager.getTask("task-fresh")).toBeDefined()
})
})

View File

@@ -12,6 +12,8 @@ import {
} from "../hook-message-injector"
import { subagentSessions } from "../claude-code-session-state"
const TASK_TTL_MS = 30 * 60 * 1000
type OpencodeClient = PluginInput["client"]
interface MessagePartInfo {
@@ -345,11 +347,12 @@ export class BackgroundManager {
},
query: { directory: this.directory },
})
this.clearNotificationsForTask(taskId)
log("[background-agent] Successfully sent prompt to parent session:", { parentSessionID: task.parentSessionID })
} catch (error) {
log("[background-agent] prompt failed:", String(error))
} finally {
// Always clean up both maps to prevent memory leaks
this.clearNotificationsForTask(taskId)
this.tasks.delete(taskId)
log("[background-agent] Removed completed task from memory:", taskId)
}
@@ -377,7 +380,42 @@ export class BackgroundManager {
return false
}
private pruneStaleTasksAndNotifications(): void {
const now = Date.now()
for (const [taskId, task] of this.tasks.entries()) {
const age = now - task.startedAt.getTime()
if (age > TASK_TTL_MS) {
log("[background-agent] Pruning stale task:", { taskId, age: Math.round(age / 1000) + "s" })
task.status = "error"
task.error = "Task timed out after 30 minutes"
task.completedAt = new Date()
this.clearNotificationsForTask(taskId)
this.tasks.delete(taskId)
subagentSessions.delete(task.sessionID)
}
}
for (const [sessionID, notifications] of this.notifications.entries()) {
if (notifications.length === 0) {
this.notifications.delete(sessionID)
continue
}
const validNotifications = notifications.filter((task) => {
const age = now - task.startedAt.getTime()
return age <= TASK_TTL_MS
})
if (validNotifications.length === 0) {
this.notifications.delete(sessionID)
} else if (validNotifications.length !== notifications.length) {
this.notifications.set(sessionID, validNotifications)
}
}
}
private async pollRunningTasks(): Promise<void> {
this.pruneStaleTasksAndNotifications()
const statusResult = await this.client.session.status()
const allStatuses = (statusResult.data ?? {}) as Record<string, { type: string }>

View File

@@ -2,6 +2,7 @@ import type { CommandDefinition } from "../claude-code-command-loader"
import type { BuiltinCommandName, BuiltinCommands } from "./types"
import { INIT_DEEP_TEMPLATE } from "./templates/init-deep"
import { RALPH_LOOP_TEMPLATE, CANCEL_RALPH_TEMPLATE } from "./templates/ralph-loop"
import { REFACTOR_TEMPLATE } from "./templates/refactor"
const BUILTIN_COMMAND_DEFINITIONS: Record<BuiltinCommandName, Omit<CommandDefinition, "name">> = {
"init-deep": {
@@ -32,6 +33,14 @@ $ARGUMENTS
${CANCEL_RALPH_TEMPLATE}
</command-instruction>`,
},
refactor: {
description:
"(builtin) Intelligent refactoring command with LSP, AST-grep, architecture analysis, codemap, and TDD verification.",
template: `<command-instruction>
${REFACTOR_TEMPLATE}
</command-instruction>`,
argumentHint: "<refactoring-target> [--scope=<file|module|project>] [--strategy=<safe|aggressive>]",
},
}
export function loadBuiltinCommands(

View File

@@ -0,0 +1,624 @@
export const REFACTOR_TEMPLATE = `# Intelligent Refactor Command
## Usage
\`\`\`
/refactor <refactoring-target> [--scope=<file|module|project>] [--strategy=<safe|aggressive>]
Arguments:
refactoring-target: What to refactor. Can be:
- File path: src/auth/handler.ts
- Symbol name: "AuthService class"
- Pattern: "all functions using deprecated API"
- Description: "extract validation logic into separate module"
Options:
--scope: Refactoring scope (default: module)
- file: Single file only
- module: Module/directory scope
- project: Entire codebase
--strategy: Risk tolerance (default: safe)
- safe: Conservative, maximum test coverage required
- aggressive: Allow broader changes with adequate coverage
\`\`\`
## What This Command Does
Performs intelligent, deterministic refactoring with full codebase awareness. Unlike blind search-and-replace, this command:
1. **Understands your intent** - Analyzes what you actually want to achieve
2. **Maps the codebase** - Builds a definitive codemap before touching anything
3. **Assesses risk** - Evaluates test coverage and determines verification strategy
4. **Plans meticulously** - Creates a detailed plan with Plan agent
5. **Executes precisely** - Step-by-step refactoring with LSP and AST-grep
6. **Verifies constantly** - Runs tests after each change to ensure zero regression
---
# PHASE 0: INTENT GATE (MANDATORY FIRST STEP)
**BEFORE ANY ACTION, classify and validate the request.**
## Step 0.1: Parse Request Type
| Signal | Classification | Action |
|--------|----------------|--------|
| Specific file/symbol | Explicit | Proceed to codebase analysis |
| "Refactor X to Y" | Clear transformation | Proceed to codebase analysis |
| "Improve", "Clean up" | Open-ended | **MUST ask**: "What specific improvement?" |
| Ambiguous scope | Uncertain | **MUST ask**: "Which modules/files?" |
| Missing context | Incomplete | **MUST ask**: "What's the desired outcome?" |
## Step 0.2: Validate Understanding
Before proceeding, confirm:
- [ ] Target is clearly identified
- [ ] Desired outcome is understood
- [ ] Scope is defined (file/module/project)
- [ ] Success criteria can be articulated
**If ANY of above is unclear, ASK CLARIFYING QUESTION:**
\`\`\`
I want to make sure I understand the refactoring goal correctly.
**What I understood**: [interpretation]
**What I'm unsure about**: [specific ambiguity]
Options I see:
1. [Option A] - [implications]
2. [Option B] - [implications]
**My recommendation**: [suggestion with reasoning]
Should I proceed with [recommendation], or would you prefer differently?
\`\`\`
## Step 0.3: Create Initial Todos
**IMMEDIATELY after understanding the request, create todos:**
\`\`\`
TodoWrite([
{"id": "phase-1", "content": "PHASE 1: Codebase Analysis - launch parallel explore agents", "status": "pending", "priority": "high"},
{"id": "phase-2", "content": "PHASE 2: Build Codemap - map dependencies and impact zones", "status": "pending", "priority": "high"},
{"id": "phase-3", "content": "PHASE 3: Test Assessment - analyze test coverage and verification strategy", "status": "pending", "priority": "high"},
{"id": "phase-4", "content": "PHASE 4: Plan Generation - invoke Plan agent for detailed refactoring plan", "status": "pending", "priority": "high"},
{"id": "phase-5", "content": "PHASE 5: Execute Refactoring - step-by-step with continuous verification", "status": "pending", "priority": "high"},
{"id": "phase-6", "content": "PHASE 6: Final Verification - full test suite and regression check", "status": "pending", "priority": "high"}
])
\`\`\`
---
# PHASE 1: CODEBASE ANALYSIS (PARALLEL EXPLORATION)
**Mark phase-1 as in_progress.**
## 1.1: Launch Parallel Explore Agents (BACKGROUND)
Fire ALL of these simultaneously using \`call_omo_agent\`:
\`\`\`
// Agent 1: Find the refactoring target
call_omo_agent(
subagent_type="explore",
run_in_background=true,
prompt="Find all occurrences and definitions of [TARGET].
Report: file paths, line numbers, usage patterns."
)
// Agent 2: Find related code
call_omo_agent(
subagent_type="explore",
run_in_background=true,
prompt="Find all code that imports, uses, or depends on [TARGET].
Report: dependency chains, import graphs."
)
// Agent 3: Find similar patterns
call_omo_agent(
subagent_type="explore",
run_in_background=true,
prompt="Find similar code patterns to [TARGET] in the codebase.
Report: analogous implementations, established conventions."
)
// Agent 4: Find tests
call_omo_agent(
subagent_type="explore",
run_in_background=true,
prompt="Find all test files related to [TARGET].
Report: test file paths, test case names, coverage indicators."
)
// Agent 5: Architecture context
call_omo_agent(
subagent_type="explore",
run_in_background=true,
prompt="Find architectural patterns and module organization around [TARGET].
Report: module boundaries, layer structure, design patterns in use."
)
\`\`\`
## 1.2: Direct Tool Exploration (WHILE AGENTS RUN)
While background agents are running, use direct tools:
### LSP Tools for Precise Analysis:
\`\`\`typescript
// Get symbol information at target location
lsp_hover(filePath, line, character) // Type info, docs, signatures
// Find definition(s)
lsp_goto_definition(filePath, line, character) // Where is it defined?
// Find ALL usages across workspace
lsp_find_references(filePath, line, character, includeDeclaration=true)
// Get file structure
lsp_document_symbols(filePath) // Hierarchical outline
// Search symbols by name
lsp_workspace_symbols(filePath, query="[target_symbol]")
// Get current diagnostics
lsp_diagnostics(filePath) // Errors, warnings before we start
\`\`\`
### AST-Grep for Pattern Analysis:
\`\`\`typescript
// Find structural patterns
ast_grep_search(
pattern="function $NAME($$$) { $$$ }", // or relevant pattern
lang="typescript", // or relevant language
paths=["src/"]
)
// Preview refactoring (DRY RUN)
ast_grep_replace(
pattern="[old_pattern]",
rewrite="[new_pattern]",
lang="[language]",
dryRun=true // ALWAYS preview first
)
\`\`\`
### Grep for Text Patterns:
\`\`\`
grep(pattern="[search_term]", path="src/", include="*.ts")
\`\`\`
## 1.3: Collect Background Results
\`\`\`
background_output(task_id="[agent_1_id]")
background_output(task_id="[agent_2_id]")
...
\`\`\`
**Mark phase-1 as completed after all results collected.**
---
# PHASE 2: BUILD CODEMAP (DEPENDENCY MAPPING)
**Mark phase-2 as in_progress.**
## 2.1: Construct Definitive Codemap
Based on Phase 1 results, build:
\`\`\`
## CODEMAP: [TARGET]
### Core Files (Direct Impact)
- \`path/to/file.ts:L10-L50\` - Primary definition
- \`path/to/file2.ts:L25\` - Key usage
### Dependency Graph
\`\`\`
[TARGET]
├── imports from:
│ ├── module-a (types)
│ └── module-b (utils)
├── imported by:
│ ├── consumer-1.ts
│ ├── consumer-2.ts
│ └── consumer-3.ts
└── used by:
├── handler.ts (direct call)
└── service.ts (dependency injection)
\`\`\`
### Impact Zones
| Zone | Risk Level | Files Affected | Test Coverage |
|------|------------|----------------|---------------|
| Core | HIGH | 3 files | 85% covered |
| Consumers | MEDIUM | 8 files | 70% covered |
| Edge | LOW | 2 files | 50% covered |
### Established Patterns
- Pattern A: [description] - used in N places
- Pattern B: [description] - established convention
\`\`\`
## 2.2: Identify Refactoring Constraints
Based on codemap:
- **MUST follow**: [existing patterns identified]
- **MUST NOT break**: [critical dependencies]
- **Safe to change**: [isolated code zones]
- **Requires migration**: [breaking changes impact]
**Mark phase-2 as completed.**
---
# PHASE 3: TEST ASSESSMENT (VERIFICATION STRATEGY)
**Mark phase-3 as in_progress.**
## 3.1: Detect Test Infrastructure
\`\`\`bash
# Check for test commands
cat package.json | jq '.scripts | keys[] | select(test("test"))'
# Or for Python
ls -la pytest.ini pyproject.toml setup.cfg
# Or for Go
ls -la *_test.go
\`\`\`
## 3.2: Analyze Test Coverage
\`\`\`
// Find all tests related to target
call_omo_agent(
subagent_type="explore",
run_in_background=false, // Need this synchronously
prompt="Analyze test coverage for [TARGET]:
1. Which test files cover this code?
2. What test cases exist?
3. Are there integration tests?
4. What edge cases are tested?
5. Estimated coverage percentage?"
)
\`\`\`
## 3.3: Determine Verification Strategy
Based on test analysis:
| Coverage Level | Strategy |
|----------------|----------|
| HIGH (>80%) | Run existing tests after each step |
| MEDIUM (50-80%) | Run tests + add safety assertions |
| LOW (<50%) | **PAUSE**: Propose adding tests first |
| NONE | **BLOCK**: Refuse aggressive refactoring |
**If coverage is LOW or NONE, ask user:**
\`\`\`
Test coverage for [TARGET] is [LEVEL].
**Risk Assessment**: Refactoring without adequate tests is dangerous.
Options:
1. Add tests first, then refactor (RECOMMENDED)
2. Proceed with extra caution, manual verification required
3. Abort refactoring
Which approach do you prefer?
\`\`\`
## 3.4: Document Verification Plan
\`\`\`
## VERIFICATION PLAN
### Test Commands
- Unit: \`bun test\` / \`npm test\` / \`pytest\` / etc.
- Integration: [command if exists]
- Type check: \`tsc --noEmit\` / \`pyright\` / etc.
### Verification Checkpoints
After each refactoring step:
1. lsp_diagnostics → zero new errors
2. Run test command → all pass
3. Type check → clean
### Regression Indicators
- [Specific test that must pass]
- [Behavior that must be preserved]
- [API contract that must not change]
\`\`\`
**Mark phase-3 as completed.**
---
# PHASE 4: PLAN GENERATION (PLAN AGENT)
**Mark phase-4 as in_progress.**
## 4.1: Invoke Plan Agent
\`\`\`
Task(
subagent_type="plan",
prompt="Create a detailed refactoring plan:
## Refactoring Goal
[User's original request]
## Codemap (from Phase 2)
[Insert codemap here]
## Test Coverage (from Phase 3)
[Insert verification plan here]
## Constraints
- MUST follow existing patterns: [list]
- MUST NOT break: [critical paths]
- MUST run tests after each step
## Requirements
1. Break down into atomic refactoring steps
2. Each step must be independently verifiable
3. Order steps by dependency (what must happen first)
4. Specify exact files and line ranges for each step
5. Include rollback strategy for each step
6. Define commit checkpoints"
)
\`\`\`
## 4.2: Review and Validate Plan
After receiving plan from Plan agent:
1. **Verify completeness**: All identified files addressed?
2. **Verify safety**: Each step reversible?
3. **Verify order**: Dependencies respected?
4. **Verify verification**: Test commands specified?
## 4.3: Register Detailed Todos
Convert Plan agent output into granular todos:
\`\`\`
TodoWrite([
// Each step from the plan becomes a todo
{"id": "refactor-1", "content": "Step 1: [description]", "status": "pending", "priority": "high"},
{"id": "verify-1", "content": "Verify Step 1: run tests", "status": "pending", "priority": "high"},
{"id": "refactor-2", "content": "Step 2: [description]", "status": "pending", "priority": "medium"},
{"id": "verify-2", "content": "Verify Step 2: run tests", "status": "pending", "priority": "medium"},
// ... continue for all steps
])
\`\`\`
**Mark phase-4 as completed.**
---
# PHASE 5: EXECUTE REFACTORING (DETERMINISTIC EXECUTION)
**Mark phase-5 as in_progress.**
## 5.1: Execution Protocol
For EACH refactoring step:
### Pre-Step
1. Mark step todo as \`in_progress\`
2. Read current file state
3. Verify lsp_diagnostics is baseline
### Execute Step
Use appropriate tool:
**For Symbol Renames:**
\`\`\`typescript
lsp_prepare_rename(filePath, line, character) // Validate rename is possible
lsp_rename(filePath, line, character, newName) // Execute rename
\`\`\`
**For Pattern Transformations:**
\`\`\`typescript
// Preview first
ast_grep_replace(pattern, rewrite, lang, dryRun=true)
// If preview looks good, execute
ast_grep_replace(pattern, rewrite, lang, dryRun=false)
\`\`\`
**For Structural Changes:**
\`\`\`typescript
// Use Edit tool for precise changes
edit(filePath, oldString, newString)
\`\`\`
### Post-Step Verification (MANDATORY)
\`\`\`typescript
// 1. Check diagnostics
lsp_diagnostics(filePath) // Must be clean or same as baseline
// 2. Run tests
bash("bun test") // Or appropriate test command
// 3. Type check
bash("tsc --noEmit") // Or appropriate type check
\`\`\`
### Step Completion
1. If verification passes → Mark step todo as \`completed\`
2. If verification fails → **STOP AND FIX**
## 5.2: Failure Recovery Protocol
If ANY verification fails:
1. **STOP** immediately
2. **REVERT** the failed change
3. **DIAGNOSE** what went wrong
4. **OPTIONS**:
- Fix the issue and retry
- Skip this step (if optional)
- Consult oracle agent for help
- Ask user for guidance
**NEVER proceed to next step with broken tests.**
## 5.3: Commit Checkpoints
After each logical group of changes:
\`\`\`bash
git add [changed-files]
git commit -m "refactor(scope): description
[details of what was changed and why]"
\`\`\`
**Mark phase-5 as completed when all refactoring steps done.**
---
# PHASE 6: FINAL VERIFICATION (REGRESSION CHECK)
**Mark phase-6 as in_progress.**
## 6.1: Full Test Suite
\`\`\`bash
# Run complete test suite
bun test # or npm test, pytest, go test, etc.
\`\`\`
## 6.2: Type Check
\`\`\`bash
# Full type check
tsc --noEmit # or equivalent
\`\`\`
## 6.3: Lint Check
\`\`\`bash
# Run linter
eslint . # or equivalent
\`\`\`
## 6.4: Build Verification (if applicable)
\`\`\`bash
# Ensure build still works
bun run build # or npm run build, etc.
\`\`\`
## 6.5: Final Diagnostics
\`\`\`typescript
// Check all changed files
for (file of changedFiles) {
lsp_diagnostics(file) // Must all be clean
}
\`\`\`
## 6.6: Generate Summary
\`\`\`markdown
## Refactoring Complete
### What Changed
- [List of changes made]
### Files Modified
- \`path/to/file.ts\` - [what changed]
- \`path/to/file2.ts\` - [what changed]
### Verification Results
- Tests: PASSED (X/Y passing)
- Type Check: CLEAN
- Lint: CLEAN
- Build: SUCCESS
### No Regressions Detected
All existing tests pass. No new errors introduced.
\`\`\`
**Mark phase-6 as completed.**
---
# CRITICAL RULES
## NEVER DO
- Skip lsp_diagnostics check after changes
- Proceed with failing tests
- Make changes without understanding impact
- Use \`as any\`, \`@ts-ignore\`, \`@ts-expect-error\`
- Delete tests to make them pass
- Commit broken code
- Refactor without understanding existing patterns
## ALWAYS DO
- Understand before changing
- Preview before applying (ast_grep dryRun=true)
- Verify after every change
- Follow existing codebase patterns
- Keep todos updated in real-time
- Commit at logical checkpoints
- Report issues immediately
## ABORT CONDITIONS
If any of these occur, **STOP and consult user**:
- Test coverage is zero for target code
- Changes would break public API
- Refactoring scope is unclear
- 3 consecutive verification failures
- User-defined constraints violated
---
# Tool Usage Philosophy
You already know these tools. Use them intelligently:
## LSP Tools
Leverage the full LSP toolset (\`lsp_*\`) for precision analysis. Key patterns:
- **Understand before changing**: \`lsp_hover\`, \`lsp_goto_definition\` to grasp context
- **Impact analysis**: \`lsp_find_references\` to map all usages before modification
- **Safe refactoring**: \`lsp_prepare_rename\`\`lsp_rename\` for symbol renames
- **Continuous verification**: \`lsp_diagnostics\` after every change
## AST-Grep
Use \`ast_grep_search\` and \`ast_grep_replace\` for structural transformations.
**Critical**: Always \`dryRun=true\` first, review, then execute.
## Agents
- \`explore\`: Parallel codebase pattern discovery
- \`plan\`: Detailed refactoring plan generation
- \`oracle\`: Consult for complex architectural decisions
- \`librarian\`: **Use proactively** when encountering deprecated methods or library migration tasks. Query official docs and OSS examples for modern replacements.
## Deprecated Code & Library Migration
When you encounter deprecated methods/APIs during refactoring:
1. Fire \`librarian\` to find the recommended modern alternative
2. **DO NOT auto-upgrade to latest version** unless user explicitly requests migration
3. If user requests library migration, use \`librarian\` to fetch latest API docs before making changes
---
**Remember: Refactoring without tests is reckless. Refactoring without understanding is destructive. This command ensures you do neither.**
<user-request>
$ARGUMENTS
</user-request>
`

View File

@@ -1,6 +1,6 @@
import type { CommandDefinition } from "../claude-code-command-loader"
export type BuiltinCommandName = "init-deep" | "ralph-loop" | "cancel-ralph"
export type BuiltinCommandName = "init-deep" | "ralph-loop" | "cancel-ralph" | "refactor"
export interface BuiltinCommandConfig {
disabled_commands?: BuiltinCommandName[]

View File

@@ -1,5 +1,6 @@
import { existsSync, readdirSync, readFileSync, realpathSync, type Dirent } from "fs"
import { promises as fs, type Dirent } from "fs"
import { join, basename } from "path"
import { homedir } from "os"
import { parseFrontmatter } from "../../shared/frontmatter"
import { sanitizeModelField } from "../../shared/model-sanitizer"
import { isMarkdownFile } from "../../shared/file-utils"
@@ -7,19 +8,21 @@ import { getClaudeConfigDir } from "../../shared"
import { log } from "../../shared/logger"
import type { CommandScope, CommandDefinition, CommandFrontmatter, LoadedCommand } from "./types"
function loadCommandsFromDir(
async function loadCommandsFromDir(
commandsDir: string,
scope: CommandScope,
visited: Set<string> = new Set(),
prefix: string = ""
): LoadedCommand[] {
if (!existsSync(commandsDir)) {
): Promise<LoadedCommand[]> {
try {
await fs.access(commandsDir)
} catch {
return []
}
let realPath: string
try {
realPath = realpathSync(commandsDir)
realPath = await fs.realpath(commandsDir)
} catch (error) {
log(`Failed to resolve command directory: ${commandsDir}`, error)
return []
@@ -32,7 +35,7 @@ function loadCommandsFromDir(
let entries: Dirent[]
try {
entries = readdirSync(commandsDir, { withFileTypes: true })
entries = await fs.readdir(commandsDir, { withFileTypes: true })
} catch (error) {
log(`Failed to read command directory: ${commandsDir}`, error)
return []
@@ -45,7 +48,8 @@ function loadCommandsFromDir(
if (entry.name.startsWith(".")) continue
const subDirPath = join(commandsDir, entry.name)
const subPrefix = prefix ? `${prefix}:${entry.name}` : entry.name
commands.push(...loadCommandsFromDir(subDirPath, scope, visited, subPrefix))
const subCommands = await loadCommandsFromDir(subDirPath, scope, visited, subPrefix)
commands.push(...subCommands)
continue
}
@@ -56,7 +60,7 @@ function loadCommandsFromDir(
const commandName = prefix ? `${prefix}:${baseCommandName}` : baseCommandName
try {
const content = readFileSync(commandPath, "utf-8")
const content = await fs.readFile(commandPath, "utf-8")
const { data, body } = parseFrontmatter<CommandFrontmatter>(content)
const wrappedTemplate = `<command-instruction>
@@ -105,27 +109,36 @@ function commandsToRecord(commands: LoadedCommand[]): Record<string, CommandDefi
return result
}
export function loadUserCommands(): Record<string, CommandDefinition> {
export async function loadUserCommands(): Promise<Record<string, CommandDefinition>> {
const userCommandsDir = join(getClaudeConfigDir(), "commands")
const commands = loadCommandsFromDir(userCommandsDir, "user")
const commands = await loadCommandsFromDir(userCommandsDir, "user")
return commandsToRecord(commands)
}
export function loadProjectCommands(): Record<string, CommandDefinition> {
export async function loadProjectCommands(): Promise<Record<string, CommandDefinition>> {
const projectCommandsDir = join(process.cwd(), ".claude", "commands")
const commands = loadCommandsFromDir(projectCommandsDir, "project")
const commands = await loadCommandsFromDir(projectCommandsDir, "project")
return commandsToRecord(commands)
}
export function loadOpencodeGlobalCommands(): Record<string, CommandDefinition> {
const { homedir } = require("os")
export async function loadOpencodeGlobalCommands(): Promise<Record<string, CommandDefinition>> {
const opencodeCommandsDir = join(homedir(), ".config", "opencode", "command")
const commands = loadCommandsFromDir(opencodeCommandsDir, "opencode")
const commands = await loadCommandsFromDir(opencodeCommandsDir, "opencode")
return commandsToRecord(commands)
}
export function loadOpencodeProjectCommands(): Record<string, CommandDefinition> {
export async function loadOpencodeProjectCommands(): Promise<Record<string, CommandDefinition>> {
const opencodeProjectDir = join(process.cwd(), ".opencode", "command")
const commands = loadCommandsFromDir(opencodeProjectDir, "opencode-project")
const commands = await loadCommandsFromDir(opencodeProjectDir, "opencode-project")
return commandsToRecord(commands)
}
export async function loadAllCommands(): Promise<Record<string, CommandDefinition>> {
const [user, project, global, projectOpencode] = await Promise.all([
loadUserCommands(),
loadProjectCommands(),
loadOpencodeGlobalCommands(),
loadOpencodeProjectCommands(),
])
return { ...projectOpencode, ...global, ...project, ...user }
}

View File

@@ -464,11 +464,13 @@ export interface PluginComponentsResult {
export async function loadAllPluginComponents(options?: PluginLoaderOptions): Promise<PluginComponentsResult> {
const { plugins, errors } = discoverInstalledPlugins(options)
const commands = loadPluginCommands(plugins)
const skills = loadPluginSkillsAsCommands(plugins)
const agents = loadPluginAgents(plugins)
const mcpServers = await loadPluginMcpServers(plugins)
const hooksConfigs = loadPluginHooksConfigs(plugins)
const [commands, skills, agents, mcpServers, hooksConfigs] = await Promise.all([
Promise.resolve(loadPluginCommands(plugins)),
Promise.resolve(loadPluginSkillsAsCommands(plugins)),
Promise.resolve(loadPluginAgents(plugins)),
loadPluginMcpServers(plugins),
Promise.resolve(loadPluginHooksConfigs(plugins)),
])
log(`Loaded ${plugins.length} plugins with ${Object.keys(commands).length} commands, ${Object.keys(skills).length} skills, ${Object.keys(agents).length} agents, ${Object.keys(mcpServers).length} MCP servers`)

View File

@@ -0,0 +1,330 @@
import { describe, it, expect, beforeEach } from "bun:test"
import { ContextCollector } from "./collector"
import type { ContextPriority, ContextSourceType } from "./types"
describe("ContextCollector", () => {
let collector: ContextCollector
beforeEach(() => {
collector = new ContextCollector()
})
describe("register", () => {
it("registers context for a session", () => {
// #given
const sessionID = "ses_test1"
const options = {
id: "ulw-context",
source: "keyword-detector" as ContextSourceType,
content: "Ultrawork mode activated",
}
// #when
collector.register(sessionID, options)
// #then
const pending = collector.getPending(sessionID)
expect(pending.hasContent).toBe(true)
expect(pending.entries).toHaveLength(1)
expect(pending.entries[0].content).toBe("Ultrawork mode activated")
})
it("assigns default priority of 'normal' when not specified", () => {
// #given
const sessionID = "ses_test2"
// #when
collector.register(sessionID, {
id: "test",
source: "keyword-detector",
content: "test content",
})
// #then
const pending = collector.getPending(sessionID)
expect(pending.entries[0].priority).toBe("normal")
})
it("uses specified priority", () => {
// #given
const sessionID = "ses_test3"
// #when
collector.register(sessionID, {
id: "critical-context",
source: "keyword-detector",
content: "critical content",
priority: "critical",
})
// #then
const pending = collector.getPending(sessionID)
expect(pending.entries[0].priority).toBe("critical")
})
it("deduplicates by source + id combination", () => {
// #given
const sessionID = "ses_test4"
const options = {
id: "ulw-context",
source: "keyword-detector" as ContextSourceType,
content: "First content",
}
// #when
collector.register(sessionID, options)
collector.register(sessionID, { ...options, content: "Updated content" })
// #then
const pending = collector.getPending(sessionID)
expect(pending.entries).toHaveLength(1)
expect(pending.entries[0].content).toBe("Updated content")
})
it("allows same id from different sources", () => {
// #given
const sessionID = "ses_test5"
// #when
collector.register(sessionID, {
id: "context-1",
source: "keyword-detector",
content: "From keyword-detector",
})
collector.register(sessionID, {
id: "context-1",
source: "rules-injector",
content: "From rules-injector",
})
// #then
const pending = collector.getPending(sessionID)
expect(pending.entries).toHaveLength(2)
})
})
describe("getPending", () => {
it("returns empty result for session with no context", () => {
// #given
const sessionID = "ses_empty"
// #when
const pending = collector.getPending(sessionID)
// #then
expect(pending.hasContent).toBe(false)
expect(pending.entries).toHaveLength(0)
expect(pending.merged).toBe("")
})
it("merges multiple contexts with separator", () => {
// #given
const sessionID = "ses_merge"
collector.register(sessionID, {
id: "ctx-1",
source: "keyword-detector",
content: "First context",
})
collector.register(sessionID, {
id: "ctx-2",
source: "rules-injector",
content: "Second context",
})
// #when
const pending = collector.getPending(sessionID)
// #then
expect(pending.hasContent).toBe(true)
expect(pending.merged).toContain("First context")
expect(pending.merged).toContain("Second context")
})
it("orders contexts by priority (critical > high > normal > low)", () => {
// #given
const sessionID = "ses_priority"
collector.register(sessionID, {
id: "low",
source: "custom",
content: "LOW",
priority: "low",
})
collector.register(sessionID, {
id: "critical",
source: "custom",
content: "CRITICAL",
priority: "critical",
})
collector.register(sessionID, {
id: "normal",
source: "custom",
content: "NORMAL",
priority: "normal",
})
collector.register(sessionID, {
id: "high",
source: "custom",
content: "HIGH",
priority: "high",
})
// #when
const pending = collector.getPending(sessionID)
// #then
const order = pending.entries.map((e) => e.priority)
expect(order).toEqual(["critical", "high", "normal", "low"])
})
it("maintains registration order within same priority", () => {
// #given
const sessionID = "ses_order"
collector.register(sessionID, {
id: "first",
source: "custom",
content: "First",
priority: "normal",
})
collector.register(sessionID, {
id: "second",
source: "custom",
content: "Second",
priority: "normal",
})
collector.register(sessionID, {
id: "third",
source: "custom",
content: "Third",
priority: "normal",
})
// #when
const pending = collector.getPending(sessionID)
// #then
const ids = pending.entries.map((e) => e.id)
expect(ids).toEqual(["first", "second", "third"])
})
})
describe("consume", () => {
it("clears pending context for session", () => {
// #given
const sessionID = "ses_consume"
collector.register(sessionID, {
id: "ctx",
source: "keyword-detector",
content: "test",
})
// #when
collector.consume(sessionID)
// #then
const pending = collector.getPending(sessionID)
expect(pending.hasContent).toBe(false)
})
it("returns the consumed context", () => {
// #given
const sessionID = "ses_consume_return"
collector.register(sessionID, {
id: "ctx",
source: "keyword-detector",
content: "test content",
})
// #when
const consumed = collector.consume(sessionID)
// #then
expect(consumed.hasContent).toBe(true)
expect(consumed.entries[0].content).toBe("test content")
})
it("does not affect other sessions", () => {
// #given
const session1 = "ses_1"
const session2 = "ses_2"
collector.register(session1, {
id: "ctx",
source: "keyword-detector",
content: "session 1",
})
collector.register(session2, {
id: "ctx",
source: "keyword-detector",
content: "session 2",
})
// #when
collector.consume(session1)
// #then
expect(collector.getPending(session1).hasContent).toBe(false)
expect(collector.getPending(session2).hasContent).toBe(true)
})
})
describe("clear", () => {
it("removes all context for a session", () => {
// #given
const sessionID = "ses_clear"
collector.register(sessionID, {
id: "ctx-1",
source: "keyword-detector",
content: "test 1",
})
collector.register(sessionID, {
id: "ctx-2",
source: "rules-injector",
content: "test 2",
})
// #when
collector.clear(sessionID)
// #then
expect(collector.getPending(sessionID).hasContent).toBe(false)
})
})
describe("hasPending", () => {
it("returns true when session has pending context", () => {
// #given
const sessionID = "ses_has"
collector.register(sessionID, {
id: "ctx",
source: "keyword-detector",
content: "test",
})
// #when / #then
expect(collector.hasPending(sessionID)).toBe(true)
})
it("returns false when session has no pending context", () => {
// #given
const sessionID = "ses_empty"
// #when / #then
expect(collector.hasPending(sessionID)).toBe(false)
})
it("returns false after consume", () => {
// #given
const sessionID = "ses_after_consume"
collector.register(sessionID, {
id: "ctx",
source: "keyword-detector",
content: "test",
})
// #when
collector.consume(sessionID)
// #then
expect(collector.hasPending(sessionID)).toBe(false)
})
})
})

View File

@@ -0,0 +1,85 @@
import type {
ContextEntry,
ContextPriority,
PendingContext,
RegisterContextOptions,
} from "./types"
const PRIORITY_ORDER: Record<ContextPriority, number> = {
critical: 0,
high: 1,
normal: 2,
low: 3,
}
const CONTEXT_SEPARATOR = "\n\n---\n\n"
export class ContextCollector {
private sessions: Map<string, Map<string, ContextEntry>> = new Map()
register(sessionID: string, options: RegisterContextOptions): void {
if (!this.sessions.has(sessionID)) {
this.sessions.set(sessionID, new Map())
}
const sessionMap = this.sessions.get(sessionID)!
const key = `${options.source}:${options.id}`
const entry: ContextEntry = {
id: options.id,
source: options.source,
content: options.content,
priority: options.priority ?? "normal",
timestamp: Date.now(),
metadata: options.metadata,
}
sessionMap.set(key, entry)
}
getPending(sessionID: string): PendingContext {
const sessionMap = this.sessions.get(sessionID)
if (!sessionMap || sessionMap.size === 0) {
return {
merged: "",
entries: [],
hasContent: false,
}
}
const entries = this.sortEntries([...sessionMap.values()])
const merged = entries.map((e) => e.content).join(CONTEXT_SEPARATOR)
return {
merged,
entries,
hasContent: entries.length > 0,
}
}
consume(sessionID: string): PendingContext {
const pending = this.getPending(sessionID)
this.clear(sessionID)
return pending
}
clear(sessionID: string): void {
this.sessions.delete(sessionID)
}
hasPending(sessionID: string): boolean {
const sessionMap = this.sessions.get(sessionID)
return sessionMap !== undefined && sessionMap.size > 0
}
private sortEntries(entries: ContextEntry[]): ContextEntry[] {
return entries.sort((a, b) => {
const priorityDiff = PRIORITY_ORDER[a.priority] - PRIORITY_ORDER[b.priority]
if (priorityDiff !== 0) return priorityDiff
return a.timestamp - b.timestamp
})
}
}
export const contextCollector = new ContextCollector()

View File

@@ -0,0 +1,16 @@
export { ContextCollector, contextCollector } from "./collector"
export {
injectPendingContext,
createContextInjectorHook,
createContextInjectorMessagesTransformHook,
} from "./injector"
export type {
ContextSourceType,
ContextPriority,
ContextEntry,
RegisterContextOptions,
PendingContext,
MessageContext,
OutputParts,
InjectionStrategy,
} from "./types"

View File

@@ -0,0 +1,292 @@
import { describe, it, expect, beforeEach } from "bun:test"
import { ContextCollector } from "./collector"
import {
injectPendingContext,
createContextInjectorHook,
createContextInjectorMessagesTransformHook,
} from "./injector"
describe("injectPendingContext", () => {
let collector: ContextCollector
beforeEach(() => {
collector = new ContextCollector()
})
describe("when parts have text content", () => {
it("prepends context to first text part", () => {
// #given
const sessionID = "ses_inject1"
collector.register(sessionID, {
id: "ulw",
source: "keyword-detector",
content: "Ultrawork mode activated",
})
const parts = [{ type: "text", text: "User message" }]
// #when
const result = injectPendingContext(collector, sessionID, parts)
// #then
expect(result.injected).toBe(true)
expect(parts[0].text).toContain("Ultrawork mode activated")
expect(parts[0].text).toContain("User message")
})
it("uses separator between context and original message", () => {
// #given
const sessionID = "ses_inject2"
collector.register(sessionID, {
id: "ctx",
source: "keyword-detector",
content: "Context content",
})
const parts = [{ type: "text", text: "Original message" }]
// #when
injectPendingContext(collector, sessionID, parts)
// #then
expect(parts[0].text).toBe("Context content\n\n---\n\nOriginal message")
})
it("consumes context after injection", () => {
// #given
const sessionID = "ses_inject3"
collector.register(sessionID, {
id: "ctx",
source: "keyword-detector",
content: "Context",
})
const parts = [{ type: "text", text: "Message" }]
// #when
injectPendingContext(collector, sessionID, parts)
// #then
expect(collector.hasPending(sessionID)).toBe(false)
})
it("returns injected=false when no pending context", () => {
// #given
const sessionID = "ses_empty"
const parts = [{ type: "text", text: "Message" }]
// #when
const result = injectPendingContext(collector, sessionID, parts)
// #then
expect(result.injected).toBe(false)
expect(parts[0].text).toBe("Message")
})
})
describe("when parts have no text content", () => {
it("does not inject and preserves context", () => {
// #given
const sessionID = "ses_notext"
collector.register(sessionID, {
id: "ctx",
source: "keyword-detector",
content: "Context",
})
const parts = [{ type: "image", url: "https://example.com/img.png" }]
// #when
const result = injectPendingContext(collector, sessionID, parts)
// #then
expect(result.injected).toBe(false)
expect(collector.hasPending(sessionID)).toBe(true)
})
})
describe("with multiple text parts", () => {
it("injects into first text part only", () => {
// #given
const sessionID = "ses_multi"
collector.register(sessionID, {
id: "ctx",
source: "keyword-detector",
content: "Context",
})
const parts = [
{ type: "text", text: "First" },
{ type: "text", text: "Second" },
]
// #when
injectPendingContext(collector, sessionID, parts)
// #then
expect(parts[0].text).toContain("Context")
expect(parts[1].text).toBe("Second")
})
})
})
describe("createContextInjectorHook", () => {
let collector: ContextCollector
beforeEach(() => {
collector = new ContextCollector()
})
describe("chat.message handler", () => {
it("is a no-op (context injection moved to messages transform)", async () => {
// #given
const hook = createContextInjectorHook(collector)
const sessionID = "ses_hook1"
collector.register(sessionID, {
id: "ctx",
source: "keyword-detector",
content: "Hook context",
})
const input = { sessionID }
const output = {
message: {},
parts: [{ type: "text", text: "User message" }],
}
// #when
await hook["chat.message"](input, output)
// #then
expect(output.parts[0].text).toBe("User message")
expect(collector.hasPending(sessionID)).toBe(true)
})
it("does nothing when no pending context", async () => {
// #given
const hook = createContextInjectorHook(collector)
const sessionID = "ses_hook2"
const input = { sessionID }
const output = {
message: {},
parts: [{ type: "text", text: "User message" }],
}
// #when
await hook["chat.message"](input, output)
// #then
expect(output.parts[0].text).toBe("User message")
})
})
})
describe("createContextInjectorMessagesTransformHook", () => {
let collector: ContextCollector
beforeEach(() => {
collector = new ContextCollector()
})
const createMockMessage = (
role: "user" | "assistant",
text: string,
sessionID: string
) => ({
info: {
id: `msg_${Date.now()}_${Math.random()}`,
sessionID,
role,
time: { created: Date.now() },
agent: "Sisyphus",
model: { providerID: "test", modelID: "test" },
path: { cwd: "/", root: "/" },
},
parts: [
{
id: `part_${Date.now()}`,
sessionID,
messageID: `msg_${Date.now()}`,
type: "text" as const,
text,
},
],
})
it("inserts synthetic message before last user message", async () => {
// #given
const hook = createContextInjectorMessagesTransformHook(collector)
const sessionID = "ses_transform1"
collector.register(sessionID, {
id: "ulw",
source: "keyword-detector",
content: "Ultrawork context",
})
const messages = [
createMockMessage("user", "First message", sessionID),
createMockMessage("assistant", "Response", sessionID),
createMockMessage("user", "Second message", sessionID),
]
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const output = { messages } as any
// #when
await hook["experimental.chat.messages.transform"]!({}, output)
// #then
expect(output.messages.length).toBe(4)
expect(output.messages[2].parts[0].text).toBe("Ultrawork context")
expect(output.messages[2].parts[0].synthetic).toBe(true)
expect(output.messages[3].parts[0].text).toBe("Second message")
})
it("does nothing when no pending context", async () => {
// #given
const hook = createContextInjectorMessagesTransformHook(collector)
const sessionID = "ses_transform2"
const messages = [createMockMessage("user", "Hello world", sessionID)]
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const output = { messages } as any
// #when
await hook["experimental.chat.messages.transform"]!({}, output)
// #then
expect(output.messages.length).toBe(1)
})
it("does nothing when no user messages", async () => {
// #given
const hook = createContextInjectorMessagesTransformHook(collector)
const sessionID = "ses_transform3"
collector.register(sessionID, {
id: "ctx",
source: "keyword-detector",
content: "Context",
})
const messages = [createMockMessage("assistant", "Response", sessionID)]
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const output = { messages } as any
// #when
await hook["experimental.chat.messages.transform"]!({}, output)
// #then
expect(output.messages.length).toBe(1)
expect(collector.hasPending(sessionID)).toBe(true)
})
it("consumes context after injection", async () => {
// #given
const hook = createContextInjectorMessagesTransformHook(collector)
const sessionID = "ses_transform4"
collector.register(sessionID, {
id: "ctx",
source: "keyword-detector",
content: "Context",
})
const messages = [createMockMessage("user", "Message", sessionID)]
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const output = { messages } as any
// #when
await hook["experimental.chat.messages.transform"]!({}, output)
// #then
expect(collector.hasPending(sessionID)).toBe(false)
})
})

View File

@@ -0,0 +1,156 @@
import type { ContextCollector } from "./collector"
import type { Message, Part } from "@opencode-ai/sdk"
import { log } from "../../shared"
interface OutputPart {
type: string
text?: string
[key: string]: unknown
}
interface InjectionResult {
injected: boolean
contextLength: number
}
export function injectPendingContext(
collector: ContextCollector,
sessionID: string,
parts: OutputPart[]
): InjectionResult {
if (!collector.hasPending(sessionID)) {
return { injected: false, contextLength: 0 }
}
const textPartIndex = parts.findIndex((p) => p.type === "text" && p.text !== undefined)
if (textPartIndex === -1) {
return { injected: false, contextLength: 0 }
}
const pending = collector.consume(sessionID)
const originalText = parts[textPartIndex].text ?? ""
parts[textPartIndex].text = `${pending.merged}\n\n---\n\n${originalText}`
return {
injected: true,
contextLength: pending.merged.length,
}
}
interface ChatMessageInput {
sessionID: string
agent?: string
model?: { providerID: string; modelID: string }
messageID?: string
}
interface ChatMessageOutput {
message: Record<string, unknown>
parts: OutputPart[]
}
export function createContextInjectorHook(collector: ContextCollector) {
return {
"chat.message": async (
_input: ChatMessageInput,
_output: ChatMessageOutput
): Promise<void> => {
void collector
},
}
}
interface MessageWithParts {
info: Message
parts: Part[]
}
type MessagesTransformHook = {
"experimental.chat.messages.transform"?: (
input: Record<string, never>,
output: { messages: MessageWithParts[] }
) => Promise<void>
}
export function createContextInjectorMessagesTransformHook(
collector: ContextCollector
): MessagesTransformHook {
return {
"experimental.chat.messages.transform": async (_input, output) => {
const { messages } = output
if (messages.length === 0) {
return
}
let lastUserMessageIndex = -1
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i].info.role === "user") {
lastUserMessageIndex = i
break
}
}
if (lastUserMessageIndex === -1) {
return
}
const lastUserMessage = messages[lastUserMessageIndex]
const sessionID = (lastUserMessage.info as unknown as { sessionID?: string }).sessionID
if (!sessionID) {
return
}
if (!collector.hasPending(sessionID)) {
return
}
const pending = collector.consume(sessionID)
if (!pending.hasContent) {
return
}
const refInfo = lastUserMessage.info as unknown as {
sessionID?: string
agent?: string
model?: { providerID?: string; modelID?: string }
path?: { cwd?: string; root?: string }
}
const syntheticMessageId = `synthetic_ctx_${Date.now()}`
const syntheticPartId = `synthetic_ctx_part_${Date.now()}`
const now = Date.now()
const syntheticMessage: MessageWithParts = {
info: {
id: syntheticMessageId,
sessionID: sessionID,
role: "user",
time: { created: now },
agent: refInfo.agent ?? "Sisyphus",
model: refInfo.model ?? { providerID: "unknown", modelID: "unknown" },
path: refInfo.path ?? { cwd: "/", root: "/" },
} as unknown as Message,
parts: [
{
id: syntheticPartId,
sessionID: sessionID,
messageID: syntheticMessageId,
type: "text",
text: pending.merged,
synthetic: true,
time: { start: now, end: now },
} as Part,
],
}
messages.splice(lastUserMessageIndex, 0, syntheticMessage)
log("[context-injector] Injected synthetic message from collector", {
sessionID,
insertIndex: lastUserMessageIndex,
contextLength: pending.merged.length,
newMessageCount: messages.length,
})
},
}
}

View File

@@ -0,0 +1,91 @@
/**
* Source identifier for context injection
* Each source registers context that will be merged and injected together
*/
export type ContextSourceType =
| "keyword-detector"
| "rules-injector"
| "directory-agents"
| "directory-readme"
| "custom"
/**
* Priority levels for context ordering
* Higher priority contexts appear first in the merged output
*/
export type ContextPriority = "critical" | "high" | "normal" | "low"
/**
* A single context entry registered by a source
*/
export interface ContextEntry {
/** Unique identifier for this entry within the source */
id: string
/** The source that registered this context */
source: ContextSourceType
/** The actual context content to inject */
content: string
/** Priority for ordering (default: normal) */
priority: ContextPriority
/** Timestamp when registered */
timestamp: number
/** Optional metadata for debugging/logging */
metadata?: Record<string, unknown>
}
/**
* Options for registering context
*/
export interface RegisterContextOptions {
/** Unique ID for this context entry (used for deduplication) */
id: string
/** Source identifier */
source: ContextSourceType
/** The content to inject */
content: string
/** Priority for ordering (default: normal) */
priority?: ContextPriority
/** Optional metadata */
metadata?: Record<string, unknown>
}
/**
* Result of getting pending context for a session
*/
export interface PendingContext {
/** Merged context string, ready for injection */
merged: string
/** Individual entries that were merged */
entries: ContextEntry[]
/** Whether there's any content to inject */
hasContent: boolean
}
/**
* Message context from the original user message
* Used when injecting to match the message format
*/
export interface MessageContext {
agent?: string
model?: {
providerID?: string
modelID?: string
}
path?: {
cwd?: string
root?: string
}
tools?: Record<string, boolean>
}
/**
* Output parts from chat.message hook
*/
export interface OutputParts {
parts: Array<{ type: string; text?: string; [key: string]: unknown }>
}
/**
* Injection strategy
*/
export type InjectionStrategy = "prepend-parts" | "storage" | "auto"

View File

@@ -0,0 +1,448 @@
import { describe, it, expect, beforeEach, afterEach } from "bun:test"
import { mkdirSync, writeFileSync, rmSync, chmodSync } from "fs"
import { join } from "path"
import { tmpdir } from "os"
import type { LoadedSkill } from "./types"
const TEST_DIR = join(tmpdir(), "async-loader-test-" + Date.now())
const SKILLS_DIR = join(TEST_DIR, ".opencode", "skill")
function createTestSkill(name: string, content: string, mcpJson?: object): string {
const skillDir = join(SKILLS_DIR, name)
mkdirSync(skillDir, { recursive: true })
const skillPath = join(skillDir, "SKILL.md")
writeFileSync(skillPath, content)
if (mcpJson) {
writeFileSync(join(skillDir, "mcp.json"), JSON.stringify(mcpJson, null, 2))
}
return skillDir
}
function createDirectSkill(name: string, content: string): string {
mkdirSync(SKILLS_DIR, { recursive: true })
const skillPath = join(SKILLS_DIR, `${name}.md`)
writeFileSync(skillPath, content)
return skillPath
}
describe("async-loader", () => {
beforeEach(() => {
mkdirSync(TEST_DIR, { recursive: true })
})
afterEach(() => {
rmSync(TEST_DIR, { recursive: true, force: true })
})
describe("discoverSkillsInDirAsync", () => {
it("returns empty array for non-existent directory", async () => {
// #given - non-existent directory
const nonExistentDir = join(TEST_DIR, "does-not-exist")
// #when
const { discoverSkillsInDirAsync } = await import("./async-loader")
const skills = await discoverSkillsInDirAsync(nonExistentDir)
// #then - should return empty array, not throw
expect(skills).toEqual([])
})
it("discovers skills from SKILL.md in directory", async () => {
// #given
const skillContent = `---
name: test-skill
description: A test skill
---
This is the skill body.
`
createTestSkill("test-skill", skillContent)
// #when
const { discoverSkillsInDirAsync } = await import("./async-loader")
const skills = await discoverSkillsInDirAsync(SKILLS_DIR)
// #then
expect(skills).toHaveLength(1)
expect(skills[0].name).toBe("test-skill")
expect(skills[0].definition.description).toContain("A test skill")
})
it("discovers skills from {name}.md pattern in directory", async () => {
// #given
const skillContent = `---
name: named-skill
description: Named pattern skill
---
Skill body.
`
const skillDir = join(SKILLS_DIR, "named-skill")
mkdirSync(skillDir, { recursive: true })
writeFileSync(join(skillDir, "named-skill.md"), skillContent)
// #when
const { discoverSkillsInDirAsync } = await import("./async-loader")
const skills = await discoverSkillsInDirAsync(SKILLS_DIR)
// #then
expect(skills).toHaveLength(1)
expect(skills[0].name).toBe("named-skill")
})
it("discovers direct .md files", async () => {
// #given
const skillContent = `---
name: direct-skill
description: Direct markdown file
---
Direct skill.
`
createDirectSkill("direct-skill", skillContent)
// #when
const { discoverSkillsInDirAsync } = await import("./async-loader")
const skills = await discoverSkillsInDirAsync(SKILLS_DIR)
// #then
expect(skills).toHaveLength(1)
expect(skills[0].name).toBe("direct-skill")
})
it("skips entries starting with dot", async () => {
// #given
const validContent = `---
name: valid-skill
---
Valid.
`
const hiddenContent = `---
name: hidden-skill
---
Hidden.
`
createTestSkill("valid-skill", validContent)
createTestSkill(".hidden-skill", hiddenContent)
// #when
const { discoverSkillsInDirAsync } = await import("./async-loader")
const skills = await discoverSkillsInDirAsync(SKILLS_DIR)
// #then - only valid-skill should be discovered
expect(skills).toHaveLength(1)
expect(skills[0]?.name).toBe("valid-skill")
})
it("skips invalid files and continues with valid ones", async () => {
// #given - one valid, one invalid (unreadable)
const validContent = `---
name: valid-skill
---
Valid skill.
`
const invalidContent = `---
name: invalid-skill
---
Invalid skill.
`
createTestSkill("valid-skill", validContent)
const invalidDir = createTestSkill("invalid-skill", invalidContent)
const invalidFile = join(invalidDir, "SKILL.md")
// Make file unreadable on Unix systems
if (process.platform !== "win32") {
chmodSync(invalidFile, 0o000)
}
// #when
const { discoverSkillsInDirAsync } = await import("./async-loader")
const skills = await discoverSkillsInDirAsync(SKILLS_DIR)
// #then - should skip invalid and return only valid
expect(skills.length).toBeGreaterThanOrEqual(1)
expect(skills.some((s: LoadedSkill) => s.name === "valid-skill")).toBe(true)
// Cleanup: restore permissions before cleanup
if (process.platform !== "win32") {
chmodSync(invalidFile, 0o644)
}
})
it("discovers multiple skills correctly", async () => {
// #given
const skill1 = `---
name: skill-one
description: First skill
---
Skill one.
`
const skill2 = `---
name: skill-two
description: Second skill
---
Skill two.
`
createTestSkill("skill-one", skill1)
createTestSkill("skill-two", skill2)
// #when
const { discoverSkillsInDirAsync } = await import("./async-loader")
const asyncSkills = await discoverSkillsInDirAsync(SKILLS_DIR)
// #then
expect(asyncSkills.length).toBe(2)
expect(asyncSkills.map((s: LoadedSkill) => s.name).sort()).toEqual(["skill-one", "skill-two"])
const skill1Result = asyncSkills.find((s: LoadedSkill) => s.name === "skill-one")
expect(skill1Result?.definition.description).toContain("First skill")
})
it("loads MCP config from frontmatter", async () => {
// #given
const skillContent = `---
name: mcp-skill
description: Skill with MCP
mcp:
sqlite:
command: uvx
args: [mcp-server-sqlite]
---
MCP skill.
`
createTestSkill("mcp-skill", skillContent)
// #when
const { discoverSkillsInDirAsync } = await import("./async-loader")
const skills = await discoverSkillsInDirAsync(SKILLS_DIR)
// #then
const skill = skills.find((s: LoadedSkill) => s.name === "mcp-skill")
expect(skill?.mcpConfig).toBeDefined()
expect(skill?.mcpConfig?.sqlite).toBeDefined()
expect(skill?.mcpConfig?.sqlite?.command).toBe("uvx")
})
it("loads MCP config from mcp.json file", async () => {
// #given
const skillContent = `---
name: json-mcp-skill
description: Skill with mcp.json
---
Skill body.
`
const mcpJson = {
mcpServers: {
playwright: {
command: "npx",
args: ["@playwright/mcp"]
}
}
}
createTestSkill("json-mcp-skill", skillContent, mcpJson)
// #when
const { discoverSkillsInDirAsync } = await import("./async-loader")
const skills = await discoverSkillsInDirAsync(SKILLS_DIR)
// #then
const skill = skills.find((s: LoadedSkill) => s.name === "json-mcp-skill")
expect(skill?.mcpConfig?.playwright).toBeDefined()
expect(skill?.mcpConfig?.playwright?.command).toBe("npx")
})
it("prioritizes mcp.json over frontmatter MCP", async () => {
// #given
const skillContent = `---
name: priority-test
mcp:
from-yaml:
command: yaml-cmd
---
Skill.
`
const mcpJson = {
mcpServers: {
"from-json": {
command: "json-cmd"
}
}
}
createTestSkill("priority-test", skillContent, mcpJson)
// #when
const { discoverSkillsInDirAsync } = await import("./async-loader")
const skills = await discoverSkillsInDirAsync(SKILLS_DIR)
// #then - mcp.json should take priority
const skill = skills.find((s: LoadedSkill) => s.name === "priority-test")
expect(skill?.mcpConfig?.["from-json"]).toBeDefined()
expect(skill?.mcpConfig?.["from-yaml"]).toBeUndefined()
})
})
describe("mapWithConcurrency", () => {
it("processes items with concurrency limit", async () => {
// #given
const { mapWithConcurrency } = await import("./async-loader")
const items = Array.from({ length: 50 }, (_, i) => i)
let maxConcurrent = 0
let currentConcurrent = 0
const mapper = async (item: number) => {
currentConcurrent++
maxConcurrent = Math.max(maxConcurrent, currentConcurrent)
await new Promise(resolve => setTimeout(resolve, 10))
currentConcurrent--
return item * 2
}
// #when
const results = await mapWithConcurrency(items, mapper, 16)
// #then
expect(results).toEqual(items.map(i => i * 2))
expect(maxConcurrent).toBeLessThanOrEqual(16)
expect(maxConcurrent).toBeGreaterThan(1) // Should actually run concurrently
})
it("handles empty array", async () => {
// #given
const { mapWithConcurrency } = await import("./async-loader")
// #when
const results = await mapWithConcurrency([], async (x: number) => x * 2, 16)
// #then
expect(results).toEqual([])
})
it("handles single item", async () => {
// #given
const { mapWithConcurrency } = await import("./async-loader")
// #when
const results = await mapWithConcurrency([42], async (x: number) => x * 2, 16)
// #then
expect(results).toEqual([84])
})
})
describe("loadSkillFromPathAsync", () => {
it("loads skill from valid path", async () => {
// #given
const skillContent = `---
name: path-skill
description: Loaded from path
---
Path skill.
`
const skillDir = createTestSkill("path-skill", skillContent)
const skillPath = join(skillDir, "SKILL.md")
// #when
const { loadSkillFromPathAsync } = await import("./async-loader")
const skill = await loadSkillFromPathAsync(skillPath, skillDir, "path-skill", "opencode-project")
// #then
expect(skill).not.toBeNull()
expect(skill?.name).toBe("path-skill")
expect(skill?.scope).toBe("opencode-project")
})
it("returns null for invalid path", async () => {
// #given
const invalidPath = join(TEST_DIR, "nonexistent.md")
// #when
const { loadSkillFromPathAsync } = await import("./async-loader")
const skill = await loadSkillFromPathAsync(invalidPath, TEST_DIR, "invalid", "opencode")
// #then
expect(skill).toBeNull()
})
it("returns null for malformed skill file", async () => {
// #given
const malformedContent = "This is not valid frontmatter content\nNo YAML here!"
mkdirSync(SKILLS_DIR, { recursive: true })
const malformedPath = join(SKILLS_DIR, "malformed.md")
writeFileSync(malformedPath, malformedContent)
// #when
const { loadSkillFromPathAsync } = await import("./async-loader")
const skill = await loadSkillFromPathAsync(malformedPath, SKILLS_DIR, "malformed", "user")
// #then
expect(skill).not.toBeNull() // parseFrontmatter handles missing frontmatter gracefully
})
})
describe("loadMcpJsonFromDirAsync", () => {
it("loads mcp.json with mcpServers format", async () => {
// #given
mkdirSync(SKILLS_DIR, { recursive: true })
const mcpJson = {
mcpServers: {
test: {
command: "test-cmd",
args: ["arg1"]
}
}
}
writeFileSync(join(SKILLS_DIR, "mcp.json"), JSON.stringify(mcpJson))
// #when
const { loadMcpJsonFromDirAsync } = await import("./async-loader")
const config = await loadMcpJsonFromDirAsync(SKILLS_DIR)
// #then
expect(config).toBeDefined()
expect(config?.test).toBeDefined()
expect(config?.test?.command).toBe("test-cmd")
})
it("returns undefined for non-existent mcp.json", async () => {
// #given
mkdirSync(SKILLS_DIR, { recursive: true })
// #when
const { loadMcpJsonFromDirAsync } = await import("./async-loader")
const config = await loadMcpJsonFromDirAsync(SKILLS_DIR)
// #then
expect(config).toBeUndefined()
})
it("returns undefined for invalid JSON", async () => {
// #given
mkdirSync(SKILLS_DIR, { recursive: true })
writeFileSync(join(SKILLS_DIR, "mcp.json"), "{ invalid json }")
// #when
const { loadMcpJsonFromDirAsync } = await import("./async-loader")
const config = await loadMcpJsonFromDirAsync(SKILLS_DIR)
// #then
expect(config).toBeUndefined()
})
it("supports direct format without mcpServers", async () => {
// #given
mkdirSync(SKILLS_DIR, { recursive: true })
const mcpJson = {
direct: {
command: "direct-cmd",
args: ["arg"]
}
}
writeFileSync(join(SKILLS_DIR, "mcp.json"), JSON.stringify(mcpJson))
// #when
const { loadMcpJsonFromDirAsync } = await import("./async-loader")
const config = await loadMcpJsonFromDirAsync(SKILLS_DIR)
// #then
expect(config?.direct).toBeDefined()
expect(config?.direct?.command).toBe("direct-cmd")
})
})
})

View File

@@ -0,0 +1,180 @@
import { readFile, readdir } from "fs/promises"
import type { Dirent } from "fs"
import { join, basename } from "path"
import yaml from "js-yaml"
import { parseFrontmatter } from "../../shared/frontmatter"
import { sanitizeModelField } from "../../shared/model-sanitizer"
import { resolveSymlink, isMarkdownFile } from "../../shared/file-utils"
import type { CommandDefinition } from "../claude-code-command-loader/types"
import type { SkillScope, SkillMetadata, LoadedSkill } from "./types"
import type { SkillMcpConfig } from "../skill-mcp-manager/types"
export async function mapWithConcurrency<T, R>(
items: T[],
mapper: (item: T) => Promise<R>,
concurrency: number
): Promise<R[]> {
const results: R[] = new Array(items.length)
let index = 0
const worker = async () => {
while (index < items.length) {
const currentIndex = index++
results[currentIndex] = await mapper(items[currentIndex])
}
}
const workers = Array.from({ length: Math.min(concurrency, items.length) }, () => worker())
await Promise.all(workers)
return results
}
function parseSkillMcpConfigFromFrontmatter(content: string): SkillMcpConfig | undefined {
const frontmatterMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---/)
if (!frontmatterMatch) return undefined
try {
const parsed = yaml.load(frontmatterMatch[1]) as Record<string, unknown>
if (parsed && typeof parsed === "object" && "mcp" in parsed && parsed.mcp) {
return parsed.mcp as SkillMcpConfig
}
} catch {
return undefined
}
return undefined
}
export async function loadMcpJsonFromDirAsync(skillDir: string): Promise<SkillMcpConfig | undefined> {
const mcpJsonPath = join(skillDir, "mcp.json")
try {
const content = await readFile(mcpJsonPath, "utf-8")
const parsed = JSON.parse(content) as Record<string, unknown>
if (parsed && typeof parsed === "object" && "mcpServers" in parsed && parsed.mcpServers) {
return parsed.mcpServers as SkillMcpConfig
}
if (parsed && typeof parsed === "object" && !("mcpServers" in parsed)) {
const hasCommandField = Object.values(parsed).some(
(v) => v && typeof v === "object" && "command" in (v as Record<string, unknown>)
)
if (hasCommandField) {
return parsed as SkillMcpConfig
}
}
} catch {
return undefined
}
return undefined
}
export async function loadSkillFromPathAsync(
skillPath: string,
resolvedPath: string,
defaultName: string,
scope: SkillScope
): Promise<LoadedSkill | null> {
try {
const content = await readFile(skillPath, "utf-8")
const { data, body, parseError } = parseFrontmatter<SkillMetadata>(content)
if (parseError) return null
const frontmatterMcp = parseSkillMcpConfigFromFrontmatter(content)
const mcpJsonMcp = await loadMcpJsonFromDirAsync(resolvedPath)
const mcpConfig = mcpJsonMcp || frontmatterMcp
const skillName = data.name || defaultName
const originalDescription = data.description || ""
const isOpencodeSource = scope === "opencode" || scope === "opencode-project"
const formattedDescription = `(${scope} - Skill) ${originalDescription}`
const wrappedTemplate = `<skill-instruction>
Base directory for this skill: ${resolvedPath}/
File references (@path) in this skill are relative to this directory.
${body.trim()}
</skill-instruction>
<user-request>
$ARGUMENTS
</user-request>`
const definition: CommandDefinition = {
name: skillName,
description: formattedDescription,
template: wrappedTemplate,
model: sanitizeModelField(data.model, isOpencodeSource ? "opencode" : "claude-code"),
agent: data.agent,
subtask: data.subtask,
argumentHint: data["argument-hint"],
}
return {
name: skillName,
path: skillPath,
resolvedPath,
definition,
scope,
license: data.license,
compatibility: data.compatibility,
metadata: data.metadata,
allowedTools: parseAllowedTools(data["allowed-tools"]),
mcpConfig,
}
} catch {
return null
}
}
function parseAllowedTools(allowedTools: string | undefined): string[] | undefined {
if (!allowedTools) return undefined
return allowedTools.split(/\s+/).filter(Boolean)
}
export async function discoverSkillsInDirAsync(skillsDir: string): Promise<LoadedSkill[]> {
try {
const entries = await readdir(skillsDir, { withFileTypes: true })
const processEntry = async (entry: Dirent): Promise<LoadedSkill | null> => {
if (entry.name.startsWith(".")) return null
const entryPath = join(skillsDir, entry.name)
if (entry.isDirectory() || entry.isSymbolicLink()) {
const resolvedPath = resolveSymlink(entryPath)
const dirName = entry.name
const skillMdPath = join(resolvedPath, "SKILL.md")
try {
await readFile(skillMdPath, "utf-8")
return await loadSkillFromPathAsync(skillMdPath, resolvedPath, dirName, "opencode-project")
} catch {
const namedSkillMdPath = join(resolvedPath, `${dirName}.md`)
try {
await readFile(namedSkillMdPath, "utf-8")
return await loadSkillFromPathAsync(namedSkillMdPath, resolvedPath, dirName, "opencode-project")
} catch {
return null
}
}
}
if (isMarkdownFile(entry)) {
const skillName = basename(entry.name, ".md")
return await loadSkillFromPathAsync(entryPath, skillsDir, skillName, "opencode-project")
}
return null
}
const skillPromises = await mapWithConcurrency(entries, processEntry, 16)
return skillPromises.filter((skill): skill is LoadedSkill => skill !== null)
} catch (error: unknown) {
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
return []
}
return []
}
}

View File

@@ -0,0 +1,210 @@
import { describe, it, expect, beforeEach, afterEach } from "bun:test"
import { mkdirSync, writeFileSync, rmSync } from "fs"
import { join } from "path"
import { tmpdir } from "os"
import { discoverAllSkillsBlocking } from "./blocking"
import type { SkillScope } from "./types"
const TEST_DIR = join(tmpdir(), `blocking-test-${Date.now()}`)
beforeEach(() => {
mkdirSync(TEST_DIR, { recursive: true })
})
afterEach(() => {
rmSync(TEST_DIR, { recursive: true, force: true })
})
describe("discoverAllSkillsBlocking", () => {
it("returns skills synchronously from valid directories", () => {
// #given valid skill directory
const skillDir = join(TEST_DIR, "skills")
mkdirSync(skillDir, { recursive: true })
const skillMdPath = join(skillDir, "test-skill.md")
writeFileSync(
skillMdPath,
`---
name: test-skill
description: A test skill
---
This is test skill content.`
)
const dirs = [skillDir]
const scopes: SkillScope[] = ["opencode-project"]
// #when discoverAllSkillsBlocking called
const skills = discoverAllSkillsBlocking(dirs, scopes)
// #then returns skills synchronously
expect(skills).toBeArray()
expect(skills.length).toBe(1)
expect(skills[0].name).toBe("test-skill")
expect(skills[0].definition.description).toContain("test skill")
})
it("returns empty array for empty directories", () => {
// #given empty directory
const emptyDir = join(TEST_DIR, "empty")
mkdirSync(emptyDir, { recursive: true })
const dirs = [emptyDir]
const scopes: SkillScope[] = ["opencode-project"]
// #when discoverAllSkillsBlocking called
const skills = discoverAllSkillsBlocking(dirs, scopes)
// #then returns empty array
expect(skills).toBeArray()
expect(skills.length).toBe(0)
})
it("returns empty array for non-existent directories", () => {
// #given non-existent directory
const nonExistentDir = join(TEST_DIR, "does-not-exist")
const dirs = [nonExistentDir]
const scopes: SkillScope[] = ["opencode-project"]
// #when discoverAllSkillsBlocking called
const skills = discoverAllSkillsBlocking(dirs, scopes)
// #then returns empty array (no throw)
expect(skills).toBeArray()
expect(skills.length).toBe(0)
})
it("handles multiple directories with mixed content", () => {
// #given multiple directories with valid and invalid skills
const dir1 = join(TEST_DIR, "dir1")
const dir2 = join(TEST_DIR, "dir2")
mkdirSync(dir1, { recursive: true })
mkdirSync(dir2, { recursive: true })
writeFileSync(
join(dir1, "skill1.md"),
`---
name: skill1
description: First skill
---
Skill 1 content.`
)
writeFileSync(
join(dir2, "skill2.md"),
`---
name: skill2
description: Second skill
---
Skill 2 content.`
)
const dirs = [dir1, dir2]
const scopes: SkillScope[] = ["opencode-project"]
// #when discoverAllSkillsBlocking called
const skills = discoverAllSkillsBlocking(dirs, scopes)
// #then returns all valid skills
expect(skills).toBeArray()
expect(skills.length).toBe(2)
const skillNames = skills.map(s => s.name).sort()
expect(skillNames).toEqual(["skill1", "skill2"])
})
it("skips invalid YAML files", () => {
// #given directory with invalid YAML
const skillDir = join(TEST_DIR, "skills")
mkdirSync(skillDir, { recursive: true })
const validSkillPath = join(skillDir, "valid.md")
writeFileSync(
validSkillPath,
`---
name: valid-skill
description: Valid skill
---
Valid skill content.`
)
const invalidSkillPath = join(skillDir, "invalid.md")
writeFileSync(
invalidSkillPath,
`---
name: invalid skill
description: [ invalid yaml
---
Invalid content.`
)
const dirs = [skillDir]
const scopes: SkillScope[] = ["opencode-project"]
// #when discoverAllSkillsBlocking called
const skills = discoverAllSkillsBlocking(dirs, scopes)
// #then skips invalid, returns valid
expect(skills).toBeArray()
expect(skills.length).toBe(1)
expect(skills[0].name).toBe("valid-skill")
})
it("handles directory-based skills with SKILL.md", () => {
// #given directory-based skill structure
const skillsDir = join(TEST_DIR, "skills")
const mySkillDir = join(skillsDir, "my-skill")
mkdirSync(mySkillDir, { recursive: true })
const skillMdPath = join(mySkillDir, "SKILL.md")
writeFileSync(
skillMdPath,
`---
name: my-skill
description: Directory-based skill
---
This is a directory-based skill.`
)
const dirs = [skillsDir]
const scopes: SkillScope[] = ["opencode-project"]
// #when discoverAllSkillsBlocking called
const skills = discoverAllSkillsBlocking(dirs, scopes)
// #then returns skill from SKILL.md
expect(skills).toBeArray()
expect(skills.length).toBe(1)
expect(skills[0].name).toBe("my-skill")
})
it("processes large skill sets without timeout", () => {
// #given directory with many skills (20+)
const skillDir = join(TEST_DIR, "many-skills")
mkdirSync(skillDir, { recursive: true })
const skillCount = 25
for (let i = 0; i < skillCount; i++) {
const skillPath = join(skillDir, `skill-${i}.md`)
writeFileSync(
skillPath,
`---
name: skill-${i}
description: Skill number ${i}
---
Content for skill ${i}.`
)
}
const dirs = [skillDir]
const scopes: SkillScope[] = ["opencode-project"]
// #when discoverAllSkillsBlocking called
const skills = discoverAllSkillsBlocking(dirs, scopes)
// #then completes without timeout
expect(skills).toBeArray()
expect(skills.length).toBe(skillCount)
})
})

View File

@@ -0,0 +1,62 @@
import { Worker, MessageChannel, receiveMessageOnPort } from "worker_threads"
import type { LoadedSkill, SkillScope } from "./types"
interface WorkerInput {
dirs: string[]
scopes: SkillScope[]
}
interface WorkerOutputSuccess {
ok: true
skills: LoadedSkill[]
}
interface WorkerOutputError {
ok: false
error: { message: string; stack?: string }
}
type WorkerOutput = WorkerOutputSuccess | WorkerOutputError
const TIMEOUT_MS = 30000
export function discoverAllSkillsBlocking(dirs: string[], scopes: SkillScope[]): LoadedSkill[] {
const signal = new Int32Array(new SharedArrayBuffer(4))
const { port1, port2 } = new MessageChannel()
const worker = new Worker(new URL("./discover-worker.ts", import.meta.url), {
workerData: { signal }
})
worker.postMessage({ port: port2 }, [port2])
const input: WorkerInput = { dirs, scopes }
port1.postMessage(input)
const waitResult = Atomics.wait(signal, 0, 0, TIMEOUT_MS)
if (waitResult === "timed-out") {
worker.terminate()
port1.close()
throw new Error(`Worker timeout after ${TIMEOUT_MS}ms`)
}
const message = receiveMessageOnPort(port1)
worker.terminate()
port1.close()
if (!message) {
throw new Error("Worker did not return result")
}
const output = message.message as WorkerOutput
if (output.ok === false) {
const error = new Error(output.error.message)
error.stack = output.error.stack
throw error
}
return output.skills
}

View File

@@ -0,0 +1,59 @@
import { workerData, parentPort } from "worker_threads"
import type { MessagePort } from "worker_threads"
import { discoverSkillsInDirAsync } from "./async-loader"
import type { LoadedSkill, SkillScope } from "./types"
interface WorkerInput {
dirs: string[]
scopes: SkillScope[]
}
interface WorkerOutputSuccess {
ok: true
skills: LoadedSkill[]
}
interface WorkerOutputError {
ok: false
error: { message: string; stack?: string }
}
type WorkerOutput = WorkerOutputSuccess | WorkerOutputError
const { signal } = workerData as { signal: Int32Array }
if (!parentPort) {
throw new Error("Worker must be run with parentPort")
}
parentPort.once("message", (data: { port: MessagePort }) => {
const { port } = data
port.on("message", async (input: WorkerInput) => {
try {
const results = await Promise.all(
input.dirs.map(dir => discoverSkillsInDirAsync(dir))
)
const skills = results.flat()
const output: WorkerOutputSuccess = { ok: true, skills }
port.postMessage(output)
Atomics.store(signal, 0, 1)
Atomics.notify(signal, 0)
} catch (error: unknown) {
const output: WorkerOutputError = {
ok: false,
error: {
message: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
},
}
port.postMessage(output)
Atomics.store(signal, 0, 1)
Atomics.notify(signal, 0)
}
})
})

View File

@@ -53,7 +53,7 @@ This is the skill body.
process.chdir(TEST_DIR)
try {
const skills = discoverSkills({ includeClaudeCodePaths: false })
const skills = await discoverSkills({ includeClaudeCodePaths: false })
const skill = skills.find(s => s.name === "test-skill")
// #then
@@ -89,7 +89,7 @@ This is a simple skill.
process.chdir(TEST_DIR)
try {
const skills = discoverSkills({ includeClaudeCodePaths: false })
const skills = await discoverSkills({ includeClaudeCodePaths: false })
const skill = skills.find(s => s.name === "simple-skill")
// #then
@@ -122,7 +122,7 @@ Skill with env vars.
process.chdir(TEST_DIR)
try {
const skills = discoverSkills({ includeClaudeCodePaths: false })
const skills = await discoverSkills({ includeClaudeCodePaths: false })
const skill = skills.find(s => s.name === "env-skill")
// #then
@@ -149,7 +149,7 @@ Skill body.
process.chdir(TEST_DIR)
try {
const skills = discoverSkills({ includeClaudeCodePaths: false })
const skills = await discoverSkills({ includeClaudeCodePaths: false })
// #then - when YAML fails, skill uses directory name as fallback
const skill = skills.find(s => s.name === "bad-yaml-skill")
@@ -186,7 +186,7 @@ Skill body.
process.chdir(TEST_DIR)
try {
const skills = discoverSkills({ includeClaudeCodePaths: false })
const skills = await discoverSkills({ includeClaudeCodePaths: false })
const skill = skills.find(s => s.name === "ampcode-skill")
// #then
@@ -227,7 +227,7 @@ Skill body.
process.chdir(TEST_DIR)
try {
const skills = discoverSkills({ includeClaudeCodePaths: false })
const skills = await discoverSkills({ includeClaudeCodePaths: false })
const skill = skills.find(s => s.name === "priority-skill")
// #then - mcp.json should take priority
@@ -259,7 +259,7 @@ Skill body.
process.chdir(TEST_DIR)
try {
const skills = discoverSkills({ includeClaudeCodePaths: false })
const skills = await discoverSkills({ includeClaudeCodePaths: false })
const skill = skills.find(s => s.name === "direct-format")
// #then

View File

@@ -1,13 +1,13 @@
import { existsSync, readdirSync, readFileSync } from "fs"
import { join, basename, dirname } from "path"
import { promises as fs } from "fs"
import { join, basename } from "path"
import { homedir } from "os"
import yaml from "js-yaml"
import { parseFrontmatter } from "../../shared/frontmatter"
import { sanitizeModelField } from "../../shared/model-sanitizer"
import { resolveSymlink, isMarkdownFile } from "../../shared/file-utils"
import { resolveSymlinkAsync, isMarkdownFile } from "../../shared/file-utils"
import { getClaudeConfigDir } from "../../shared"
import type { CommandDefinition } from "../claude-code-command-loader/types"
import type { SkillScope, SkillMetadata, LoadedSkill } from "./types"
import type { SkillScope, SkillMetadata, LoadedSkill, LazyContentLoader } from "./types"
import type { SkillMcpConfig } from "../skill-mcp-manager/types"
function parseSkillMcpConfigFromFrontmatter(content: string): SkillMcpConfig | undefined {
@@ -25,20 +25,17 @@ function parseSkillMcpConfigFromFrontmatter(content: string): SkillMcpConfig | u
return undefined
}
function loadMcpJsonFromDir(skillDir: string): SkillMcpConfig | undefined {
async function loadMcpJsonFromDir(skillDir: string): Promise<SkillMcpConfig | undefined> {
const mcpJsonPath = join(skillDir, "mcp.json")
if (!existsSync(mcpJsonPath)) return undefined
try {
const content = readFileSync(mcpJsonPath, "utf-8")
const content = await fs.readFile(mcpJsonPath, "utf-8")
const parsed = JSON.parse(content) as Record<string, unknown>
// AmpCode format: { "mcpServers": { "name": { ... } } }
if (parsed && typeof parsed === "object" && "mcpServers" in parsed && parsed.mcpServers) {
return parsed.mcpServers as SkillMcpConfig
}
// Also support direct format: { "name": { command: ..., args: ... } }
if (parsed && typeof parsed === "object" && !("mcpServers" in parsed)) {
const hasCommandField = Object.values(parsed).some(
(v) => v && typeof v === "object" && "command" in (v as Record<string, unknown>)
@@ -58,17 +55,17 @@ function parseAllowedTools(allowedTools: string | undefined): string[] | undefin
return allowedTools.split(/\s+/).filter(Boolean)
}
function loadSkillFromPath(
async function loadSkillFromPath(
skillPath: string,
resolvedPath: string,
defaultName: string,
scope: SkillScope
): LoadedSkill | null {
): Promise<LoadedSkill | null> {
try {
const content = readFileSync(skillPath, "utf-8")
const { data, body } = parseFrontmatter<SkillMetadata>(content)
const content = await fs.readFile(skillPath, "utf-8")
const { data } = parseFrontmatter<SkillMetadata>(content)
const frontmatterMcp = parseSkillMcpConfigFromFrontmatter(content)
const mcpJsonMcp = loadMcpJsonFromDir(resolvedPath)
const mcpJsonMcp = await loadMcpJsonFromDir(resolvedPath)
const mcpConfig = mcpJsonMcp || frontmatterMcp
const skillName = data.name || defaultName
@@ -76,7 +73,14 @@ function loadSkillFromPath(
const isOpencodeSource = scope === "opencode" || scope === "opencode-project"
const formattedDescription = `(${scope} - Skill) ${originalDescription}`
const wrappedTemplate = `<skill-instruction>
const lazyContent: LazyContentLoader = {
loaded: false,
content: undefined,
load: async () => {
if (!lazyContent.loaded) {
const fileContent = await fs.readFile(skillPath, "utf-8")
const { body } = parseFrontmatter<SkillMetadata>(fileContent)
lazyContent.content = `<skill-instruction>
Base directory for this skill: ${resolvedPath}/
File references (@path) in this skill are relative to this directory.
@@ -86,11 +90,16 @@ ${body.trim()}
<user-request>
$ARGUMENTS
</user-request>`
lazyContent.loaded = true
}
return lazyContent.content!
},
}
const definition: CommandDefinition = {
name: skillName,
description: formattedDescription,
template: wrappedTemplate,
template: "",
model: sanitizeModelField(data.model, isOpencodeSource ? "opencode" : "claude-code"),
agent: data.agent,
subtask: data.subtask,
@@ -108,24 +117,15 @@ $ARGUMENTS
metadata: data.metadata,
allowedTools: parseAllowedTools(data["allowed-tools"]),
mcpConfig,
lazyContent,
}
} catch {
return null
}
}
/**
* Load skills from a directory, supporting BOTH patterns:
* - Directory with SKILL.md: skill-name/SKILL.md
* - Directory with {SKILLNAME}.md: skill-name/{SKILLNAME}.md
* - Direct markdown file: skill-name.md
*/
function loadSkillsFromDir(skillsDir: string, scope: SkillScope): LoadedSkill[] {
if (!existsSync(skillsDir)) {
return []
}
const entries = readdirSync(skillsDir, { withFileTypes: true })
async function loadSkillsFromDir(skillsDir: string, scope: SkillScope): Promise<LoadedSkill[]> {
const entries = await fs.readdir(skillsDir, { withFileTypes: true }).catch(() => [])
const skills: LoadedSkill[] = []
for (const entry of entries) {
@@ -134,21 +134,25 @@ function loadSkillsFromDir(skillsDir: string, scope: SkillScope): LoadedSkill[]
const entryPath = join(skillsDir, entry.name)
if (entry.isDirectory() || entry.isSymbolicLink()) {
const resolvedPath = resolveSymlink(entryPath)
const resolvedPath = await resolveSymlinkAsync(entryPath)
const dirName = entry.name
const skillMdPath = join(resolvedPath, "SKILL.md")
if (existsSync(skillMdPath)) {
const skill = loadSkillFromPath(skillMdPath, resolvedPath, dirName, scope)
try {
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`)
if (existsSync(namedSkillMdPath)) {
const skill = loadSkillFromPath(namedSkillMdPath, resolvedPath, dirName, scope)
try {
await fs.access(namedSkillMdPath)
const skill = await loadSkillFromPath(namedSkillMdPath, resolvedPath, dirName, scope)
if (skill) skills.push(skill)
continue
} catch {
}
continue
@@ -156,7 +160,7 @@ function loadSkillsFromDir(skillsDir: string, scope: SkillScope): LoadedSkill[]
if (isMarkdownFile(entry)) {
const skillName = basename(entry.name, ".md")
const skill = loadSkillFromPath(entryPath, skillsDir, skillName, scope)
const skill = await loadSkillFromPath(entryPath, skillsDir, skillName, scope)
if (skill) skills.push(skill)
}
}
@@ -173,116 +177,86 @@ function skillsToRecord(skills: LoadedSkill[]): Record<string, CommandDefinition
return result
}
/**
* Load skills from Claude Code user directory (~/.claude/skills/)
*/
export function loadUserSkills(): Record<string, CommandDefinition> {
export async function loadUserSkills(): Promise<Record<string, CommandDefinition>> {
const userSkillsDir = join(getClaudeConfigDir(), "skills")
const skills = loadSkillsFromDir(userSkillsDir, "user")
const skills = await loadSkillsFromDir(userSkillsDir, "user")
return skillsToRecord(skills)
}
/**
* Load skills from Claude Code project directory (.claude/skills/)
*/
export function loadProjectSkills(): Record<string, CommandDefinition> {
export async function loadProjectSkills(): Promise<Record<string, CommandDefinition>> {
const projectSkillsDir = join(process.cwd(), ".claude", "skills")
const skills = loadSkillsFromDir(projectSkillsDir, "project")
const skills = await loadSkillsFromDir(projectSkillsDir, "project")
return skillsToRecord(skills)
}
/**
* Load skills from OpenCode global directory (~/.config/opencode/skill/)
*/
export function loadOpencodeGlobalSkills(): Record<string, CommandDefinition> {
export async function loadOpencodeGlobalSkills(): Promise<Record<string, CommandDefinition>> {
const opencodeSkillsDir = join(homedir(), ".config", "opencode", "skill")
const skills = loadSkillsFromDir(opencodeSkillsDir, "opencode")
const skills = await loadSkillsFromDir(opencodeSkillsDir, "opencode")
return skillsToRecord(skills)
}
/**
* Load skills from OpenCode project directory (.opencode/skill/)
*/
export function loadOpencodeProjectSkills(): Record<string, CommandDefinition> {
export async function loadOpencodeProjectSkills(): Promise<Record<string, CommandDefinition>> {
const opencodeProjectDir = join(process.cwd(), ".opencode", "skill")
const skills = loadSkillsFromDir(opencodeProjectDir, "opencode-project")
const skills = await loadSkillsFromDir(opencodeProjectDir, "opencode-project")
return skillsToRecord(skills)
}
/**
* Discover all skills from all sources with priority ordering.
* Priority order: opencode-project > project > opencode > user
*
* @returns Array of LoadedSkill objects for use in slashcommand discovery
*/
export function discoverAllSkills(): LoadedSkill[] {
const opencodeProjectDir = join(process.cwd(), ".opencode", "skill")
const projectDir = join(process.cwd(), ".claude", "skills")
const opencodeGlobalDir = join(homedir(), ".config", "opencode", "skill")
const userDir = join(getClaudeConfigDir(), "skills")
const opencodeProjectSkills = loadSkillsFromDir(opencodeProjectDir, "opencode-project")
const projectSkills = loadSkillsFromDir(projectDir, "project")
const opencodeGlobalSkills = loadSkillsFromDir(opencodeGlobalDir, "opencode")
const userSkills = loadSkillsFromDir(userDir, "user")
return [...opencodeProjectSkills, ...projectSkills, ...opencodeGlobalSkills, ...userSkills]
}
export interface DiscoverSkillsOptions {
includeClaudeCodePaths?: boolean
}
/**
* Discover skills with optional filtering.
* When includeClaudeCodePaths is false, only loads from OpenCode paths.
*/
export function discoverSkills(options: DiscoverSkillsOptions = {}): LoadedSkill[] {
export async function discoverAllSkills(): Promise<LoadedSkill[]> {
const [opencodeProjectSkills, projectSkills, opencodeGlobalSkills, userSkills] = await Promise.all([
discoverOpencodeProjectSkills(),
discoverProjectClaudeSkills(),
discoverOpencodeGlobalSkills(),
discoverUserClaudeSkills(),
])
return [...opencodeProjectSkills, ...projectSkills, ...opencodeGlobalSkills, ...userSkills]
}
export async function discoverSkills(options: DiscoverSkillsOptions = {}): Promise<LoadedSkill[]> {
const { includeClaudeCodePaths = true } = options
const opencodeProjectDir = join(process.cwd(), ".opencode", "skill")
const opencodeGlobalDir = join(homedir(), ".config", "opencode", "skill")
const opencodeProjectSkills = loadSkillsFromDir(opencodeProjectDir, "opencode-project")
const opencodeGlobalSkills = loadSkillsFromDir(opencodeGlobalDir, "opencode")
const [opencodeProjectSkills, opencodeGlobalSkills] = await Promise.all([
discoverOpencodeProjectSkills(),
discoverOpencodeGlobalSkills(),
])
if (!includeClaudeCodePaths) {
return [...opencodeProjectSkills, ...opencodeGlobalSkills]
}
const projectDir = join(process.cwd(), ".claude", "skills")
const userDir = join(getClaudeConfigDir(), "skills")
const projectSkills = loadSkillsFromDir(projectDir, "project")
const userSkills = loadSkillsFromDir(userDir, "user")
const [projectSkills, userSkills] = await Promise.all([
discoverProjectClaudeSkills(),
discoverUserClaudeSkills(),
])
return [...opencodeProjectSkills, ...projectSkills, ...opencodeGlobalSkills, ...userSkills]
}
/**
* Get a skill by name from all available sources.
*/
export function getSkillByName(name: string, options: DiscoverSkillsOptions = {}): LoadedSkill | undefined {
const skills = discoverSkills(options)
export async function getSkillByName(name: string, options: DiscoverSkillsOptions = {}): Promise<LoadedSkill | undefined> {
const skills = await discoverSkills(options)
return skills.find(s => s.name === name)
}
export function discoverUserClaudeSkills(): LoadedSkill[] {
export async function discoverUserClaudeSkills(): Promise<LoadedSkill[]> {
const userSkillsDir = join(getClaudeConfigDir(), "skills")
return loadSkillsFromDir(userSkillsDir, "user")
}
export function discoverProjectClaudeSkills(): LoadedSkill[] {
export async function discoverProjectClaudeSkills(): Promise<LoadedSkill[]> {
const projectSkillsDir = join(process.cwd(), ".claude", "skills")
return loadSkillsFromDir(projectSkillsDir, "project")
}
export function discoverOpencodeGlobalSkills(): LoadedSkill[] {
export async function discoverOpencodeGlobalSkills(): Promise<LoadedSkill[]> {
const opencodeSkillsDir = join(homedir(), ".config", "opencode", "skill")
return loadSkillsFromDir(opencodeSkillsDir, "opencode")
}
export function discoverOpencodeProjectSkills(): LoadedSkill[] {
export async function discoverOpencodeProjectSkills(): Promise<LoadedSkill[]> {
const opencodeProjectDir = join(process.cwd(), ".opencode", "skill")
return loadSkillsFromDir(opencodeProjectDir, "opencode-project")
}

View File

@@ -17,6 +17,12 @@ export interface SkillMetadata {
mcp?: SkillMcpConfig
}
export interface LazyContentLoader {
loaded: boolean
content?: string
load: () => Promise<string>
}
export interface LoadedSkill {
name: string
path?: string
@@ -28,4 +34,5 @@ export interface LoadedSkill {
metadata?: Record<string, string>
allowedTools?: string[]
mcpConfig?: SkillMcpConfig
lazyContent?: LazyContentLoader
}

View File

@@ -0,0 +1,201 @@
import { describe, it, expect, beforeEach, afterEach } from "bun:test"
import { createCleanMcpEnvironment, EXCLUDED_ENV_PATTERNS } from "./env-cleaner"
describe("createCleanMcpEnvironment", () => {
// Store original env to restore after tests
const originalEnv = { ...process.env }
afterEach(() => {
// Restore original environment
for (const key of Object.keys(process.env)) {
if (!(key in originalEnv)) {
delete process.env[key]
}
}
for (const [key, value] of Object.entries(originalEnv)) {
process.env[key] = value
}
})
describe("NPM_CONFIG_* filtering", () => {
it("filters out uppercase NPM_CONFIG_* variables", () => {
// #given
process.env.NPM_CONFIG_REGISTRY = "https://private.registry.com"
process.env.NPM_CONFIG_CACHE = "/some/cache/path"
process.env.NPM_CONFIG_PREFIX = "/some/prefix"
process.env.PATH = "/usr/bin"
// #when
const cleanEnv = createCleanMcpEnvironment()
// #then
expect(cleanEnv.NPM_CONFIG_REGISTRY).toBeUndefined()
expect(cleanEnv.NPM_CONFIG_CACHE).toBeUndefined()
expect(cleanEnv.NPM_CONFIG_PREFIX).toBeUndefined()
expect(cleanEnv.PATH).toBe("/usr/bin")
})
it("filters out lowercase npm_config_* variables", () => {
// #given
process.env.npm_config_registry = "https://private.registry.com"
process.env.npm_config_cache = "/some/cache/path"
process.env.npm_config_https_proxy = "http://proxy:8080"
process.env.npm_config_proxy = "http://proxy:8080"
process.env.HOME = "/home/user"
// #when
const cleanEnv = createCleanMcpEnvironment()
// #then
expect(cleanEnv.npm_config_registry).toBeUndefined()
expect(cleanEnv.npm_config_cache).toBeUndefined()
expect(cleanEnv.npm_config_https_proxy).toBeUndefined()
expect(cleanEnv.npm_config_proxy).toBeUndefined()
expect(cleanEnv.HOME).toBe("/home/user")
})
})
describe("YARN_* filtering", () => {
it("filters out YARN_* variables", () => {
// #given
process.env.YARN_CACHE_FOLDER = "/yarn/cache"
process.env.YARN_ENABLE_IMMUTABLE_INSTALLS = "true"
process.env.YARN_REGISTRY = "https://yarn.registry.com"
process.env.NODE_ENV = "production"
// #when
const cleanEnv = createCleanMcpEnvironment()
// #then
expect(cleanEnv.YARN_CACHE_FOLDER).toBeUndefined()
expect(cleanEnv.YARN_ENABLE_IMMUTABLE_INSTALLS).toBeUndefined()
expect(cleanEnv.YARN_REGISTRY).toBeUndefined()
expect(cleanEnv.NODE_ENV).toBe("production")
})
})
describe("PNPM_* filtering", () => {
it("filters out PNPM_* variables", () => {
// #given
process.env.PNPM_HOME = "/pnpm/home"
process.env.PNPM_STORE_DIR = "/pnpm/store"
process.env.USER = "testuser"
// #when
const cleanEnv = createCleanMcpEnvironment()
// #then
expect(cleanEnv.PNPM_HOME).toBeUndefined()
expect(cleanEnv.PNPM_STORE_DIR).toBeUndefined()
expect(cleanEnv.USER).toBe("testuser")
})
})
describe("NO_UPDATE_NOTIFIER filtering", () => {
it("filters out NO_UPDATE_NOTIFIER variable", () => {
// #given
process.env.NO_UPDATE_NOTIFIER = "1"
process.env.SHELL = "/bin/bash"
// #when
const cleanEnv = createCleanMcpEnvironment()
// #then
expect(cleanEnv.NO_UPDATE_NOTIFIER).toBeUndefined()
expect(cleanEnv.SHELL).toBe("/bin/bash")
})
})
describe("custom environment overlay", () => {
it("merges custom env on top of clean process.env", () => {
// #given
process.env.PATH = "/usr/bin"
process.env.NPM_CONFIG_REGISTRY = "https://private.registry.com"
const customEnv = {
MCP_API_KEY: "secret-key",
CUSTOM_VAR: "custom-value",
}
// #when
const cleanEnv = createCleanMcpEnvironment(customEnv)
// #then
expect(cleanEnv.PATH).toBe("/usr/bin")
expect(cleanEnv.NPM_CONFIG_REGISTRY).toBeUndefined()
expect(cleanEnv.MCP_API_KEY).toBe("secret-key")
expect(cleanEnv.CUSTOM_VAR).toBe("custom-value")
})
it("custom env can override process.env values", () => {
// #given
process.env.NODE_ENV = "development"
const customEnv = {
NODE_ENV: "production",
}
// #when
const cleanEnv = createCleanMcpEnvironment(customEnv)
// #then
expect(cleanEnv.NODE_ENV).toBe("production")
})
})
describe("undefined value handling", () => {
it("skips undefined values from process.env", () => {
// #given - process.env can have undefined values in TypeScript
const envWithUndefined = { ...process.env, UNDEFINED_VAR: undefined }
Object.assign(process.env, envWithUndefined)
// #when
const cleanEnv = createCleanMcpEnvironment()
// #then - should not throw and should not include undefined values
expect(cleanEnv.UNDEFINED_VAR).toBeUndefined()
expect(Object.values(cleanEnv).every((v) => v !== undefined)).toBe(true)
})
})
describe("mixed case handling", () => {
it("filters both uppercase and lowercase npm config variants", () => {
// #given - pnpm/yarn can set both cases simultaneously
process.env.NPM_CONFIG_CACHE = "/uppercase/cache"
process.env.npm_config_cache = "/lowercase/cache"
process.env.NPM_CONFIG_REGISTRY = "https://uppercase.registry.com"
process.env.npm_config_registry = "https://lowercase.registry.com"
// #when
const cleanEnv = createCleanMcpEnvironment()
// #then
expect(cleanEnv.NPM_CONFIG_CACHE).toBeUndefined()
expect(cleanEnv.npm_config_cache).toBeUndefined()
expect(cleanEnv.NPM_CONFIG_REGISTRY).toBeUndefined()
expect(cleanEnv.npm_config_registry).toBeUndefined()
})
})
})
describe("EXCLUDED_ENV_PATTERNS", () => {
it("contains patterns for npm, yarn, and pnpm configs", () => {
// #given / #when / #then
expect(EXCLUDED_ENV_PATTERNS.length).toBeGreaterThanOrEqual(4)
// Test that patterns match expected strings
const testCases = [
{ pattern: "NPM_CONFIG_REGISTRY", shouldMatch: true },
{ pattern: "npm_config_registry", shouldMatch: true },
{ pattern: "YARN_CACHE_FOLDER", shouldMatch: true },
{ pattern: "PNPM_HOME", shouldMatch: true },
{ pattern: "NO_UPDATE_NOTIFIER", shouldMatch: true },
{ pattern: "PATH", shouldMatch: false },
{ pattern: "HOME", shouldMatch: false },
{ pattern: "NODE_ENV", shouldMatch: false },
]
for (const { pattern, shouldMatch } of testCases) {
const matches = EXCLUDED_ENV_PATTERNS.some((regex: RegExp) => regex.test(pattern))
expect(matches).toBe(shouldMatch)
}
})
})

View File

@@ -0,0 +1,27 @@
// Filters npm/pnpm/yarn config env vars that break MCP servers in pnpm projects (#456)
export const EXCLUDED_ENV_PATTERNS: RegExp[] = [
/^NPM_CONFIG_/i,
/^npm_config_/,
/^YARN_/,
/^PNPM_/,
/^NO_UPDATE_NOTIFIER$/,
]
export function createCleanMcpEnvironment(
customEnv: Record<string, string> = {}
): Record<string, string> {
const cleanEnv: Record<string, string> = {}
for (const [key, value] of Object.entries(process.env)) {
if (value === undefined) continue
const shouldExclude = EXCLUDED_ENV_PATTERNS.some((pattern) => pattern.test(key))
if (!shouldExclude) {
cleanEnv[key] = value
}
}
Object.assign(cleanEnv, customEnv)
return cleanEnv
}

View File

@@ -3,21 +3,68 @@ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
import type { Tool, Resource, Prompt } from "@modelcontextprotocol/sdk/types.js"
import type { ClaudeCodeMcpServer } from "../claude-code-mcp-loader/types"
import { expandEnvVarsInObject } from "../claude-code-mcp-loader/env-expander"
import { createCleanMcpEnvironment } from "./env-cleaner"
import type { SkillMcpClientInfo, SkillMcpServerContext } from "./types"
interface ManagedClient {
client: Client
transport: StdioClientTransport
skillName: string
lastUsedAt: number
}
export class SkillMcpManager {
private clients: Map<string, ManagedClient> = new Map()
private pendingConnections: Map<string, Promise<Client>> = new Map()
private cleanupRegistered = false
private cleanupInterval: ReturnType<typeof setInterval> | null = null
private readonly IDLE_TIMEOUT = 5 * 60 * 1000
private getClientKey(info: SkillMcpClientInfo): string {
return `${info.sessionID}:${info.skillName}:${info.serverName}`
}
private registerProcessCleanup(): void {
if (this.cleanupRegistered) return
this.cleanupRegistered = true
const cleanup = async () => {
for (const [, managed] of this.clients) {
try {
await managed.client.close()
} catch {
// Ignore errors during cleanup
}
try {
await managed.transport.close()
} catch {
// Transport may already be terminated
}
}
this.clients.clear()
this.pendingConnections.clear()
}
// Note: 'exit' event is synchronous-only in Node.js, so we use 'beforeExit' for async cleanup
// However, 'beforeExit' is not emitted on explicit process.exit() calls
// Signal handlers are made async to properly await cleanup
process.on("SIGINT", async () => {
await cleanup()
process.exit(0)
})
process.on("SIGTERM", async () => {
await cleanup()
process.exit(0)
})
if (process.platform === "win32") {
process.on("SIGBREAK", async () => {
await cleanup()
process.exit(0)
})
}
}
async getOrCreateClient(
info: SkillMcpClientInfo,
config: ClaudeCodeMcpServer
@@ -26,12 +73,26 @@ export class SkillMcpManager {
const existing = this.clients.get(key)
if (existing) {
existing.lastUsedAt = Date.now()
return existing.client
}
// Prevent race condition: if a connection is already in progress, wait for it
const pending = this.pendingConnections.get(key)
if (pending) {
return pending
}
const expandedConfig = expandEnvVarsInObject(config)
const client = await this.createClient(info, expandedConfig)
return client
const connectionPromise = this.createClient(info, expandedConfig)
this.pendingConnections.set(key, connectionPromise)
try {
const client = await connectionPromise
return client
} finally {
this.pendingConnections.delete(key)
}
}
private async createClient(
@@ -55,15 +116,9 @@ export class SkillMcpManager {
const command = config.command
const args = config.args || []
// Always inherit parent process environment
const mergedEnv: Record<string, string> = {}
for (const [key, value] of Object.entries(process.env)) {
if (value !== undefined) mergedEnv[key] = value
}
// Overlay with skill-specific env vars if present
if (config.env) {
Object.assign(mergedEnv, config.env)
}
const mergedEnv = createCleanMcpEnvironment(config.env)
this.registerProcessCleanup()
const transport = new StdioClientTransport({
command,
@@ -80,6 +135,12 @@ export class SkillMcpManager {
try {
await client.connect(transport)
} catch (error) {
// Close transport to prevent orphaned MCP process on connection failure
try {
await transport.close()
} catch {
// Process may already be terminated
}
const errorMessage = error instanceof Error ? error.message : String(error)
throw new Error(
`Failed to connect to MCP server "${info.serverName}".\n\n` +
@@ -92,7 +153,8 @@ export class SkillMcpManager {
)
}
this.clients.set(key, { client, transport, skillName: info.skillName })
this.clients.set(key, { client, transport, skillName: info.skillName, lastUsedAt: Date.now() })
this.startCleanupTimer()
return client
}
@@ -102,26 +164,64 @@ export class SkillMcpManager {
for (const [key, managed] of this.clients.entries()) {
if (key.startsWith(`${sessionID}:`)) {
keysToRemove.push(key)
// Delete from map first to prevent re-entrancy during async close
this.clients.delete(key)
try {
await managed.client.close()
} catch {
// Ignore close errors - process may already be terminated
}
try {
await managed.transport.close()
} catch {
// Transport may already be terminated
}
}
}
for (const key of keysToRemove) {
this.clients.delete(key)
}
}
async disconnectAll(): Promise<void> {
for (const [, managed] of this.clients.entries()) {
this.stopCleanupTimer()
const clients = Array.from(this.clients.values())
this.clients.clear()
for (const managed of clients) {
try {
await managed.client.close()
} catch { /* process may already be terminated */ }
try {
await managed.transport.close()
} catch { /* transport may already be terminated */ }
}
}
private startCleanupTimer(): void {
if (this.cleanupInterval) return
this.cleanupInterval = setInterval(() => {
this.cleanupIdleClients()
}, 60_000)
this.cleanupInterval.unref()
}
private stopCleanupTimer(): void {
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval)
this.cleanupInterval = null
}
}
private async cleanupIdleClients(): Promise<void> {
const now = Date.now()
for (const [key, managed] of this.clients) {
if (now - managed.lastUsedAt > this.IDLE_TIMEOUT) {
this.clients.delete(key)
try {
await managed.client.close()
} catch { /* process may already be terminated */ }
try {
await managed.transport.close()
} catch { /* transport may already be terminated */ }
}
}
this.clients.clear()
}
async listTools(
@@ -193,10 +293,13 @@ export class SkillMcpManager {
const key = this.getClientKey(info)
const existing = this.clients.get(key)
if (existing) {
this.clients.delete(key)
try {
await existing.client.close()
} catch { /* process may already be terminated */ }
this.clients.delete(key)
try {
await existing.transport.close()
} catch { /* transport may already be terminated */ }
return await this.getOrCreateClient(info, config)
}
throw error

View File

@@ -16,7 +16,6 @@ export const TARGET_TOOLS = new Set([
"webfetch",
"context7_resolve-library-id",
"context7_get-library-docs",
"websearch_exa_web_search_exa",
"grep_app_searchgithub",
]);

View File

@@ -39,7 +39,7 @@ type Client = {
query: { directory: string };
}) => Promise<unknown>;
prompt_async: (opts: {
path: { sessionID: string };
path: { id: string };
body: { parts: Array<{ type: string; text: string }> };
query: { directory: string };
}) => Promise<unknown>;
@@ -408,7 +408,7 @@ export async function executeCompact(
setTimeout(async () => {
try {
await (client as Client).session.prompt_async({
path: { sessionID },
path: { id: sessionID },
body: { parts: [{ type: "text", text: "Continue" }] },
query: { directory },
});
@@ -506,7 +506,7 @@ export async function executeCompact(
setTimeout(async () => {
try {
await (client as Client).session.prompt_async({
path: { sessionID },
path: { id: sessionID },
body: { parts: [{ type: "text", text: "Continue" }] },
query: { directory },
});

View File

@@ -94,7 +94,7 @@ function skillToCommandInfo(skill: LoadedSkill): CommandInfo {
}
}
function discoverAllCommands(): CommandInfo[] {
async function discoverAllCommands(): Promise<CommandInfo[]> {
const userCommandsDir = join(getClaudeConfigDir(), "commands")
const projectCommandsDir = join(process.cwd(), ".claude", "commands")
const opencodeGlobalDir = join(homedir(), ".config", "opencode", "command")
@@ -105,7 +105,7 @@ function discoverAllCommands(): CommandInfo[] {
const projectCommands = discoverCommandsFromDir(projectCommandsDir, "project")
const opencodeProjectCommands = discoverCommandsFromDir(opencodeProjectDir, "opencode-project")
const skills = discoverAllSkills()
const skills = await discoverAllSkills()
const skillCommands = skills.map(skillToCommandInfo)
return [
@@ -117,8 +117,8 @@ function discoverAllCommands(): CommandInfo[] {
]
}
function findCommand(commandName: string): CommandInfo | null {
const allCommands = discoverAllCommands()
async function findCommand(commandName: string): Promise<CommandInfo | null> {
const allCommands = await discoverAllCommands()
return allCommands.find(
(cmd) => cmd.name.toLowerCase() === commandName.toLowerCase()
) ?? null
@@ -170,7 +170,7 @@ export interface ExecuteResult {
}
export async function executeSlashCommand(parsed: ParsedSlashCommand): Promise<ExecuteResult> {
const command = findCommand(parsed.command)
const command = await findCommand(parsed.command)
if (!command) {
return {

View File

@@ -28,6 +28,7 @@ import { recordToolUse, recordToolResult, getTranscriptPath, recordUserMessage }
import type { PluginConfig } from "./types"
import { log, isHookDisabled } from "../../shared"
import { injectHookMessage } from "../../features/hook-message-injector"
import { detectKeywordsWithType, removeCodeBlocks } from "../keyword-detector"
const sessionFirstMessageProcessed = new Set<string>()
const sessionErrorState = new Map<string, { hasError: boolean; errorMessage?: string }>()
@@ -137,9 +138,21 @@ export function createClaudeCodeHooksHook(ctx: PluginInput, config: PluginConfig
return
}
if (result.messages.length > 0) {
const hookContent = result.messages.join("\n\n")
log(`[claude-code-hooks] Injecting ${result.messages.length} hook messages`, { sessionID: input.sessionID, contentLength: hookContent.length, isFirstMessage })
const detectedKeywords = detectKeywordsWithType(removeCodeBlocks(prompt), input.agent)
const keywordMessages = detectedKeywords.map((k) => k.message)
if (keywordMessages.length > 0) {
log("[claude-code-hooks] Detected keywords", {
sessionID: input.sessionID,
types: detectedKeywords.map((k) => k.type),
})
}
const allMessages = [...keywordMessages, ...result.messages]
if (allMessages.length > 0) {
const hookContent = allMessages.join("\n\n")
log(`[claude-code-hooks] Injecting ${allMessages.length} messages (${keywordMessages.length} keyword + ${result.messages.length} hook)`, { sessionID: input.sessionID, contentLength: hookContent.length, isFirstMessage })
if (isFirstMessage) {
const idx = output.parts.findIndex((p) => p.type === "text" && p.text)

View File

@@ -1,7 +1,11 @@
import type { PluginInput } from "@opencode-ai/plugin"
const ANTHROPIC_DISPLAY_LIMIT = 1_000_000
const ANTHROPIC_ACTUAL_LIMIT = 200_000
const ANTHROPIC_ACTUAL_LIMIT =
process.env.ANTHROPIC_1M_CONTEXT === "true" ||
process.env.VERTEX_ANTHROPIC_1M_CONTEXT === "true"
? 1_000_000
: 200_000
const CONTEXT_WARNING_THRESHOLD = 0.70
const CONTEXT_REMINDER = `[SYSTEM REMINDER - 1M Context Window]

View File

@@ -0,0 +1,126 @@
import { describe, it, expect, beforeEach } from "bun:test"
import { createEditErrorRecoveryHook, EDIT_ERROR_REMINDER, EDIT_ERROR_PATTERNS } from "./index"
describe("createEditErrorRecoveryHook", () => {
let hook: ReturnType<typeof createEditErrorRecoveryHook>
beforeEach(() => {
hook = createEditErrorRecoveryHook({} as any)
})
describe("tool.execute.after", () => {
const createInput = (tool: string) => ({
tool,
sessionID: "test-session",
callID: "test-call-id",
})
const createOutput = (outputText: string) => ({
title: "Edit",
output: outputText,
metadata: {},
})
describe("#given Edit tool with oldString/newString same error", () => {
describe("#when the error message is detected", () => {
it("#then should append the recovery reminder", async () => {
const input = createInput("Edit")
const output = createOutput("Error: oldString and newString must be different")
await hook["tool.execute.after"](input, output)
expect(output.output).toContain(EDIT_ERROR_REMINDER)
expect(output.output).toContain("oldString and newString must be different")
})
})
describe("#when the error appears without Error prefix", () => {
it("#then should still detect and append reminder", async () => {
const input = createInput("Edit")
const output = createOutput("oldString and newString must be different")
await hook["tool.execute.after"](input, output)
expect(output.output).toContain(EDIT_ERROR_REMINDER)
})
})
})
describe("#given Edit tool with oldString not found error", () => {
describe("#when oldString not found in content", () => {
it("#then should append the recovery reminder", async () => {
const input = createInput("Edit")
const output = createOutput("Error: oldString not found in content")
await hook["tool.execute.after"](input, output)
expect(output.output).toContain(EDIT_ERROR_REMINDER)
})
})
})
describe("#given Edit tool with multiple matches error", () => {
describe("#when oldString found multiple times", () => {
it("#then should append the recovery reminder", async () => {
const input = createInput("Edit")
const output = createOutput(
"Error: oldString found multiple times and requires more code context to uniquely identify the intended match"
)
await hook["tool.execute.after"](input, output)
expect(output.output).toContain(EDIT_ERROR_REMINDER)
})
})
})
describe("#given non-Edit tool", () => {
describe("#when tool is not Edit", () => {
it("#then should not modify output", async () => {
const input = createInput("Read")
const originalOutput = "some output"
const output = createOutput(originalOutput)
await hook["tool.execute.after"](input, output)
expect(output.output).toBe(originalOutput)
})
})
})
describe("#given Edit tool with successful output", () => {
describe("#when no error in output", () => {
it("#then should not modify output", async () => {
const input = createInput("Edit")
const originalOutput = "File edited successfully"
const output = createOutput(originalOutput)
await hook["tool.execute.after"](input, output)
expect(output.output).toBe(originalOutput)
})
})
})
describe("#given case insensitive tool name", () => {
describe("#when tool is 'edit' lowercase", () => {
it("#then should still detect and append reminder", async () => {
const input = createInput("edit")
const output = createOutput("oldString and newString must be different")
await hook["tool.execute.after"](input, output)
expect(output.output).toContain(EDIT_ERROR_REMINDER)
})
})
})
})
describe("EDIT_ERROR_PATTERNS", () => {
it("#then should contain all known Edit error patterns", () => {
expect(EDIT_ERROR_PATTERNS).toContain("oldString and newString must be different")
expect(EDIT_ERROR_PATTERNS).toContain("oldString not found")
expect(EDIT_ERROR_PATTERNS).toContain("oldString found multiple times")
})
})
})

View File

@@ -0,0 +1,57 @@
import type { PluginInput } from "@opencode-ai/plugin"
/**
* Known Edit tool error patterns that indicate the AI made a mistake
*/
export const EDIT_ERROR_PATTERNS = [
"oldString and newString must be different",
"oldString not found",
"oldString found multiple times",
] as const
/**
* System reminder injected when Edit tool fails due to AI mistake
* Short, direct, and commanding - forces immediate corrective action
*/
export const EDIT_ERROR_REMINDER = `
[EDIT ERROR - IMMEDIATE ACTION REQUIRED]
You made an Edit mistake. STOP and do this NOW:
1. READ the file immediately to see its ACTUAL current state
2. VERIFY what the content really looks like (your assumption was wrong)
3. APOLOGIZE briefly to the user for the error
4. CONTINUE with corrected action based on the real file content
DO NOT attempt another edit until you've read and verified the file state.
`
/**
* Detects Edit tool errors caused by AI mistakes and injects a recovery reminder
*
* This hook catches common Edit tool failures:
* - oldString and newString must be different (trying to "edit" to same content)
* - oldString not found (wrong assumption about file content)
* - oldString found multiple times (ambiguous match, need more context)
*
* @see https://github.com/sst/opencode/issues/4718
*/
export function createEditErrorRecoveryHook(_ctx: PluginInput) {
return {
"tool.execute.after": async (
input: { tool: string; sessionID: string; callID: string },
output: { title: string; output: string; metadata: unknown }
) => {
if (input.tool.toLowerCase() !== "edit") return
const outputLower = output.output.toLowerCase()
const hasEditError = EDIT_ERROR_PATTERNS.some((pattern) =>
outputLower.includes(pattern.toLowerCase())
)
if (hasEditError) {
output.output += `\n${EDIT_ERROR_REMINDER}`
}
},
}
}

View File

@@ -24,3 +24,4 @@ export { createEmptyMessageSanitizerHook } from "./empty-message-sanitizer";
export { createThinkingBlockValidatorHook } from "./thinking-block-validator";
export { createRalphLoopHook, type RalphLoopHook } from "./ralph-loop";
export { createAutoSlashCommandHook } from "./auto-slash-command";
export { createEditErrorRecoveryHook } from "./edit-error-recovery";

View File

@@ -1,11 +1,92 @@
export const CODE_BLOCK_PATTERN = /```[\s\S]*?```/g
export const INLINE_CODE_PATTERN = /`[^`]+`/g
export const KEYWORD_DETECTORS: Array<{ pattern: RegExp; message: string }> = [
// ULTRAWORK: ulw, ultrawork
{
pattern: /(ultrawork|ulw)/i,
message: `<ultrawork-mode>
const ULTRAWORK_PLANNER_SECTION = `## CRITICAL: YOU ARE A PLANNER, NOT AN IMPLEMENTER
**IDENTITY CONSTRAINT (NON-NEGOTIABLE):**
You ARE the planner. You ARE NOT an implementer. You DO NOT write code. You DO NOT execute tasks.
**TOOL RESTRICTIONS (SYSTEM-ENFORCED):**
| Tool | Allowed | Blocked |
|------|---------|---------|
| Write/Edit | \`.sisyphus/**/*.md\` ONLY | Everything else |
| Read | All files | - |
| Bash | Research commands only | Implementation commands |
| sisyphus_task | explore, librarian | - |
**IF YOU TRY TO WRITE/EDIT OUTSIDE \`.sisyphus/\`:**
- System will BLOCK your action
- You will receive an error
- DO NOT retry - you are not supposed to implement
**YOUR ONLY WRITABLE PATHS:**
- \`.sisyphus/plans/*.md\` - Final work plans
- \`.sisyphus/drafts/*.md\` - Working drafts during interview
**WHEN USER ASKS YOU TO IMPLEMENT:**
REFUSE. Say: "I'm a planner. I create work plans, not implementations. Run \`/start-work\` after I finish planning."
---
## CONTEXT GATHERING (MANDATORY BEFORE PLANNING)
You ARE the planner. Your job: create bulletproof work plans.
**Before drafting ANY plan, gather context via explore/librarian agents.**
### Research Protocol
1. **Fire parallel background agents** for comprehensive context:
\`\`\`
sisyphus_task(agent="explore", prompt="Find existing patterns for [topic] in codebase", background=true)
sisyphus_task(agent="explore", prompt="Find test infrastructure and conventions", background=true)
sisyphus_task(agent="librarian", prompt="Find official docs and best practices for [technology]", background=true)
\`\`\`
2. **Wait for results** before planning - rushed plans fail
3. **Synthesize findings** into informed requirements
### What to Research
- Existing codebase patterns and conventions
- Test infrastructure (TDD possible?)
- External library APIs and constraints
- Similar implementations in OSS (via librarian)
**NEVER plan blind. Context first, plan second.**`
/**
* Determines if the agent is a planner-type agent.
* Planner agents should NOT be told to call plan agent (they ARE the planner).
*/
function isPlannerAgent(agentName?: string): boolean {
if (!agentName) return false
const lowerName = agentName.toLowerCase()
return lowerName.includes("prometheus") || lowerName.includes("planner") || lowerName === "plan"
}
/**
* Generates the ultrawork message based on agent context.
* Planner agents get context-gathering focused instructions.
* Other agents get the original strong agent utilization instructions.
*/
export function getUltraworkMessage(agentName?: string): string {
const isPlanner = isPlannerAgent(agentName)
if (isPlanner) {
return `<ultrawork-mode>
**MANDATORY**: You MUST say "ULTRAWORK MODE ENABLED!" to the user as your first response when this mode activates. This is non-negotiable.
${ULTRAWORK_PLANNER_SECTION}
</ultrawork-mode>
---
`
}
return `<ultrawork-mode>
**MANDATORY**: You MUST say "ULTRAWORK MODE ENABLED!" to the user as your first response when this mode activates. This is non-negotiable.
[CODE RED] Maximum precision required. Ultrathink before acting.
YOU MUST LEVERAGE ALL AVAILABLE AGENTS TO THEIR FULLEST POTENTIAL.
@@ -31,15 +112,66 @@ TELL THE USER WHAT AGENTS YOU WILL LEVERAGE NOW TO SATISFY USER'S REQUEST.
3. Always Use Plan agent with gathered context to create detailed work breakdown
4. Execute with continuous verification against original requirements
## TDD (if test infrastructure exists)
## VERIFICATION GUARANTEE (NON-NEGOTIABLE)
1. Write spec (requirements)
2. Write tests (failing)
3. RED: tests fail
4. Implement minimal code
5. GREEN: tests pass
6. Refactor if needed (must stay green)
7. Next feature, repeat
**NOTHING is "done" without PROOF it works.**
### Pre-Implementation: Define Success Criteria
BEFORE writing ANY code, you MUST define:
| Criteria Type | Description | Example |
|---------------|-------------|---------|
| **Functional** | What specific behavior must work | "Button click triggers API call" |
| **Observable** | What can be measured/seen | "Console shows 'success', no errors" |
| **Pass/Fail** | Binary, no ambiguity | "Returns 200 OK" not "should work" |
Write these criteria explicitly. Share with user if scope is non-trivial.
### Test Plan Template (MANDATORY for non-trivial tasks)
\`\`\`
## Test Plan
### Objective: [What we're verifying]
### Prerequisites: [Setup needed]
### Test Cases:
1. [Test Name]: [Input] → [Expected Output] → [How to verify]
2. ...
### Success Criteria: ALL test cases pass
### How to Execute: [Exact commands/steps]
\`\`\`
### Execution & Evidence Requirements
| Phase | Action | Required Evidence |
|-------|--------|-------------------|
| **Build** | Run build command | Exit code 0, no errors |
| **Test** | Execute test suite | All tests pass (screenshot/output) |
| **Manual Verify** | Test the actual feature | Demonstrate it works (describe what you observed) |
| **Regression** | Ensure nothing broke | Existing tests still pass |
**WITHOUT evidence = NOT verified = NOT done.**
### TDD Workflow (when test infrastructure exists)
1. **SPEC**: Define what "working" means (success criteria above)
2. **RED**: Write failing test → Run it → Confirm it FAILS
3. **GREEN**: Write minimal code → Run test → Confirm it PASSES
4. **REFACTOR**: Clean up → Tests MUST stay green
5. **VERIFY**: Run full test suite, confirm no regressions
6. **EVIDENCE**: Report what you ran and what output you saw
### Verification Anti-Patterns (BLOCKING)
| Violation | Why It Fails |
|-----------|--------------|
| "It should work now" | No evidence. Run it. |
| "I added the tests" | Did they pass? Show output. |
| "Fixed the bug" | How do you know? What did you test? |
| "Implementation complete" | Did you verify against success criteria? |
| Skipping test execution | Tests exist to be RUN, not just written |
**CLAIM NOTHING WITHOUT PROOF. EXECUTE. VERIFY. SHOW EVIDENCE.**
## ZERO TOLERANCE FAILURES
- **NO Scope Reduction**: Never make "demo", "skeleton", "simplified", "basic" versions - deliver FULL implementation
@@ -55,7 +187,13 @@ THE USER ASKED FOR X. DELIVER EXACTLY X. NOT A SUBSET. NOT A DEMO. NOT A STARTIN
---
`,
`
}
export const KEYWORD_DETECTORS: Array<{ pattern: RegExp; message: string | ((agentName?: string) => string) }> = [
{
pattern: /(ultrawork|ulw)/i,
message: getUltraworkMessage,
},
// SEARCH: EN/KO/JP/CN/VN
{

View File

@@ -13,20 +13,30 @@ export function removeCodeBlocks(text: string): string {
return text.replace(CODE_BLOCK_PATTERN, "").replace(INLINE_CODE_PATTERN, "")
}
export function detectKeywords(text: string): string[] {
/**
* Resolves message to string, handling both static strings and dynamic functions.
*/
function resolveMessage(
message: string | ((agentName?: string) => string),
agentName?: string
): string {
return typeof message === "function" ? message(agentName) : message
}
export function detectKeywords(text: string, agentName?: string): string[] {
const textWithoutCode = removeCodeBlocks(text)
return KEYWORD_DETECTORS.filter(({ pattern }) =>
pattern.test(textWithoutCode)
).map(({ message }) => message)
).map(({ message }) => resolveMessage(message, agentName))
}
export function detectKeywordsWithType(text: string): DetectedKeyword[] {
export function detectKeywordsWithType(text: string, agentName?: string): DetectedKeyword[] {
const textWithoutCode = removeCodeBlocks(text)
const types: Array<"ultrawork" | "search" | "analyze"> = ["ultrawork", "search", "analyze"]
return KEYWORD_DETECTORS.map(({ pattern, message }, index) => ({
matches: pattern.test(textWithoutCode),
type: types[index],
message,
message: resolveMessage(message, agentName),
}))
.filter((result) => result.matches)
.map(({ type, message }) => ({ type, message }))

View File

@@ -1,15 +1,11 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { detectKeywords, detectKeywordsWithType, extractPromptText } from "./detector"
import { detectKeywordsWithType, extractPromptText, removeCodeBlocks } from "./detector"
import { log } from "../../shared"
import { injectHookMessage } from "../../features/hook-message-injector"
export * from "./detector"
export * from "./constants"
export * from "./types"
const sessionFirstMessageProcessed = new Set<string>()
const sessionUltraworkNotified = new Set<string>()
export function createKeywordDetectorHook(ctx: PluginInput) {
return {
"chat.message": async (
@@ -24,65 +20,37 @@ export function createKeywordDetectorHook(ctx: PluginInput) {
parts: Array<{ type: string; text?: string; [key: string]: unknown }>
}
): Promise<void> => {
const isFirstMessage = !sessionFirstMessageProcessed.has(input.sessionID)
sessionFirstMessageProcessed.add(input.sessionID)
const promptText = extractPromptText(output.parts)
const detectedKeywords = detectKeywordsWithType(promptText)
const messages = detectedKeywords.map((k) => k.message)
const detectedKeywords = detectKeywordsWithType(removeCodeBlocks(promptText), input.agent)
if (messages.length === 0) {
if (detectedKeywords.length === 0) {
return
}
const hasUltrawork = detectedKeywords.some((k) => k.type === "ultrawork")
if (hasUltrawork && !sessionUltraworkNotified.has(input.sessionID)) {
sessionUltraworkNotified.add(input.sessionID)
if (hasUltrawork) {
log(`[keyword-detector] Ultrawork mode activated`, { sessionID: input.sessionID })
ctx.client.tui.showToast({
body: {
title: "Ultrawork Mode Activated",
message: "Maximum precision engaged. All agents at your disposal.",
variant: "success" as const,
duration: 3000,
},
}).catch((err) => log(`[keyword-detector] Failed to show toast`, { error: err, sessionID: input.sessionID }))
output.message.variant = "max"
ctx.client.tui
.showToast({
body: {
title: "Ultrawork Mode Activated",
message: "Maximum precision engaged. All agents at your disposal.",
variant: "success" as const,
duration: 3000,
},
})
.catch((err) =>
log(`[keyword-detector] Failed to show toast`, { error: err, sessionID: input.sessionID })
)
}
const context = messages.join("\n")
// First message: transform parts directly (for title generation compatibility)
if (isFirstMessage) {
log(`Keywords detected on first message, transforming parts directly`, { sessionID: input.sessionID, keywordCount: messages.length })
const idx = output.parts.findIndex((p) => p.type === "text" && p.text)
if (idx >= 0) {
output.parts[idx].text = `${context}\n\n---\n\n${output.parts[idx].text ?? ""}`
}
return
}
// Subsequent messages: inject as separate message
log(`Keywords detected: ${messages.length}`, { sessionID: input.sessionID })
const message = output.message as {
agent?: string
model?: { modelID?: string; providerID?: string }
path?: { cwd?: string; root?: string }
tools?: Record<string, boolean>
}
log(`[keyword-detector] Injecting context for ${messages.length} keywords`, { sessionID: input.sessionID, contextLength: context.length })
const success = injectHookMessage(input.sessionID, context, {
agent: message.agent,
model: message.model,
path: message.path,
tools: message.tools,
log(`[keyword-detector] Detected ${detectedKeywords.length} keywords`, {
sessionID: input.sessionID,
types: detectedKeywords.map((k) => k.type),
})
if (success) {
log("Keyword context injected", { sessionID: input.sessionID })
}
},
}
}

View File

@@ -0,0 +1,150 @@
import { describe, test, expect } from "bun:test"
import { createNonInteractiveEnvHook, NON_INTERACTIVE_ENV } from "./index"
describe("non-interactive-env hook", () => {
const mockCtx = {} as Parameters<typeof createNonInteractiveEnvHook>[0]
describe("git command modification", () => {
test("#given git command #when hook executes #then prepends export statement", async () => {
const hook = createNonInteractiveEnvHook(mockCtx)
const output: { args: Record<string, unknown>; message?: string } = {
args: { command: "git commit -m 'test'" },
}
await hook["tool.execute.before"](
{ tool: "bash", sessionID: "test", callID: "1" },
output
)
const cmd = output.args.command as string
expect(cmd).toStartWith("export ")
expect(cmd).toContain("GIT_EDITOR=:")
expect(cmd).toContain("EDITOR=:")
expect(cmd).toContain("PAGER=cat")
expect(cmd).toContain("; git commit -m 'test'")
})
test("#given chained git commands #when hook executes #then export applies to all", async () => {
const hook = createNonInteractiveEnvHook(mockCtx)
const output: { args: Record<string, unknown>; message?: string } = {
args: { command: "git add file && git rebase --continue" },
}
await hook["tool.execute.before"](
{ tool: "bash", sessionID: "test", callID: "1" },
output
)
const cmd = output.args.command as string
expect(cmd).toStartWith("export ")
expect(cmd).toContain("; git add file && git rebase --continue")
})
test("#given non-git bash command #when hook executes #then command unchanged", async () => {
const hook = createNonInteractiveEnvHook(mockCtx)
const output: { args: Record<string, unknown>; message?: string } = {
args: { command: "ls -la" },
}
await hook["tool.execute.before"](
{ tool: "bash", sessionID: "test", callID: "1" },
output
)
expect(output.args.command).toBe("ls -la")
})
test("#given non-bash tool #when hook executes #then command unchanged", async () => {
const hook = createNonInteractiveEnvHook(mockCtx)
const output: { args: Record<string, unknown>; message?: string } = {
args: { command: "git status" },
}
await hook["tool.execute.before"](
{ tool: "Read", sessionID: "test", callID: "1" },
output
)
expect(output.args.command).toBe("git status")
})
test("#given empty command #when hook executes #then no error", async () => {
const hook = createNonInteractiveEnvHook(mockCtx)
const output: { args: Record<string, unknown>; message?: string } = {
args: {},
}
await hook["tool.execute.before"](
{ tool: "bash", sessionID: "test", callID: "1" },
output
)
expect(output.args.command).toBeUndefined()
})
})
describe("shell escaping", () => {
test("#given git command #when building prefix #then VISUAL properly escaped", async () => {
const hook = createNonInteractiveEnvHook(mockCtx)
const output: { args: Record<string, unknown>; message?: string } = {
args: { command: "git status" },
}
await hook["tool.execute.before"](
{ tool: "bash", sessionID: "test", callID: "1" },
output
)
const cmd = output.args.command as string
expect(cmd).toContain("VISUAL=''")
})
test("#given git command #when building prefix #then all NON_INTERACTIVE_ENV vars included", async () => {
const hook = createNonInteractiveEnvHook(mockCtx)
const output: { args: Record<string, unknown>; message?: string } = {
args: { command: "git log" },
}
await hook["tool.execute.before"](
{ tool: "bash", sessionID: "test", callID: "1" },
output
)
const cmd = output.args.command as string
for (const key of Object.keys(NON_INTERACTIVE_ENV)) {
expect(cmd).toContain(`${key}=`)
}
})
})
describe("banned command detection", () => {
test("#given vim command #when hook executes #then warning message set", async () => {
const hook = createNonInteractiveEnvHook(mockCtx)
const output: { args: Record<string, unknown>; message?: string } = {
args: { command: "vim file.txt" },
}
await hook["tool.execute.before"](
{ tool: "bash", sessionID: "test", callID: "1" },
output
)
expect(output.message).toContain("vim")
expect(output.message).toContain("interactive")
})
test("#given safe command #when hook executes #then no warning", async () => {
const hook = createNonInteractiveEnvHook(mockCtx)
const output: { args: Record<string, unknown>; message?: string } = {
args: { command: "ls -la" },
}
await hook["tool.execute.before"](
{ tool: "bash", sessionID: "test", callID: "1" },
output
)
expect(output.message).toBeUndefined()
})
})
})

View File

@@ -19,6 +19,35 @@ function detectBannedCommand(command: string): string | undefined {
return undefined
}
/**
* Shell-escape a value for use in VAR=value prefix.
* Wraps in single quotes if contains special chars.
*/
function shellEscape(value: string): string {
// Empty string needs quotes
if (value === "") return "''"
// If contains special chars, wrap in single quotes (escape existing single quotes)
if (/[^a-zA-Z0-9_\-.:\/]/.test(value)) {
return `'${value.replace(/'/g, "'\\''")}'`
}
return value
}
/**
* Build export statement for environment variables.
* Uses `export VAR1=val1 VAR2=val2;` format to ensure variables
* apply to ALL commands in a chain (e.g., `cmd1 && cmd2`).
*
* Previous approach used VAR=value prefix which only applies to the first command.
* OpenCode's bash tool ignores args.env, so we must prepend to command.
*/
function buildEnvPrefix(env: Record<string, string>): string {
const exports = Object.entries(env)
.map(([key, value]) => `${key}=${shellEscape(value)}`)
.join(" ")
return `export ${exports};`
}
export function createNonInteractiveEnvHook(_ctx: PluginInput) {
return {
"tool.execute.before": async (
@@ -34,20 +63,27 @@ export function createNonInteractiveEnvHook(_ctx: PluginInput) {
return
}
output.args.env = {
...process.env,
...(output.args.env as Record<string, string> | undefined),
...NON_INTERACTIVE_ENV,
}
const bannedCmd = detectBannedCommand(command)
if (bannedCmd) {
output.message = `⚠️ Warning: '${bannedCmd}' is an interactive command that may hang in non-interactive environments.`
}
log(`[${HOOK_NAME}] Set non-interactive environment variables`, {
// Only prepend env vars for git commands (editor blocking, pager, etc.)
const isGitCommand = /\bgit\b/.test(command)
if (!isGitCommand) {
return
}
// OpenCode's bash tool uses hardcoded `...process.env` in spawn(),
// ignoring any args.env we might set. Prepend export statement to command.
// Uses `export VAR=val;` format to ensure variables apply to ALL commands
// in a chain (e.g., `git add file && git rebase --continue`).
const envPrefix = buildEnvPrefix(NON_INTERACTIVE_ENV)
output.args.command = `${envPrefix} ${command}`
log(`[${HOOK_NAME}] Prepended non-interactive env vars to git command`, {
sessionID: input.sessionID,
env: NON_INTERACTIVE_ENV,
envPrefix,
})
},
}

View File

@@ -48,7 +48,11 @@ interface MessageWrapper {
}
const CLAUDE_MODEL_PATTERN = /claude-(opus|sonnet|haiku)/i
const CLAUDE_DEFAULT_CONTEXT_LIMIT = 200_000
const CLAUDE_DEFAULT_CONTEXT_LIMIT =
process.env.ANTHROPIC_1M_CONTEXT === "true" ||
process.env.VERTEX_ANTHROPIC_1M_CONTEXT === "true"
? 1_000_000
: 200_000
function isSupportedModel(modelID: string): boolean {
return CLAUDE_MODEL_PATTERN.test(modelID)

View File

@@ -302,6 +302,77 @@ describe("ralph-loop", () => {
expect(promptCalls.length).toBe(0)
})
test("should clear orphaned state when original session no longer exists", async () => {
// #given - state file exists from a previous session that no longer exists
const state: RalphLoopState = {
active: true,
iteration: 3,
max_iterations: 50,
completion_promise: "DONE",
started_at: "2025-12-30T01:00:00Z",
prompt: "Build something",
session_id: "orphaned-session-999", // This session no longer exists
}
writeState(TEST_DIR, state)
// Mock sessionExists to return false for the orphaned session
const hook = createRalphLoopHook(createMockPluginInput(), {
checkSessionExists: async (sessionID: string) => {
// Orphaned session doesn't exist, current session does
return sessionID !== "orphaned-session-999"
},
})
// #when - a new session goes idle (different from the orphaned session in state)
await hook.event({
event: {
type: "session.idle",
properties: { sessionID: "new-session-456" },
},
})
// #then - orphaned state should be cleared
expect(hook.getState()).toBeNull()
// #then - no continuation injected (state was cleared, not resumed)
expect(promptCalls.length).toBe(0)
})
test("should NOT clear state when original session still exists (different active session)", async () => {
// #given - state file exists from a session that still exists
const state: RalphLoopState = {
active: true,
iteration: 2,
max_iterations: 50,
completion_promise: "DONE",
started_at: "2025-12-30T01:00:00Z",
prompt: "Build something",
session_id: "active-session-123", // This session still exists
}
writeState(TEST_DIR, state)
// Mock sessionExists to return true for the active session
const hook = createRalphLoopHook(createMockPluginInput(), {
checkSessionExists: async (sessionID: string) => {
// Original session still exists
return sessionID === "active-session-123" || sessionID === "new-session-456"
},
})
// #when - a different session goes idle
await hook.event({
event: {
type: "session.idle",
properties: { sessionID: "new-session-456" },
},
})
// #then - state should NOT be cleared (original session still active)
expect(hook.getState()).not.toBeNull()
expect(hook.getState()?.session_id).toBe("active-session-123")
// #then - no continuation injected (it's a different session's loop)
expect(promptCalls.length).toBe(0)
})
test("should use default config values", () => {
// #given - hook with config
const hook = createRalphLoopHook(createMockPluginInput(), {
@@ -423,5 +494,162 @@ describe("ralph-loop", () => {
expect(promptCalls[0].text).toContain("Create a calculator app")
expect(promptCalls[0].text).toContain("<promise>CALCULATOR_DONE</promise>")
})
test("should clear loop state on user abort (MessageAbortedError)", async () => {
// #given - active loop
const hook = createRalphLoopHook(createMockPluginInput())
hook.startLoop("session-123", "Build something")
expect(hook.getState()).not.toBeNull()
// #when - user aborts (Ctrl+C)
await hook.event({
event: {
type: "session.error",
properties: {
sessionID: "session-123",
error: { name: "MessageAbortedError", message: "User aborted" },
},
},
})
// #then - loop state should be cleared immediately
expect(hook.getState()).toBeNull()
})
test("should NOT set recovery mode on user abort", async () => {
// #given - active loop
const hook = createRalphLoopHook(createMockPluginInput())
hook.startLoop("session-123", "Build something")
// #when - user aborts (Ctrl+C)
await hook.event({
event: {
type: "session.error",
properties: {
sessionID: "session-123",
error: { name: "MessageAbortedError" },
},
},
})
// Start a new loop
hook.startLoop("session-123", "New task")
// #when - session goes idle immediately (should work, no recovery mode)
await hook.event({
event: { type: "session.idle", properties: { sessionID: "session-123" } },
})
// #then - continuation should be injected (not blocked by recovery)
expect(promptCalls.length).toBe(1)
})
test("should only check LAST assistant message for completion", async () => {
// #given - multiple assistant messages, only first has completion promise
mockSessionMessages = [
{ info: { role: "user" }, parts: [{ type: "text", text: "Start task" }] },
{ info: { role: "assistant" }, parts: [{ type: "text", text: "I'll work on it. <promise>DONE</promise>" }] },
{ info: { role: "user" }, parts: [{ type: "text", text: "Continue" }] },
{ info: { role: "assistant" }, parts: [{ type: "text", text: "Working on more features..." }] },
]
const hook = createRalphLoopHook(createMockPluginInput(), {
getTranscriptPath: () => join(TEST_DIR, "nonexistent.jsonl"),
})
hook.startLoop("session-123", "Build something", { completionPromise: "DONE" })
// #when - session goes idle
await hook.event({
event: { type: "session.idle", properties: { sessionID: "session-123" } },
})
// #then - loop should continue (last message has no completion promise)
expect(promptCalls.length).toBe(1)
expect(hook.getState()?.iteration).toBe(2)
})
test("should detect completion only in LAST assistant message", async () => {
// #given - last assistant message has completion promise
mockSessionMessages = [
{ info: { role: "user" }, parts: [{ type: "text", text: "Start task" }] },
{ info: { role: "assistant" }, parts: [{ type: "text", text: "Starting work..." }] },
{ info: { role: "user" }, parts: [{ type: "text", text: "Continue" }] },
{ info: { role: "assistant" }, parts: [{ type: "text", text: "Task complete! <promise>DONE</promise>" }] },
]
const hook = createRalphLoopHook(createMockPluginInput(), {
getTranscriptPath: () => join(TEST_DIR, "nonexistent.jsonl"),
})
hook.startLoop("session-123", "Build something", { completionPromise: "DONE" })
// #when - session goes idle
await hook.event({
event: { type: "session.idle", properties: { sessionID: "session-123" } },
})
// #then - loop should complete (last message has completion promise)
expect(promptCalls.length).toBe(0)
expect(toastCalls.some((t) => t.title === "Ralph Loop Complete!")).toBe(true)
expect(hook.getState()).toBeNull()
})
test("should check transcript BEFORE API to optimize performance", async () => {
// #given - transcript has completion promise
const transcriptPath = join(TEST_DIR, "transcript.jsonl")
writeFileSync(transcriptPath, JSON.stringify({ content: "<promise>DONE</promise>" }))
mockSessionMessages = [
{ info: { role: "assistant" }, parts: [{ type: "text", text: "No promise here" }] },
]
const hook = createRalphLoopHook(createMockPluginInput(), {
getTranscriptPath: () => transcriptPath,
})
hook.startLoop("session-123", "Build something", { completionPromise: "DONE" })
// #when - session goes idle
await hook.event({
event: { type: "session.idle", properties: { sessionID: "session-123" } },
})
// #then - should complete via transcript (API not called when transcript succeeds)
expect(promptCalls.length).toBe(0)
expect(hook.getState()).toBeNull()
// API should NOT be called since transcript found completion
expect(messagesCalls.length).toBe(0)
})
})
describe("API timeout protection", () => {
test("should not hang when session.messages() times out", async () => {
// #given - slow API that takes longer than timeout
const slowMock = {
...createMockPluginInput(),
client: {
...createMockPluginInput().client,
session: {
...createMockPluginInput().client.session,
messages: async () => {
// Simulate slow API (would hang without timeout)
await new Promise((resolve) => setTimeout(resolve, 10000))
return { data: [] }
},
},
},
}
const hook = createRalphLoopHook(slowMock as any, {
getTranscriptPath: () => join(TEST_DIR, "nonexistent.jsonl"),
apiTimeout: 100, // 100ms timeout for test
})
hook.startLoop("session-123", "Build something")
// #when - session goes idle (API will timeout)
const startTime = Date.now()
await hook.event({
event: { type: "session.idle", properties: { sessionID: "session-123" } },
})
const elapsed = Date.now() - startTime
// #then - should complete within timeout + buffer (not hang for 10s)
expect(elapsed).toBeLessThan(500)
// #then - loop should continue (API timeout = no completion detected)
expect(promptCalls.length).toBe(1)
})
})
})

View File

@@ -53,6 +53,8 @@ export interface RalphLoopHook {
getState: () => RalphLoopState | null
}
const DEFAULT_API_TIMEOUT = 3000
export function createRalphLoopHook(
ctx: PluginInput,
options?: RalphLoopOptions
@@ -61,6 +63,8 @@ export function createRalphLoopHook(
const config = options?.config
const stateDir = config?.state_dir
const getTranscriptPath = options?.getTranscriptPath ?? getDefaultTranscriptPath
const apiTimeout = options?.apiTimeout ?? DEFAULT_API_TIMEOUT
const checkSessionExists = options?.checkSessionExists
function getSessionState(sessionID: string): SessionState {
let state = sessions.get(sessionID)
@@ -97,32 +101,34 @@ export function createRalphLoopHook(
promise: string
): Promise<boolean> {
try {
const response = await ctx.client.session.messages({
path: { id: sessionID },
query: { directory: ctx.directory },
})
const response = await Promise.race([
ctx.client.session.messages({
path: { id: sessionID },
query: { directory: ctx.directory },
}),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error("API timeout")), apiTimeout)
),
])
const messages = (response as { data?: unknown[] }).data ?? []
if (!Array.isArray(messages)) return false
const assistantMessages = (messages as OpenCodeSessionMessage[]).filter(
(msg) => msg.info?.role === "assistant"
)
const lastAssistant = assistantMessages[assistantMessages.length - 1]
if (!lastAssistant?.parts) return false
const pattern = new RegExp(`<promise>\\s*${escapeRegex(promise)}\\s*</promise>`, "is")
const responseText = lastAssistant.parts
.filter((p) => p.type === "text")
.map((p) => p.text ?? "")
.join("\n")
for (const msg of messages as OpenCodeSessionMessage[]) {
if (msg.info?.role !== "assistant") continue
for (const part of msg.parts || []) {
if (part.type === "text" && part.text) {
if (pattern.test(part.text)) {
return true
}
}
}
}
return false
return pattern.test(responseText)
} catch (err) {
log(`[${HOOK_NAME}] Failed to fetch session messages`, { sessionID, error: String(err) })
log(`[${HOOK_NAME}] Session messages check failed`, { sessionID, error: String(err) })
return false
}
}
@@ -194,23 +200,40 @@ export function createRalphLoopHook(
}
if (state.session_id && state.session_id !== sessionID) {
if (checkSessionExists) {
try {
const originalSessionExists = await checkSessionExists(state.session_id)
if (!originalSessionExists) {
clearState(ctx.directory, stateDir)
log(`[${HOOK_NAME}] Cleared orphaned state from deleted session`, {
orphanedSessionId: state.session_id,
currentSessionId: sessionID,
})
return
}
} catch (err) {
log(`[${HOOK_NAME}] Failed to check session existence`, {
sessionId: state.session_id,
error: String(err),
})
}
}
return
}
const completionDetectedViaApi = await detectCompletionInSessionMessages(
sessionID,
state.completion_promise
)
const transcriptPath = getTranscriptPath(sessionID)
const completionDetectedViaTranscript = detectCompletionPromise(transcriptPath, state.completion_promise)
if (completionDetectedViaApi || completionDetectedViaTranscript) {
const completionDetectedViaApi = completionDetectedViaTranscript
? false
: await detectCompletionInSessionMessages(sessionID, state.completion_promise)
if (completionDetectedViaTranscript || completionDetectedViaApi) {
log(`[${HOOK_NAME}] Completion detected!`, {
sessionID,
iteration: state.iteration,
promise: state.completion_promise,
detectedVia: completionDetectedViaApi ? "session_messages_api" : "transcript_file",
detectedVia: completionDetectedViaTranscript ? "transcript_file" : "session_messages_api",
})
clearState(ctx.directory, stateDir)
@@ -308,6 +331,20 @@ export function createRalphLoopHook(
if (event.type === "session.error") {
const sessionID = props?.sessionID as string | undefined
const error = props?.error as { name?: string } | undefined
if (error?.name === "MessageAbortedError") {
if (sessionID) {
const state = readState(ctx.directory, stateDir)
if (state?.session_id === sessionID) {
clearState(ctx.directory, stateDir)
log(`[${HOOK_NAME}] User aborted, loop cleared`, { sessionID })
}
sessions.delete(sessionID)
}
return
}
if (sessionID) {
const sessionState = getSessionState(sessionID)
sessionState.isRecovering = true

View File

@@ -13,4 +13,6 @@ export interface RalphLoopState {
export interface RalphLoopOptions {
config?: RalphLoopConfig
getTranscriptPath?: (sessionId: string) => string
apiTimeout?: number
checkSessionExists?: (sessionId: string) => Promise<boolean>
}

View File

@@ -0,0 +1,203 @@
import { describe, expect, it } from "bun:test"
import { detectErrorType } from "./index"
describe("detectErrorType", () => {
describe("thinking_block_order errors", () => {
it("should detect 'first block' error pattern", () => {
// #given an error about thinking being the first block
const error = {
message: "messages.0: thinking block must not be the first block",
}
// #when detectErrorType is called
const result = detectErrorType(error)
// #then should return thinking_block_order
expect(result).toBe("thinking_block_order")
})
it("should detect 'must start with' error pattern", () => {
// #given an error about message must start with something
const error = {
message: "messages.5: thinking must start with text or tool_use",
}
// #when detectErrorType is called
const result = detectErrorType(error)
// #then should return thinking_block_order
expect(result).toBe("thinking_block_order")
})
it("should detect 'preceeding' error pattern", () => {
// #given an error about preceeding block
const error = {
message: "messages.10: thinking requires preceeding text block",
}
// #when detectErrorType is called
const result = detectErrorType(error)
// #then should return thinking_block_order
expect(result).toBe("thinking_block_order")
})
it("should detect 'expected/found' error pattern", () => {
// #given an error about expected vs found
const error = {
message: "messages.3: thinking block expected text but found tool_use",
}
// #when detectErrorType is called
const result = detectErrorType(error)
// #then should return thinking_block_order
expect(result).toBe("thinking_block_order")
})
it("should detect 'final block cannot be thinking' error pattern", () => {
// #given an error about final block cannot be thinking
const error = {
message:
"messages.125: The final block in an assistant message cannot be thinking.",
}
// #when detectErrorType is called
const result = detectErrorType(error)
// #then should return thinking_block_order
expect(result).toBe("thinking_block_order")
})
it("should detect 'final block' variant error pattern", () => {
// #given an error mentioning final block with thinking
const error = {
message:
"messages.17: thinking in the final block is not allowed in assistant messages",
}
// #when detectErrorType is called
const result = detectErrorType(error)
// #then should return thinking_block_order
expect(result).toBe("thinking_block_order")
})
it("should detect 'cannot be thinking' error pattern", () => {
// #given an error using 'cannot be thinking' phrasing
const error = {
message:
"messages.219: The last block in an assistant message cannot be thinking content",
}
// #when detectErrorType is called
const result = detectErrorType(error)
// #then should return thinking_block_order
expect(result).toBe("thinking_block_order")
})
})
describe("tool_result_missing errors", () => {
it("should detect tool_use/tool_result mismatch", () => {
// #given an error about tool_use without tool_result
const error = {
message: "tool_use block requires corresponding tool_result",
}
// #when detectErrorType is called
const result = detectErrorType(error)
// #then should return tool_result_missing
expect(result).toBe("tool_result_missing")
})
})
describe("thinking_disabled_violation errors", () => {
it("should detect thinking disabled violation", () => {
// #given an error about thinking being disabled
const error = {
message:
"thinking is disabled for this model and cannot contain thinking blocks",
}
// #when detectErrorType is called
const result = detectErrorType(error)
// #then should return thinking_disabled_violation
expect(result).toBe("thinking_disabled_violation")
})
})
describe("unrecognized errors", () => {
it("should return null for unrecognized error patterns", () => {
// #given an unrelated error
const error = {
message: "Rate limit exceeded",
}
// #when detectErrorType is called
const result = detectErrorType(error)
// #then should return null
expect(result).toBeNull()
})
it("should return null for empty error", () => {
// #given an empty error
const error = {}
// #when detectErrorType is called
const result = detectErrorType(error)
// #then should return null
expect(result).toBeNull()
})
it("should return null for null error", () => {
// #given a null error
const error = null
// #when detectErrorType is called
const result = detectErrorType(error)
// #then should return null
expect(result).toBeNull()
})
})
describe("nested error objects", () => {
it("should detect error in data.error.message path", () => {
// #given an error with nested structure
const error = {
data: {
error: {
message:
"messages.163: The final block in an assistant message cannot be thinking.",
},
},
}
// #when detectErrorType is called
const result = detectErrorType(error)
// #then should return thinking_block_order
expect(result).toBe("thinking_block_order")
})
it("should detect error in error.message path", () => {
// #given an error with error.message structure
const error = {
error: {
message: "messages.169: final block cannot be thinking",
},
}
// #when detectErrorType is called
const result = detectErrorType(error)
// #then should return thinking_block_order
expect(result).toBe("thinking_block_order")
})
})
})

View File

@@ -122,7 +122,7 @@ function extractMessageIndex(error: unknown): number | null {
return match ? parseInt(match[1], 10) : null
}
function detectErrorType(error: unknown): RecoveryErrorType {
export function detectErrorType(error: unknown): RecoveryErrorType {
const message = getErrorMessage(error)
if (message.includes("tool_use") && message.includes("tool_result")) {
@@ -134,6 +134,8 @@ function detectErrorType(error: unknown): RecoveryErrorType {
(message.includes("first block") ||
message.includes("must start with") ||
message.includes("preceeding") ||
message.includes("final block") ||
message.includes("cannot be thinking") ||
(message.includes("expected") && message.includes("found")))
) {
return "thinking_block_order"

View File

@@ -322,4 +322,140 @@ describe("think-mode switcher", () => {
expect(config.maxTokens).toBe(64000)
})
})
describe("Custom provider prefixes support", () => {
describe("getHighVariant with prefixes", () => {
it("should preserve vertex_ai/ prefix when getting high variant", () => {
// #given a model ID with vertex_ai/ prefix
const variant = getHighVariant("vertex_ai/claude-sonnet-4-5")
// #then should return high variant with prefix preserved
expect(variant).toBe("vertex_ai/claude-sonnet-4-5-high")
})
it("should preserve openai/ prefix when getting high variant", () => {
// #given a model ID with openai/ prefix
const variant = getHighVariant("openai/gpt-5-2")
// #then should return high variant with prefix preserved
expect(variant).toBe("openai/gpt-5-2-high")
})
it("should handle prefixes with dots in version numbers", () => {
// #given a model ID with prefix and dots
const variant = getHighVariant("vertex_ai/claude-opus-4.5")
// #then should normalize dots and preserve prefix
expect(variant).toBe("vertex_ai/claude-opus-4-5-high")
})
it("should handle multiple different prefixes", () => {
// #given various custom prefixes
expect(getHighVariant("azure/gpt-5")).toBe("azure/gpt-5-high")
expect(getHighVariant("bedrock/claude-sonnet-4-5")).toBe("bedrock/claude-sonnet-4-5-high")
expect(getHighVariant("custom-llm/gemini-3-pro")).toBe("custom-llm/gemini-3-pro-high")
})
it("should return null for prefixed models without high variant mapping", () => {
// #given prefixed model IDs without high variant mapping
expect(getHighVariant("vertex_ai/unknown-model")).toBeNull()
expect(getHighVariant("custom/llama-3-70b")).toBeNull()
})
it("should return null for already-high prefixed models", () => {
// #given prefixed model IDs that are already high
expect(getHighVariant("vertex_ai/claude-opus-4-5-high")).toBeNull()
expect(getHighVariant("openai/gpt-5-2-high")).toBeNull()
})
})
describe("isAlreadyHighVariant with prefixes", () => {
it("should detect -high suffix in prefixed models", () => {
// #given prefixed model IDs with -high suffix
expect(isAlreadyHighVariant("vertex_ai/claude-opus-4-5-high")).toBe(true)
expect(isAlreadyHighVariant("openai/gpt-5-2-high")).toBe(true)
expect(isAlreadyHighVariant("custom/gemini-3-pro-high")).toBe(true)
})
it("should return false for prefixed base models", () => {
// #given prefixed base model IDs without -high suffix
expect(isAlreadyHighVariant("vertex_ai/claude-opus-4-5")).toBe(false)
expect(isAlreadyHighVariant("openai/gpt-5-2")).toBe(false)
})
it("should handle prefixed models with dots", () => {
// #given prefixed model IDs with dots
expect(isAlreadyHighVariant("vertex_ai/gpt-5.2")).toBe(false)
expect(isAlreadyHighVariant("vertex_ai/gpt-5.2-high")).toBe(true)
})
})
describe("getThinkingConfig with prefixes", () => {
it("should return null for custom providers (not in THINKING_CONFIGS)", () => {
// #given custom provider with prefixed Claude model
const config = getThinkingConfig("dia-llm", "vertex_ai/claude-sonnet-4-5")
// #then should return null (custom provider not in THINKING_CONFIGS)
expect(config).toBeNull()
})
it("should work with prefixed models on known providers", () => {
// #given known provider (anthropic) with prefixed model
// This tests that the base model name is correctly extracted for capability check
const config = getThinkingConfig("anthropic", "custom-prefix/claude-opus-4-5")
// #then should return thinking config (base model is capable)
expect(config).not.toBeNull()
expect(config?.thinking).toBeDefined()
})
it("should return null for prefixed models that are already high", () => {
// #given prefixed already-high model
const config = getThinkingConfig("anthropic", "vertex_ai/claude-opus-4-5-high")
// #then should return null
expect(config).toBeNull()
})
})
describe("Real-world custom provider scenario", () => {
it("should handle LLM proxy with vertex_ai prefix correctly", () => {
// #given a custom LLM proxy provider using vertex_ai/ prefix
const providerID = "dia-llm"
const modelID = "vertex_ai/claude-sonnet-4-5"
// #when getting high variant
const highVariant = getHighVariant(modelID)
// #then should preserve the prefix
expect(highVariant).toBe("vertex_ai/claude-sonnet-4-5-high")
// #and when checking if already high
expect(isAlreadyHighVariant(modelID)).toBe(false)
expect(isAlreadyHighVariant(highVariant!)).toBe(true)
// #and when getting thinking config for custom provider
const config = getThinkingConfig(providerID, modelID)
// #then should return null (custom provider, not anthropic)
// This prevents applying incompatible thinking configs to custom providers
expect(config).toBeNull()
})
it("should not break when switching to high variant in think mode", () => {
// #given think mode switching vertex_ai/claude model to high variant
const original = "vertex_ai/claude-opus-4-5"
const high = getHighVariant(original)
// #then the high variant should be valid
expect(high).toBe("vertex_ai/claude-opus-4-5-high")
// #and should be recognized as already high
expect(isAlreadyHighVariant(high!)).toBe(true)
// #and switching again should return null (already high)
expect(getHighVariant(high!)).toBeNull()
})
})
})
})

View File

@@ -16,6 +16,26 @@
* inconsistencies defensively while maintaining backwards compatibility.
*/
/**
* Extracts provider-specific prefix from model ID (if present).
* Custom providers may use prefixes for routing (e.g., vertex_ai/, openai/).
*
* @example
* extractModelPrefix("vertex_ai/claude-sonnet-4-5") // { prefix: "vertex_ai/", base: "claude-sonnet-4-5" }
* extractModelPrefix("claude-sonnet-4-5") // { prefix: "", base: "claude-sonnet-4-5" }
* extractModelPrefix("openai/gpt-5.2") // { prefix: "openai/", base: "gpt-5.2" }
*/
function extractModelPrefix(modelID: string): { prefix: string; base: string } {
const slashIndex = modelID.indexOf("/")
if (slashIndex === -1) {
return { prefix: "", base: modelID }
}
return {
prefix: modelID.slice(0, slashIndex + 1),
base: modelID.slice(slashIndex + 1),
}
}
/**
* 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).
@@ -25,6 +45,7 @@
* normalizeModelID("claude-opus-4.5") // "claude-opus-4-5"
* normalizeModelID("gemini-3.5-pro") // "gemini-3-5-pro"
* normalizeModelID("gpt-5.2") // "gpt-5-2"
* normalizeModelID("vertex_ai/claude-opus-4.5") // "vertex_ai/claude-opus-4-5"
*/
function normalizeModelID(modelID: string): string {
// Replace dots with hyphens when followed by a digit
@@ -142,16 +163,27 @@ const THINKING_CAPABLE_MODELS = {
export function getHighVariant(modelID: string): string | null {
const normalized = normalizeModelID(modelID)
const { prefix, base } = extractModelPrefix(normalized)
if (ALREADY_HIGH.has(normalized)) {
// Check if already high variant (with or without prefix)
if (ALREADY_HIGH.has(base) || base.endsWith("-high")) {
return null
}
return HIGH_VARIANT_MAP[normalized] ?? null
// Look up high variant for base model
const highBase = HIGH_VARIANT_MAP[base]
if (!highBase) {
return null
}
// Preserve prefix in the high variant
return prefix + highBase
}
export function isAlreadyHighVariant(modelID: string): boolean {
const normalized = normalizeModelID(modelID)
return ALREADY_HIGH.has(normalized) || normalized.endsWith("-high")
const { base } = extractModelPrefix(normalized)
return ALREADY_HIGH.has(base) || base.endsWith("-high")
}
type ThinkingProvider = keyof typeof THINKING_CONFIGS
@@ -165,6 +197,7 @@ export function getThinkingConfig(
modelID: string
): Record<string, unknown> | null {
const normalized = normalizeModelID(modelID)
const { base } = extractModelPrefix(normalized)
if (isAlreadyHighVariant(normalized)) {
return null
@@ -179,9 +212,10 @@ export function getThinkingConfig(
const config = THINKING_CONFIGS[resolvedProvider]
const capablePatterns = THINKING_CAPABLE_MODELS[resolvedProvider]
const modelLower = normalized.toLowerCase()
// Check capability using base model name (without prefix)
const baseLower = base.toLowerCase()
const isCapable = capablePatterns.some((pattern) =>
modelLower.includes(pattern.toLowerCase())
baseLower.includes(pattern.toLowerCase())
)
return isCapable ? config : null

View File

@@ -8,6 +8,16 @@ describe("todo-continuation-enforcer", () => {
let promptCalls: Array<{ sessionID: string; agent?: string; model?: { providerID?: string; modelID?: string }; text: string }>
let toastCalls: Array<{ title: string; message: string }>
interface MockMessage {
info: {
id: string
role: "user" | "assistant"
error?: { name: string; data?: { message: string } }
}
}
let mockMessages: MockMessage[] = []
function createMockPluginInput() {
return {
client: {
@@ -16,6 +26,7 @@ describe("todo-continuation-enforcer", () => {
{ id: "1", content: "Task 1", status: "pending", priority: "high" },
{ id: "2", content: "Task 2", status: "completed", priority: "medium" },
]}),
messages: async () => ({ data: mockMessages }),
prompt: async (opts: any) => {
promptCalls.push({
sessionID: opts.path.id,
@@ -51,6 +62,7 @@ describe("todo-continuation-enforcer", () => {
beforeEach(() => {
promptCalls = []
toastCalls = []
mockMessages = []
setMainSession(undefined)
subagentSessions.clear()
})
@@ -165,58 +177,9 @@ describe("todo-continuation-enforcer", () => {
expect(promptCalls[0].sessionID).toBe(bgTaskSession)
})
test("should skip injection when abort error occurs immediately before idle", async () => {
// #given - session that just had an abort error
const sessionID = "main-error"
setMainSession(sessionID)
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
// #when - abort error occurs
await hook.handler({
event: { type: "session.error", properties: { sessionID, error: { name: "AbortError", message: "aborted" } } },
})
// #when - session goes idle immediately after abort
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
await new Promise(r => setTimeout(r, 3000))
// #then - no continuation injected (abort was immediately before idle)
expect(promptCalls).toHaveLength(0)
})
test("should clear abort state on user message and allow injection", async () => {
// #given - session with abort error, then user clears it
const sessionID = "main-error-clear"
setMainSession(sessionID)
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
// #when - abort error occurs
await hook.handler({
event: { type: "session.error", properties: { sessionID, error: { message: "aborted" } } },
})
// #when - user sends message (clears abort state)
await hook.handler({
event: { type: "message.updated", properties: { info: { sessionID, role: "user" } } },
})
// #when - session goes idle
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
await new Promise(r => setTimeout(r, 2500))
// #then - continuation injected (abort state was cleared by user message)
expect(promptCalls.length).toBe(1)
})
test("should cancel countdown on user message", async () => {
test("should cancel countdown on user message after grace period", async () => {
// #given - session starting countdown
const sessionID = "main-cancel"
setMainSession(sessionID)
@@ -228,7 +191,8 @@ describe("todo-continuation-enforcer", () => {
event: { type: "session.idle", properties: { sessionID } },
})
// #when - user sends message immediately (before 2s countdown)
// #when - wait past grace period (500ms), then user sends message
await new Promise(r => setTimeout(r, 600))
await hook.handler({
event: {
type: "message.updated",
@@ -236,11 +200,37 @@ describe("todo-continuation-enforcer", () => {
},
})
// #then - wait past countdown time and verify no injection
// #then - wait past countdown time and verify no injection (countdown was cancelled)
await new Promise(r => setTimeout(r, 2500))
expect(promptCalls).toHaveLength(0)
})
test("should ignore user message within grace period", async () => {
// #given - session starting countdown
const sessionID = "main-grace"
setMainSession(sessionID)
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
// #when - session goes idle
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
// #when - user message arrives within grace period (immediately)
await hook.handler({
event: {
type: "message.updated",
properties: { info: { sessionID, role: "user" } }
},
})
// #then - countdown should continue (message was ignored)
// wait past 2s countdown and verify injection happens
await new Promise(r => setTimeout(r, 2500))
expect(promptCalls).toHaveLength(1)
})
test("should cancel countdown on assistant activity", async () => {
// #given - session starting countdown
const sessionID = "main-assistant"
@@ -388,7 +378,7 @@ describe("todo-continuation-enforcer", () => {
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
await new Promise(r => setTimeout(r, 2500))
await new Promise(r => setTimeout(r, 3500))
// #then - first injection happened
expect(promptCalls.length).toBe(1)
@@ -397,118 +387,17 @@ describe("todo-continuation-enforcer", () => {
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
await new Promise(r => setTimeout(r, 2500))
await new Promise(r => setTimeout(r, 3500))
// #then - second injection also happened (no throttle blocking)
expect(promptCalls.length).toBe(2)
}, { timeout: 10000 })
}, { timeout: 15000 })
// ============================================================
// ABORT "IMMEDIATELY BEFORE" DETECTION TESTS
// These tests verify that abort errors only block continuation
// when they occur IMMEDIATELY before session.idle, not based
// on a time-based cooldown.
// ============================================================
test("should skip injection ONLY when abort error occurs immediately before idle", async () => {
// #given - session with incomplete todos
const sessionID = "main-abort-immediate"
setMainSession(sessionID)
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
// #when - abort error occurs (with abort-specific error)
await hook.handler({
event: {
type: "session.error",
properties: {
sessionID,
error: { name: "MessageAbortedError", message: "The operation was aborted" }
}
},
})
// #when - session goes idle IMMEDIATELY after abort (no other events in between)
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
await new Promise(r => setTimeout(r, 3000))
// #then - no continuation injected (abort was immediately before idle)
expect(promptCalls).toHaveLength(0)
})
test("should inject normally when abort error is followed by assistant activity before idle", async () => {
// #given - session with incomplete todos
const sessionID = "main-abort-then-assistant"
setMainSession(sessionID)
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
// #when - abort error occurs
await hook.handler({
event: {
type: "session.error",
properties: {
sessionID,
error: { name: "MessageAbortedError", message: "The operation was aborted" }
}
},
})
// #when - assistant sends a message (intervening event clears abort state)
await hook.handler({
event: {
type: "message.updated",
properties: { info: { sessionID, role: "assistant" } }
},
})
// #when - session goes idle (abort is no longer "immediately before")
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
await new Promise(r => setTimeout(r, 2500))
// #then - continuation injected (abort was NOT immediately before idle)
expect(promptCalls.length).toBe(1)
})
test("should inject normally when abort error is followed by tool execution before idle", async () => {
// #given - session with incomplete todos
const sessionID = "main-abort-then-tool"
setMainSession(sessionID)
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
// #when - abort error occurs
await hook.handler({
event: {
type: "session.error",
properties: {
sessionID,
error: { message: "aborted" }
}
},
})
// #when - tool execution happens (intervening event)
await hook.handler({
event: { type: "tool.execute.after", properties: { sessionID } },
})
// #when - session goes idle
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
await new Promise(r => setTimeout(r, 2500))
// #then - continuation injected (abort was NOT immediately before idle)
expect(promptCalls.length).toBe(1)
})
test("should NOT skip for non-abort errors even if immediately before idle", async () => {
// #given - session with incomplete todos
@@ -539,74 +428,105 @@ describe("todo-continuation-enforcer", () => {
expect(promptCalls.length).toBe(1)
})
test("should inject after abort if time passes and new idle event occurs", async () => {
// #given - session with incomplete todos, abort happened previously
const sessionID = "main-abort-time-passed"
// ============================================================
// API-BASED ABORT DETECTION TESTS
// These tests verify that abort is detected by checking
// the last assistant message's error field via session.messages API
// ============================================================
test("should skip injection when last assistant message has MessageAbortedError", async () => {
// #given - session where last assistant message was aborted
const sessionID = "main-api-abort"
setMainSession(sessionID)
mockMessages = [
{ info: { id: "msg-1", role: "user" } },
{ info: { id: "msg-2", role: "assistant", error: { name: "MessageAbortedError", data: { message: "The operation was aborted" } } } },
]
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
// #when - abort error occurs
await hook.handler({
event: {
type: "session.error",
properties: {
sessionID,
error: { name: "AbortError", message: "cancelled" }
}
},
})
// #when - first idle (immediately after abort) - should be skipped
// #when - session goes idle
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
await new Promise(r => setTimeout(r, 3000))
// #then - no continuation (last message was aborted)
expect(promptCalls).toHaveLength(0)
})
// #when - second idle event occurs (abort is no longer "immediately before")
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
await new Promise(r => setTimeout(r, 2500))
// #then - continuation injected on second idle (abort state was consumed)
expect(promptCalls.length).toBe(1)
}, { timeout: 10000 })
test("should handle multiple abort errors correctly - only last one matters", async () => {
// #given - session with incomplete todos
const sessionID = "main-multi-abort"
test("should inject when last assistant message has no error", async () => {
// #given - session where last assistant message completed normally
const sessionID = "main-api-no-error"
setMainSession(sessionID)
mockMessages = [
{ info: { id: "msg-1", role: "user" } },
{ info: { id: "msg-2", role: "assistant" } },
]
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
// #when - first abort error
await hook.handler({
event: {
type: "session.error",
properties: { sessionID, error: { message: "aborted" } }
},
})
// #when - second abort error (immediately before idle)
await hook.handler({
event: {
type: "session.error",
properties: { sessionID, error: { message: "interrupted" } }
},
})
// #when - idle immediately after second abort
// #when - session goes idle
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
await new Promise(r => setTimeout(r, 3000))
// #then - no continuation (abort was immediately before)
// #then - continuation injected (no abort)
expect(promptCalls.length).toBe(1)
})
test("should inject when last message is from user (not assistant)", async () => {
// #given - session where last message is from user
const sessionID = "main-api-user-last"
setMainSession(sessionID)
mockMessages = [
{ info: { id: "msg-1", role: "assistant" } },
{ info: { id: "msg-2", role: "user" } },
]
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
// #when - session goes idle
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
await new Promise(r => setTimeout(r, 3000))
// #then - continuation injected (last message is user, not aborted assistant)
expect(promptCalls.length).toBe(1)
})
test("should skip when last assistant message has any abort-like error", async () => {
// #given - session where last assistant message has AbortError (DOMException style)
const sessionID = "main-api-abort-dom"
setMainSession(sessionID)
mockMessages = [
{ info: { id: "msg-1", role: "user" } },
{ info: { id: "msg-2", role: "assistant", error: { name: "AbortError" } } },
]
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
// #when - session goes idle
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
await new Promise(r => setTimeout(r, 3000))
// #then - no continuation (abort error detected)
expect(promptCalls).toHaveLength(0)
})
})

View File

@@ -29,10 +29,10 @@ interface Todo {
}
interface SessionState {
lastEventWasAbortError?: boolean
countdownTimer?: ReturnType<typeof setTimeout>
countdownInterval?: ReturnType<typeof setInterval>
isRecovering?: boolean
countdownStartedAt?: number
}
const CONTINUATION_PROMPT = `[SYSTEM REMINDER - TODO CONTINUATION]
@@ -45,6 +45,7 @@ Incomplete tasks remain in your todo list. Continue working on the next pending
const COUNTDOWN_SECONDS = 2
const TOAST_DURATION_MS = 900
const COUNTDOWN_GRACE_PERIOD_MS = 500
function getMessageDir(sessionID: string): string | null {
if (!existsSync(MESSAGE_STORAGE)) return null
@@ -60,31 +61,30 @@ function getMessageDir(sessionID: string): string | null {
return null
}
function isAbortError(error: unknown): boolean {
if (!error) return false
if (typeof error === "object") {
const errObj = error as Record<string, unknown>
const name = errObj.name as string | undefined
const message = (errObj.message as string | undefined)?.toLowerCase() ?? ""
if (name === "MessageAbortedError" || name === "AbortError") return true
if (name === "DOMException" && message.includes("abort")) return true
if (message.includes("aborted") || message.includes("cancelled") || message.includes("interrupted")) return true
}
if (typeof error === "string") {
const lower = error.toLowerCase()
return lower.includes("abort") || lower.includes("cancel") || lower.includes("interrupt")
}
return false
}
function getIncompleteCount(todos: Todo[]): number {
return todos.filter(t => t.status !== "completed" && t.status !== "cancelled").length
}
interface MessageInfo {
id?: string
role?: string
error?: { name?: string; data?: unknown }
}
function isLastAssistantMessageAborted(messages: Array<{ info?: MessageInfo }>): boolean {
if (!messages || messages.length === 0) return false
const assistantMessages = messages.filter(m => m.info?.role === "assistant")
if (assistantMessages.length === 0) return false
const lastAssistant = assistantMessages[assistantMessages.length - 1]
const errorName = lastAssistant.info?.error?.name
if (!errorName) return false
return errorName === "MessageAbortedError" || errorName === "AbortError"
}
export function createTodoContinuationEnforcer(
ctx: PluginInput,
options: TodoContinuationEnforcerOptions = {}
@@ -113,6 +113,7 @@ export function createTodoContinuationEnforcer(
clearInterval(state.countdownInterval)
state.countdownInterval = undefined
}
state.countdownStartedAt = undefined
}
function cleanup(sessionID: string): void {
@@ -228,6 +229,7 @@ export function createTodoContinuationEnforcer(
let secondsRemaining = COUNTDOWN_SECONDS
showCountdownToast(secondsRemaining, incompleteCount)
state.countdownStartedAt = Date.now()
state.countdownInterval = setInterval(() => {
secondsRemaining--
@@ -251,12 +253,8 @@ export function createTodoContinuationEnforcer(
const sessionID = props?.sessionID as string | undefined
if (!sessionID) return
const state = getState(sessionID)
const isAbort = isAbortError(props?.error)
state.lastEventWasAbortError = isAbort
cancelCountdown(sessionID)
log(`[${HOOK_NAME}] session.error`, { sessionID, isAbort })
log(`[${HOOK_NAME}] session.error`, { sessionID })
return
}
@@ -282,12 +280,6 @@ export function createTodoContinuationEnforcer(
return
}
if (state.lastEventWasAbortError) {
state.lastEventWasAbortError = false
log(`[${HOOK_NAME}] Skipped: abort error immediately before idle`, { sessionID })
return
}
const hasRunningBgTasks = backgroundManager
? backgroundManager.getTasksByParentSession(sessionID).some(t => t.status === "running")
: false
@@ -297,6 +289,21 @@ export function createTodoContinuationEnforcer(
return
}
try {
const messagesResp = await ctx.client.session.messages({
path: { id: sessionID },
query: { directory: ctx.directory },
})
const messages = (messagesResp as { data?: Array<{ info?: MessageInfo }> }).data ?? []
if (isLastAssistantMessageAborted(messages)) {
log(`[${HOOK_NAME}] Skipped: last assistant message was aborted`, { sessionID })
return
}
} catch (err) {
log(`[${HOOK_NAME}] Messages fetch failed, continuing`, { sessionID, error: String(err) })
}
let todos: Todo[] = []
try {
const response = await ctx.client.session.todo({ path: { id: sessionID } })
@@ -328,14 +335,16 @@ export function createTodoContinuationEnforcer(
if (!sessionID) return
const state = sessions.get(sessionID)
if (state) {
state.lastEventWasAbortError = false
}
if (role === "user") {
const state = sessions.get(sessionID)
if (state?.countdownStartedAt) {
const elapsed = Date.now() - state.countdownStartedAt
if (elapsed < COUNTDOWN_GRACE_PERIOD_MS) {
log(`[${HOOK_NAME}] Ignoring user message in grace period`, { sessionID, elapsed })
return
}
}
cancelCountdown(sessionID)
log(`[${HOOK_NAME}] User message: cleared abort state`, { sessionID })
}
if (role === "assistant") {
@@ -350,10 +359,6 @@ export function createTodoContinuationEnforcer(
const role = info?.role as string | undefined
if (sessionID && role === "assistant") {
const state = sessions.get(sessionID)
if (state) {
state.lastEventWasAbortError = false
}
cancelCountdown(sessionID)
}
return
@@ -362,10 +367,6 @@ export function createTodoContinuationEnforcer(
if (event.type === "tool.execute.before" || event.type === "tool.execute.after") {
const sessionID = props?.sessionID as string | undefined
if (sessionID) {
const state = sessions.get(sessionID)
if (state) {
state.lastEventWasAbortError = false
}
cancelCountdown(sessionID)
}
return

View File

@@ -0,0 +1,168 @@
import { describe, it, expect, beforeEach, mock, spyOn } from "bun:test"
import { createToolOutputTruncatorHook } from "./tool-output-truncator"
import * as dynamicTruncator from "../shared/dynamic-truncator"
describe("createToolOutputTruncatorHook", () => {
let hook: ReturnType<typeof createToolOutputTruncatorHook>
let truncateSpy: ReturnType<typeof spyOn>
beforeEach(() => {
truncateSpy = spyOn(dynamicTruncator, "createDynamicTruncator").mockReturnValue({
truncate: mock(async (_sessionID: string, output: string, options?: { targetMaxTokens?: number }) => ({
result: output,
truncated: false,
targetMaxTokens: options?.targetMaxTokens,
})),
getUsage: mock(async () => null),
truncateSync: mock(() => ({ result: "", truncated: false })),
})
hook = createToolOutputTruncatorHook({} as never)
})
describe("tool.execute.after", () => {
const createInput = (tool: string) => ({
tool,
sessionID: "test-session",
callID: "test-call-id",
})
const createOutput = (outputText: string) => ({
title: "Result",
output: outputText,
metadata: {},
})
describe("#given webfetch tool", () => {
describe("#when output is processed", () => {
it("#then should use aggressive truncation limit (10k tokens)", async () => {
const truncateMock = mock(async (_sessionID: string, _output: string, options?: { targetMaxTokens?: number }) => ({
result: "truncated",
truncated: true,
targetMaxTokens: options?.targetMaxTokens,
}))
truncateSpy.mockReturnValue({
truncate: truncateMock,
getUsage: mock(async () => null),
truncateSync: mock(() => ({ result: "", truncated: false })),
})
hook = createToolOutputTruncatorHook({} as never)
const input = createInput("webfetch")
const output = createOutput("large content")
await hook["tool.execute.after"](input, output)
expect(truncateMock).toHaveBeenCalledWith(
"test-session",
"large content",
{ targetMaxTokens: 10_000 }
)
})
})
describe("#when using WebFetch variant", () => {
it("#then should also use aggressive truncation limit", async () => {
const truncateMock = mock(async (_sessionID: string, _output: string, options?: { targetMaxTokens?: number }) => ({
result: "truncated",
truncated: true,
}))
truncateSpy.mockReturnValue({
truncate: truncateMock,
getUsage: mock(async () => null),
truncateSync: mock(() => ({ result: "", truncated: false })),
})
hook = createToolOutputTruncatorHook({} as never)
const input = createInput("WebFetch")
const output = createOutput("large content")
await hook["tool.execute.after"](input, output)
expect(truncateMock).toHaveBeenCalledWith(
"test-session",
"large content",
{ targetMaxTokens: 10_000 }
)
})
})
})
describe("#given grep tool", () => {
describe("#when output is processed", () => {
it("#then should use default truncation limit (50k tokens)", async () => {
const truncateMock = mock(async (_sessionID: string, _output: string, options?: { targetMaxTokens?: number }) => ({
result: "truncated",
truncated: true,
}))
truncateSpy.mockReturnValue({
truncate: truncateMock,
getUsage: mock(async () => null),
truncateSync: mock(() => ({ result: "", truncated: false })),
})
hook = createToolOutputTruncatorHook({} as never)
const input = createInput("grep")
const output = createOutput("grep output")
await hook["tool.execute.after"](input, output)
expect(truncateMock).toHaveBeenCalledWith(
"test-session",
"grep output",
{ targetMaxTokens: 50_000 }
)
})
})
})
describe("#given non-truncatable tool", () => {
describe("#when tool is not in TRUNCATABLE_TOOLS list", () => {
it("#then should not call truncator", async () => {
const truncateMock = mock(async () => ({
result: "truncated",
truncated: true,
}))
truncateSpy.mockReturnValue({
truncate: truncateMock,
getUsage: mock(async () => null),
truncateSync: mock(() => ({ result: "", truncated: false })),
})
hook = createToolOutputTruncatorHook({} as never)
const input = createInput("Read")
const output = createOutput("file content")
await hook["tool.execute.after"](input, output)
expect(truncateMock).not.toHaveBeenCalled()
})
})
})
describe("#given truncate_all_tool_outputs enabled", () => {
describe("#when any tool output is processed", () => {
it("#then should truncate non-listed tools too", async () => {
const truncateMock = mock(async (_sessionID: string, _output: string, options?: { targetMaxTokens?: number }) => ({
result: "truncated",
truncated: true,
}))
truncateSpy.mockReturnValue({
truncate: truncateMock,
getUsage: mock(async () => null),
truncateSync: mock(() => ({ result: "", truncated: false })),
})
hook = createToolOutputTruncatorHook({} as never, {
experimental: { truncate_all_tool_outputs: true },
})
const input = createInput("Read")
const output = createOutput("file content")
await hook["tool.execute.after"](input, output)
expect(truncateMock).toHaveBeenCalled()
})
})
})
})
})

View File

@@ -2,6 +2,9 @@ import type { PluginInput } from "@opencode-ai/plugin"
import type { ExperimentalConfig } from "../config/schema"
import { createDynamicTruncator } from "../shared/dynamic-truncator"
const DEFAULT_MAX_TOKENS = 50_000 // ~200k chars
const WEBFETCH_MAX_TOKENS = 10_000 // ~40k chars - web pages need aggressive truncation
const TRUNCATABLE_TOOLS = [
"grep",
"Grep",
@@ -21,6 +24,11 @@ const TRUNCATABLE_TOOLS = [
"WebFetch",
]
const TOOL_SPECIFIC_MAX_TOKENS: Record<string, number> = {
webfetch: WEBFETCH_MAX_TOKENS,
WebFetch: WEBFETCH_MAX_TOKENS,
}
interface ToolOutputTruncatorOptions {
experimental?: ExperimentalConfig
}
@@ -36,7 +44,12 @@ export function createToolOutputTruncatorHook(ctx: PluginInput, options?: ToolOu
if (!truncateAll && !TRUNCATABLE_TOOLS.includes(input.tool)) return
try {
const { result, truncated } = await truncator.truncate(input.sessionID, output.output)
const targetMaxTokens = TOOL_SPECIFIC_MAX_TOKENS[input.tool] ?? DEFAULT_MAX_TOKENS
const { result, truncated } = await truncator.truncate(
input.sessionID,
output.output,
{ targetMaxTokens }
)
if (truncated) {
output.output = result
}

View File

@@ -25,7 +25,13 @@ import {
createThinkingBlockValidatorHook,
createRalphLoopHook,
createAutoSlashCommandHook,
createEditErrorRecoveryHook,
} from "./hooks";
import {
contextCollector,
createContextInjectorHook,
createContextInjectorMessagesTransformHook,
} from "./features/context-injector";
import { createGoogleAntigravityAuthPlugin } from "./auth/antigravity";
import {
discoverUserClaudeSkills,
@@ -47,18 +53,24 @@ import {
createLookAt,
createSkillTool,
createSkillMcpTool,
createSlashcommandTool,
discoverCommandsSync,
sessionExists,
interactive_bash,
getTmuxPath,
startTmuxCheck,
} from "./tools";
import { BackgroundManager } from "./features/background-agent";
import { SkillMcpManager } from "./features/skill-mcp-manager";
import { type OhMyOpenCodeConfig, type HookName } from "./config";
import { type HookName } from "./config";
import { log } from "./shared";
import { loadPluginConfig } from "./plugin-config";
import { createModelCacheState, getModelLimit } from "./plugin-state";
import { createConfigHandler } from "./plugin-handlers";
const OhMyOpenCodePlugin: Plugin = async (ctx) => {
// Start background tmux check immediately
startTmuxCheck();
const pluginConfig = loadPluginConfig(ctx.directory, ctx);
const disabledHooks = new Set(pluginConfig.disabled_hooks ?? []);
const isHookEnabled = (hookName: HookName) => !disabledHooks.has(hookName);
@@ -128,6 +140,9 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
const keywordDetector = isHookEnabled("keyword-detector")
? createKeywordDetectorHook(ctx)
: null;
const contextInjector = createContextInjectorHook(contextCollector);
const contextInjectorMessagesTransform =
createContextInjectorMessagesTransformHook(contextCollector);
const agentUsageReminder = isHookEnabled("agent-usage-reminder")
? createAgentUsageReminderHook(ctx)
: null;
@@ -145,13 +160,20 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
: null;
const ralphLoop = isHookEnabled("ralph-loop")
? createRalphLoopHook(ctx, { config: pluginConfig.ralph_loop })
? createRalphLoopHook(ctx, {
config: pluginConfig.ralph_loop,
checkSessionExists: async (sessionId) => sessionExists(sessionId),
})
: null;
const autoSlashCommand = isHookEnabled("auto-slash-command")
? createAutoSlashCommandHook()
: null;
const editErrorRecovery = isHookEnabled("edit-error-recovery")
? createEditErrorRecoveryHook(ctx)
: null;
const backgroundManager = new BackgroundManager(ctx);
const todoContinuationEnforcer = isHookEnabled("todo-continuation-enforcer")
@@ -184,13 +206,19 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
return true;
});
const includeClaudeSkills = pluginConfig.claude_code?.skills !== false;
const [userSkills, globalSkills, projectSkills, opencodeProjectSkills] = await Promise.all([
includeClaudeSkills ? discoverUserClaudeSkills() : Promise.resolve([]),
discoverOpencodeGlobalSkills(),
includeClaudeSkills ? discoverProjectClaudeSkills() : Promise.resolve([]),
discoverOpencodeProjectSkills(),
]);
const mergedSkills = mergeSkills(
builtinSkills,
pluginConfig.skills,
includeClaudeSkills ? discoverUserClaudeSkills() : [],
discoverOpencodeGlobalSkills(),
includeClaudeSkills ? discoverProjectClaudeSkills() : [],
discoverOpencodeProjectSkills()
userSkills,
globalSkills,
projectSkills,
opencodeProjectSkills
);
const skillMcpManager = new SkillMcpManager();
const getSessionIDForMcp = () => getMainSessionID() || "";
@@ -205,12 +233,15 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
getSessionID: getSessionIDForMcp,
});
const googleAuthHooks =
pluginConfig.google_auth !== false
? await createGoogleAntigravityAuthPlugin(ctx)
: null;
const commands = discoverCommandsSync();
const slashcommandTool = createSlashcommandTool({
commands,
skills: mergedSkills,
});
const tmuxAvailable = await getTmuxPath();
const googleAuthHooks = pluginConfig.google_auth !== false
? await createGoogleAntigravityAuthPlugin(ctx)
: null;
const configHandler = createConfigHandler({
ctx,
@@ -228,12 +259,14 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
look_at: lookAt,
skill: skillTool,
skill_mcp: skillMcpTool,
...(tmuxAvailable ? { interactive_bash } : {}),
slashcommand: slashcommandTool,
interactive_bash,
},
"chat.message": async (input, output) => {
await claudeCodeHooks["chat.message"]?.(input, output);
await keywordDetector?.["chat.message"]?.(input, output);
await contextInjector["chat.message"]?.(input, output);
await autoSlashCommand?.["chat.message"]?.(input, output);
if (ralphLoop) {
@@ -294,6 +327,8 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
input: Record<string, never>,
output: { messages: Array<{ info: unknown; parts: unknown[] }> }
) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await contextInjectorMessagesTransform?.["experimental.chat.messages.transform"]?.(input, output as any);
await thinkingBlockValidator?.[
"experimental.chat.messages.transform"
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -436,6 +471,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
await emptyTaskResponseDetector?.["tool.execute.after"](input, output);
await agentUsageReminder?.["tool.execute.after"](input, output);
await interactiveBashSession?.["tool.execute.after"](input, output);
await editErrorRecovery?.["tool.execute.after"](input, output);
},
};
};

80
src/mcp/index.test.ts Normal file
View File

@@ -0,0 +1,80 @@
import { describe, expect, test } from "bun:test"
import { createBuiltinMcps } from "./index"
describe("createBuiltinMcps", () => {
test("should return all MCPs when disabled_mcps is empty", () => {
//#given
const disabledMcps: string[] = []
//#when
const result = createBuiltinMcps(disabledMcps)
//#then
expect(result).toHaveProperty("context7")
expect(result).toHaveProperty("grep_app")
expect(Object.keys(result)).toHaveLength(2)
})
test("should filter out disabled built-in MCPs", () => {
//#given
const disabledMcps = ["context7"]
//#when
const result = createBuiltinMcps(disabledMcps)
//#then
expect(result).not.toHaveProperty("context7")
expect(result).toHaveProperty("grep_app")
expect(Object.keys(result)).toHaveLength(1)
})
test("should filter out both built-in MCPs when both disabled", () => {
//#given
const disabledMcps = ["context7", "grep_app"]
//#when
const result = createBuiltinMcps(disabledMcps)
//#then
expect(result).not.toHaveProperty("context7")
expect(result).not.toHaveProperty("grep_app")
expect(Object.keys(result)).toHaveLength(0)
})
test("should ignore custom MCP names in disabled_mcps", () => {
//#given
const disabledMcps = ["context7", "playwright", "custom"]
//#when
const result = createBuiltinMcps(disabledMcps)
//#then
expect(result).not.toHaveProperty("context7")
expect(result).toHaveProperty("grep_app")
expect(Object.keys(result)).toHaveLength(1)
})
test("should handle empty disabled_mcps by default", () => {
//#given
//#when
const result = createBuiltinMcps()
//#then
expect(result).toHaveProperty("context7")
expect(result).toHaveProperty("grep_app")
expect(Object.keys(result)).toHaveLength(2)
})
test("should only filter built-in MCPs, ignoring unknown names", () => {
//#given
const disabledMcps = ["playwright", "sqlite", "unknown-mcp"]
//#when
const result = createBuiltinMcps(disabledMcps)
//#then
expect(result).toHaveProperty("context7")
expect(result).toHaveProperty("grep_app")
expect(Object.keys(result)).toHaveLength(2)
})
})

View File

@@ -1,4 +1,3 @@
import { websearch_exa } from "./websearch-exa"
import { context7 } from "./context7"
import { grep_app } from "./grep-app"
import type { McpName } from "./types"
@@ -6,16 +5,15 @@ import type { McpName } from "./types"
export { McpNameSchema, type McpName } from "./types"
const allBuiltinMcps: Record<McpName, { type: "remote"; url: string; enabled: boolean }> = {
websearch_exa,
context7,
grep_app,
}
export function createBuiltinMcps(disabledMcps: McpName[] = []) {
export function createBuiltinMcps(disabledMcps: string[] = []) {
const mcps: Record<string, { type: "remote"; url: string; enabled: boolean }> = {}
for (const [name, config] of Object.entries(allBuiltinMcps)) {
if (!disabledMcps.includes(name as McpName)) {
if (!disabledMcps.includes(name)) {
mcps[name] = config
}
}

View File

@@ -1,5 +1,9 @@
import { z } from "zod"
export const McpNameSchema = z.enum(["websearch_exa", "context7", "grep_app"])
export const McpNameSchema = z.enum(["context7", "grep_app"])
export type McpName = z.infer<typeof McpNameSchema>
export const AnyMcpNameSchema = z.string().min(1)
export type AnyMcpName = z.infer<typeof AnyMcpNameSchema>

View File

@@ -1,5 +0,0 @@
export const websearch_exa = {
type: "remote" as const,
url: "https://mcp.exa.ai/mcp?tools=web_search_exa",
enabled: true,
}

View File

@@ -21,6 +21,7 @@ import { loadAllPluginComponents } from "../features/claude-code-plugin-loader";
import { createBuiltinMcps } from "../mcp";
import type { OhMyOpenCodeConfig } from "../config";
import { log } from "../shared";
import { migrateAgentConfig } from "../shared/permission-compat";
import { PLAN_SYSTEM_PROMPT, PLAN_PERMISSION } from "../agents/plan-prompt";
import type { ModelCacheState } from "../plugin-state";
@@ -95,13 +96,24 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
config.model as string | undefined
);
// Claude Code agents: Do NOT apply permission migration
// Claude Code uses whitelist-based tools format which is semantically different
// from OpenCode's denylist-based permission system
const userAgents = (pluginConfig.claude_code?.agents ?? true)
? loadUserAgents()
: {};
const projectAgents = (pluginConfig.claude_code?.agents ?? true)
? loadProjectAgents()
: {};
const pluginAgents = pluginComponents.agents;
// Plugin agents: Apply permission migration for compatibility
const rawPluginAgents = pluginComponents.agents;
const pluginAgents = Object.fromEntries(
Object.entries(rawPluginAgents).map(([k, v]) => [
k,
v ? migrateAgentConfig(v as Record<string, unknown>) : v,
])
);
const isSisyphusEnabled = pluginConfig.sisyphus_agent?.disabled !== true;
const builderEnabled =
@@ -132,10 +144,13 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
if (builderEnabled) {
const { name: _buildName, ...buildConfigWithoutName } =
configAgent?.build ?? {};
const migratedBuildConfig = migrateAgentConfig(
buildConfigWithoutName as Record<string, unknown>
);
const openCodeBuilderOverride =
pluginConfig.agents?.["OpenCode-Builder"];
const openCodeBuilderBase = {
...buildConfigWithoutName,
...migratedBuildConfig,
description: `${configAgent?.build?.description ?? "Build agent"} (OpenCode default)`,
};
@@ -145,12 +160,17 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
}
if (plannerEnabled) {
const { name: _planName, ...planConfigWithoutName } =
const { name: _planName, mode: _planMode, ...planConfigWithoutName } =
configAgent?.plan ?? {};
const migratedPlanConfig = migrateAgentConfig(
planConfigWithoutName as Record<string, unknown>
);
const plannerSisyphusOverride =
pluginConfig.agents?.["Planner-Sisyphus"];
const defaultModel = config.model as string | undefined;
const plannerSisyphusBase = {
...planConfigWithoutName,
model: (migratedPlanConfig as Record<string, unknown>).model ?? defaultModel,
mode: "primary" as const,
prompt: PLAN_SYSTEM_PROMPT,
permission: PLAN_PERMISSION,
description: `${configAgent?.plan?.description ?? "Plan agent"} (OhMyOpenCode version)`,
@@ -162,16 +182,29 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
: plannerSisyphusBase;
}
const filteredConfigAgents = configAgent
? Object.fromEntries(
Object.entries(configAgent).filter(([key]) => {
const filteredConfigAgents = configAgent
? Object.fromEntries(
Object.entries(configAgent)
.filter(([key]) => {
if (key === "build") return false;
if (key === "plan" && replacePlan) return false;
return true;
})
)
.map(([key, value]) => [
key,
value ? migrateAgentConfig(value as Record<string, unknown>) : value,
])
)
: {};
const migratedBuild = configAgent?.build
? migrateAgentConfig(configAgent.build as Record<string, unknown>)
: {};
const planDemoteConfig = replacePlan
? { mode: "subagent" as const, hidden: true }
: undefined;
config.agent = {
...agentConfig,
...Object.fromEntries(
@@ -181,10 +214,8 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
...projectAgents,
...pluginAgents,
...filteredConfigAgents,
build: { ...configAgent?.build, mode: "subagent" },
...(replacePlan
? { plan: { ...configAgent?.plan, mode: "subagent" } }
: {}),
build: { ...migratedBuild, mode: "subagent", hidden: true },
...(planDemoteConfig ? { plan: planDemoteConfig } : {}),
};
} else {
config.agent = {
@@ -243,24 +274,31 @@ export function createConfigHandler(deps: ConfigHandlerDeps) {
};
const builtinCommands = loadBuiltinCommands(pluginConfig.disabled_commands);
const userCommands = (pluginConfig.claude_code?.commands ?? true)
? loadUserCommands()
: {};
const opencodeGlobalCommands = loadOpencodeGlobalCommands();
const systemCommands = (config.command as Record<string, unknown>) ?? {};
const projectCommands = (pluginConfig.claude_code?.commands ?? true)
? loadProjectCommands()
: {};
const opencodeProjectCommands = loadOpencodeProjectCommands();
const userSkills = (pluginConfig.claude_code?.skills ?? true)
? loadUserSkills()
: {};
const projectSkills = (pluginConfig.claude_code?.skills ?? true)
? loadProjectSkills()
: {};
const opencodeGlobalSkills = loadOpencodeGlobalSkills();
const opencodeProjectSkills = loadOpencodeProjectSkills();
// Parallel loading of all commands and skills for faster startup
const includeClaudeCommands = pluginConfig.claude_code?.commands ?? true;
const includeClaudeSkills = pluginConfig.claude_code?.skills ?? true;
const [
userCommands,
projectCommands,
opencodeGlobalCommands,
opencodeProjectCommands,
userSkills,
projectSkills,
opencodeGlobalSkills,
opencodeProjectSkills,
] = await Promise.all([
includeClaudeCommands ? loadUserCommands() : Promise.resolve({}),
includeClaudeCommands ? loadProjectCommands() : Promise.resolve({}),
loadOpencodeGlobalCommands(),
loadOpencodeProjectCommands(),
includeClaudeSkills ? loadUserSkills() : Promise.resolve({}),
includeClaudeSkills ? loadProjectSkills() : Promise.resolve({}),
loadOpencodeGlobalSkills(),
loadOpencodeProjectSkills(),
]);
config.command = {
...builtinCommands,

View File

@@ -1,6 +1,10 @@
import type { PluginInput } from "@opencode-ai/plugin";
const ANTHROPIC_ACTUAL_LIMIT = 200_000;
const ANTHROPIC_ACTUAL_LIMIT =
process.env.ANTHROPIC_1M_CONTEXT === "true" ||
process.env.VERTEX_ANTHROPIC_1M_CONTEXT === "true"
? 1_000_000
: 200_000;
const CHARS_PER_TOKEN_ESTIMATE = 4;
const DEFAULT_TARGET_MAX_TOKENS = 50_000;

View File

@@ -1,4 +1,5 @@
import { lstatSync, readlinkSync } from "fs"
import { promises as fs } from "fs"
import { resolve } from "path"
export function isMarkdownFile(entry: { name: string; isFile: () => boolean }): boolean {
@@ -24,3 +25,16 @@ export function resolveSymlink(filePath: string): string {
return filePath
}
}
export async function resolveSymlinkAsync(filePath: string): Promise<string> {
try {
const stats = await fs.lstat(filePath)
if (stats.isSymbolicLink()) {
const linkTarget = await fs.readlink(filePath)
return resolve(filePath, "..", linkTarget)
}
return filePath
} catch {
return filePath
}
}

View File

@@ -3,6 +3,8 @@ import yaml from "js-yaml"
export interface FrontmatterResult<T = Record<string, unknown>> {
data: T
body: string
hadFrontmatter: boolean
parseError: boolean
}
export function parseFrontmatter<T = Record<string, unknown>>(
@@ -12,7 +14,7 @@ export function parseFrontmatter<T = Record<string, unknown>>(
const match = content.match(frontmatterRegex)
if (!match) {
return { data: {} as T, body: content }
return { data: {} as T, body: content, hadFrontmatter: false, parseError: false }
}
const yamlContent = match[1]
@@ -22,8 +24,8 @@ export function parseFrontmatter<T = Record<string, unknown>>(
// Use JSON_SCHEMA for security - prevents code execution via YAML tags
const parsed = yaml.load(yamlContent, { schema: yaml.JSON_SCHEMA })
const data = (parsed ?? {}) as T
return { data, body }
return { data, body, hadFrontmatter: true, parseError: false }
} catch {
return { data: {} as T, body }
return { data: {} as T, body, hadFrontmatter: true, parseError: true }
}
}

View File

@@ -16,3 +16,6 @@ export * from "./config-errors"
export * from "./claude-config-dir"
export * from "./jsonc-parser"
export * from "./migration"
export * from "./opencode-config-dir"
export * from "./opencode-version"
export * from "./permission-compat"

View File

@@ -0,0 +1,224 @@
import { describe, test, expect, beforeEach, afterEach } from "bun:test"
import { homedir } from "node:os"
import { join } from "node:path"
import {
getOpenCodeConfigDir,
getOpenCodeConfigPaths,
isDevBuild,
detectExistingConfigDir,
TAURI_APP_IDENTIFIER,
TAURI_APP_IDENTIFIER_DEV,
} from "./opencode-config-dir"
describe("opencode-config-dir", () => {
let originalPlatform: NodeJS.Platform
let originalEnv: Record<string, string | undefined>
beforeEach(() => {
originalPlatform = process.platform
originalEnv = {
APPDATA: process.env.APPDATA,
XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME,
XDG_DATA_HOME: process.env.XDG_DATA_HOME,
}
})
afterEach(() => {
Object.defineProperty(process, "platform", { value: originalPlatform })
for (const [key, value] of Object.entries(originalEnv)) {
if (value !== undefined) {
process.env[key] = value
} else {
delete process.env[key]
}
}
})
describe("isDevBuild", () => {
test("returns false for null version", () => {
expect(isDevBuild(null)).toBe(false)
})
test("returns false for undefined version", () => {
expect(isDevBuild(undefined)).toBe(false)
})
test("returns false for production version", () => {
expect(isDevBuild("1.0.200")).toBe(false)
expect(isDevBuild("2.1.0")).toBe(false)
})
test("returns true for version containing -dev", () => {
expect(isDevBuild("1.0.0-dev")).toBe(true)
expect(isDevBuild("1.0.0-dev.123")).toBe(true)
})
test("returns true for version containing .dev", () => {
expect(isDevBuild("1.0.0.dev")).toBe(true)
expect(isDevBuild("1.0.0.dev.456")).toBe(true)
})
})
describe("getOpenCodeConfigDir", () => {
describe("for opencode CLI binary", () => {
test("returns ~/.config/opencode on Linux", () => {
// #given opencode CLI binary detected, platform is Linux
Object.defineProperty(process, "platform", { value: "linux" })
delete process.env.XDG_CONFIG_HOME
// #when getOpenCodeConfigDir is called with binary="opencode"
const result = getOpenCodeConfigDir({ binary: "opencode", version: "1.0.200" })
// #then returns ~/.config/opencode
expect(result).toBe(join(homedir(), ".config", "opencode"))
})
test("returns $XDG_CONFIG_HOME/opencode on Linux when XDG_CONFIG_HOME is set", () => {
// #given opencode CLI binary detected, platform is Linux with XDG_CONFIG_HOME set
Object.defineProperty(process, "platform", { value: "linux" })
process.env.XDG_CONFIG_HOME = "/custom/config"
// #when getOpenCodeConfigDir is called with binary="opencode"
const result = getOpenCodeConfigDir({ binary: "opencode", version: "1.0.200" })
// #then returns $XDG_CONFIG_HOME/opencode
expect(result).toBe("/custom/config/opencode")
})
test("returns ~/.config/opencode on macOS", () => {
// #given opencode CLI binary detected, platform is macOS
Object.defineProperty(process, "platform", { value: "darwin" })
delete process.env.XDG_CONFIG_HOME
// #when getOpenCodeConfigDir is called with binary="opencode"
const result = getOpenCodeConfigDir({ binary: "opencode", version: "1.0.200" })
// #then returns ~/.config/opencode
expect(result).toBe(join(homedir(), ".config", "opencode"))
})
test("returns ~/.config/opencode on Windows by default", () => {
// #given opencode CLI binary detected, platform is Windows
Object.defineProperty(process, "platform", { value: "win32" })
delete process.env.APPDATA
// #when getOpenCodeConfigDir is called with binary="opencode"
const result = getOpenCodeConfigDir({ binary: "opencode", version: "1.0.200", checkExisting: false })
// #then returns ~/.config/opencode (cross-platform default)
expect(result).toBe(join(homedir(), ".config", "opencode"))
})
})
describe("for opencode-desktop Tauri binary", () => {
test("returns ~/.config/ai.opencode.desktop on Linux", () => {
// #given opencode-desktop binary detected, platform is Linux
Object.defineProperty(process, "platform", { value: "linux" })
delete process.env.XDG_CONFIG_HOME
// #when getOpenCodeConfigDir is called with binary="opencode-desktop"
const result = getOpenCodeConfigDir({ binary: "opencode-desktop", version: "1.0.200", checkExisting: false })
// #then returns ~/.config/ai.opencode.desktop
expect(result).toBe(join(homedir(), ".config", TAURI_APP_IDENTIFIER))
})
test("returns ~/Library/Application Support/ai.opencode.desktop on macOS", () => {
// #given opencode-desktop binary detected, platform is macOS
Object.defineProperty(process, "platform", { value: "darwin" })
// #when getOpenCodeConfigDir is called with binary="opencode-desktop"
const result = getOpenCodeConfigDir({ binary: "opencode-desktop", version: "1.0.200", checkExisting: false })
// #then returns ~/Library/Application Support/ai.opencode.desktop
expect(result).toBe(join(homedir(), "Library", "Application Support", TAURI_APP_IDENTIFIER))
})
test("returns %APPDATA%/ai.opencode.desktop on Windows", () => {
// #given opencode-desktop binary detected, platform is Windows
Object.defineProperty(process, "platform", { value: "win32" })
process.env.APPDATA = "C:\\Users\\TestUser\\AppData\\Roaming"
// #when getOpenCodeConfigDir is called with binary="opencode-desktop"
const result = getOpenCodeConfigDir({ binary: "opencode-desktop", version: "1.0.200", checkExisting: false })
// #then returns %APPDATA%/ai.opencode.desktop
expect(result).toBe(join("C:\\Users\\TestUser\\AppData\\Roaming", TAURI_APP_IDENTIFIER))
})
})
describe("dev build detection", () => {
test("returns ai.opencode.desktop.dev path when dev version detected", () => {
// #given opencode-desktop dev version
Object.defineProperty(process, "platform", { value: "linux" })
delete process.env.XDG_CONFIG_HOME
// #when getOpenCodeConfigDir is called with dev version
const result = getOpenCodeConfigDir({ binary: "opencode-desktop", version: "1.0.0-dev.123", checkExisting: false })
// #then returns path with ai.opencode.desktop.dev
expect(result).toBe(join(homedir(), ".config", TAURI_APP_IDENTIFIER_DEV))
})
test("returns ai.opencode.desktop.dev on macOS for dev build", () => {
// #given opencode-desktop dev version on macOS
Object.defineProperty(process, "platform", { value: "darwin" })
// #when getOpenCodeConfigDir is called with dev version
const result = getOpenCodeConfigDir({ binary: "opencode-desktop", version: "1.0.0-dev", checkExisting: false })
// #then returns path with ai.opencode.desktop.dev
expect(result).toBe(join(homedir(), "Library", "Application Support", TAURI_APP_IDENTIFIER_DEV))
})
})
})
describe("getOpenCodeConfigPaths", () => {
test("returns all config paths for CLI binary", () => {
// #given opencode CLI binary on Linux
Object.defineProperty(process, "platform", { value: "linux" })
delete process.env.XDG_CONFIG_HOME
// #when getOpenCodeConfigPaths is called
const paths = getOpenCodeConfigPaths({ binary: "opencode", version: "1.0.200" })
// #then returns all expected paths
const expectedDir = join(homedir(), ".config", "opencode")
expect(paths.configDir).toBe(expectedDir)
expect(paths.configJson).toBe(join(expectedDir, "opencode.json"))
expect(paths.configJsonc).toBe(join(expectedDir, "opencode.jsonc"))
expect(paths.packageJson).toBe(join(expectedDir, "package.json"))
expect(paths.omoConfig).toBe(join(expectedDir, "oh-my-opencode.json"))
})
test("returns all config paths for desktop binary", () => {
// #given opencode-desktop binary on macOS
Object.defineProperty(process, "platform", { value: "darwin" })
// #when getOpenCodeConfigPaths is called
const paths = getOpenCodeConfigPaths({ binary: "opencode-desktop", version: "1.0.200", checkExisting: false })
// #then returns all expected paths
const expectedDir = join(homedir(), "Library", "Application Support", TAURI_APP_IDENTIFIER)
expect(paths.configDir).toBe(expectedDir)
expect(paths.configJson).toBe(join(expectedDir, "opencode.json"))
expect(paths.configJsonc).toBe(join(expectedDir, "opencode.jsonc"))
expect(paths.packageJson).toBe(join(expectedDir, "package.json"))
expect(paths.omoConfig).toBe(join(expectedDir, "oh-my-opencode.json"))
})
})
describe("detectExistingConfigDir", () => {
test("returns null when no config exists", () => {
// #given no config files exist
Object.defineProperty(process, "platform", { value: "linux" })
delete process.env.XDG_CONFIG_HOME
// #when detectExistingConfigDir is called
const result = detectExistingConfigDir("opencode", "1.0.200")
// #then result is either null or a valid string path
expect(result === null || typeof result === "string").toBe(true)
})
})
})

View File

@@ -0,0 +1,132 @@
import { existsSync } from "node:fs"
import { homedir } from "node:os"
import { join } from "node:path"
export type OpenCodeBinaryType = "opencode" | "opencode-desktop"
export interface OpenCodeConfigDirOptions {
binary: OpenCodeBinaryType
version?: string | null
checkExisting?: boolean
}
export interface OpenCodeConfigPaths {
configDir: string
configJson: string
configJsonc: string
packageJson: string
omoConfig: string
}
export const TAURI_APP_IDENTIFIER = "ai.opencode.desktop"
export const TAURI_APP_IDENTIFIER_DEV = "ai.opencode.desktop.dev"
export function isDevBuild(version: string | null | undefined): boolean {
if (!version) return false
return version.includes("-dev") || version.includes(".dev")
}
function getTauriConfigDir(identifier: string): string {
const platform = process.platform
switch (platform) {
case "darwin":
return join(homedir(), "Library", "Application Support", identifier)
case "win32": {
const appData = process.env.APPDATA || join(homedir(), "AppData", "Roaming")
return join(appData, identifier)
}
case "linux":
default: {
const xdgConfig = process.env.XDG_CONFIG_HOME || join(homedir(), ".config")
return join(xdgConfig, identifier)
}
}
}
function getCliConfigDir(): string {
if (process.platform === "win32") {
const crossPlatformDir = join(homedir(), ".config", "opencode")
const crossPlatformConfig = join(crossPlatformDir, "opencode.json")
if (existsSync(crossPlatformConfig)) {
return crossPlatformDir
}
const appData = process.env.APPDATA || join(homedir(), "AppData", "Roaming")
const appdataDir = join(appData, "opencode")
const appdataConfig = join(appdataDir, "opencode.json")
if (existsSync(appdataConfig)) {
return appdataDir
}
return crossPlatformDir
}
const xdgConfig = process.env.XDG_CONFIG_HOME || join(homedir(), ".config")
return join(xdgConfig, "opencode")
}
export function getOpenCodeConfigDir(options: OpenCodeConfigDirOptions): string {
const { binary, version, checkExisting = true } = options
if (binary === "opencode") {
return getCliConfigDir()
}
const identifier = isDevBuild(version) ? TAURI_APP_IDENTIFIER_DEV : TAURI_APP_IDENTIFIER
const tauriDir = getTauriConfigDir(identifier)
if (checkExisting) {
const legacyDir = getCliConfigDir()
const legacyConfig = join(legacyDir, "opencode.json")
const legacyConfigC = join(legacyDir, "opencode.jsonc")
if (existsSync(legacyConfig) || existsSync(legacyConfigC)) {
return legacyDir
}
}
return tauriDir
}
export function getOpenCodeConfigPaths(options: OpenCodeConfigDirOptions): OpenCodeConfigPaths {
const configDir = getOpenCodeConfigDir(options)
return {
configDir,
configJson: join(configDir, "opencode.json"),
configJsonc: join(configDir, "opencode.jsonc"),
packageJson: join(configDir, "package.json"),
omoConfig: join(configDir, "oh-my-opencode.json"),
}
}
export function detectExistingConfigDir(binary: OpenCodeBinaryType, version?: string | null): string | null {
const locations: string[] = []
if (binary === "opencode-desktop") {
const identifier = isDevBuild(version) ? TAURI_APP_IDENTIFIER_DEV : TAURI_APP_IDENTIFIER
locations.push(getTauriConfigDir(identifier))
if (isDevBuild(version)) {
locations.push(getTauriConfigDir(TAURI_APP_IDENTIFIER))
}
}
locations.push(getCliConfigDir())
for (const dir of locations) {
const configJson = join(dir, "opencode.json")
const configJsonc = join(dir, "opencode.jsonc")
if (existsSync(configJson) || existsSync(configJsonc)) {
return dir
}
}
return null
}

View File

@@ -0,0 +1,257 @@
import { describe, test, expect, beforeEach, afterEach, spyOn, mock } from "bun:test"
import * as childProcess from "child_process"
import {
parseVersion,
compareVersions,
isVersionGte,
isVersionLt,
getOpenCodeVersion,
supportsNewPermissionSystem,
usesLegacyToolsSystem,
resetVersionCache,
setVersionCache,
PERMISSION_BREAKING_VERSION,
} from "./opencode-version"
describe("opencode-version", () => {
describe("parseVersion", () => {
test("parses simple version", () => {
// #given a simple version string
const version = "1.2.3"
// #when parsed
const result = parseVersion(version)
// #then returns array of numbers
expect(result).toEqual([1, 2, 3])
})
test("handles v prefix", () => {
// #given version with v prefix
const version = "v1.2.3"
// #when parsed
const result = parseVersion(version)
// #then strips prefix and parses correctly
expect(result).toEqual([1, 2, 3])
})
test("handles prerelease suffix", () => {
// #given version with prerelease
const version = "1.2.3-beta.1"
// #when parsed
const result = parseVersion(version)
// #then ignores prerelease part
expect(result).toEqual([1, 2, 3])
})
test("handles two-part version", () => {
// #given two-part version
const version = "1.2"
// #when parsed
const result = parseVersion(version)
// #then returns two numbers
expect(result).toEqual([1, 2])
})
})
describe("compareVersions", () => {
test("returns 0 for equal versions", () => {
// #given two equal versions
// #when compared
const result = compareVersions("1.1.1", "1.1.1")
// #then returns 0
expect(result).toBe(0)
})
test("returns 1 when a > b", () => {
// #given a is greater than b
// #when compared
const result = compareVersions("1.2.0", "1.1.0")
// #then returns 1
expect(result).toBe(1)
})
test("returns -1 when a < b", () => {
// #given a is less than b
// #when compared
const result = compareVersions("1.0.9", "1.1.0")
// #then returns -1
expect(result).toBe(-1)
})
test("handles different length versions", () => {
// #given versions with different lengths
// #when compared
expect(compareVersions("1.1", "1.1.0")).toBe(0)
expect(compareVersions("1.1.1", "1.1")).toBe(1)
expect(compareVersions("1.1", "1.1.1")).toBe(-1)
})
test("handles major version differences", () => {
// #given major version difference
// #when compared
expect(compareVersions("2.0.0", "1.9.9")).toBe(1)
expect(compareVersions("1.9.9", "2.0.0")).toBe(-1)
})
})
describe("isVersionGte", () => {
test("returns true when a >= b", () => {
expect(isVersionGte("1.1.1", "1.1.1")).toBe(true)
expect(isVersionGte("1.1.2", "1.1.1")).toBe(true)
expect(isVersionGte("1.2.0", "1.1.1")).toBe(true)
expect(isVersionGte("2.0.0", "1.1.1")).toBe(true)
})
test("returns false when a < b", () => {
expect(isVersionGte("1.1.0", "1.1.1")).toBe(false)
expect(isVersionGte("1.0.9", "1.1.1")).toBe(false)
expect(isVersionGte("0.9.9", "1.1.1")).toBe(false)
})
})
describe("isVersionLt", () => {
test("returns true when a < b", () => {
expect(isVersionLt("1.1.0", "1.1.1")).toBe(true)
expect(isVersionLt("1.0.150", "1.1.1")).toBe(true)
})
test("returns false when a >= b", () => {
expect(isVersionLt("1.1.1", "1.1.1")).toBe(false)
expect(isVersionLt("1.1.2", "1.1.1")).toBe(false)
})
})
describe("getOpenCodeVersion", () => {
beforeEach(() => {
resetVersionCache()
})
afterEach(() => {
resetVersionCache()
})
test("returns cached version on subsequent calls", () => {
// #given version is set in cache
setVersionCache("1.2.3")
// #when getting version
const result = getOpenCodeVersion()
// #then returns cached value
expect(result).toBe("1.2.3")
})
test("returns null when cache is set to null", () => {
// #given cache is explicitly set to null
setVersionCache(null)
// #when getting version (cache is already set)
const result = getOpenCodeVersion()
// #then returns null without executing command
expect(result).toBe(null)
})
})
describe("supportsNewPermissionSystem", () => {
beforeEach(() => {
resetVersionCache()
})
afterEach(() => {
resetVersionCache()
})
test("returns true for v1.1.1", () => {
// #given version is 1.1.1
setVersionCache("1.1.1")
// #when checking permission system support
const result = supportsNewPermissionSystem()
// #then returns true
expect(result).toBe(true)
})
test("returns true for versions above 1.1.1", () => {
// #given version is above 1.1.1
setVersionCache("1.2.0")
// #when checking
const result = supportsNewPermissionSystem()
// #then returns true
expect(result).toBe(true)
})
test("returns false for versions below 1.1.1", () => {
// #given version is below 1.1.1
setVersionCache("1.1.0")
// #when checking
const result = supportsNewPermissionSystem()
// #then returns false
expect(result).toBe(false)
})
test("returns true when version cannot be detected", () => {
// #given version is null (undetectable)
setVersionCache(null)
// #when checking
const result = supportsNewPermissionSystem()
// #then returns true (assume newer version)
expect(result).toBe(true)
})
})
describe("usesLegacyToolsSystem", () => {
beforeEach(() => {
resetVersionCache()
})
afterEach(() => {
resetVersionCache()
})
test("returns true for versions below 1.1.1", () => {
// #given version is below 1.1.1
setVersionCache("1.0.150")
// #when checking
const result = usesLegacyToolsSystem()
// #then returns true
expect(result).toBe(true)
})
test("returns false for v1.1.1 and above", () => {
// #given version is 1.1.1
setVersionCache("1.1.1")
// #when checking
const result = usesLegacyToolsSystem()
// #then returns false
expect(result).toBe(false)
})
})
describe("PERMISSION_BREAKING_VERSION", () => {
test("is set to 1.1.1", () => {
expect(PERMISSION_BREAKING_VERSION).toBe("1.1.1")
})
})
})

View File

@@ -0,0 +1,72 @@
import { execSync } from "child_process"
export const PERMISSION_BREAKING_VERSION = "1.1.1"
const NOT_CACHED = Symbol("NOT_CACHED")
let cachedVersion: string | null | typeof NOT_CACHED = NOT_CACHED
export function parseVersion(version: string): number[] {
const cleaned = version.replace(/^v/, "").split("-")[0]
return cleaned.split(".").map((n) => parseInt(n, 10) || 0)
}
export function compareVersions(a: string, b: string): -1 | 0 | 1 {
const partsA = parseVersion(a)
const partsB = parseVersion(b)
const maxLen = Math.max(partsA.length, partsB.length)
for (let i = 0; i < maxLen; i++) {
const numA = partsA[i] ?? 0
const numB = partsB[i] ?? 0
if (numA < numB) return -1
if (numA > numB) return 1
}
return 0
}
export function isVersionGte(a: string, b: string): boolean {
return compareVersions(a, b) >= 0
}
export function isVersionLt(a: string, b: string): boolean {
return compareVersions(a, b) < 0
}
export function getOpenCodeVersion(): string | null {
if (cachedVersion !== NOT_CACHED) {
return cachedVersion
}
try {
const result = execSync("opencode --version", {
encoding: "utf-8",
timeout: 5000,
stdio: ["pipe", "pipe", "pipe"],
}).trim()
const versionMatch = result.match(/(\d+\.\d+\.\d+(?:-[\w.]+)?)/)
cachedVersion = versionMatch?.[1] ?? null
return cachedVersion
} catch {
cachedVersion = null
return null
}
}
export function supportsNewPermissionSystem(): boolean {
const version = getOpenCodeVersion()
if (!version) return true
return isVersionGte(version, PERMISSION_BREAKING_VERSION)
}
export function usesLegacyToolsSystem(): boolean {
return !supportsNewPermissionSystem()
}
export function resetVersionCache(): void {
cachedVersion = NOT_CACHED
}
export function setVersionCache(version: string | null): void {
cachedVersion = version
}

View File

@@ -0,0 +1,158 @@
import { describe, test, expect, beforeEach, afterEach } from "bun:test"
import {
createAgentToolRestrictions,
migrateToolsToPermission,
migratePermissionToTools,
migrateAgentConfig,
} from "./permission-compat"
import { setVersionCache, resetVersionCache } from "./opencode-version"
describe("permission-compat", () => {
beforeEach(() => {
resetVersionCache()
})
afterEach(() => {
resetVersionCache()
})
describe("createAgentToolRestrictions", () => {
test("returns permission format for v1.1.1+", () => {
// #given version is 1.1.1
setVersionCache("1.1.1")
// #when creating restrictions
const result = createAgentToolRestrictions(["write", "edit"])
// #then returns permission format
expect(result).toEqual({
permission: { write: "deny", edit: "deny" },
})
})
test("returns tools format for versions below 1.1.1", () => {
// #given version is below 1.1.1
setVersionCache("1.0.150")
// #when creating restrictions
const result = createAgentToolRestrictions(["write", "edit"])
// #then returns tools format
expect(result).toEqual({
tools: { write: false, edit: false },
})
})
test("assumes new format when version unknown", () => {
// #given version is null
setVersionCache(null)
// #when creating restrictions
const result = createAgentToolRestrictions(["write"])
// #then returns permission format (assumes new version)
expect(result).toEqual({
permission: { write: "deny" },
})
})
})
describe("migrateToolsToPermission", () => {
test("converts boolean tools to permission values", () => {
// #given tools config
const tools = { write: false, edit: true, bash: false }
// #when migrating
const result = migrateToolsToPermission(tools)
// #then converts correctly
expect(result).toEqual({
write: "deny",
edit: "allow",
bash: "deny",
})
})
})
describe("migratePermissionToTools", () => {
test("converts permission to boolean tools", () => {
// #given permission config
const permission = { write: "deny" as const, edit: "allow" as const }
// #when migrating
const result = migratePermissionToTools(permission)
// #then converts correctly
expect(result).toEqual({ write: false, edit: true })
})
test("excludes ask values", () => {
// #given permission with ask
const permission = {
write: "deny" as const,
edit: "ask" as const,
bash: "allow" as const,
}
// #when migrating
const result = migratePermissionToTools(permission)
// #then ask is excluded
expect(result).toEqual({ write: false, bash: true })
})
})
describe("migrateAgentConfig", () => {
test("migrates tools to permission for v1.1.1+", () => {
// #given v1.1.1 and config with tools
setVersionCache("1.1.1")
const config = {
model: "test",
tools: { write: false, edit: false },
}
// #when migrating
const result = migrateAgentConfig(config)
// #then converts to permission
expect(result.tools).toBeUndefined()
expect(result.permission).toEqual({ write: "deny", edit: "deny" })
expect(result.model).toBe("test")
})
test("migrates permission to tools for old versions", () => {
// #given old version and config with permission
setVersionCache("1.0.150")
const config = {
model: "test",
permission: { write: "deny" as const, edit: "deny" as const },
}
// #when migrating
const result = migrateAgentConfig(config)
// #then converts to tools
expect(result.permission).toBeUndefined()
expect(result.tools).toEqual({ write: false, edit: false })
})
test("preserves other config fields", () => {
// #given config with other fields
setVersionCache("1.1.1")
const config = {
model: "test",
temperature: 0.5,
prompt: "hello",
tools: { write: false },
}
// #when migrating
const result = migrateAgentConfig(config)
// #then preserves other fields
expect(result.model).toBe("test")
expect(result.temperature).toBe(0.5)
expect(result.prompt).toBe("hello")
})
})
})

View File

@@ -0,0 +1,79 @@
import { supportsNewPermissionSystem } from "./opencode-version"
export type PermissionValue = "ask" | "allow" | "deny"
export interface LegacyToolsFormat {
tools: Record<string, boolean>
}
export interface NewPermissionFormat {
permission: Record<string, PermissionValue>
}
export type VersionAwareRestrictions = LegacyToolsFormat | NewPermissionFormat
export function createAgentToolRestrictions(
denyTools: string[]
): VersionAwareRestrictions {
if (supportsNewPermissionSystem()) {
return {
permission: Object.fromEntries(
denyTools.map((tool) => [tool, "deny" as const])
),
}
}
return {
tools: Object.fromEntries(denyTools.map((tool) => [tool, false])),
}
}
export function migrateToolsToPermission(
tools: Record<string, boolean>
): Record<string, PermissionValue> {
return Object.fromEntries(
Object.entries(tools).map(([key, value]) => [
key,
value ? ("allow" as const) : ("deny" as const),
])
)
}
export function migratePermissionToTools(
permission: Record<string, PermissionValue>
): Record<string, boolean> {
return Object.fromEntries(
Object.entries(permission)
.filter(([, value]) => value !== "ask")
.map(([key, value]) => [key, value === "allow"])
)
}
export function migrateAgentConfig(
config: Record<string, unknown>
): Record<string, unknown> {
const result = { ...config }
if (supportsNewPermissionSystem()) {
if (result.tools && typeof result.tools === "object") {
const existingPermission =
(result.permission as Record<string, PermissionValue>) || {}
const migratedPermission = migrateToolsToPermission(
result.tools as Record<string, boolean>
)
result.permission = { ...migratedPermission, ...existingPermission }
delete result.tools
}
} else {
if (result.permission && typeof result.permission === "object") {
const existingTools = (result.tools as Record<string, boolean>) || {}
const migratedTools = migratePermissionToTools(
result.permission as Record<string, PermissionValue>
)
result.tools = { ...migratedTools, ...existingTools }
delete result.permission
}
}
return result
}

View File

@@ -1,4 +1,4 @@
import type { CliMatch, AnalyzeResult, SgResult } from "./types"
import type { AnalyzeResult, SgResult } from "./types"
export function formatSearchResult(result: SgResult): string {
if (result.error) {
@@ -93,7 +93,7 @@ export function formatAnalyzeResult(results: AnalyzeResult[], extractedMetaVars:
return lines.join("\n")
}
export function formatTransformResult(original: string, transformed: string, editCount: number): string {
export function formatTransformResult(_original: string, transformed: string, editCount: number): string {
if (editCount === 0) {
return "No matches found to transform"
}

View File

@@ -19,7 +19,7 @@ import {
import { grep } from "./grep"
import { glob } from "./glob"
import { slashcommand } from "./slashcommand"
export { createSlashcommandTool, discoverCommandsSync } from "./slashcommand"
import {
session_list,
@@ -28,6 +28,8 @@ import {
session_info,
} from "./session-manager"
export { sessionExists } from "./session-manager/storage"
export { interactive_bash, startBackgroundCheck as startTmuxCheck } from "./interactive-bash"
export { createSkillTool } from "./skill"
export { getTmuxPath } from "./interactive-bash/utils"
@@ -71,7 +73,6 @@ export const builtinTools: Record<string, ToolDefinition> = {
ast_grep_replace,
grep,
glob,
slashcommand,
session_list,
session_read,
session_search,

View File

@@ -69,10 +69,21 @@ export const LSP_INSTALL_HINTS: Record<string, string> = {
php: "npm install -g intelephense",
dart: "Included with Dart SDK",
"terraform-ls": "See https://github.com/hashicorp/terraform-ls",
terraform: "See https://github.com/hashicorp/terraform-ls",
prisma: "npm install -g prisma",
"ocaml-lsp": "opam install ocaml-lsp-server",
texlab: "See https://github.com/latex-lsp/texlab",
dockerfile: "npm install -g dockerfile-language-server-nodejs",
gleam: "See https://gleam.run/getting-started/installing/",
"clojure-lsp": "See https://clojure-lsp.io/installation/",
nixd: "nix profile install nixpkgs#nixd",
tinymist: "See https://github.com/Myriad-Dreamin/tinymist",
"haskell-language-server": "ghcup install hls",
bash: "npm install -g bash-language-server",
}
// Synced with OpenCode's server.ts
// https://github.com/sst/opencode/blob/main/packages/opencode/src/lsp/server.ts
// https://github.com/sst/opencode/blob/dev/packages/opencode/src/lsp/server.ts
export const BUILTIN_SERVERS: Record<string, Omit<LSPServerConfig, "id">> = {
typescript: {
command: ["typescript-language-server", "--stdio"],
@@ -161,6 +172,11 @@ export const BUILTIN_SERVERS: Record<string, Omit<LSPServerConfig, "id">> = {
command: ["astro-ls", "--stdio"],
extensions: [".astro"],
},
bash: {
command: ["bash-language-server", "start"],
extensions: [".sh", ".bash", ".zsh", ".ksh"],
},
// Keep legacy alias for backward compatibility
"bash-ls": {
command: ["bash-language-server", "start"],
extensions: [".sh", ".bash", ".zsh", ".ksh"],
@@ -185,14 +201,55 @@ export const BUILTIN_SERVERS: Record<string, Omit<LSPServerConfig, "id">> = {
command: ["dart", "language-server", "--lsp"],
extensions: [".dart"],
},
terraform: {
command: ["terraform-ls", "serve"],
extensions: [".tf", ".tfvars"],
},
// Legacy alias for backward compatibility
"terraform-ls": {
command: ["terraform-ls", "serve"],
extensions: [".tf", ".tfvars"],
},
prisma: {
command: ["prisma", "language-server"],
extensions: [".prisma"],
},
"ocaml-lsp": {
command: ["ocamllsp"],
extensions: [".ml", ".mli"],
},
texlab: {
command: ["texlab"],
extensions: [".tex", ".bib"],
},
dockerfile: {
command: ["docker-langserver", "--stdio"],
extensions: [".dockerfile"],
},
gleam: {
command: ["gleam", "lsp"],
extensions: [".gleam"],
},
"clojure-lsp": {
command: ["clojure-lsp", "listen"],
extensions: [".clj", ".cljs", ".cljc", ".edn"],
},
nixd: {
command: ["nixd"],
extensions: [".nix"],
},
tinymist: {
command: ["tinymist"],
extensions: [".typ", ".typc"],
},
"haskell-language-server": {
command: ["haskell-language-server-wrapper", "--lsp"],
extensions: [".hs", ".lhs"],
},
}
// Synced with OpenCode's language.ts
// https://github.com/sst/opencode/blob/main/packages/opencode/src/lsp/language.ts
// https://github.com/sst/opencode/blob/dev/packages/opencode/src/lsp/language.ts
export const EXT_TO_LANG: Record<string, string> = {
".abap": "abap",
".bat": "bat",
@@ -306,6 +363,14 @@ export const EXT_TO_LANG: Record<string, string> = {
".tf": "terraform",
".tfvars": "terraform-vars",
".hcl": "hcl",
".nix": "nix",
".typ": "typst",
".typc": "typst",
".ets": "typescript",
".lhs": "haskell",
".kt": "kotlin",
".kts": "kotlin",
".prisma": "prisma",
// Additional extensions not in OpenCode
".h": "c",
".hpp": "cpp",

View File

@@ -40,7 +40,13 @@ function formatSkillsXml(skills: SkillInfo[]): string {
return `\n\n<available_skills>\n${skillsXml}\n</available_skills>`
}
function extractSkillBody(skill: LoadedSkill): string {
async function extractSkillBody(skill: LoadedSkill): Promise<string> {
if (skill.lazyContent) {
const fullTemplate = await skill.lazyContent.load()
const templateMatch = fullTemplate.match(/<skill-instruction>([\s\S]*?)<\/skill-instruction>/)
return templateMatch ? templateMatch[1].trim() : fullTemplate
}
if (skill.path) {
const content = readFileSync(skill.path, "utf-8")
const { body } = parseFrontmatter(content)
@@ -123,29 +129,45 @@ async function formatMcpCapabilities(
}
export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition {
const skills = options.skills ?? discoverSkills({ includeClaudeCodePaths: !options.opencodeOnly })
const skillInfos = skills.map(loadedSkillToInfo)
let cachedSkills: LoadedSkill[] | null = null
let cachedDescription: string | null = null
const description = skillInfos.length === 0
? TOOL_DESCRIPTION_NO_SKILLS
: TOOL_DESCRIPTION_PREFIX + formatSkillsXml(skillInfos)
const getSkills = async (): Promise<LoadedSkill[]> => {
if (options.skills) return options.skills
if (cachedSkills) return cachedSkills
cachedSkills = await discoverSkills({ includeClaudeCodePaths: !options.opencodeOnly })
return cachedSkills
}
const getDescription = async (): Promise<string> => {
if (cachedDescription) return cachedDescription
const skills = await getSkills()
const skillInfos = skills.map(loadedSkillToInfo)
cachedDescription = skillInfos.length === 0
? TOOL_DESCRIPTION_NO_SKILLS
: TOOL_DESCRIPTION_PREFIX + formatSkillsXml(skillInfos)
return cachedDescription
}
getDescription()
return tool({
description,
get description() {
return cachedDescription ?? TOOL_DESCRIPTION_PREFIX
},
args: {
name: tool.schema.string().describe("The skill identifier from available_skills (e.g., 'code-review')"),
},
async execute(args: SkillArgs) {
const skill = options.skills
? skills.find(s => s.name === args.name)
: skills.find(s => s.name === args.name)
const skills = await getSkills()
const skill = skills.find(s => s.name === args.name)
if (!skill) {
const available = skills.map(s => s.name).join(", ")
throw new Error(`Skill "${args.name}" not found. Available skills: ${available || "none"}`)
}
const body = extractSkillBody(skill)
const body = await extractSkillBody(skill)
const dir = skill.path ? dirname(skill.path) : skill.resolvedPath || process.cwd()
const output = [

View File

@@ -1,2 +1,2 @@
export * from "./types"
export { slashcommand } from "./tools"
export { slashcommand, createSlashcommandTool, discoverCommandsSync } from "./tools"

View File

@@ -6,7 +6,7 @@ import type { CommandFrontmatter } from "../../features/claude-code-command-load
import { isMarkdownFile } from "../../shared/file-utils"
import { getClaudeConfigDir } from "../../shared"
import { discoverAllSkills, type LoadedSkill } from "../../features/opencode-skill-loader"
import type { CommandScope, CommandMetadata, CommandInfo } from "./types"
import type { CommandScope, CommandMetadata, CommandInfo, SlashcommandToolOptions } from "./types"
function discoverCommandsFromDir(commandsDir: string, scope: CommandScope): CommandInfo[] {
if (!existsSync(commandsDir)) {
@@ -51,7 +51,7 @@ function discoverCommandsFromDir(commandsDir: string, scope: CommandScope): Comm
return commands
}
function discoverCommandsSync(): CommandInfo[] {
export function discoverCommandsSync(): CommandInfo[] {
const { homedir } = require("os")
const userCommandsDir = join(getClaudeConfigDir(), "commands")
const projectCommandsDir = join(process.cwd(), ".claude", "commands")
@@ -83,19 +83,6 @@ function skillToCommandInfo(skill: LoadedSkill): CommandInfo {
}
}
const availableCommands = discoverCommandsSync()
const availableSkills = discoverAllSkills()
const availableItems = [
...availableCommands,
...availableSkills.map(skillToCommandInfo),
]
const commandListForDescription = availableItems
.map((cmd) => {
const hint = cmd.metadata.argumentHint ? ` ${cmd.metadata.argumentHint}` : ""
return `- /${cmd.name}${hint}: ${cmd.metadata.description} (${cmd.scope})`
})
.join("\n")
async function formatLoadedCommand(cmd: CommandInfo): Promise<string> {
const sections: string[] = []
@@ -151,62 +138,109 @@ function formatCommandList(items: CommandInfo[]): string {
return lines.join("\n")
}
export const slashcommand: ToolDefinition = tool({
description: `Load a skill to get detailed instructions for a specific task.
const TOOL_DESCRIPTION_PREFIX = `Load a skill to get detailed instructions for a specific task.
Skills provide specialized knowledge and step-by-step guidance.
Use this when a task matches an available skill's description.
`
function buildDescriptionFromItems(items: CommandInfo[]): string {
const commandListForDescription = items
.map((cmd) => {
const hint = cmd.metadata.argumentHint ? ` ${cmd.metadata.argumentHint}` : ""
return `- /${cmd.name}${hint}: ${cmd.metadata.description} (${cmd.scope})`
})
.join("\n")
return `${TOOL_DESCRIPTION_PREFIX}
<available_skills>
${commandListForDescription}
</available_skills>`,
</available_skills>`
}
args: {
command: tool.schema
.string()
.describe(
"The slash command to execute (without the leading slash). E.g., 'commit', 'plan', 'execute'."
),
},
export function createSlashcommandTool(options: SlashcommandToolOptions = {}): ToolDefinition {
let cachedCommands: CommandInfo[] | null = options.commands ?? null
let cachedSkills: LoadedSkill[] | null = options.skills ?? null
let cachedDescription: string | null = null
async execute(args) {
const commands = discoverCommandsSync()
const skills = discoverAllSkills()
const allItems = [
...commands,
...skills.map(skillToCommandInfo),
]
const getCommands = (): CommandInfo[] => {
if (cachedCommands) return cachedCommands
cachedCommands = discoverCommandsSync()
return cachedCommands
}
if (!args.command) {
return formatCommandList(allItems) + "\n\nProvide a command or skill name to execute."
}
const getSkills = async (): Promise<LoadedSkill[]> => {
if (cachedSkills) return cachedSkills
cachedSkills = await discoverAllSkills()
return cachedSkills
}
const cmdName = args.command.replace(/^\//, "")
const getAllItems = async (): Promise<CommandInfo[]> => {
const commands = getCommands()
const skills = await getSkills()
return [...commands, ...skills.map(skillToCommandInfo)]
}
const exactMatch = allItems.find(
(cmd) => cmd.name.toLowerCase() === cmdName.toLowerCase()
)
const buildDescription = async (): Promise<string> => {
if (cachedDescription) return cachedDescription
const allItems = await getAllItems()
cachedDescription = buildDescriptionFromItems(allItems)
return cachedDescription
}
if (exactMatch) {
return await formatLoadedCommand(exactMatch)
}
// Pre-warm the cache immediately
buildDescription()
const partialMatches = allItems.filter((cmd) =>
cmd.name.toLowerCase().includes(cmdName.toLowerCase())
)
return tool({
get description() {
return cachedDescription ?? TOOL_DESCRIPTION_PREFIX
},
if (partialMatches.length > 0) {
const matchList = partialMatches.map((cmd) => `/${cmd.name}`).join(", ")
return (
`No exact match for "/${cmdName}". Did you mean: ${matchList}?\n\n` +
formatCommandList(allItems)
args: {
command: tool.schema
.string()
.describe(
"The slash command to execute (without the leading slash). E.g., 'commit', 'plan', 'execute'."
),
},
async execute(args) {
const allItems = await getAllItems()
if (!args.command) {
return formatCommandList(allItems) + "\n\nProvide a command or skill name to execute."
}
const cmdName = args.command.replace(/^\//, "")
const exactMatch = allItems.find(
(cmd) => cmd.name.toLowerCase() === cmdName.toLowerCase()
)
}
return (
`Command or skill "/${cmdName}" not found.\n\n` +
formatCommandList(allItems) +
"\n\nTry a different name."
)
},
})
if (exactMatch) {
return await formatLoadedCommand(exactMatch)
}
const partialMatches = allItems.filter((cmd) =>
cmd.name.toLowerCase().includes(cmdName.toLowerCase())
)
if (partialMatches.length > 0) {
const matchList = partialMatches.map((cmd) => `/${cmd.name}`).join(", ")
return (
`No exact match for "/${cmdName}". Did you mean: ${matchList}?\n\n` +
formatCommandList(allItems)
)
}
return (
`Command or skill "/${cmdName}" not found.\n\n` +
formatCommandList(allItems) +
"\n\nTry a different name."
)
},
})
}
// Default instance for backward compatibility (lazy loading)
export const slashcommand = createSlashcommandTool()

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