Compare commits

...

74 Commits

Author SHA1 Message Date
github-actions[bot]
8d570af3dd release: v2.12.0 2026-01-02 11:41:56 +00:00
YeonGyu-Kim
ddeabb1a8b fix(claude-code-hooks): handle UserPromptSubmit on first message properly
- Allow UserPromptSubmit hooks to run on first message (used for title generation)
- For first message: prepend hook content directly to message parts
- For subsequent messages: use file system injection as before
- Preserves hook injection integrity while enabling title generation hooks

🤖 Generated with assistance of OhMyOpenCode (https://github.com/code-yeongyu/oh-my-opencode)
2026-01-02 20:29:01 +09:00
YeonGyu-Kim
7a896fd2b9 fix(token-limit-recovery): exclude thinking block errors from token limit detection
- Removed 'invalid_request_error' from TOKEN_LIMIT_KEYWORDS (too broad)
- Added THINKING_BLOCK_ERROR_PATTERNS to explicitly detect thinking block structure errors
- Added isThinkingBlockError() function to filter these out before token limit checks
- Prevents 'thinking block order' errors from triggering compaction instead of session-recovery

🤖 Generated with assistance of OhMyOpenCode (https://github.com/code-yeongyu/oh-my-opencode)
2026-01-02 20:28:37 +09:00
YeonGyu-Kim
823f12d88d fix(todo-continuation-enforcer): preserve model/provider from nearest message
When injecting continuation prompts, extract and pass the model field

(providerID + modelID) from the nearest stored message, matching the

pattern used in background-agent/manager.ts and session-recovery.

Also updated tests to capture the model field for verification.
2026-01-02 19:07:44 +09:00
github-actions[bot]
bf3dd91da2 release: v2.11.0 2026-01-02 08:10:44 +00:00
YeonGyu-Kim
fd957e7ed0 let it not mess up tui 2026-01-02 16:39:44 +09:00
Sisyphus
3ba61790ab fix(ralph-loop): detect completion promise from session messages API (#413)
* fix(ralph-loop): detect completion promise from session messages API

The completion promise (e.g., <promise>DONE</promise>) was not being detected
because assistant text messages were never recorded to the transcript file.
Only user messages, tool uses, and tool results were recorded.

This fix adds a new detection method that fetches session messages via the
OpenCode API and checks assistant text messages for the completion promise.
The transcript file check is kept as a fallback.

Fixes #412

* refactor(ralph-loop): address review feedback

- Simplify response parsing to use response.data consistently (greptile)
- Add session ID tracking to messages mock for better test coverage (cubic)
- Add assertion to verify correct session ID is passed to API

---------

Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
2026-01-02 16:37:04 +09:00
YeonGyu-Kim
3224c15578 Merge branch 'fix-sentry-skill-project-loading' into dev 2026-01-02 16:13:44 +09:00
YeonGyu-Kim
a51ad98182 fix(skill-mcp): always inherit process.env for MCP servers
- Always merge parent process.env when spawning MCP child processes
- Overlay config.env on top if present (for skill-specific overrides)
- Fixes issue where skills without explicit env: block started with zero environment variables
- Adds 2 tests for env inheritance behavior

🤖 Generated with assistance of OhMyOpenCode (https://github.com/code-yeongyu/oh-my-opencode)
2026-01-02 16:07:33 +09:00
YeonGyu-Kim
b98a1b28f8 fix(non-interactive-env): inherit process.env before applying NON_INTERACTIVE_ENV overrides
Previously, the hook only set NON_INTERACTIVE_ENV without inheriting the parent process environment, causing subprocess to run without proper PATH and other env vars. Now it properly inherits process.env first, then applies GIT_EDITOR=":" and EDITOR=":" to prevent interactive editors from spawning.

🤖 Generated with assistance of OhMyOpenCode
https://github.com/code-yeongyu/oh-my-opencode
2026-01-02 16:03:48 +09:00
YeonGyu-Kim
9a92dc8d95 fix(agents): pass available agents to Sisyphus for dynamic prompt sections (#411) (#414)
Pass available agent metadata to createSisyphusAgent so the delegation table
and other dynamic prompt sections are populated instead of being empty.

Root cause: buildAgent() only passed `model` to createSisyphusAgent, but not
`availableAgents`. This caused the delegation table, tool selection table, and
other dynamic sections to be built with empty arrays.

Fix:
1. Import all agent metadata exports (*_PROMPT_METADATA)
2. Create agentMetadata map
3. Build non-Sisyphus agents first, collecting AvailableAgent[]
4. Pass availableAgents to createSisyphusAgent

🤖 Generated with assistance of OhMyOpenCode (https://github.com/code-yeongyu/oh-my-opencode)
2026-01-02 15:42:48 +09:00
Sisyphus
99711dacc1 feat(commands): add handoffs support for speckit compatibility (#410)
* feat(commands): add handoffs support for speckit compatibility

- Upgrade frontmatter parser to use js-yaml for complex YAML support
- Add HandoffDefinition interface for speckit-style workflow transitions
- Update CommandFrontmatter and CommandDefinition to include handoffs
- Add comprehensive tests for backward compatibility and complex YAML
- Fix type parameters in auto-slash-command and slashcommand tools

Closes #407

* fix(frontmatter): use JSON_SCHEMA for security and add extra fields tolerance tests

- Use JSON_SCHEMA in yaml.load() to prevent code execution via YAML tags
- Add tests to verify extra fields in frontmatter don't cause failures
- Address Greptile security review comment

---------

Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
2026-01-02 15:11:14 +09:00
Sisyphus
6eaa96f421 fix: Windows auto-update path detection and install function support (#404)
* fix: Windows auto-update path detection and add install function support

- Fix Windows path inconsistency: check ~/.config first, then %APPDATA%
- Add actual update installation via runBunInstall when autoUpdate=true
- Check both Windows config locations for comprehensive detection

Closes #402

* fix: address review feedback on error logging and message clarity

- Update misleading log message to clarify fallback behavior
- Serialize error object before logging to prevent {} serialization

---------

Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
2026-01-02 14:28:44 +09:00
YeonGyu-Kim
f6b066ecfa fix(todo-continuation-enforcer): replace time-based cooldown with event-order abort detection
Replace the time-based ERROR_COOLDOWN_MS mechanism with an event-order based
lastEventWasAbortError flag. This fixes the bug where abort errors permanently
block todo continuation since session.idle only fires once during the cooldown.

Now abort errors only skip continuation when they occur IMMEDIATELY before
session.idle event. Any intervening event (message, tool execution) clears the
abort state, allowing normal continuation flow on the next idle.

Comprehensive tests added to verify:
- Abort detection correctly blocks continuation when immediately before idle
- Intervening events (assistant message, tool execution) clear abort state
- Non-abort errors don't block continuation
- Multiple abort events (only last one matters)
- No time-based throttle preventing consecutive injections

🤖 Generated with assistance of [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-02 14:04:13 +09:00
YeonGyu-Kim
4434a59cf0 fix(non-interactive-env): use shell no-op for editor env vars
- Changed GIT_EDITOR, EDITOR, GIT_SEQUENCE_EDITOR from 'true' to ':' (shell no-op builtin)
- Changed VISUAL from 'true' to '' (empty string to prevent fallback issues)
- Added GIT_MERGE_AUTOEDIT: 'no' to prevent merge editor prompts

Fixes SIGTERM issues when git tries to open editors in non-interactive environments.

🤖 Generated with assistance of OhMyOpenCode (https://github.com/code-yeongyu/oh-my-opencode)
2026-01-02 11:55:18 +09:00
YeonGyu-Kim
038d838e63 refactor(index): extract config loading and handlers to reduce file size
Reduce index.ts from 724 to 458 lines (37% reduction):
- Extract config loading to plugin-config.ts
- Extract ModelCacheState to plugin-state.ts
- Extract config handler to plugin-handlers/config-handler.ts

All 408 tests pass, TypeScript typecheck clean.

🤖 Generated with assistance of OhMyOpenCode (https://github.com/code-yeongyu/oh-my-opencode)
2026-01-02 11:35:56 +09:00
YeonGyu-Kim
dc057e9910 fix(recovery): restore compaction pipeline sufficient check and conservative charsPerToken
Fixes critical v2.10.0 compaction regression where truncation ALWAYS returned early
without checking if it was sufficient, causing PHASE 3 (Summarize) to be skipped.
This led to "history disappears" symptom where all context was lost after compaction.

Changes:
- Restored aggressiveResult.sufficient check before early return in executor
- Only return from Truncate phase if truncation successfully reduced tokens below limit
- Otherwise fall through to Summarize phase when truncation is insufficient
- Restored conservative charsPerToken=4 (was changed to 2, too aggressive)
- Added 2 regression tests:
  * Test 1: Verify Summarize is called when truncation is insufficient
  * Test 2: Verify Summarize is skipped when truncation is sufficient

Regression details:
- v2.10.0 changed charsPerToken from 4 to 2, making truncation too aggressive
- Early return removed sufficient check, skipping fallback to Summarize
- Users reported complete loss of conversation history after compaction

🤖 Generated with assistance of OhMyOpenCode (https://github.com/code-yeongyu/oh-my-opencode)
2026-01-02 11:34:33 +09:00
YeonGyu-Kim
d4787c477a fix(recovery): implement early exit in tool output truncation
🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-02 11:14:28 +09:00
YeonGyu-Kim
e6ffdc4352 feat(claude-code-mcp-loader): auto-disable builtin skills with overlapping MCP servers
Add getSystemMcpServerNames() sync function to detect system-configured MCP
servers from .mcp.json files (user, project, and local scopes). Builtin skills
like playwright are now automatically excluded when their MCP server is already
configured in system config, preventing duplicate MCP server registration.

Also adds comprehensive test suite with 5 BDD-style tests covering empty config,
project/local scopes, disabled servers, and merged configurations.

Changes:
- loader.ts: Add getSystemMcpServerNames() function + readFileSync import
- loader.test.ts: Add 5 tests for getSystemMcpServerNames() edge cases
- index.ts: Filter builtin skills to exclude those with overlapping MCP names

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-02 10:55:54 +09:00
YeonGyu-Kim
a1fe0f8517 docs(agents): regenerate all AGENTS.md files with comprehensive codebase analysis
- Regenerated root AGENTS.md with overview, structure, and complexity hotspots
- Regenerated all 7 subdirectory AGENTS.md files: hooks, tools, features, agents, cli, auth, shared
- Used 11 background explore agents for comprehensive feature and architecture analysis
- All files within size limits (root: 112 lines, subdirs: 57-68 lines)
- Includes where-to-look guide, conventions, anti-patterns, and agent model information

🤖 Generated with assistance of oh-my-opencode
2026-01-02 10:42:38 +09:00
Sisyphus
bebe6607d4 feat(rules-injector): add GitHub Copilot instructions format support (#403)
* feat(rules-injector): add GitHub Copilot instructions format support

- Add .github/instructions/ directory to rule discovery paths
- Add applyTo as alias for globs field in frontmatter parser
- Support .github/copilot-instructions.md single-file format (always-apply)
- Filter .github/instructions/ to only accept *.instructions.md files
- Add comprehensive tests for parser and finder

Closes #397

* fix(rules-injector): use cross-platform path separator in calculateDistance

path.relative() returns OS-native separators (backslashes on Windows),
but the code was splitting by forward slash only, causing incorrect
distance calculations on Windows.

Use regex /[/\]/ to handle both separator types.

---------

Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
2026-01-02 10:27:33 +09:00
YeonGyu-Kim
f088f008cc Add comprehensive MCP loader documentation
- Added 'MCP LOADER (claude-code-mcp-loader)' section to src/features/AGENTS.md
- Documented .mcp.json file locations with priority order (user, project, local)
- Specified .mcp.json format with complete structure and examples
- Documented server types (stdio, http, sse) with required fields
- Added environment variable expansion syntax documentation
- Provided practical examples for stdio, http, and disabled servers
- Added transformation reference table for Claude Code → OpenCode format
- Improved discoverability for users setting up MCP servers via Claude Code configuration

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-02 00:57:54 +09:00
YeonGyu-Kim
f64210c505 docs: add ultrawork guidance and skill-mcp feature to READMEs
- Add "🪄 The Magic Word: ultrawork" section to all READMEs (EN, KO, JA, ZH-CN)
- Add Skill-Embedded MCP Support feature documentation
- Update installer to show ultrawork tip and star request after completion

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-02 00:42:37 +09:00
Sisyphus
b75383fb99 docs: add npx alternative and Snap Bun warning for Ubuntu users (#401)
Closes #389

- Add npx as alternative when bunx doesn't work
- Add warning about Snap-installed Bun not working with bunx
- Update all localized READMEs (ko, ja, zh-cn) with same changes

Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
2026-01-02 00:31:04 +09:00
github-actions[bot]
70fe08a15f release: v2.10.0 2026-01-01 15:29:27 +00:00
Sisyphus
13ebeb9853 fix: correct preemptive_compaction schema comment default value (#400)
The JSDoc comment incorrectly stated 'default: false' but since v2.9.0
preemptive compaction is enabled by default.

Fixes #398

Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
2026-01-02 00:24:41 +09:00
YeonGyu-Kim
2452a4789d fix(tests): resolve mock.module leakage breaking ralph-loop tests
The node:fs mock in skill/tools.test.ts was replacing the entire module,
causing fs functions to be undefined for tests running afterwards.
Fixed by preserving original fs functions and only intercepting skill paths.

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-02 00:23:46 +09:00
YeonGyu-Kim
44640b985d docs(agents): update AGENTS.md with skill-mcp feature documentation
- Update timestamp to 2026-01-02T00:10:00+09:00 and commit hash b0c39e2
- Add 'Add skill' and 'Skill MCP' sections to WHERE TO LOOK table
- Add 'Self-planning for complex tasks' anti-pattern to ANTI-PATTERNS
- Update complexity hotspots with current accurate line counts (src/index.ts 723, src/cli/config-manager.ts 669, etc.)
- Add SKILL MCP MANAGER and BUILTIN SKILLS sections to src/features/AGENTS.md
- Document skill-mcp-manager lifecycle and builtin-skills location
- Update src/tools/AGENTS.md to include skill and skill-mcp tool categories
- Update testing note to reference 360+ tests passing
- Add Skill MCP note to NOTES section describing YAML frontmatter MCP config

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-02 00:14:38 +09:00
YeonGyu-Kim
794b5263c2 fix(keyword-detector): remove word boundary requirement for ulw/ultrawork detection 2026-01-02 00:10:46 +09:00
YeonGyu-Kim
b0c39e222a feat(builtin-skills): add playwright skill with MCP config and disabled_skills option
- Add playwright as builtin skill with MCP server configuration
- Add disabled_skills config option to disable specific builtin skills
- Update BuiltinSkill type to include mcpConfig field
- Update skill merger to handle mcpConfig from builtin to loaded skills
- Merge disabled_skills config and filter unavailable builtin skills at plugin init
- Update README with Built-in Skills documentation
- Regenerate JSON schema

🤖 Generated with assistance of OhMyOpenCode (https://github.com/code-yeongyu/oh-my-opencode)
2026-01-02 00:01:44 +09:00
YeonGyu-Kim
bd05f5b434 feat(skill_mcp): add dynamic truncation and grep filtering
- Add skill_mcp and webfetch to TRUNCATABLE_TOOLS list
- Add grep parameter for regex filtering of output lines
- Prevents token overflow from large MCP responses

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-01 23:43:00 +09:00
YeonGyu-Kim
a82575b55f feat(skill): display MCP tool inputSchema when loading skills
Previously only tool names were shown. Now each tool displays its full
inputSchema JSON so LLM can construct correct skill_mcp calls.

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-01 23:30:33 +09:00
YeonGyu-Kim
ff760e5865 feat(skill-loader): support mcp.json file for AmpCode compatibility
- Added loadMcpJsonFromDir() to load MCP config from skill directory's mcp.json
- Supports AmpCode format (mcpServers wrapper) and direct format
- mcp.json takes priority over YAML frontmatter when both exist
- Added 3 tests covering mcpServers format, priority, and direct format

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-01 23:02:43 +09:00
YeonGyu-Kim
4039722160 feat(plugin): integrate skill_mcp tool with session-scoped lifecycle management
🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-01 23:02:43 +09:00
YeonGyu-Kim
439785ef90 feat(skill): display MCP server capabilities when skill with MCP is loaded
🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-01 23:02:43 +09:00
YeonGyu-Kim
e5330311dd feat(tools): add skill_mcp tool for invoking skill-embedded MCP operations
🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-01 23:02:43 +09:00
YeonGyu-Kim
b122273c2f feat(skill-loader): parse MCP server config from skill frontmatter
🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-01 23:02:43 +09:00
YeonGyu-Kim
06dee7248b feat(skill-mcp): add MCP client manager with lazy loading and session cleanup
🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-01 23:02:43 +09:00
YeonGyu-Kim
c8aed3f428 chore(deps): add @modelcontextprotocol/sdk and js-yaml for skill-embedded MCP support
🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-01 23:02:43 +09:00
Sisyphus
1d4b5dec4a feat(mcp): restrict grep_app tools to librarian agent only (#395)
* feat(mcp): restrict grep_app tools to librarian agent only

Reduces token usage by disabling grep_app MCP tools globally and enabling them only for the librarian agent, which uses them for GitHub code search during documentation lookups.

Changes:
- Add grep_app_* tool disable globally in config.tools
- Add grep_app_* tool enable for librarian agent
- Remove grep_app references from explore agent prompt (no access)

Closes #394

* chore: changes by sisyphus-dev-ai

---------

Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
2026-01-01 22:53:28 +09:00
YeonGyu-Kim
a217610ae4 Fix Bun mock.module() leak between test files preventing ralph-loop tests from passing
Replace mock.module() with spyOn() in auto-slash-command test to prevent shared module mocking from leaking to other test files. Remove unused mock.module() from think-mode test. This ensures test isolation so ralph-loop tests pass in both isolation and full suite runs.

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-01 22:05:39 +09:00
YeonGyu-Kim
b3775719b4 Update AGENTS.md documentation hierarchy with auth and hooks details
- Update root AGENTS.md with timestamp 2026-01-01T21:15:00+09:00, commit 490c0b6
- Add auto-slash-command and ralph-loop hooks to structure documentation
- Add complexity hotspots, unique styles, and notes sections
- Create src/auth/AGENTS.md documenting Antigravity OAuth architecture (57 lines)
- Update src/hooks/AGENTS.md with new hooks documentation

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-01 21:36:37 +09:00
YeonGyu-Kim
490c0b626f Add auto-slash-command hook for intercepting and replacing slash commands
This hook intercepts user messages starting with '/' and REPLACES them with the actual command template output instead of injecting instructions. The implementation includes:

- Slash command detection (detector.ts) - identifies messages starting with '/'
- Command discovery and execution (executor.ts) - loads templates from ~/.claude/commands/ or similar
- Hook integration (index.ts) - registers with chat.message event to replace output.parts
- Comprehensive test coverage - 37 tests covering detection, replacement, error handling, and command exclusions
- Configuration support in HookNameSchema

Key features:
- Supports excluded commands to skip processing
- Loads command templates from user's command directory
- Replaces user input before reaching the LLM
- Tests all edge cases including missing files, malformed templates, and special commands

🤖 Generated with assistance of OhMyOpenCode (https://github.com/code-yeongyu/oh-my-opencode)
2026-01-01 21:03:27 +09:00
YeonGyu-Kim
b30c17ac77 fix(recovery): more aggressive truncation, remove revert fallback
- Change charsPerToken from 4 to 2 for more aggressive truncation calculation
- Remove revert fallback (PHASE 2.5)
- Always try Continue after truncation if anything was truncated

🤖 Generated with assistance of OhMyOpenCode (https://github.com/code-yeongyu/oh-my-opencode)
2026-01-01 21:03:27 +09:00
YeonGyu-Kim
a5983f1678 fix(anthropic-context-window-limit-recovery): add revert fallback when truncation insufficient
When over token limit after truncation, use session.revert to remove last message instead of attempting summarize (which would also fail). Skip summarize entirely when still over limit to prevent infinite loop.

🤖 Generated with assistance of [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-01 21:03:27 +09:00
YeonGyu-Kim
2948d94a3c Change recovery phase ordering to DCP → Truncate → Summarize
When session hits token limit (e.g. 207k > 200k), the summarize API also fails
because it needs to process the full 207k tokens. By truncating FIRST, we reduce
token count before attempting summarize.

Changes:
- PHASE 1: DCP (Dynamic Context Pruning) - prune duplicate tool calls first
- PHASE 2: Aggressive Truncation - always try when over limit
- PHASE 3: Summarize - last resort after DCP and truncation

🤖 Generated with assistance of [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-01 21:03:27 +09:00
YeonGyu-Kim
c66cfbb8c6 Remove invalid model reference from publish command
🤖 Generated with assistance of [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-01 21:03:27 +09:00
Sisyphus
f66c886e0d feat(keyword-detector): show toast notification when ultrawork mode is activated (#393)
* feat(keyword-detector): show toast notification when ultrawork mode is activated

When users trigger ultrawork mode (via 'ultrawork' or 'ulw' keywords), a toast
notification now appears to confirm the mode is active. The notification is
shown once per session to avoid spamming.

Changes:
- Add detectKeywordsWithType() to identify which keyword type triggered
- Show 'Ultrawork Mode Activated' toast with success variant
- Track notified sessions to prevent duplicate toasts

Closes #392

* fix(keyword-detector): fix index bug in detectKeywordsWithType and add error logging

- Fix P1: detectKeywordsWithType now maps before filtering to preserve
  original KEYWORD_DETECTORS indices. Previously, the index used was from
  the filtered array, causing incorrect type assignment (e.g., 'search'
  match would incorrectly return 'ultrawork' type).

- Fix P2: Replace silent .catch(() => {}) with proper error logging using
  the log function for easier debugging when toast notifications fail.

---------

Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
2026-01-01 20:58:02 +09:00
Udo
1c55385cb5 feat(command-loader): add recursive subdirectory scanning for commands (#378)
Support organizing commands in subdirectories with colon-separated naming
(e.g., myproject/deploy.md becomes myproject:deploy).

- Recursively traverse subdirectories and load all .md command files
- Prefix nested command names with directory path (colon-separated)
- Protect against circular symlinks via visited path tracking
- Skip hidden directories (consistent with other loaders)
- Graceful error handling with logging for debugging
2026-01-01 20:34:40 +09:00
Sisyphus
f3db564b2e fix: reduce context duplication from ~22k to ~11k tokens (#383)
* fix: reduce context duplication from ~22k to ~11k tokens

Remove redundant env info and root AGENTS.md injection that OpenCode
already provides, addressing significant token waste on startup.

Changes:
- src/agents/utils.ts: Remove duplicated env fields (working dir,
  platform, date) from createEnvContext(), keep only OmO-specific
  fields (time, timezone, locale)
- src/hooks/directory-agents-injector/index.ts: Skip root AGENTS.md
  injection since OpenCode's system.ts already loads it via custom()

Fixes #379

* refactor: remove unused _directory parameter from createEnvContext()

---------

Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
2026-01-01 20:23:23 +09:00
Sisyphus
15b0ee80e1 feat(doctor): add GitHub CLI check (#384)
Add doctor check for GitHub CLI (gh) that verifies:
- Binary installation status
- Authentication status with GitHub
- Account details and token scopes when authenticated

Closes #374

Co-authored-by: sisyphus-dev-ai <sisyphus-dev-ai@users.noreply.github.com>
2026-01-01 20:17:22 +09:00
github-actions[bot]
2cab836a3b release: v2.9.1 2026-01-01 06:44:05 +00:00
YeonGyu-Kim
4efa58616f Add skill support to sisyphus agent
🤖 Generated with assistance of OhMyOpenCode (https://github.com/code-yeongyu/oh-my-opencode)
2026-01-01 15:37:24 +09:00
YeonGyu-Kim
fbae3aeb6b update readme 2026-01-01 14:03:27 +09:00
github-actions[bot]
74da07d584 @vsumner has signed the CLA in code-yeongyu/oh-my-opencode#388 2025-12-31 20:40:23 +00:00
github-actions[bot]
7cd04a246c @eudresfs has signed the CLA in code-yeongyu/oh-my-opencode#385 2025-12-31 18:03:41 +00:00
github-actions[bot]
1de7df4933 @ul8 has signed the CLA in code-yeongyu/oh-my-opencode#378 2025-12-31 08:16:57 +00:00
github-actions[bot]
ea6121ee1c @gtg7784 has signed the CLA in code-yeongyu/oh-my-opencode#377 2025-12-31 08:05:36 +00:00
Junho Yeo
4939f81625 THE ORCHESTRATOR IS COMING (#375) 2025-12-31 16:06:14 +09:00
github-actions[bot]
820b339fae @junhoyeo has signed the CLA in code-yeongyu/oh-my-opencode#375 2025-12-31 07:05:05 +00:00
YeonGyu-Kim
5412578600 docs: regenerate AGENTS.md hierarchy via init-deep
🤖 Generated with assistance of OhMyOpenCode (https://github.com/code-yeongyu/oh-my-opencode)
2025-12-31 14:07:14 +09:00
github-actions[bot]
502e9f504f release: v2.9.0 2025-12-31 04:53:17 +00:00
YeonGyu-Kim
8c3d413c8a Restructure /init-deep command prompt with dynamic phases and concurrent execution
- Reduce phases: 5 → 4 (discovery, scoring, generate, review)
- Implement concurrent execution: fire background explore agents + LSP simultaneously
- Add dynamic agent spawning based on project scale (files, lines, depth, large files, monorepo, languages)
- Convert to telegraphic style: ~50% shorter (~427 → ~301 lines)
- Clarify --create-new behavior: read existing → delete → regenerate

Addresses issue #368 requirements for dynamic agent spawning and concurrent explore+LSP execution.

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-31 13:40:57 +09:00
YeonGyu-Kim
b51d0bdf65 Revert "feat(keyword-detector): improve ultrawork-mode prompt with LSP in main session execution"
This reverts commit b2adda6e90.
2025-12-31 13:22:38 +09:00
YeonGyu-Kim
b2adda6e90 feat(keyword-detector): improve ultrawork-mode prompt with LSP in main session execution
- Add LSP IN MAIN SESSION execution rule for parallel codemap building
- Restructure WORKFLOW step 2 as PARALLEL PHASE with two concurrent tracks:
  - Background agents spawned via background_task for exploration/research
  - Main session using LSP tools (lsp_document_symbols, lsp_workspace_symbols, lsp_goto_definition, lsp_find_references, lsp_hover) for codebase understanding
- Enables agent to build comprehensive codebase context while background agents explore in parallel

🤖 Generated with assistance of OhMyOpenCode (https://github.com/code-yeongyu/oh-my-opencode)
2025-12-31 13:17:53 +09:00
YeonGyu-Kim
0da20f21b0 feat(session-manager): add project path filtering for session listing
- Add SESSION_STORAGE constant for session metadata directory
- Add getMainSessions() function to retrieve main sessions with filtering:
  - Sorts sessions by updated time (newest first)
  - Filters out child sessions (with parentID)
  - Filters sessions by directory path
- Update session_list tool to use new getMainSessions():
  - Add project_path parameter (default: current working directory)
  - Maintains existing date range filtering and limit behavior

🤖 Generated with assistance of OhMyOpenCode
2025-12-31 13:14:59 +09:00
YeonGyu-Kim
2f1ede072f refactor(index): use migration module from shared
Removes inline migration logic from index.ts and imports from shared/migration module.
This completes the refactoring to extract testable migration logic into a dedicated module.

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-31 13:14:59 +09:00
YeonGyu-Kim
ffeb92eb13 refactor(config): extract config migration logic to testable module
- Extract AGENT_NAME_MAP, HOOK_NAME_MAP, and migration functions to src/shared/migration.ts
- Add comprehensive BDD-style test suite in src/shared/migration.test.ts with 15 test cases
- Export migration functions from src/shared/index.ts
- Improves testability and maintainability of config migration logic

Tests cover:
- Agent name migrations (omo → Sisyphus, OmO-Plan → Planner-Sisyphus)
- Hook name migrations (anthropic-auto-compact → anthropic-context-window-limit-recovery)
- Config key migrations (omo_agent → sisyphus_agent)
- Case-insensitive lookups and edge cases

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-31 13:14:59 +09:00
YeonGyu-Kim
d49c221cb1 fix(anthropic-context-window-limit-recovery): remove emergency fallback message revert logic
Remove the FallbackState interface and related fallback recovery mechanism that deleted message pairs when all other compaction attempts failed. This simplifies the recovery strategy by eliminating the last-resort fallback approach.

Changes:
- Removed FallbackState interface and FALLBACK_CONFIG from types.ts
- Removed fallbackStateBySession from AutoCompactState
- Removed getOrCreateFallbackState and getLastMessagePair functions
- Removed emergency revert block that deleted user+assistant message pairs
- Updated clearSessionState and timeout reset logic
- Removed related test cases

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-31 13:14:59 +09:00
YeonGyu-Kim
dea17dc3ba fix(command-loader): strip incompatible fields before registering with OpenCode
Slash commands with arguments were silently failing in OpenCode TUI because
command definitions included 'name' and 'argumentHint' fields that don't exist
in OpenCode's Command schema. Strip these fields before registration across
all command/skill loaders to ensure compatibility.

Affected loaders:
- builtin commands
- claude-code command loader
- opencode skill loader
- claude-code plugin loader

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-31 13:14:59 +09:00
YeonGyu-Kim
c6efe70f09 feat(agents): implement dynamic Sisyphus prompt system with agent metadata
Introduce a new dynamic prompt generation system for Sisyphus orchestrator
that leverages agent metadata for intelligent delegation. This revives the
dynamic-sisyphus-agent-prompt branch with comprehensive refactoring.

Changes:
- Add AgentPromptMetadata, AgentCategory, AgentCost, DelegationTrigger types
- Create sisyphus-prompt-builder with dynamic prompt generation logic
- Add AGENT_PROMPT_METADATA exports to all agent modules (oracle, librarian,
  explore, frontend-ui-ux-engineer, document-writer, multimodal-looker)
- Refactor sisyphus.ts to use buildDynamicSisyphusPrompt()
- Add AvailableAgent type export for type safety

This enables Sisyphus to make intelligent agent selection decisions based on
agent capabilities, costs, and delegation triggers, improving orchestration
efficiency.

🤖 Generated with assistance of OhMyOpenCode
(https://github.com/code-yeongyu/oh-my-opencode)
2025-12-31 13:14:59 +09:00
sisyphus-dev-ai
8cbdfbaf78 feat(init-deep): restructure Phase 1 to fire background explore first, then LSP codemap
- Step 1: Fire ALL background explore agents immediately (non-blocking)
- Step 2: Main session builds codemap understanding using LSP tools while background runs
- Step 3: Collect background results after main session analysis

This maximizes throughput by having agents discover patterns while the main session
analyzes code structure semantically via LSP.
2025-12-31 03:56:34 +00:00
Sisyphus
7cb3f23c2b feat: make preemptive compaction enabled by default (#372) 2025-12-31 12:55:39 +09:00
Sisyphus
471cf868ff fix(doctor): unify version check to use same source as get-local-version (#367) 2025-12-31 12:11:10 +09:00
118 changed files with 7487 additions and 1814 deletions

BIN
.github/assets/orchestrator-sisyphus.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 984 KiB

View File

@@ -1,7 +1,6 @@
---
description: Publish oh-my-opencode to npm via GitHub Actions workflow
argument-hint: <patch|minor|major>
model: opencode/big-pickle
---
<command-instruction>

115
AGENTS.md
View File

@@ -1,29 +1,29 @@
# PROJECT KNOWLEDGE BASE
**Generated:** 2025-12-28T19:26:00+09:00
**Commit:** 122e918
**Generated:** 2026-01-02T10:35:00+09:00
**Commit:** bebe660
**Branch:** dev
## OVERVIEW
OpenCode plugin implementing Claude Code/AmpCode features. Multi-model agent orchestration (GPT-5.2, Claude, Gemini, Grok), LSP tools (11), AST-Grep search, MCP integrations (context7, websearch_exa, grep_app). "oh-my-zsh" for OpenCode.
OpenCode plugin: multi-model agent orchestration (Claude Opus 4.5, GPT-5.2, Gemini 3, Grok), 11 LSP tools, AST-Grep, Claude Code compatibility layer. "oh-my-zsh" for OpenCode.
## STRUCTURE
```
oh-my-opencode/
├── src/
│ ├── agents/ # AI agents (7): Sisyphus, oracle, librarian, explore, frontend, document-writer, multimodal-looker
│ ├── hooks/ # 21 lifecycle hooks - see src/hooks/AGENTS.md
│ ├── tools/ # LSP, AST-Grep, Grep, Glob, etc. - see src/tools/AGENTS.md
│ ├── mcp/ # MCP servers: context7, websearch_exa, grep_app
│ ├── features/ # Claude Code compatibility - see src/features/AGENTS.md
│ ├── agents/ # 7 AI agents - see src/agents/AGENTS.md
│ ├── hooks/ # 22 lifecycle hooks - see src/hooks/AGENTS.md
│ ├── tools/ # LSP, AST-Grep, session mgmt - see src/tools/AGENTS.md
│ ├── features/ # Claude Code compat layer - see src/features/AGENTS.md
│ ├── 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
│ ├── config/ # Zod schema, TypeScript types
── auth/ # Google Antigravity OAuth (antigravity/)
│ ├── shared/ # Utilities: deep-merge, pattern-matcher, logger, etc.
│ └── index.ts # Main plugin entry (OhMyOpenCodePlugin)
── index.ts # Main plugin entry (723 lines)
├── script/ # build-schema.ts, publish.ts, generate-changelog.ts
├── assets/ # JSON schema
└── dist/ # Build output (ESM + .d.ts)
```
@@ -31,58 +31,44 @@ oh-my-opencode/
| Task | Location | Notes |
|------|----------|-------|
| Add agent | `src/agents/` | Create .ts, add to builtinAgents in index.ts, update types.ts |
| Add hook | `src/hooks/` | Create dir with createXXXHook(), export from index.ts |
| Add tool | `src/tools/` | Dir with index/types/constants/tools.ts, add to builtinTools |
| Add agent | `src/agents/` | Create .ts, add to builtinAgents, update types.ts |
| Add hook | `src/hooks/` | Dir with createXXXHook(), export from index.ts |
| Add tool | `src/tools/` | Dir with constants/types/tools.ts, add to builtinTools |
| Add MCP | `src/mcp/` | Create config, add to index.ts |
| LSP behavior | `src/tools/lsp/` | client.ts (connection), tools.ts (handlers) |
| AST-Grep | `src/tools/ast-grep/` | napi.ts for @ast-grep/napi binding |
| Google OAuth | `src/auth/antigravity/` | OAuth plugin for Google models |
| Config schema | `src/config/schema.ts` | Zod schema, run `bun run build:schema` after changes |
| Add skill | `src/features/builtin-skills/` | Dir with SKILL.md |
| 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 |
| Background agents | `src/features/background-agent/` | manager.ts for task management |
| Interactive terminal | `src/tools/interactive-bash/` | tmux session management |
## CONVENTIONS
- **Package manager**: Bun only (`bun run`, `bun build`, `bunx`)
- **Bun only**: `bun run`, `bun test`, `bunx` (NEVER npm/npx)
- **Types**: bun-types (not @types/node)
- **Build**: Dual output - `bun build` (ESM) + `tsc --emitDeclarationOnly`
- **Exports**: Barrel pattern - `export * from "./module"` in index.ts
- **Directory naming**: kebab-case (`ast-grep/`, `claude-code-hooks/`)
- **Tool structure**: index.ts, types.ts, constants.ts, tools.ts, utils.ts
- **Hook pattern**: `createXXXHook(input: PluginInput)` returning event handlers
- **Test style**: BDD comments `#given`, `#when`, `#then` (same as AAA)
- **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)
- **Temperature**: 0.1 for code agents, max 0.3
## ANTI-PATTERNS (THIS PROJECT)
## ANTI-PATTERNS
- **npm/yarn**: Use bun exclusively
- **@types/node**: Use bun-types
- **Bash file ops**: Never mkdir/touch/rm/cp/mv for file creation in code
- **Direct bun publish**: GitHub Actions workflow_dispatch only (OIDC provenance)
- **Local version bump**: Version managed by CI workflow
- **Year 2024**: NEVER use 2024 in code/prompts (use current year)
- **Rush completion**: Never mark tasks complete without verification
- **Over-exploration**: Stop searching when sufficient context found
## UNIQUE STYLES
- **Platform**: Union type `"darwin" | "linux" | "win32" | "unsupported"`
- **Optional props**: Extensive `?` for optional interface properties
- **Flexible objects**: `Record<string, unknown>` for dynamic configs
- **Error handling**: Consistent try/catch with async/await
- **Agent tools**: `tools: { include: [...] }` or `tools: { exclude: [...] }`
- **Temperature**: Most agents use `0.1` for consistency
- **Hook naming**: `createXXXHook` function convention
| Category | Forbidden |
|----------|-----------|
| Type Safety | `as any`, `@ts-ignore`, `@ts-expect-error` |
| Package Manager | npm, yarn, npx |
| File Ops | Bash mkdir/touch/rm for code file creation |
| Publishing | Direct `bun publish`, local version bump |
| Agent Behavior | High temp (>0.3), broad tool access, sequential agent calls |
| Hooks | Heavy PreToolUse logic, blocking without reason |
| Year | 2024 in code/prompts (use current year) |
## AGENT MODELS
| Agent | Model | Purpose |
|-------|-------|---------|
| Sisyphus | anthropic/claude-opus-4-5 | Primary orchestrator |
| oracle | openai/gpt-5.2 | Strategic advisor, code review |
| librarian | anthropic/claude-sonnet-4-5 | Multi-repo analysis, docs |
| explore | opencode/grok-code | Fast codebase exploration |
| oracle | openai/gpt-5.2 | Strategy, code review |
| librarian | anthropic/claude-sonnet-4-5 | Docs, OSS research |
| explore | opencode/grok-code | Fast codebase grep |
| frontend-ui-ux-engineer | google/gemini-3-pro-preview | UI generation |
| document-writer | google/gemini-3-pro-preview | Technical docs |
| multimodal-looker | google/gemini-3-flash | PDF/image analysis |
@@ -93,8 +79,7 @@ oh-my-opencode/
bun run typecheck # Type check
bun run build # ESM + declarations + schema
bun run rebuild # Clean + Build
bun run build:schema # Schema only
bun test # Run tests
bun test # Run tests (380+)
```
## DEPLOYMENT
@@ -102,20 +87,26 @@ bun test # Run tests
**GitHub Actions workflow_dispatch only**
1. Never modify package.json version locally
2. Commit & push changes
3. Trigger `publish` workflow: `gh workflow run publish -f bump=patch`
2. Commit & push to dev
3. Trigger: `gh workflow run publish -f bump=patch|minor|major`
**Critical**: Never `bun publish` directly. Never bump version locally.
CI auto-commits schema changes on master, maintains rolling `next` draft release on dev.
## CI PIPELINE
## COMPLEXITY HOTSPOTS
- **ci.yml**: Parallel test/typecheck, build verification, auto-commit schema on master
- **publish.yml**: Manual workflow_dispatch, version bump, changelog, OIDC npm publish
| File | Lines | Description |
|------|-------|-------------|
| `src/index.ts` | 723 | 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/agents/sisyphus.ts` | 504 | Orchestrator prompt |
## NOTES
- **Testing**: Bun native test (`bun test`), BDD-style `#given/#when/#then`
- **OpenCode**: Requires >= 1.0.150
- **Multi-lang docs**: README.md (EN), README.ko.md (KO), README.ja.md (JA), README.zh-cn.md (ZH-CN)
- **Config**: `~/.config/opencode/oh-my-opencode.json` (user) or `.opencode/oh-my-opencode.json` (project)
- **Trusted deps**: @ast-grep/cli, @ast-grep/napi, @code-yeongyu/comment-checker
- **Config**: `~/.config/opencode/oh-my-opencode.json` or `.opencode/oh-my-opencode.json`
- **JSONC**: Config files support comments and trailing commas
- **Claude Code**: Full compat layer for settings.json hooks, commands, skills, agents, MCPs
- **Skill MCP**: Skills can embed MCP server configs in YAML frontmatter

View File

@@ -128,6 +128,12 @@ Windows から Linux に初めて乗り換えた時のこと、自分の思い
- なぜこれが良いのか、単なるボイラープレートではないのか、具体的に何が良くなるのか聞いてみてください。
- もしあなたが LLM エージェントなら、このプロジェクトによって今すぐ何が可能になるのか、ユーザーに説明してください。
### 🪄 魔法の言葉:`ultrawork`
**これ全部読みたくない?プロンプトに `ultrawork`(または `ulw`を入れるだけでOK。**
それだけです。私たちが提供するすべての機能が魔法のように動作します—並列エージェント、バックグラウンドタスク、深い探索、そして完了するまで止まらない実行。エージェントが自動的にすべてを処理します。
### 読みたい方のために:シジフォスに会う
![Meet Sisyphus](.github/assets/sisyphus.png)
@@ -223,8 +229,12 @@ OpenCode がインストールされていない場合は、[OpenCode インス
```bash
bunx oh-my-opencode install --no-tui --claude=<yes|no|max20> --chatgpt=<yes|no> --gemini=<yes|no>
# bunx が動作しない場合は npx を使用
npx oh-my-opencode install --no-tui --claude=<yes|no|max20> --chatgpt=<yes|no> --gemini=<yes|no>
```
> **Ubuntu/Debian ユーザーへの注意**: Snap で Bun をインストールした場合 (`/snap/bin/bun`)、Snap のサンドボックス化により `bunx` が「script not found」エラーで失敗します。代わりに `npx` を使用するか、公式インストーラーで Bun を再インストールしてください: `curl -fsSL https://bun.sh/install | bash`
**例:**
- すべてのサブスクリプション + max20: `bunx oh-my-opencode install --no-tui --claude=max20 --chatgpt=yes --gemini=yes`
- Claude のみmax20 なし): `bunx oh-my-opencode install --no-tui --claude=yes --chatgpt=no --gemini=no`
@@ -660,6 +670,10 @@ Oh My OpenCode は以下の場所からフックを読み込んで実行しま
- **Empty Message Sanitizer**: 空のチャットメッセージによるAPIエラーを防止します。送信前にメッセージ内容を自動的にサニタイズします。
- **Grep Output Truncator**: grep は山のようなテキストを返すことがあります。残りのコンテキストウィンドウに応じて動的に出力を切り詰めます—50% の余裕を維持し、最大 50k トークンに制限します。
- **Tool Output Truncator**: 同じ考え方をより広範囲に適用します。Grep、Glob、LSP ツール、AST-grep の出力を切り詰めます。一度の冗長な検索がコンテキスト全体を食いつぶすのを防ぎます。
- **Preemptive Compaction**: トークン制限に達する前にセッションを事前にコンパクションします。コンテキストウィンドウ使用率85%で実行されます。**デフォルトで有効。** `disabled_hooks: ["preemptive-compaction"]`で無効化できます。
- **Compaction Context Injector**: セッションコンパクション中に重要なコンテキストAGENTS.md、現在のディレクトリ情報を保持し、重要な状態を失わないようにします。
- **Thinking Block Validator**: thinking ブロックを検証し、適切なフォーマットを確保し、不正な thinking コンテンツによる API エラーを防ぎます。
- **Claude Code Hooks**: Claude Code の settings.json からフックを実行します - これは PreToolUse/PostToolUse/UserPromptSubmit/Stop フックを実行する互換性レイヤーです。
## 設定
@@ -755,7 +769,19 @@ Oh My OpenCode は以下の場所からフックを読み込んで実行しま
}
```
各エージェントでサポートされるオプション:`model`, `temperature`, `top_p`, `prompt`, `tools`, `disable`, `description`, `mode`, `color`, `permission`。
各エージェントでサポートされるオプション:`model`, `temperature`, `top_p`, `prompt`, `prompt_append`, `tools`, `disable`, `description`, `mode`, `color`, `permission`。
`prompt_append` を使用すると、デフォルトのシステムプロンプトを置き換えずに追加の指示を付け加えられます:
```json
{
"agents": {
"librarian": {
"prompt_append": "Emacs Lisp のドキュメント検索には常に elisp-dev-mcp を使用してください。"
}
}
}
```
`Sisyphus` (メインオーケストレーター) と `build` (デフォルトエージェント) も同じオプションで設定をオーバーライドできます。
@@ -874,7 +900,7 @@ Oh My OpenCode は以下の場所からフックを読み込んで実行しま
}
```
利用可能なフック:`todo-continuation-enforcer`, `context-window-monitor`, `session-recovery`, `session-notification`, `comment-checker`, `grep-output-truncator`, `tool-output-truncator`, `directory-agents-injector`, `directory-readme-injector`, `empty-task-response-detector`, `think-mode`, `anthropic-context-window-limit-recovery`, `rules-injector`, `background-notification`, `auto-update-checker`, `startup-toast`, `keyword-detector`, `agent-usage-reminder`, `non-interactive-env`, `interactive-bash-session`, `empty-message-sanitizer`, `compaction-context-injector`, `thinking-block-validator`, `claude-code-hooks`, `ralph-loop`
利用可能なフック:`todo-continuation-enforcer`, `context-window-monitor`, `session-recovery`, `session-notification`, `comment-checker`, `grep-output-truncator`, `tool-output-truncator`, `directory-agents-injector`, `directory-readme-injector`, `empty-task-response-detector`, `think-mode`, `anthropic-context-window-limit-recovery`, `rules-injector`, `background-notification`, `auto-update-checker`, `startup-toast`, `keyword-detector`, `agent-usage-reminder`, `non-interactive-env`, `interactive-bash-session`, `empty-message-sanitizer`, `compaction-context-injector`, `thinking-block-validator`, `claude-code-hooks`, `ralph-loop`, `preemptive-compaction`
**`auto-update-checker`と`startup-toast`について**: `startup-toast` フックは `auto-update-checker` のサブ機能です。アップデートチェックは有効なまま起動トースト通知のみを無効化するには、`disabled_hooks` に `"startup-toast"` を追加してください。すべてのアップデートチェック機能(トーストを含む)を無効化するには、`"auto-update-checker"` を追加してください。
@@ -926,7 +952,7 @@ OpenCode でサポートされるすべての LSP 構成およびカスタム設
```json
{
"experimental": {
"preemptive_compaction": true,
"preemptive_compaction_threshold": 0.85,
"truncate_all_tool_outputs": true,
"aggressive_truncation": true,
"auto_resume": true
@@ -936,8 +962,7 @@ OpenCode でサポートされるすべての LSP 構成およびカスタム設
| オプション | デフォルト | 説明 |
| --------------------------------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `preemptive_compaction` | `false` | トークン制限に達する前にセッションを事前にコンパクションします。デフォルトでコンテキストウィンドウ使用率80%で実行されます。 |
| `preemptive_compaction_threshold` | `0.80` | プリエンプティブコンパクションをトリガーする閾値0.5-0.95)。`preemptive_compaction`が有効な場合のみ適用されます。 |
| `preemptive_compaction_threshold` | `0.85` | プリエンプティブコンパクションをトリガーする閾値0.5-0.95)。`preemptive-compaction` フックはデフォルトで有効です。このオプションで閾値をカスタマイズできます。 |
| `truncate_all_tool_outputs` | `false` | ホワイトリストのツールGrep、Glob、LSP、AST-grepだけでなく、すべてのツール出力を切り詰めます。Tool output truncator はデフォルトで有効です - `disabled_hooks`で無効化できます。 |
| `aggressive_truncation` | `false` | トークン制限を超えた場合、ツール出力を積極的に切り詰めて制限内に収めます。デフォルトの切り詰めより積極的です。不十分な場合は要約/復元にフォールバックします。 |
| `auto_resume` | `false` | thinking block エラーや thinking disabled violation からの回復成功後、自動的にセッションを再開します。最後のユーザーメッセージを抽出して続行します。 |

View File

@@ -127,6 +127,12 @@ OpenCode 가 낭만이 사라진것같은 오늘날의 시대에, 당신에게
- 이게 왜 좋고, 그냥 뻔한 셋업이 아닌지, 당장 무엇이 더 좋아지는지 알려달라고 해보세요.
- 당신이 LLM 에이전트라면, 사용자의 입장에서 이 프로젝트와 함께 당장 어떤것들이 가능해지는지 사용자에게 설명하세요.
### 🪄 마법의 단어: `ultrawork`
**이거 다 읽기 싫으시죠? 프롬프트에 `ultrawork` (또는 `ulw`) 만 넣으세요.**
그게 다입니다. 우리가 제공하는 모든 기능이 마법처럼 작동합니다—병렬 에이전트, 백그라운드 태스크, 깊은 탐색, 그리고 완료될 때까지 멈추지 않는 실행. 에이전트가 알아서 다 합니다.
### 하지만 읽고 싶은 당신을 위해: 시지푸스를 만나보세요
![Meet Sisyphus](.github/assets/sisyphus.png)
@@ -220,8 +226,12 @@ OpenCode가 설치되어 있지 않다면, [OpenCode 설치 가이드](https://o
```bash
bunx oh-my-opencode install --no-tui --claude=<yes|no|max20> --chatgpt=<yes|no> --gemini=<yes|no>
# bunx가 작동하지 않으면 npx 사용
npx oh-my-opencode install --no-tui --claude=<yes|no|max20> --chatgpt=<yes|no> --gemini=<yes|no>
```
> **Ubuntu/Debian 사용자 참고**: Snap으로 Bun을 설치한 경우 (`/snap/bin/bun`), Snap의 샌드박싱으로 인해 `bunx`가 "script not found" 오류와 함께 실패합니다. 대신 `npx`를 사용하거나, 공식 설치 스크립트로 Bun을 재설치하세요: `curl -fsSL https://bun.sh/install | bash`
**예시:**
- 모든 구독 + max20: `bunx oh-my-opencode install --no-tui --claude=max20 --chatgpt=yes --gemini=yes`
- Claude만 (max20 없음): `bunx oh-my-opencode install --no-tui --claude=yes --chatgpt=no --gemini=no`
@@ -653,7 +663,7 @@ Oh My OpenCode는 다음 위치의 훅을 읽고 실행합니다:
- **Empty Message Sanitizer**: 빈 채팅 메시지로 인한 API 오류를 방지합니다. 전송 전 메시지 내용을 자동으로 정리합니다.
- **Grep Output Truncator**: grep은 산더미 같은 텍스트를 반환할 수 있습니다. 남은 컨텍스트 윈도우에 따라 동적으로 출력을 축소합니다—50% 여유 공간 유지, 최대 50k 토큰.
- **Tool Output Truncator**: 같은 아이디어, 더 넓은 범위. Grep, Glob, LSP 도구, AST-grep의 출력을 축소합니다. 한 번의 장황한 검색이 전체 컨텍스트를 잡아먹는 것을 방지합니다.
- **선제적 압축 (Preemptive Compaction)**: 세션 토큰 한계에 도달하기 전에 선제적으로 세션을 압축합니다. 문제가 발생하기 전에 미리 실행됩니다.
- **선제적 압축 (Preemptive Compaction)**: 세션 토큰 한계에 도달하기 전에 선제적으로 세션을 압축합니다. 컨텍스트 윈도우 사용량 85%에서 실행됩니다. **기본적으로 활성화됨.** `disabled_hooks: ["preemptive-compaction"]`으로 비활성화 가능.
- **압축 컨텍스트 주입기 (Compaction Context Injector)**: 세션 압축 중에 중요한 컨텍스트(AGENTS.md, 현재 디렉토리 정보 등)를 유지하여 중요한 상태를 잃지 않도록 합니다.
- **사고 블록 검증기 (Thinking Block Validator)**: 사고(thinking) 블록의 형식이 올바른지 검증하여 잘못된 형식으로 인한 API 오류를 방지합니다.
- **Claude Code 훅 (Claude Code Hooks)**: Claude Code의 settings.json에 설정된 훅을 실행합니다. PreToolUse/PostToolUse/UserPromptSubmit/Stop 이벤트를 지원하는 호환성 레이어입니다.
@@ -752,7 +762,19 @@ Schema 자동 완성이 지원됩니다:
}
```
각 에이전트에서 지원하는 옵션: `model`, `temperature`, `top_p`, `prompt`, `tools`, `disable`, `description`, `mode`, `color`, `permission`.
각 에이전트에서 지원하는 옵션: `model`, `temperature`, `top_p`, `prompt`, `prompt_append`, `tools`, `disable`, `description`, `mode`, `color`, `permission`.
`prompt_append`를 사용하면 기본 시스템 프롬프트를 대체하지 않고 추가 지시사항을 덧붙일 수 있습니다:
```json
{
"agents": {
"librarian": {
"prompt_append": "Emacs Lisp 문서 조회 시 항상 elisp-dev-mcp를 사용하세요."
}
}
}
```
`Sisyphus` (메인 오케스트레이터)와 `build` (기본 에이전트)도 동일한 옵션으로 설정을 오버라이드할 수 있습니다.
@@ -871,7 +893,7 @@ Schema 자동 완성이 지원됩니다:
}
```
사용 가능한 훅: `todo-continuation-enforcer`, `context-window-monitor`, `session-recovery`, `session-notification`, `comment-checker`, `grep-output-truncator`, `tool-output-truncator`, `directory-agents-injector`, `directory-readme-injector`, `empty-task-response-detector`, `think-mode`, `anthropic-context-window-limit-recovery`, `rules-injector`, `background-notification`, `auto-update-checker`, `startup-toast`, `keyword-detector`, `agent-usage-reminder`, `non-interactive-env`, `interactive-bash-session`, `empty-message-sanitizer`, `compaction-context-injector`, `thinking-block-validator`, `claude-code-hooks`, `ralph-loop`
사용 가능한 훅: `todo-continuation-enforcer`, `context-window-monitor`, `session-recovery`, `session-notification`, `comment-checker`, `grep-output-truncator`, `tool-output-truncator`, `directory-agents-injector`, `directory-readme-injector`, `empty-task-response-detector`, `think-mode`, `anthropic-context-window-limit-recovery`, `rules-injector`, `background-notification`, `auto-update-checker`, `startup-toast`, `keyword-detector`, `agent-usage-reminder`, `non-interactive-env`, `interactive-bash-session`, `empty-message-sanitizer`, `compaction-context-injector`, `thinking-block-validator`, `claude-code-hooks`, `ralph-loop`, `preemptive-compaction`
**`auto-update-checker`와 `startup-toast`에 대한 참고사항**: `startup-toast` 훅은 `auto-update-checker`의 하위 기능입니다. 업데이트 확인은 유지하면서 시작 토스트 알림만 비활성화하려면 `disabled_hooks`에 `"startup-toast"`를 추가하세요. 모든 업데이트 확인 기능(토스트 포함)을 비활성화하려면 `"auto-update-checker"`를 추가하세요.
@@ -923,7 +945,7 @@ OpenCode 에서 지원하는 모든 LSP 구성 및 커스텀 설정 (opencode.js
```json
{
"experimental": {
"preemptive_compaction": true,
"preemptive_compaction_threshold": 0.85,
"truncate_all_tool_outputs": true,
"aggressive_truncation": true,
"auto_resume": true
@@ -933,8 +955,7 @@ OpenCode 에서 지원하는 모든 LSP 구성 및 커스텀 설정 (opencode.js
| 옵션 | 기본값 | 설명 |
| --------------------------------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `preemptive_compaction` | `false` | 토큰 제한에 도달하기 전에 세션을 미리 컴팩션합니다. 기본적으로 컨텍스트 윈도우 사용량이 80%일 때 실행됩니다. |
| `preemptive_compaction_threshold` | `0.80` | 선제적 컴팩션을 트리거할 임계값 비율(0.5-0.95). `preemptive_compaction`이 활성화된 경우에만 적용됩니다. |
| `preemptive_compaction_threshold` | `0.85` | 선제적 컴팩션을 트리거할 임계값 비율(0.5-0.95). `preemptive-compaction` 훅은 기본적으로 활성화되어 있으며, 이 옵션으로 임계값을 커스터마이즈할 수 있습니다. |
| `truncate_all_tool_outputs` | `false` | 화이트리스트 도구(Grep, Glob, LSP, AST-grep)만이 아닌 모든 도구 출력을 잘라냅니다. Tool output truncator는 기본적으로 활성화됩니다 - `disabled_hooks`로 비활성화 가능합니다. |
| `aggressive_truncation` | `false` | 토큰 제한을 초과하면 도구 출력을 공격적으로 잘라내어 제한 내에 맞춥니다. 기본 truncation보다 더 공격적입니다. 부족하면 요약/복구로 fallback합니다. |
| `auto_resume` | `false` | thinking block 에러나 thinking disabled violation으로부터 성공적으로 복구한 후 자동으로 세션을 재개합니다. 마지막 사용자 메시지를 추출하여 계속합니다. |

View File

@@ -2,6 +2,9 @@
>
> *"I aim to spark a software revolution by creating a world where agent-generated code is indistinguishable from human code, yet capable of achieving vastly more. I have poured my personal time, passion, and funds into this journey, and I will continue to do so."*
>
> [![The Orchestrator is coming](./.github/assets/orchestrator-sisyphus.png)](https://x.com/justsisyphus/status/2006250634354548963)
> > **The Orchestrator is coming. This Week. [Get notified on X](https://x.com/justsisyphus/status/2006250634354548963)**
>
> 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. |
@@ -149,6 +152,12 @@ Hand this doc to an agent and let them set it up.
- Ask why it's good, why it's not just another boilerplate setup, what actually gets better.
- If you're an LLM agent, explain to the user what becomes possible right now with this project.
### 🪄 The Magic Word: `ultrawork`
**Don't want to read all this? Just include `ultrawork` (or `ulw`) in your prompt.**
That's it. All the features we provide will work like magic—parallel agents, background tasks, deep exploration, and relentless execution until completion. The agent figures out the rest automatically.
### For Those Who Want to Read: Meet Sisyphus
![Meet Sisyphus](.github/assets/sisyphus.png)
@@ -201,8 +210,12 @@ Run the interactive installer:
```bash
bunx oh-my-opencode install
# or use npx if bunx doesn't work
npx oh-my-opencode install
```
> **Note for Ubuntu/Debian users**: If you installed Bun via Snap (`/snap/bin/bun`), `bunx` will fail with "script not found" due to Snap's sandboxing. Either use `npx` instead, or reinstall Bun via the official installer: `curl -fsSL https://bun.sh/install | bash`
Follow the prompts to configure your Claude, ChatGPT, and Gemini subscriptions. After installation, authenticate your providers as instructed.
**Alternative: Let an LLM Agent do it**
@@ -574,6 +587,26 @@ Instead of the agent reading massive files and bloating context, it internally l
#### I Removed Their Blockers
- Replaces built-in grep and glob tools. Default implementation has no timeout—can hang forever.
#### Skill-Embedded MCP Support
Skills can now bring their own MCP servers. Define MCP configurations directly in skill frontmatter or via `mcp.json` files:
```yaml
---
description: Browser automation skill
mcp:
playwright:
command: npx
args: ["-y", "@anthropic-ai/mcp-playwright"]
---
```
When you load a skill with embedded MCP, its tools become available automatically. The `skill_mcp` tool lets you invoke these MCP operations with full schema discovery.
**Built-in Skills:**
- **playwright**: Browser automation, web scraping, testing, and screenshots out of the box
Disable built-in skills via `disabled_skills: ["playwright"]` in your config.
### Goodbye Claude Code. Hello Oh My OpenCode.
@@ -692,7 +725,7 @@ When agents thrive, you thrive. But I want to help you directly too.
- **Empty Message Sanitizer**: Prevents API errors from empty chat messages by automatically sanitizing message content before sending.
- **Grep Output Truncator**: Grep can return mountains of text. This dynamically truncates output based on your remaining context window—keeps 50% headroom, caps at 50k tokens.
- **Tool Output Truncator**: Same idea, broader scope. Truncates output from Grep, Glob, LSP tools, and AST-grep. Prevents one verbose search from eating your entire context.
- **Preemptive Compaction**: Compacts session proactively before hitting hard token limits. Runs before you get into trouble.
- **Preemptive Compaction**: Compacts session proactively before hitting hard token limits. Runs at 85% context window usage. **Enabled by default.** Disable via `disabled_hooks: ["preemptive-compaction"]`.
- **Compaction Context Injector**: Preserves critical context (AGENTS.md, current directory info) during session compaction so you don't lose important state.
- **Thinking Block Validator**: Validates thinking blocks to ensure proper formatting and prevent API errors from malformed thinking content.
- **Claude Code Hooks**: Executes hooks from Claude Code's settings.json - this is the compatibility layer that runs PreToolUse/PostToolUse/UserPromptSubmit/Stop hooks.
@@ -791,7 +824,19 @@ Override built-in agent settings:
}
```
Each agent supports: `model`, `temperature`, `top_p`, `prompt`, `tools`, `disable`, `description`, `mode`, `color`, `permission`.
Each agent supports: `model`, `temperature`, `top_p`, `prompt`, `prompt_append`, `tools`, `disable`, `description`, `mode`, `color`, `permission`.
Use `prompt_append` to add extra instructions without replacing the default system prompt:
```json
{
"agents": {
"librarian": {
"prompt_append": "Always use the elisp-dev-mcp for Emacs Lisp documentation lookups."
}
}
}
```
You can also override settings for `Sisyphus` (the main orchestrator) and `build` (the default agent) using the same options.
@@ -831,6 +876,22 @@ Or disable via `disabled_agents` in `~/.config/opencode/oh-my-opencode.json` or
Available agents: `oracle`, `librarian`, `explore`, `frontend-ui-ux-engineer`, `document-writer`, `multimodal-looker`
### Built-in Skills
Oh My OpenCode includes built-in skills that provide additional capabilities:
- **playwright**: Browser automation with Playwright MCP. Use for web scraping, testing, screenshots, and browser interactions.
Disable built-in skills via `disabled_skills` in `~/.config/opencode/oh-my-opencode.json` or `.opencode/oh-my-opencode.json`:
```json
{
"disabled_skills": ["playwright"]
}
```
Available built-in skills: `playwright`
### Sisyphus Agent
When enabled (default), Sisyphus provides a powerful orchestrator with optional specialized agents:
@@ -910,7 +971,7 @@ Disable specific built-in hooks via `disabled_hooks` in `~/.config/opencode/oh-m
}
```
Available hooks: `todo-continuation-enforcer`, `context-window-monitor`, `session-recovery`, `session-notification`, `comment-checker`, `grep-output-truncator`, `tool-output-truncator`, `directory-agents-injector`, `directory-readme-injector`, `empty-task-response-detector`, `think-mode`, `anthropic-context-window-limit-recovery`, `rules-injector`, `background-notification`, `auto-update-checker`, `startup-toast`, `keyword-detector`, `agent-usage-reminder`, `non-interactive-env`, `interactive-bash-session`, `empty-message-sanitizer`, `compaction-context-injector`, `thinking-block-validator`, `claude-code-hooks`, `ralph-loop`
Available hooks: `todo-continuation-enforcer`, `context-window-monitor`, `session-recovery`, `session-notification`, `comment-checker`, `grep-output-truncator`, `tool-output-truncator`, `directory-agents-injector`, `directory-readme-injector`, `empty-task-response-detector`, `think-mode`, `anthropic-context-window-limit-recovery`, `rules-injector`, `background-notification`, `auto-update-checker`, `startup-toast`, `keyword-detector`, `agent-usage-reminder`, `non-interactive-env`, `interactive-bash-session`, `empty-message-sanitizer`, `compaction-context-injector`, `thinking-block-validator`, `claude-code-hooks`, `ralph-loop`, `preemptive-compaction`
**Note on `auto-update-checker` and `startup-toast`**: The `startup-toast` hook is a sub-feature of `auto-update-checker`. To disable only the startup toast notification while keeping update checking enabled, add `"startup-toast"` to `disabled_hooks`. To disable all update checking features (including the toast), add `"auto-update-checker"` to `disabled_hooks`.
@@ -962,7 +1023,7 @@ Opt-in experimental features that may change or be removed in future versions. U
```json
{
"experimental": {
"preemptive_compaction": true,
"preemptive_compaction_threshold": 0.85,
"truncate_all_tool_outputs": true,
"aggressive_truncation": true,
"auto_resume": true
@@ -972,8 +1033,7 @@ Opt-in experimental features that may change or be removed in future versions. U
| Option | Default | Description |
| --------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `preemptive_compaction` | `false` | Compacts session proactively before hitting hard token limits. Runs at 80% context window usage by default. |
| `preemptive_compaction_threshold` | `0.80` | Threshold percentage (0.5-0.95) to trigger preemptive compaction. Only applies when `preemptive_compaction` is enabled. |
| `preemptive_compaction_threshold` | `0.85` | Threshold percentage (0.5-0.95) to trigger preemptive compaction. The `preemptive-compaction` hook is enabled by default; this option customizes the threshold. |
| `truncate_all_tool_outputs` | `false` | Truncates ALL tool outputs instead of just whitelisted tools (Grep, Glob, LSP, AST-grep). Tool output truncator is enabled by default - disable via `disabled_hooks`. |
| `aggressive_truncation` | `false` | When token limit is exceeded, aggressively truncates tool outputs to fit within limits. More aggressive than the default truncation behavior. Falls back to summarize/revert if insufficient. |
| `auto_resume` | `false` | Automatically resumes session after successful recovery from thinking block errors or thinking disabled violations. Extracts the last user message and continues. |

View File

@@ -130,6 +130,12 @@ oMoMoMoMoMo···
- 问问它这玩意儿牛在哪,为什么不仅仅是个样板配置,到底能带来什么质变。
- 如果你是 LLM Agent请告诉你的用户装上这个现在立马能做到什么。
### 🪄 魔法口令:`ultrawork`
**懒得看这么多?在提示词里加上 `ultrawork`(或 `ulw`)就行了。**
就这么简单。我们提供的所有功能都会像魔法一样自动生效——并行 Agent、后台任务、深度探索、干到完才收工。Agent 会自动搞定一切。
### 如果你真的想读读看:认识西西弗斯
![Meet Sisyphus](.github/assets/sisyphus.png)
@@ -231,8 +237,12 @@ fi
```bash
bunx oh-my-opencode install --no-tui --claude=<yes|no|max20> --chatgpt=<yes|no> --gemini=<yes|no>
# 如果 bunx 不好使就换 npx
npx oh-my-opencode install --no-tui --claude=<yes|no|max20> --chatgpt=<yes|no> --gemini=<yes|no>
```
> **Ubuntu/Debian 用户注意**:如果你是用 Snap 装的 Bun (`/snap/bin/bun`),由于 Snap 的沙箱机制,`bunx` 会报 "script not found" 错误。要么改用 `npx`,要么用官方脚本重装 Bun`curl -fsSL https://bun.sh/install | bash`
**例子:**
- 全套订阅 + max20`bunx oh-my-opencode install --no-tui --claude=max20 --chatgpt=yes --gemini=yes`
- 只有 Claude没 max20`bunx oh-my-opencode install --no-tui --claude=yes --chatgpt=no --gemini=no`
@@ -664,6 +674,10 @@ Agent 爽了,你自然也爽。但我还想直接让你爽。
- **空消息清理器**:防止发空消息导致 API 报错。发出去之前自动打扫干净。
- **Grep 输出截断器**grep 结果太多?根据剩余窗口动态截断——留 50% 空间,顶天 50k token。
- **工具输出截断器**Grep、Glob、LSP、AST-grep 统统管上。防止一次无脑搜索把上下文撑爆。
- **预防性压缩 (Preemptive Compaction)**:在达到 token 限制之前主动压缩会话。在上下文窗口使用率 85% 时运行。**默认启用。** 通过 `disabled_hooks: ["preemptive-compaction"]` 禁用。
- **压缩上下文注入器**会话压缩时保留关键上下文AGENTS.md、当前目录信息防止丢失重要状态。
- **思考块验证器**:验证 thinking block 以确保格式正确,防止因格式错误的 thinking 内容而导致 API 错误。
- **Claude Code Hooks**:执行 Claude Code settings.json 中的 hooks - 这是运行 PreToolUse/PostToolUse/UserPromptSubmit/Stop hooks 的兼容层。
## 配置
@@ -759,7 +773,19 @@ Agent 爽了,你自然也爽。但我还想直接让你爽。
}
```
每个 Agent 能改这些:`model`、`temperature`、`top_p`、`prompt`、`tools`、`disable`、`description`、`mode`、`color`、`permission`。
每个 Agent 能改这些:`model`、`temperature`、`top_p`、`prompt`、`prompt_append`、`tools`、`disable`、`description`、`mode`、`color`、`permission`。
用 `prompt_append` 可以在默认系统提示后面追加额外指令,不用替换整个提示:
```json
{
"agents": {
"librarian": {
"prompt_append": "查 Emacs Lisp 文档时用 elisp-dev-mcp。"
}
}
}
```
`Sisyphus`(主编排器)和 `build`(默认 Agent也能改。
@@ -878,7 +904,7 @@ Sisyphus Agent 也能自定义:
}
```
可关的 hook`todo-continuation-enforcer`、`context-window-monitor`、`session-recovery`、`session-notification`、`comment-checker`、`grep-output-truncator`、`tool-output-truncator`、`directory-agents-injector`、`directory-readme-injector`、`empty-task-response-detector`、`think-mode`、`anthropic-context-window-limit-recovery`、`rules-injector`、`background-notification`、`auto-update-checker`、`startup-toast`、`keyword-detector`、`agent-usage-reminder`、`non-interactive-env`、`interactive-bash-session`、`empty-message-sanitizer`、`compaction-context-injector`、`thinking-block-validator`、`claude-code-hooks`、`ralph-loop`
可关的 hook`todo-continuation-enforcer`、`context-window-monitor`、`session-recovery`、`session-notification`、`comment-checker`、`grep-output-truncator`、`tool-output-truncator`、`directory-agents-injector`、`directory-readme-injector`、`empty-task-response-detector`、`think-mode`、`anthropic-context-window-limit-recovery`、`rules-injector`、`background-notification`、`auto-update-checker`、`startup-toast`、`keyword-detector`、`agent-usage-reminder`、`non-interactive-env`、`interactive-bash-session`、`empty-message-sanitizer`、`compaction-context-injector`、`thinking-block-validator`、`claude-code-hooks`、`ralph-loop`、`preemptive-compaction`
**关于 `auto-update-checker` 和 `startup-toast`**: `startup-toast` hook 是 `auto-update-checker` 的子功能。若想保持更新检查但只禁用启动提示通知,在 `disabled_hooks` 中添加 `"startup-toast"`。若要禁用所有更新检查功能(包括提示),添加 `"auto-update-checker"`。
@@ -930,7 +956,7 @@ Oh My OpenCode 送你重构工具(重命名、代码操作)。
```json
{
"experimental": {
"preemptive_compaction": true,
"preemptive_compaction_threshold": 0.85,
"truncate_all_tool_outputs": true,
"aggressive_truncation": true,
"auto_resume": true
@@ -940,8 +966,7 @@ Oh My OpenCode 送你重构工具(重命名、代码操作)。
| 选项 | 默认值 | 说明 |
| --------------------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
| `preemptive_compaction` | `false` | 在达到 token 限制之前主动压缩会话。默认在上下文窗口使用率达到 80% 时运行。 |
| `preemptive_compaction_threshold` | `0.80` | 触发预先压缩的阈值比例0.5-0.95)。仅在 `preemptive_compaction` 启用时生效。 |
| `preemptive_compaction_threshold` | `0.85` | 触发预防性压缩的阈值比例0.5-0.95)。`preemptive-compaction` 钩子默认启用;此选项用于自定义阈值。 |
| `truncate_all_tool_outputs` | `false` | 截断所有工具输出而不仅仅是白名单工具Grep、Glob、LSP、AST-grep。Tool output truncator 默认启用 - 使用 `disabled_hooks` 禁用。 |
| `aggressive_truncation` | `false` | 超出 token 限制时,激进地截断工具输出以适应限制。比默认截断更激进。不够的话会回退到摘要/恢复。 |
| `auto_resume` | `false` | 从 thinking block 错误或 thinking disabled violation 成功恢复后,自动恢复会话。提取最后一条用户消息继续执行。 |

View File

@@ -34,6 +34,15 @@
]
}
},
"disabled_skills": {
"type": "array",
"items": {
"type": "string",
"enum": [
"playwright"
]
}
},
"disabled_hooks": {
"type": "array",
"items": {
@@ -61,7 +70,11 @@
"interactive-bash-session",
"empty-message-sanitizer",
"thinking-block-validator",
"ralph-loop"
"ralph-loop",
"preemptive-compaction",
"compaction-context-injector",
"claude-code-hooks",
"auto-slash-command"
]
}
},

187
bun.lock
View File

@@ -9,11 +9,13 @@
"@ast-grep/napi": "^0.40.0",
"@clack/prompts": "^0.11.0",
"@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",
"commander": "^14.0.2",
"hono": "^4.10.4",
"js-yaml": "^4.1.1",
"jsonc-parser": "^3.3.1",
"picocolors": "^1.1.1",
"picomatch": "^4.0.2",
@@ -21,6 +23,7 @@
"zod": "^4.1.8",
},
"devDependencies": {
"@types/js-yaml": "^4.0.9",
"@types/picomatch": "^3.0.2",
"bun-types": "latest",
"typescript": "^5.7.3",
@@ -75,6 +78,10 @@
"@code-yeongyu/comment-checker": ["@code-yeongyu/comment-checker@0.6.1", "", { "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "comment-checker": "bin/comment-checker" } }, "sha512-BBremX+Y5aW8sTzlhHrLsKParupYkPOVUYmq9STrlWvBvfAme6w5IWuZCLl6nHIQScRDdvGdrAjPycJC86EZFA=="],
"@hono/node-server": ["@hono/node-server@1.19.7", "", { "peerDependencies": { "hono": "^4" } }, "sha512-vUcD0uauS7EU2caukW8z5lJKtoGMokxNbJtBiwHgpqxEXokaHCBkQUmCHhjFB1VUTWdqj25QoMkMKzgjq+uhrw=="],
"@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.25.1", "", { "dependencies": { "@hono/node-server": "^1.19.7", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.0.1", "express-rate-limit": "^7.5.0", "jose": "^6.1.1", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.0" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-yO28oVFFC7EBoiKdAn+VqRm+plcfv4v0xp6osG/VsCB0NlPZWi87ajbCZZ8f/RvOFLEu7//rSRmuZZ7lMoe3gQ=="],
"@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=="],
@@ -93,40 +100,218 @@
"@standard-schema/spec": ["@standard-schema/spec@1.0.0-beta.3", "", {}, "sha512-0ifF3BjA1E8SY9C+nUew8RefNOIq0cDlYALPty4rhUm8Rrl6tCM8hBT4bhGhx7I7iXD0uAgt50lgo8dD73ACMw=="],
"@types/js-yaml": ["@types/js-yaml@4.0.9", "", {}, "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg=="],
"@types/node": ["@types/node@24.10.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="],
"@types/picomatch": ["@types/picomatch@3.0.2", "", {}, "sha512-n0i8TD3UDB7paoMMxA3Y65vUncFJXjcUf7lQY7YyKGl6031FNjfsLs6pdLFCy2GNFxItPJG8GvvpbZc2skH7WA=="],
"accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
"ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="],
"ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="],
"arctic": ["arctic@2.3.4", "", { "dependencies": { "@oslojs/crypto": "1.0.1", "@oslojs/encoding": "1.1.0", "@oslojs/jwt": "0.2.0" } }, "sha512-+p30BOWsctZp+CVYCt7oAean/hWGW42sH5LAcRQX56ttEkFJWbzXBhmSpibbzwSJkRrotmsA+oAoJoVsU0f5xA=="],
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"aws4fetch": ["aws4fetch@1.0.20", "", {}, "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g=="],
"body-parser": ["body-parser@2.2.1", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.0", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw=="],
"bun-types": ["bun-types@1.3.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-z3Xwlg7j2l9JY27x5Qn3Wlyos8YAp0kKRlrePAOjgjMGS5IG6E7Jnlx736vH9UVI4wUICwwhC9anYL++XeOgTQ=="],
"bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="],
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
"call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="],
"commander": ["commander@14.0.2", "", {}, "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ=="],
"content-disposition": ["content-disposition@1.0.1", "", {}, "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q=="],
"content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="],
"cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="],
"cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="],
"cors": ["cors@2.8.5", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="],
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
"encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="],
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
"escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="],
"etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="],
"eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="],
"eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="],
"express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="],
"express-rate-limit": ["express-rate-limit@7.5.1", "", { "peerDependencies": { "express": ">= 4.11" } }, "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="],
"finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="],
"forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="],
"fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"hono": ["hono@4.10.8", "", {}, "sha512-DDT0A0r6wzhe8zCGoYOmMeuGu3dyTAE40HHjwUsWFTEy5WxK1x2WDSsBPlEXgPbRIFY6miDualuUDbasPogIww=="],
"jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="],
"http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="],
"iconv-lite": ["iconv-lite@0.7.1", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-2Tth85cXwGFHfvRgZWszZSvdo+0Xsqmw8k8ZwxScfcBneNUraK+dxRxRm24nszx80Y0TVio8kKLt5sLE7ZCLlw=="],
"inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="],
"ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="],
"is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="],
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="],
"js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
"json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="],
"json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="],
"jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="],
"merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="],
"mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
"mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
"object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="],
"object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="],
"on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="],
"once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="],
"parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"path-to-regexp": ["path-to-regexp@8.3.0", "", {}, "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="],
"pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="],
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
"qs": ["qs@6.14.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ=="],
"range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="],
"raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="],
"require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="],
"router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="],
"safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="],
"send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="],
"serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="],
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
"side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="],
"side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="],
"side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="],
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
"sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="],
"statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="],
"toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="],
"type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
"unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="],
"vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="],
"xdg-basedir": ["xdg-basedir@5.1.0", "", {}, "sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ=="],
"zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="],
"zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="],
"@openauthjs/openauth/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="],
"@oslojs/jwt/@oslojs/encoding": ["@oslojs/encoding@0.4.1", "", {}, "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q=="],
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode",
"version": "2.8.3",
"version": "2.12.0",
"description": "OpenCode plugin - custom agents (oracle, librarian) and enhanced features",
"main": "dist/index.js",
"types": "dist/index.d.ts",
@@ -54,11 +54,13 @@
"@ast-grep/napi": "^0.40.0",
"@clack/prompts": "^0.11.0",
"@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",
"commander": "^14.0.2",
"hono": "^4.10.4",
"js-yaml": "^4.1.1",
"jsonc-parser": "^3.3.1",
"picocolors": "^1.1.1",
"picomatch": "^4.0.2",
@@ -66,6 +68,7 @@
"zod": "^4.1.8"
},
"devDependencies": {
"@types/js-yaml": "^4.0.9",
"@types/picomatch": "^3.0.2",
"bun-types": "latest",
"typescript": "^5.7.3"

View File

@@ -103,6 +103,46 @@
"created_at": "2025-12-30T12:04:59Z",
"repoId": 1108837393,
"pullRequestNo": 349
},
{
"name": "junhoyeo",
"id": 32605822,
"comment_id": 3701585491,
"created_at": "2025-12-31T07:00:36Z",
"repoId": 1108837393,
"pullRequestNo": 375
},
{
"name": "gtg7784",
"id": 32065632,
"comment_id": 3701688739,
"created_at": "2025-12-31T08:05:25Z",
"repoId": 1108837393,
"pullRequestNo": 377
},
{
"name": "ul8",
"id": 589744,
"comment_id": 3701705644,
"created_at": "2025-12-31T08:16:46Z",
"repoId": 1108837393,
"pullRequestNo": 378
},
{
"name": "eudresfs",
"id": 66638312,
"comment_id": 3702622517,
"created_at": "2025-12-31T18:03:32Z",
"repoId": 1108837393,
"pullRequestNo": 385
},
{
"name": "vsumner",
"id": 308886,
"comment_id": 3702872360,
"created_at": "2025-12-31T20:40:20Z",
"repoId": 1108837393,
"pullRequestNo": 388
}
]
}

View File

@@ -2,19 +2,20 @@
## OVERVIEW
AI agent definitions for multi-model orchestration. 7 specialized agents: Sisyphus (orchestrator), oracle (strategy), librarian (research), explore (grep), frontend-ui-ux-engineer, document-writer, multimodal-looker.
7 AI agents for multi-model orchestration. Sisyphus orchestrates, specialists handle domains.
## STRUCTURE
```
agents/
├── sisyphus.ts # Primary orchestrator (Claude Opus 4.5)
├── oracle.ts # Strategic advisor (GPT-5.2)
├── librarian.ts # Multi-repo research (Claude Sonnet 4.5)
├── explore.ts # Fast codebase grep (Grok Code)
├── frontend-ui-ux-engineer.ts # UI generation (Gemini 3 Pro)
├── document-writer.ts # Technical docs (Gemini 3 Flash)
├── multimodal-looker.ts # PDF/image analysis (Gemini 3 Flash)
├── sisyphus.ts # Primary orchestrator (504 lines)
├── oracle.ts # Strategic advisor
├── librarian.ts # Multi-repo research
├── explore.ts # Fast codebase grep
├── frontend-ui-ux-engineer.ts # UI generation
├── document-writer.ts # Technical docs
├── multimodal-looker.ts # PDF/image analysis
├── sisyphus-prompt-builder.ts # Sisyphus prompt construction
├── build-prompt.ts # Shared build agent prompt
├── plan-prompt.ts # Shared plan agent prompt
├── types.ts # AgentModelConfig interface
@@ -24,66 +25,40 @@ agents/
## AGENT MODELS
| Agent | Default Model | Fallback | Purpose |
|-------|---------------|----------|---------|
| Sisyphus | anthropic/claude-opus-4-5 | - | Primary orchestrator with extended thinking |
| oracle | openai/gpt-5.2 | - | Architecture, debugging, code review |
| librarian | anthropic/claude-sonnet-4-5 | google/gemini-3-flash | Docs, OSS research, GitHub examples |
| explore | opencode/grok-code | google/gemini-3-flash, anthropic/claude-haiku-4-5 | Fast contextual grep |
| frontend-ui-ux-engineer | google/gemini-3-pro-preview | - | UI/UX code generation |
| Agent | Model | Fallback | Purpose |
|-------|-------|----------|---------|
| Sisyphus | anthropic/claude-opus-4-5 | - | Orchestrator with extended thinking |
| oracle | openai/gpt-5.2 | - | Architecture, debugging, review |
| librarian | anthropic/claude-sonnet-4-5 | google/gemini-3-flash | Docs, GitHub research |
| explore | opencode/grok-code | gemini-3-flash, haiku-4-5 | Contextual grep |
| frontend-ui-ux-engineer | google/gemini-3-pro-preview | - | Beautiful UI code |
| document-writer | google/gemini-3-pro-preview | - | Technical writing |
| multimodal-looker | google/gemini-3-flash | - | PDF/image analysis |
| multimodal-looker | google/gemini-3-flash | - | Visual analysis |
## HOW TO ADD AN AGENT
## HOW TO ADD
1. Create `src/agents/my-agent.ts`:
```typescript
import type { AgentConfig } from "@opencode-ai/sdk"
export const myAgent: AgentConfig = {
model: "provider/model-name",
temperature: 0.1,
system: "Agent system prompt...",
tools: { include: ["tool1", "tool2"] }, // or exclude: [...]
system: "...",
tools: { include: ["tool1"] },
}
```
2. Add to `builtinAgents` in `src/agents/index.ts`
3. Update `types.ts` if adding new config options
2. Add to `builtinAgents` in index.ts
3. Update types.ts if new config options
## AGENT CONFIG OPTIONS
## MODEL FALLBACK
| Option | Type | Description |
|--------|------|-------------|
| model | string | Model identifier (provider/model-name) |
| temperature | number | 0.0-1.0, most use 0.1 for consistency |
| system | string | System prompt (can be multiline template literal) |
| tools | object | `{ include: [...] }` or `{ exclude: [...] }` |
| top_p | number | Optional nucleus sampling |
| maxTokens | number | Optional max output tokens |
`createBuiltinAgents()` handles fallback:
1. User config override
2. Installer settings (claude max20, gemini antigravity)
3. Default model
## MODEL FALLBACK LOGIC
## ANTI-PATTERNS
`createBuiltinAgents()` in utils.ts handles model fallback:
1. Check user config override (`agents.{name}.model`)
2. Check installer settings (claude max20, gemini antigravity)
3. Use default model
**Fallback order for explore**:
- If gemini antigravity enabled → `google/gemini-3-flash`
- If claude max20 enabled → `anthropic/claude-haiku-4-5`
- Default → `opencode/grok-code` (free)
## ANTI-PATTERNS (AGENTS)
- **High temperature**: Don't use >0.3 for code-related agents
- **Broad tool access**: Prefer explicit `include` over unrestricted access
- **Monolithic prompts**: Keep prompts focused; delegate to specialized agents
- **Missing fallbacks**: Consider free/cheap fallbacks for rate-limited models
## SHARED PROMPTS
- **build-prompt.ts**: Base prompt for build agents (OpenCode default + Sisyphus variants)
- **plan-prompt.ts**: Base prompt for plan agents (Planner-Sisyphus)
Used by `src/index.ts` when creating Builder-Sisyphus and Planner-Sisyphus variants.
- High temperature (>0.3) for code agents
- Broad tool access (prefer explicit `include`)
- Monolithic prompts (delegate to specialists)
- Missing fallbacks for rate-limited models

View File

@@ -1,7 +1,17 @@
import type { AgentConfig } from "@opencode-ai/sdk"
import type { AgentPromptMetadata } from "./types"
const DEFAULT_MODEL = "google/gemini-3-flash-preview"
export const DOCUMENT_WRITER_PROMPT_METADATA: AgentPromptMetadata = {
category: "specialist",
cost: "CHEAP",
promptAlias: "Document Writer",
triggers: [
{ domain: "Documentation", trigger: "README, API docs, guides" },
],
}
export function createDocumentWriterAgent(
model: string = DEFAULT_MODEL
): AgentConfig {

View File

@@ -1,7 +1,28 @@
import type { AgentConfig } from "@opencode-ai/sdk"
import type { AgentPromptMetadata } from "./types"
const DEFAULT_MODEL = "opencode/grok-code"
export const EXPLORE_PROMPT_METADATA: AgentPromptMetadata = {
category: "exploration",
cost: "FREE",
promptAlias: "Explore",
keyTrigger: "2+ modules involved → fire `explore` background",
triggers: [
{ domain: "Explore", trigger: "Find existing codebase structure, patterns and styles" },
],
useWhen: [
"Multiple search angles needed",
"Unfamiliar module structure",
"Cross-layer pattern discovery",
],
avoidWhen: [
"You know exactly what to search",
"Single keyword/pattern suffices",
"Known file location",
],
}
export function createExploreAgent(model: string = DEFAULT_MODEL): AgentConfig {
return {
description:
@@ -87,18 +108,8 @@ Use the right tool for the job:
- **Text patterns** (strings, comments, logs): grep
- **File patterns** (find by name/extension): glob
- **History/evolution** (when added, who changed): git commands
- **External examples** (how others implement): grep_app
### grep_app Strategy
grep_app searches millions of public GitHub repos instantly — use it for external patterns and examples.
**Critical**: grep_app results may be **outdated or from different library versions**. Always:
1. Start with grep_app for broad discovery
2. Launch multiple grep_app calls with query variations in parallel
3. **Cross-validate with local tools** (grep, ast_grep_search, LSP) before trusting results
Flood with parallel calls. Trust only cross-validated results.`,
Flood with parallel calls. Cross-validate findings across multiple tools.`,
}
}

View File

@@ -1,7 +1,23 @@
import type { AgentConfig } from "@opencode-ai/sdk"
import type { AgentPromptMetadata } from "./types"
const DEFAULT_MODEL = "google/gemini-3-pro-preview"
export const FRONTEND_PROMPT_METADATA: AgentPromptMetadata = {
category: "specialist",
cost: "CHEAP",
promptAlias: "Frontend UI/UX Engineer",
triggers: [
{ domain: "Frontend UI/UX", trigger: "Visual changes only (styling, layout, animation). Pure logic changes in frontend files → handle directly" },
],
useWhen: [
"Visual/UI/UX changes: Color, spacing, layout, typography, animation, responsive breakpoints, hover states, shadows, borders, icons, images",
],
avoidWhen: [
"Pure logic: API calls, data fetching, state management, event handlers (non-visual), type definitions, utility functions, business logic",
],
}
export function createFrontendUiUxEngineerAgent(
model: string = DEFAULT_MODEL
): AgentConfig {

View File

@@ -19,3 +19,4 @@ export const builtinAgents: Record<string, AgentConfig> = {
export * from "./types"
export { createBuiltinAgents } from "./utils"
export type { AvailableAgent } from "./sisyphus-prompt-builder"

View File

@@ -1,7 +1,25 @@
import type { AgentConfig } from "@opencode-ai/sdk"
import type { AgentPromptMetadata } from "./types"
const DEFAULT_MODEL = "anthropic/claude-sonnet-4-5"
export const LIBRARIAN_PROMPT_METADATA: AgentPromptMetadata = {
category: "exploration",
cost: "CHEAP",
promptAlias: "Librarian",
keyTrigger: "External library/source mentioned → fire `librarian` background",
triggers: [
{ domain: "Librarian", trigger: "Unfamiliar packages / libraries, struggles at weird behaviour (to find existing implementation of opensource)" },
],
useWhen: [
"How do I use [library]?",
"What's the best practice for [framework feature]?",
"Why does [external dependency] behave this way?",
"Find examples of [library] usage",
"Working with unfamiliar npm/pip/cargo packages",
],
}
export function createLibrarianAgent(model: string = DEFAULT_MODEL): AgentConfig {
return {
description:

View File

@@ -1,7 +1,15 @@
import type { AgentConfig } from "@opencode-ai/sdk"
import type { AgentPromptMetadata } from "./types"
const DEFAULT_MODEL = "google/gemini-3-flash"
export const MULTIMODAL_LOOKER_PROMPT_METADATA: AgentPromptMetadata = {
category: "utility",
cost: "CHEAP",
promptAlias: "Multimodal Looker",
triggers: [],
}
export function createMultimodalLookerAgent(
model: string = DEFAULT_MODEL
): AgentConfig {

View File

@@ -1,8 +1,35 @@
import type { AgentConfig } from "@opencode-ai/sdk"
import type { AgentPromptMetadata } from "./types"
import { isGptModel } from "./types"
const DEFAULT_MODEL = "openai/gpt-5.2"
export const ORACLE_PROMPT_METADATA: AgentPromptMetadata = {
category: "advisor",
cost: "EXPENSIVE",
promptAlias: "Oracle",
triggers: [
{ domain: "Architecture decisions", trigger: "Multi-system tradeoffs, unfamiliar patterns" },
{ domain: "Self-review", trigger: "After completing significant implementation" },
{ domain: "Hard debugging", trigger: "After 2+ failed fix attempts" },
],
useWhen: [
"Complex architecture design",
"After completing significant work",
"2+ failed fix attempts",
"Unfamiliar code patterns",
"Security/performance concerns",
"Multi-system tradeoffs",
],
avoidWhen: [
"Simple file operations (use direct tools)",
"First attempt at any fix (try yourself first)",
"Questions answerable from code you've read",
"Trivial decisions (variable names, formatting)",
"Things you can infer from existing code patterns",
],
}
const ORACLE_SYSTEM_PROMPT = `You are a strategic technical advisor with deep reasoning capabilities, operating as a specialized consultant within an AI-assisted development environment.
## Context

View File

@@ -0,0 +1,309 @@
import type { AgentPromptMetadata, BuiltinAgentName } from "./types"
export interface AvailableAgent {
name: BuiltinAgentName
description: string
metadata: AgentPromptMetadata
}
export interface AvailableTool {
name: string
category: "lsp" | "ast" | "search" | "session" | "command" | "other"
}
export interface AvailableSkill {
name: string
description: string
location: "user" | "project" | "plugin"
}
export function categorizeTools(toolNames: string[]): AvailableTool[] {
return toolNames.map((name) => {
let category: AvailableTool["category"] = "other"
if (name.startsWith("lsp_")) {
category = "lsp"
} else if (name.startsWith("ast_grep")) {
category = "ast"
} else if (name === "grep" || name === "glob") {
category = "search"
} else if (name.startsWith("session_")) {
category = "session"
} else if (name === "slashcommand") {
category = "command"
}
return { name, category }
})
}
function formatToolsForPrompt(tools: AvailableTool[]): string {
const lspTools = tools.filter((t) => t.category === "lsp")
const astTools = tools.filter((t) => t.category === "ast")
const searchTools = tools.filter((t) => t.category === "search")
const parts: string[] = []
if (searchTools.length > 0) {
parts.push(...searchTools.map((t) => `\`${t.name}\``))
}
if (lspTools.length > 0) {
parts.push("`lsp_*`")
}
if (astTools.length > 0) {
parts.push("`ast_grep`")
}
return parts.join(", ")
}
export function buildKeyTriggersSection(agents: AvailableAgent[], skills: AvailableSkill[] = []): string {
const keyTriggers = agents
.filter((a) => a.metadata.keyTrigger)
.map((a) => `- ${a.metadata.keyTrigger}`)
const skillTriggers = skills
.filter((s) => s.description)
.map((s) => `- **Skill \`${s.name}\`**: ${extractTriggerFromDescription(s.description)}`)
const allTriggers = [...keyTriggers, ...skillTriggers]
if (allTriggers.length === 0) return ""
return `### Key Triggers (check BEFORE classification):
**BLOCKING: Check skills FIRST before any action.**
If a skill matches, invoke it IMMEDIATELY via \`skill\` tool.
${allTriggers.join("\n")}
- **GitHub mention (@mention in issue/PR)** → This is a WORK REQUEST. Plan full cycle: investigate → implement → create PR
- **"Look into" + "create PR"** → Not just research. Full implementation cycle expected.`
}
function extractTriggerFromDescription(description: string): string {
const triggerMatch = description.match(/Trigger[s]?[:\s]+([^.]+)/i)
if (triggerMatch) return triggerMatch[1].trim()
const activateMatch = description.match(/Activate when[:\s]+([^.]+)/i)
if (activateMatch) return activateMatch[1].trim()
const useWhenMatch = description.match(/Use (?:this )?when[:\s]+([^.]+)/i)
if (useWhenMatch) return useWhenMatch[1].trim()
return description.split(".")[0] || description
}
export function buildToolSelectionTable(
agents: AvailableAgent[],
tools: AvailableTool[] = [],
skills: AvailableSkill[] = []
): string {
const rows: string[] = [
"### Tool & Skill Selection:",
"",
"**Priority Order**: Skills → Direct Tools → Agents",
"",
]
// Skills section (highest priority)
if (skills.length > 0) {
rows.push("#### Skills (INVOKE FIRST if matching)")
rows.push("")
rows.push("| Skill | When to Use |")
rows.push("|-------|-------------|")
for (const skill of skills) {
const shortDesc = extractTriggerFromDescription(skill.description)
rows.push(`| \`${skill.name}\` | ${shortDesc} |`)
}
rows.push("")
}
// Tools and Agents table
rows.push("#### Tools & Agents")
rows.push("")
rows.push("| Resource | Cost | When to Use |")
rows.push("|----------|------|-------------|")
if (tools.length > 0) {
const toolsDisplay = formatToolsForPrompt(tools)
rows.push(`| ${toolsDisplay} | FREE | Not Complex, Scope Clear, No Implicit Assumptions |`)
}
const costOrder = { FREE: 0, CHEAP: 1, EXPENSIVE: 2 }
const sortedAgents = [...agents]
.filter((a) => a.metadata.category !== "utility")
.sort((a, b) => costOrder[a.metadata.cost] - costOrder[b.metadata.cost])
for (const agent of sortedAgents) {
const shortDesc = agent.description.split(".")[0] || agent.description
rows.push(`| \`${agent.name}\` agent | ${agent.metadata.cost} | ${shortDesc} |`)
}
rows.push("")
rows.push("**Default flow**: skill (if match) → explore/librarian (background) + tools → oracle (if required)")
return rows.join("\n")
}
export function buildExploreSection(agents: AvailableAgent[]): string {
const exploreAgent = agents.find((a) => a.name === "explore")
if (!exploreAgent) return ""
const useWhen = exploreAgent.metadata.useWhen || []
const avoidWhen = exploreAgent.metadata.avoidWhen || []
return `### Explore Agent = Contextual Grep
Use it as a **peer tool**, not a fallback. Fire liberally.
| Use Direct Tools | Use Explore Agent |
|------------------|-------------------|
${avoidWhen.map((w) => `| ${w} | |`).join("\n")}
${useWhen.map((w) => `| | ${w} |`).join("\n")}`
}
export function buildLibrarianSection(agents: AvailableAgent[]): string {
const librarianAgent = agents.find((a) => a.name === "librarian")
if (!librarianAgent) return ""
const useWhen = librarianAgent.metadata.useWhen || []
return `### Librarian Agent = Reference Grep
Search **external references** (docs, OSS, web). Fire proactively when unfamiliar libraries are involved.
| Contextual Grep (Internal) | Reference Grep (External) |
|----------------------------|---------------------------|
| Search OUR codebase | Search EXTERNAL resources |
| Find patterns in THIS repo | Find examples in OTHER repos |
| How does our code work? | How does this library work? |
| Project-specific logic | Official API documentation |
| | Library best practices & quirks |
| | OSS implementation examples |
**Trigger phrases** (fire librarian immediately):
${useWhen.map((w) => `- "${w}"`).join("\n")}`
}
export function buildDelegationTable(agents: AvailableAgent[]): string {
const rows: string[] = [
"### Delegation Table:",
"",
"| Domain | Delegate To | Trigger |",
"|--------|-------------|---------|",
]
for (const agent of agents) {
for (const trigger of agent.metadata.triggers) {
rows.push(`| ${trigger.domain} | \`${agent.name}\` | ${trigger.trigger} |`)
}
}
return rows.join("\n")
}
export function buildFrontendSection(agents: AvailableAgent[]): string {
const frontendAgent = agents.find((a) => a.name === "frontend-ui-ux-engineer")
if (!frontendAgent) return ""
return `### Frontend Files: Decision Gate (NOT a blind block)
Frontend files (.tsx, .jsx, .vue, .svelte, .css, etc.) require **classification before action**.
#### Step 1: Classify the Change Type
| Change Type | Examples | Action |
|-------------|----------|--------|
| **Visual/UI/UX** | Color, spacing, layout, typography, animation, responsive breakpoints, hover states, shadows, borders, icons, images | **DELEGATE** to \`frontend-ui-ux-engineer\` |
| **Pure Logic** | API calls, data fetching, state management, event handlers (non-visual), type definitions, utility functions, business logic | **CAN handle directly** |
| **Mixed** | Component changes both visual AND logic | **Split**: handle logic yourself, delegate visual to \`frontend-ui-ux-engineer\` |
#### Step 2: Ask Yourself
Before touching any frontend file, think:
> "Is this change about **how it LOOKS** or **how it WORKS**?"
- **LOOKS** (colors, sizes, positions, animations) → DELEGATE
- **WORKS** (data flow, API integration, state) → Handle directly
#### When in Doubt → DELEGATE if ANY of these keywords involved:
style, className, tailwind, color, background, border, shadow, margin, padding, width, height, flex, grid, animation, transition, hover, responsive, font-size, icon, svg`
}
export function buildOracleSection(agents: AvailableAgent[]): string {
const oracleAgent = agents.find((a) => a.name === "oracle")
if (!oracleAgent) return ""
const useWhen = oracleAgent.metadata.useWhen || []
const avoidWhen = oracleAgent.metadata.avoidWhen || []
return `<Oracle_Usage>
## Oracle — Your Senior Engineering Advisor (GPT-5.2)
Oracle is an expensive, high-quality reasoning model. Use it wisely.
### WHEN to Consult:
| Trigger | Action |
|---------|--------|
${useWhen.map((w) => `| ${w} | Oracle FIRST, then implement |`).join("\n")}
### WHEN NOT to Consult:
${avoidWhen.map((w) => `- ${w}`).join("\n")}
### Usage Pattern:
Briefly announce "Consulting Oracle for [reason]" before invocation.
**Exception**: This is the ONLY case where you announce before acting. For all other work, start immediately without status updates.
</Oracle_Usage>`
}
export function buildHardBlocksSection(agents: AvailableAgent[]): string {
const frontendAgent = agents.find((a) => a.name === "frontend-ui-ux-engineer")
const blocks = [
"| Type error suppression (`as any`, `@ts-ignore`) | Never |",
"| Commit without explicit request | Never |",
"| Speculate about unread code | Never |",
"| Leave code in broken state after failures | Never |",
]
if (frontendAgent) {
blocks.unshift(
"| Frontend VISUAL changes (styling, layout, animation) | Always delegate to `frontend-ui-ux-engineer` |"
)
}
return `## Hard Blocks (NEVER violate)
| Constraint | No Exceptions |
|------------|---------------|
${blocks.join("\n")}`
}
export function buildAntiPatternsSection(agents: AvailableAgent[]): string {
const frontendAgent = agents.find((a) => a.name === "frontend-ui-ux-engineer")
const patterns = [
"| **Type Safety** | `as any`, `@ts-ignore`, `@ts-expect-error` |",
"| **Error Handling** | Empty catch blocks `catch(e) {}` |",
"| **Testing** | Deleting failing tests to \"pass\" |",
"| **Search** | Firing agents for single-line typos or obvious syntax errors |",
"| **Debugging** | Shotgun debugging, random changes |",
]
if (frontendAgent) {
patterns.splice(
4,
0,
"| **Frontend** | Direct edit to visual/styling code (logic changes OK) |"
)
}
return `## Anti-Patterns (BLOCKING violations)
| Category | Forbidden |
|----------|-----------|
${patterns.join("\n")}`
}

View File

@@ -1,9 +1,22 @@
import type { AgentConfig } from "@opencode-ai/sdk"
import { isGptModel } from "./types"
import type { AvailableAgent, AvailableTool, AvailableSkill } from "./sisyphus-prompt-builder"
import {
buildKeyTriggersSection,
buildToolSelectionTable,
buildExploreSection,
buildLibrarianSection,
buildDelegationTable,
buildFrontendSection,
buildOracleSection,
buildHardBlocksSection,
buildAntiPatternsSection,
categorizeTools,
} from "./sisyphus-prompt-builder"
const DEFAULT_MODEL = "anthropic/claude-opus-4-5"
const SISYPHUS_SYSTEM_PROMPT = `<Role>
const SISYPHUS_ROLE_SECTION = `<Role>
You are "Sisyphus" - Powerful AI Agent with orchestration capabilities from OhMyOpenCode.
Named by [YeonGyu Kim](https://github.com/code-yeongyu).
@@ -21,22 +34,27 @@ Named by [YeonGyu Kim](https://github.com/code-yeongyu).
**Operating Mode**: You NEVER work alone when specialists are available. Frontend work → delegate. Deep research → parallel background agents (async subagents). Complex architecture → consult Oracle.
</Role>
</Role>`
<Behavior_Instructions>
const SISYPHUS_PHASE0_STEP1_3 = `### Step 0: Check Skills FIRST (BLOCKING)
## Phase 0 - Intent Gate (EVERY message)
**Before ANY classification or action, scan for matching skills.**
### Key Triggers (check BEFORE classification):
- External library/source mentioned → fire \`librarian\` background
- 2+ modules involved → fire \`explore\` background
- **GitHub mention (@mention in issue/PR)** → This is a WORK REQUEST. Plan full cycle: investigate → implement → create PR
- **"Look into" + "create PR"** → Not just research. Full implementation cycle expected.
\`\`\`
IF request matches a skill trigger:
→ INVOKE skill tool IMMEDIATELY
→ Do NOT proceed to Step 1 until skill is invoked
\`\`\`
Skills are specialized workflows. When relevant, they handle the task better than manual orchestration.
---
### Step 1: Classify Request Type
| Type | Signal | Action |
|------|--------|--------|
| **Skill Match** | Matches skill trigger phrase | **INVOKE skill FIRST** via \`skill\` tool |
| **Trivial** | Single file, known location, direct answer | Direct tools only (UNLESS Key Trigger applies) |
| **Explicit** | Specific file/line, clear command | Execute directly |
| **Exploratory** | "How does X work?", "Find Y" | Fire explore (1-3) + tools in parallel |
@@ -78,11 +96,9 @@ Then: Raise your concern concisely. Propose an alternative. Ask if they want to
I notice [observation]. This might cause [problem] because [reason].
Alternative: [your suggestion].
Should I proceed with your original request, or try the alternative?
\`\`\`
\`\`\``
---
## Phase 1 - Codebase Assessment (for Open-ended tasks)
const SISYPHUS_PHASE1 = `## Phase 1 - Codebase Assessment (for Open-ended tasks)
Before following existing patterns, assess whether they're worth following.
@@ -103,54 +119,9 @@ Before following existing patterns, assess whether they're worth following.
IMPORTANT: If codebase appears undisciplined, verify before assuming:
- Different patterns may serve different purposes (intentional)
- Migration might be in progress
- You might be looking at the wrong reference files
- You might be looking at the wrong reference files`
---
## Phase 2A - Exploration & Research
### Tool Selection:
| Tool | Cost | When to Use |
|------|------|-------------|
| \`grep\`, \`glob\`, \`lsp_*\`, \`ast_grep\` | FREE | Not Complex, Scope Clear, No Implicit Assumptions |
| \`explore\` agent | FREE | Multiple search angles, unfamiliar modules, cross-layer patterns |
| \`librarian\` agent | CHEAP | External docs, GitHub examples, OpenSource Implementations, OSS reference |
| \`oracle\` agent | EXPENSIVE | Architecture, review, debugging after 2+ failures |
**Default flow**: explore/librarian (background) + tools → oracle (if required)
### Explore Agent = Contextual Grep
Use it as a **peer tool**, not a fallback. Fire liberally.
| Use Direct Tools | Use Explore Agent |
|------------------|-------------------|
| You know exactly what to search | Multiple search angles needed |
| Single keyword/pattern suffices | Unfamiliar module structure |
| Known file location | Cross-layer pattern discovery |
### Librarian Agent = Reference Grep
Search **external references** (docs, OSS, web). Fire proactively when unfamiliar libraries are involved.
| Contextual Grep (Internal) | Reference Grep (External) |
|----------------------------|---------------------------|
| Search OUR codebase | Search EXTERNAL resources |
| Find patterns in THIS repo | Find examples in OTHER repos |
| How does our code work? | How does this library work? |
| Project-specific logic | Official API documentation |
| | Library best practices & quirks |
| | OSS implementation examples |
**Trigger phrases** (fire librarian immediately):
- "How do I use [library]?"
- "What's the best practice for [framework feature]?"
- "Why does [external dependency] behave this way?"
- "Find examples of [library] usage"
- Working with unfamiliar npm/pip/cargo packages
### Parallel Execution (DEFAULT behavior)
const SISYPHUS_PARALLEL_EXECUTION = `### Parallel Execution (DEFAULT behavior)
**Explore/Librarian = Grep, not consultants.
@@ -182,64 +153,16 @@ STOP searching when:
- 2 search iterations yielded no new useful data
- Direct answer found
**DO NOT over-explore. Time is precious.**
**DO NOT over-explore. Time is precious.**`
---
## Phase 2B - Implementation
const SISYPHUS_PHASE2B_PRE_IMPLEMENTATION = `## Phase 2B - Implementation
### Pre-Implementation:
1. If task has 2+ steps → Create todo list IMMEDIATELY, IN SUPER DETAIL. No announcements—just create it.
2. Mark current task \`in_progress\` before starting
3. Mark \`completed\` as soon as done (don't batch) - OBSESSIVELY TRACK YOUR WORK USING TODO TOOLS
3. Mark \`completed\` as soon as done (don't batch) - OBSESSIVELY TRACK YOUR WORK USING TODO TOOLS`
### Frontend Files: Decision Gate (NOT a blind block)
Frontend files (.tsx, .jsx, .vue, .svelte, .css, etc.) require **classification before action**.
#### Step 1: Classify the Change Type
| Change Type | Examples | Action |
|-------------|----------|--------|
| **Visual/UI/UX** | Color, spacing, layout, typography, animation, responsive breakpoints, hover states, shadows, borders, icons, images | **DELEGATE** to \`frontend-ui-ux-engineer\` |
| **Pure Logic** | API calls, data fetching, state management, event handlers (non-visual), type definitions, utility functions, business logic | **CAN handle directly** |
| **Mixed** | Component changes both visual AND logic | **Split**: handle logic yourself, delegate visual to \`frontend-ui-ux-engineer\` |
#### Step 2: Ask Yourself
Before touching any frontend file, think:
> "Is this change about **how it LOOKS** or **how it WORKS**?"
- **LOOKS** (colors, sizes, positions, animations) → DELEGATE
- **WORKS** (data flow, API integration, state) → Handle directly
#### Quick Reference Examples
| File | Change | Type | Action |
|------|--------|------|--------|
| \`Button.tsx\` | Change color blue→green | Visual | DELEGATE |
| \`Button.tsx\` | Add onClick API call | Logic | Direct |
| \`UserList.tsx\` | Add loading spinner animation | Visual | DELEGATE |
| \`UserList.tsx\` | Fix pagination logic bug | Logic | Direct |
| \`Modal.tsx\` | Make responsive for mobile | Visual | DELEGATE |
| \`Modal.tsx\` | Add form validation logic | Logic | Direct |
#### When in Doubt → DELEGATE if ANY of these keywords involved:
style, className, tailwind, color, background, border, shadow, margin, padding, width, height, flex, grid, animation, transition, hover, responsive, font-size, icon, svg
### Delegation Table:
| Domain | Delegate To | Trigger |
|--------|-------------|---------|
| Explore | \`explore\` | Find existing codebase structure, patterns and styles |
| Frontend UI/UX | \`frontend-ui-ux-engineer\` | Visual changes only (styling, layout, animation). Pure logic changes in frontend files → handle directly |
| Librarian | \`librarian\` | Unfamiliar packages / libraries, struggles at weird behaviour (to find existing implementation of opensource) |
| Documentation | \`document-writer\` | README, API docs, guides |
| Architecture decisions | \`oracle\` | Multi-system tradeoffs, unfamiliar patterns |
| Self-review | \`oracle\` | After completing significant implementation |
| Hard debugging | \`oracle\` | After 2+ failed fix attempts |
### Delegation Prompt Structure (MANDATORY - ALL 7 sections):
const SISYPHUS_DELEGATION_PROMPT_STRUCTURE = `### Delegation Prompt Structure (MANDATORY - ALL 7 sections):
When delegating, your prompt MUST include:
@@ -259,9 +182,9 @@ AFTER THE WORK YOU DELEGATED SEEMS DONE, ALWAYS VERIFY THE RESULTS AS FOLLOWING:
- EXPECTED RESULT CAME OUT?
- DID THE AGENT FOLLOWED "MUST DO" AND "MUST NOT DO" REQUIREMENTS?
**Vague prompts = rejected. Be exhaustive.**
**Vague prompts = rejected. Be exhaustive.**`
### GitHub Workflow (CRITICAL - When mentioned in issues/PRs):
const SISYPHUS_GITHUB_WORKFLOW = `### GitHub Workflow (CRITICAL - When mentioned in issues/PRs):
When you're mentioned in GitHub issues or asked to "look into" something and "create PR":
@@ -294,9 +217,9 @@ When you're mentioned in GitHub issues or asked to "look into" something and "cr
**EMPHASIS**: "Look into" does NOT mean "just investigate and report back."
It means "investigate, understand, implement a solution, and create a PR."
**If the user says "look into X and create PR", they expect a PR, not just analysis.**
**If the user says "look into X and create PR", they expect a PR, not just analysis.**`
### Code Changes:
const SISYPHUS_CODE_CHANGES = `### Code Changes:
- Match existing patterns (if codebase is disciplined)
- Propose approach first (if codebase is chaotic)
- Never suppress type errors with \`as any\`, \`@ts-ignore\`, \`@ts-expect-error\`
@@ -322,11 +245,9 @@ If project has build/test commands, run them at task completion.
| Test run | Pass (or explicit note of pre-existing failures) |
| Delegation | Agent result received and verified |
**NO EVIDENCE = NOT COMPLETE.**
**NO EVIDENCE = NOT COMPLETE.**`
---
## Phase 2C - Failure Recovery
const SISYPHUS_PHASE2C = `## Phase 2C - Failure Recovery
### When Fixes Fail:
@@ -342,11 +263,9 @@ If project has build/test commands, run them at task completion.
4. **CONSULT** Oracle with full failure context
5. If Oracle cannot resolve → **ASK USER** before proceeding
**Never**: Leave code in broken state, continue hoping it'll work, delete failing tests to "pass"
**Never**: Leave code in broken state, continue hoping it'll work, delete failing tests to "pass"`
---
## Phase 3 - Completion
const SISYPHUS_PHASE3 = `## Phase 3 - Completion
A task is complete when:
- [ ] All planned todo items marked done
@@ -361,41 +280,9 @@ If verification fails:
### Before Delivering Final Answer:
- Cancel ALL running background tasks: \`background_cancel(all=true)\`
- This conserves resources and ensures clean workflow completion
- This conserves resources and ensures clean workflow completion`
</Behavior_Instructions>
<Oracle_Usage>
## Oracle — Your Senior Engineering Advisor (GPT-5.2)
Oracle is an expensive, high-quality reasoning model. Use it wisely.
### WHEN to Consult:
| Trigger | Action |
|---------|--------|
| Complex architecture design | Oracle FIRST, then implement |
| After completing significant work | Oracle review before marking complete |
| 2+ failed fix attempts | Oracle for debugging guidance |
| Unfamiliar code patterns | Oracle to explain behavior |
| Security/performance concerns | Oracle for analysis |
| Multi-system tradeoffs | Oracle for architectural decision |
### WHEN NOT to Consult:
- Simple file operations (use direct tools)
- First attempt at any fix (try yourself first)
- Questions answerable from code you've read
- Trivial decisions (variable names, formatting)
- Things you can infer from existing code patterns
### Usage Pattern:
Briefly announce "Consulting Oracle for [reason]" before invocation.
**Exception**: This is the ONLY case where you announce before acting. For all other work, start immediately without status updates.
</Oracle_Usage>
<Task_Management>
const SISYPHUS_TASK_MANAGEMENT = `<Task_Management>
## Todo Management (CRITICAL)
**DEFAULT BEHAVIOR**: Create todos BEFORE starting any non-trivial task. This is your PRIMARY coordination mechanism.
@@ -450,9 +337,9 @@ I want to make sure I understand correctly.
Should I proceed with [recommendation], or would you prefer differently?
\`\`\`
</Task_Management>
</Task_Management>`
<Tone_and_Style>
const SISYPHUS_TONE_AND_STYLE = `<Tone_and_Style>
## Communication Style
### Be Concise
@@ -492,31 +379,9 @@ If the user's approach seems problematic:
- If user is terse, be terse
- If user wants detail, provide detail
- Adapt to their communication preference
</Tone_and_Style>
</Tone_and_Style>`
<Constraints>
## Hard Blocks (NEVER violate)
| Constraint | No Exceptions |
|------------|---------------|
| Frontend VISUAL changes (styling, layout, animation) | Always delegate to \`frontend-ui-ux-engineer\` |
| Type error suppression (\`as any\`, \`@ts-ignore\`) | Never |
| Commit without explicit request | Never |
| Speculate about unread code | Never |
| Leave code in broken state after failures | Never |
## Anti-Patterns (BLOCKING violations)
| Category | Forbidden |
|----------|-----------|
| **Type Safety** | \`as any\`, \`@ts-ignore\`, \`@ts-expect-error\` |
| **Error Handling** | Empty catch blocks \`catch(e) {}\` |
| **Testing** | Deleting failing tests to "pass" |
| **Search** | Firing agents for single-line typos or obvious syntax errors |
| **Frontend** | Direct edit to visual/styling code (logic changes OK) |
| **Debugging** | Shotgun debugging, random changes |
## Soft Guidelines
const SISYPHUS_SOFT_GUIDELINES = `## Soft Guidelines
- Prefer existing libraries over new dependencies
- Prefer small, focused changes over large refactors
@@ -525,14 +390,107 @@ If the user's approach seems problematic:
`
export function createSisyphusAgent(model: string = DEFAULT_MODEL): AgentConfig {
function buildDynamicSisyphusPrompt(
availableAgents: AvailableAgent[],
availableTools: AvailableTool[] = [],
availableSkills: AvailableSkill[] = []
): string {
const keyTriggers = buildKeyTriggersSection(availableAgents, availableSkills)
const toolSelection = buildToolSelectionTable(availableAgents, availableTools, availableSkills)
const exploreSection = buildExploreSection(availableAgents)
const librarianSection = buildLibrarianSection(availableAgents)
const frontendSection = buildFrontendSection(availableAgents)
const delegationTable = buildDelegationTable(availableAgents)
const oracleSection = buildOracleSection(availableAgents)
const hardBlocks = buildHardBlocksSection(availableAgents)
const antiPatterns = buildAntiPatternsSection(availableAgents)
const sections = [
SISYPHUS_ROLE_SECTION,
"<Behavior_Instructions>",
"",
"## Phase 0 - Intent Gate (EVERY message)",
"",
keyTriggers,
"",
SISYPHUS_PHASE0_STEP1_3,
"",
"---",
"",
SISYPHUS_PHASE1,
"",
"---",
"",
"## Phase 2A - Exploration & Research",
"",
toolSelection,
"",
exploreSection,
"",
librarianSection,
"",
SISYPHUS_PARALLEL_EXECUTION,
"",
"---",
"",
SISYPHUS_PHASE2B_PRE_IMPLEMENTATION,
"",
frontendSection,
"",
delegationTable,
"",
SISYPHUS_DELEGATION_PROMPT_STRUCTURE,
"",
SISYPHUS_GITHUB_WORKFLOW,
"",
SISYPHUS_CODE_CHANGES,
"",
"---",
"",
SISYPHUS_PHASE2C,
"",
"---",
"",
SISYPHUS_PHASE3,
"",
"</Behavior_Instructions>",
"",
oracleSection,
"",
SISYPHUS_TASK_MANAGEMENT,
"",
SISYPHUS_TONE_AND_STYLE,
"",
"<Constraints>",
hardBlocks,
"",
antiPatterns,
"",
SISYPHUS_SOFT_GUIDELINES,
]
return sections.filter((s) => s !== "").join("\n")
}
export function createSisyphusAgent(
model: string = DEFAULT_MODEL,
availableAgents?: AvailableAgent[],
availableToolNames?: string[],
availableSkills?: AvailableSkill[]
): AgentConfig {
const tools = availableToolNames ? categorizeTools(availableToolNames) : []
const skills = availableSkills ?? []
const prompt = availableAgents
? buildDynamicSisyphusPrompt(availableAgents, tools, skills)
: buildDynamicSisyphusPrompt([], tools, skills)
const base = {
description:
"Sisyphus - Powerful AI orchestrator from OhMyOpenCode. Plans obsessively with todos, assesses search complexity before exploration, delegates strategically to specialized agents. Uses explore for internal code (parallel-friendly), librarian only for external docs, and always delegates UI work to frontend engineer.",
mode: "primary" as const,
model,
maxTokens: 64000,
prompt: SISYPHUS_SYSTEM_PROMPT,
prompt,
color: "#00CED1",
}

View File

@@ -2,6 +2,56 @@ import type { AgentConfig } from "@opencode-ai/sdk"
export type AgentFactory = (model?: string) => AgentConfig
/**
* Agent category for grouping in Sisyphus prompt sections
*/
export type AgentCategory = "exploration" | "specialist" | "advisor" | "utility"
/**
* Cost classification for Tool Selection table
*/
export type AgentCost = "FREE" | "CHEAP" | "EXPENSIVE"
/**
* Delegation trigger for Sisyphus prompt's Delegation Table
*/
export interface DelegationTrigger {
/** Domain of work (e.g., "Frontend UI/UX") */
domain: string
/** When to delegate (e.g., "Visual changes only...") */
trigger: string
}
/**
* Metadata for generating Sisyphus prompt sections dynamically
* This allows adding/removing agents without manually updating the Sisyphus prompt
*/
export interface AgentPromptMetadata {
/** Category for grouping in prompt sections */
category: AgentCategory
/** Cost classification for Tool Selection table */
cost: AgentCost
/** Domain triggers for Delegation Table */
triggers: DelegationTrigger[]
/** When to use this agent (for detailed sections) */
useWhen?: string[]
/** When NOT to use this agent */
avoidWhen?: string[]
/** Optional dedicated prompt section (markdown) - for agents like Oracle that have special sections */
dedicatedSection?: string
/** Nickname/alias used in prompt (e.g., "Oracle" instead of "oracle") */
promptAlias?: string
/** Key triggers that should appear in Phase 0 (e.g., "External library mentioned → fire librarian") */
keyTrigger?: string
}
export function isGptModel(model: string): boolean {
return model.startsWith("openai/") || model.startsWith("github-copilot/gpt-")
}

View File

@@ -1,12 +1,13 @@
import type { AgentConfig } from "@opencode-ai/sdk"
import type { BuiltinAgentName, AgentOverrideConfig, AgentOverrides, AgentFactory } from "./types"
import type { BuiltinAgentName, AgentOverrideConfig, AgentOverrides, AgentFactory, AgentPromptMetadata } from "./types"
import { createSisyphusAgent } from "./sisyphus"
import { createOracleAgent } from "./oracle"
import { createLibrarianAgent } from "./librarian"
import { createExploreAgent } from "./explore"
import { createFrontendUiUxEngineerAgent } from "./frontend-ui-ux-engineer"
import { createDocumentWriterAgent } from "./document-writer"
import { createMultimodalLookerAgent } from "./multimodal-looker"
import { createOracleAgent, ORACLE_PROMPT_METADATA } from "./oracle"
import { createLibrarianAgent, LIBRARIAN_PROMPT_METADATA } from "./librarian"
import { createExploreAgent, EXPLORE_PROMPT_METADATA } from "./explore"
import { createFrontendUiUxEngineerAgent, FRONTEND_PROMPT_METADATA } from "./frontend-ui-ux-engineer"
import { createDocumentWriterAgent, DOCUMENT_WRITER_PROMPT_METADATA } from "./document-writer"
import { createMultimodalLookerAgent, MULTIMODAL_LOOKER_PROMPT_METADATA } from "./multimodal-looker"
import type { AvailableAgent } from "./sisyphus-prompt-builder"
import { deepMerge } from "../shared"
type AgentSource = AgentFactory | AgentConfig
@@ -21,6 +22,19 @@ const agentSources: Record<BuiltinAgentName, AgentSource> = {
"multimodal-looker": createMultimodalLookerAgent,
}
/**
* Metadata for each agent, used to build Sisyphus's dynamic prompt sections
* (Delegation Table, Tool Selection, Key Triggers, etc.)
*/
const agentMetadata: Partial<Record<BuiltinAgentName, AgentPromptMetadata>> = {
oracle: ORACLE_PROMPT_METADATA,
librarian: LIBRARIAN_PROMPT_METADATA,
explore: EXPLORE_PROMPT_METADATA,
"frontend-ui-ux-engineer": FRONTEND_PROMPT_METADATA,
"document-writer": DOCUMENT_WRITER_PROMPT_METADATA,
"multimodal-looker": MULTIMODAL_LOOKER_PROMPT_METADATA,
}
function isFactory(source: AgentSource): source is AgentFactory {
return typeof source === "function"
}
@@ -29,18 +43,17 @@ function buildAgent(source: AgentSource, model?: string): AgentConfig {
return isFactory(source) ? source(model) : source
}
export function createEnvContext(directory: string): string {
/**
* Creates OmO-specific environment context (time, timezone, locale).
* Note: Working directory, platform, and date are already provided by OpenCode's system.ts,
* so we only include fields that OpenCode doesn't provide to avoid duplication.
* See: https://github.com/code-yeongyu/oh-my-opencode/issues/379
*/
export function createEnvContext(): string {
const now = new Date()
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
const locale = Intl.DateTimeFormat().resolvedOptions().locale
const dateStr = now.toLocaleDateString("en-US", {
weekday: "short",
year: "numeric",
month: "short",
day: "numeric",
})
const timeStr = now.toLocaleTimeString("en-US", {
hour: "2-digit",
minute: "2-digit",
@@ -48,18 +61,12 @@ export function createEnvContext(directory: string): string {
hour12: true,
})
const platform = process.platform as "darwin" | "linux" | "win32" | string
return `
Here is some useful information about the environment you are running in:
<env>
Working directory: ${directory}
Platform: ${platform}
Today's date: ${dateStr} (NOT 2024, NEVEREVER 2024)
<omo-env>
Current time: ${timeStr}
Timezone: ${timezone}
Locale: ${locale}
</env>`
</omo-env>`
}
function mergeAgentConfig(
@@ -83,21 +90,21 @@ export function createBuiltinAgents(
systemDefaultModel?: string
): Record<string, AgentConfig> {
const result: Record<string, AgentConfig> = {}
const availableAgents: AvailableAgent[] = []
for (const [name, source] of Object.entries(agentSources)) {
const agentName = name as BuiltinAgentName
if (disabledAgents.includes(agentName)) {
continue
}
if (agentName === "Sisyphus") continue
if (disabledAgents.includes(agentName)) continue
const override = agentOverrides[agentName]
const model = override?.model ?? (agentName === "Sisyphus" ? systemDefaultModel : undefined)
const model = override?.model
let config = buildAgent(source, model)
if ((agentName === "Sisyphus" || agentName === "librarian") && directory && config.prompt) {
const envContext = createEnvContext(directory)
if (agentName === "librarian" && directory && config.prompt) {
const envContext = createEnvContext()
config = { ...config, prompt: config.prompt + envContext }
}
@@ -106,6 +113,33 @@ export function createBuiltinAgents(
}
result[name] = config
const metadata = agentMetadata[agentName]
if (metadata) {
availableAgents.push({
name: agentName,
description: config.description ?? "",
metadata,
})
}
}
if (!disabledAgents.includes("Sisyphus")) {
const sisyphusOverride = agentOverrides["Sisyphus"]
const sisyphusModel = sisyphusOverride?.model ?? systemDefaultModel
let sisyphusConfig = createSisyphusAgent(sisyphusModel, availableAgents)
if (directory && sisyphusConfig.prompt) {
const envContext = createEnvContext()
sisyphusConfig = { ...sisyphusConfig, prompt: sisyphusConfig.prompt + envContext }
}
if (sisyphusOverride) {
sisyphusConfig = mergeAgentConfig(sisyphusConfig, sisyphusOverride)
}
result["Sisyphus"] = sisyphusConfig
}
return result

57
src/auth/AGENTS.md Normal file
View File

@@ -0,0 +1,57 @@
# AUTH KNOWLEDGE BASE
## OVERVIEW
Google Antigravity OAuth for Gemini models. Token management, fetch interception, thinking block extraction.
## STRUCTURE
```
auth/
└── antigravity/
├── plugin.ts # Main export, hooks registration
├── oauth.ts # OAuth flow, token acquisition
├── token.ts # Token storage, refresh logic
├── fetch.ts # Fetch interceptor (621 lines)
├── response.ts # Response transformation (598 lines)
├── thinking.ts # Thinking block extraction (571 lines)
├── thought-signature-store.ts # Signature caching
├── message-converter.ts # Format conversion
├── request.ts # Request building
├── project.ts # Project ID management
├── tools.ts # OAuth tool registration
├── constants.ts # API endpoints, model mappings
└── types.ts
```
## KEY COMPONENTS
| File | Purpose |
|------|---------|
| fetch.ts | URL rewriting, token injection, retries |
| thinking.ts | Extract `<antThinking>` blocks |
| response.ts | Streaming SSE parsing |
| oauth.ts | Browser-based OAuth flow |
| token.ts | Token persistence, expiry |
## HOW IT WORKS
1. **Intercept**: fetch.ts intercepts Anthropic/Google requests
2. **Rewrite**: URLs → Antigravity proxy endpoints
3. **Auth**: Bearer token from stored OAuth credentials
4. **Response**: Streaming parsed, thinking blocks extracted
5. **Transform**: Normalized for OpenCode
## FEATURES
- Multi-account (up to 10 Google accounts)
- Auto-fallback on rate limit
- Thinking blocks preserved
- Antigravity proxy for AI Studio access
## ANTI-PATTERNS
- Direct API calls (use fetch interceptor)
- Tokens in code (use token.ts storage)
- Ignoring refresh (check expiry first)
- Blocking on OAuth (always async)

68
src/cli/AGENTS.md Normal file
View File

@@ -0,0 +1,68 @@
# CLI KNOWLEDGE BASE
## OVERVIEW
CLI for oh-my-opencode: interactive installer, health diagnostics (doctor), runtime launcher. Entry: `bunx oh-my-opencode`.
## STRUCTURE
```
cli/
├── index.ts # Commander.js entry, subcommand routing
├── install.ts # Interactive TUI installer (477 lines)
├── config-manager.ts # JSONC parsing, env detection (669 lines)
├── types.ts # CLI-specific types
├── doctor/ # Health check system
│ ├── index.ts # Doctor command entry
│ ├── constants.ts # Check categories
│ ├── types.ts # Check result interfaces
│ └── checks/ # 17+ individual checks
├── get-local-version/ # Version detection
└── run/ # OpenCode session launcher
```
## CLI COMMANDS
| Command | Purpose |
|---------|---------|
| `install` | Interactive setup wizard |
| `doctor` | Environment health checks |
| `run` | Launch OpenCode session |
## DOCTOR CHECKS
17+ checks in `doctor/checks/`:
- version.ts (OpenCode >= 1.0.150)
- config.ts (plugin registered)
- bun.ts, node.ts, git.ts
- anthropic-auth.ts, openai-auth.ts, google-auth.ts
- lsp-*.ts, mcp-*.ts
## CONFIG-MANAGER (669 lines)
- JSONC support (comments, trailing commas)
- Multi-source: User (~/.config/opencode/) + Project (.opencode/)
- Zod validation
- Legacy format migration
- Error aggregation for doctor
## HOW TO ADD CHECK
1. Create `src/cli/doctor/checks/my-check.ts`:
```typescript
export const myCheck: DoctorCheck = {
name: "my-check",
category: "environment",
check: async () => {
return { status: "pass" | "warn" | "fail", message: "..." }
}
}
```
2. Add to `src/cli/doctor/checks/index.ts`
## ANTI-PATTERNS
- Blocking prompts in non-TTY (check `process.stdout.isTTY`)
- Hardcoded paths (use shared utilities)
- JSON.parse for user files (use parseJsonc)
- Silent failures in doctor checks

View File

@@ -0,0 +1,106 @@
import { describe, it, expect, spyOn, afterEach } from "bun:test"
import * as gh from "./gh"
describe("gh cli check", () => {
describe("getGhCliInfo", () => {
it("returns gh cli info structure", async () => {
// #given
// #when checking gh cli info
const info = await gh.getGhCliInfo()
// #then should return valid info structure
expect(typeof info.installed).toBe("boolean")
expect(info.authenticated === true || info.authenticated === false).toBe(true)
expect(Array.isArray(info.scopes)).toBe(true)
})
})
describe("checkGhCli", () => {
let getInfoSpy: ReturnType<typeof spyOn>
afterEach(() => {
getInfoSpy?.mockRestore()
})
it("returns warn when gh is not installed", async () => {
// #given gh not installed
getInfoSpy = spyOn(gh, "getGhCliInfo").mockResolvedValue({
installed: false,
version: null,
path: null,
authenticated: false,
username: null,
scopes: [],
error: null,
})
// #when checking
const result = await gh.checkGhCli()
// #then should warn (optional)
expect(result.status).toBe("warn")
expect(result.message).toContain("Not installed")
expect(result.details).toContain("Install: https://cli.github.com/")
})
it("returns warn when gh is installed but not authenticated", async () => {
// #given gh installed but not authenticated
getInfoSpy = spyOn(gh, "getGhCliInfo").mockResolvedValue({
installed: true,
version: "2.40.0",
path: "/usr/local/bin/gh",
authenticated: false,
username: null,
scopes: [],
error: "not logged in",
})
// #when checking
const result = await gh.checkGhCli()
// #then should warn about auth
expect(result.status).toBe("warn")
expect(result.message).toContain("2.40.0")
expect(result.message).toContain("not authenticated")
expect(result.details).toContain("Authenticate: gh auth login")
})
it("returns pass when gh is installed and authenticated", async () => {
// #given gh installed and authenticated
getInfoSpy = spyOn(gh, "getGhCliInfo").mockResolvedValue({
installed: true,
version: "2.40.0",
path: "/usr/local/bin/gh",
authenticated: true,
username: "octocat",
scopes: ["repo", "read:org"],
error: null,
})
// #when checking
const result = await gh.checkGhCli()
// #then should pass
expect(result.status).toBe("pass")
expect(result.message).toContain("2.40.0")
expect(result.message).toContain("octocat")
expect(result.details).toContain("Account: octocat")
expect(result.details).toContain("Scopes: repo, read:org")
})
})
describe("getGhCliCheckDefinition", () => {
it("returns correct check definition", () => {
// #given
// #when getting definition
const def = gh.getGhCliCheckDefinition()
// #then should have correct properties
expect(def.id).toBe("gh-cli")
expect(def.name).toBe("GitHub CLI")
expect(def.category).toBe("tools")
expect(def.critical).toBe(false)
expect(typeof def.check).toBe("function")
})
})
})

171
src/cli/doctor/checks/gh.ts Normal file
View File

@@ -0,0 +1,171 @@
import type { CheckResult, CheckDefinition } from "../types"
import { CHECK_IDS, CHECK_NAMES } from "../constants"
export interface GhCliInfo {
installed: boolean
version: string | null
path: string | null
authenticated: boolean
username: string | null
scopes: string[]
error: string | null
}
async function checkBinaryExists(binary: string): Promise<{ exists: boolean; path: string | null }> {
try {
const proc = Bun.spawn(["which", binary], { stdout: "pipe", stderr: "pipe" })
const output = await new Response(proc.stdout).text()
await proc.exited
if (proc.exitCode === 0) {
return { exists: true, path: output.trim() }
}
} catch {
// intentionally empty - binary not found
}
return { exists: false, path: null }
}
async function getGhVersion(): Promise<string | null> {
try {
const proc = Bun.spawn(["gh", "--version"], { stdout: "pipe", stderr: "pipe" })
const output = await new Response(proc.stdout).text()
await proc.exited
if (proc.exitCode === 0) {
const match = output.match(/gh version (\S+)/)
return match?.[1] ?? output.trim().split("\n")[0]
}
} catch {
// intentionally empty - version unavailable
}
return null
}
async function getGhAuthStatus(): Promise<{
authenticated: boolean
username: string | null
scopes: string[]
error: string | null
}> {
try {
const proc = Bun.spawn(["gh", "auth", "status"], {
stdout: "pipe",
stderr: "pipe",
env: { ...process.env, GH_NO_UPDATE_NOTIFIER: "1" },
})
const stdout = await new Response(proc.stdout).text()
const stderr = await new Response(proc.stderr).text()
await proc.exited
const output = stderr || stdout
if (proc.exitCode === 0) {
const usernameMatch = output.match(/Logged in to github\.com account (\S+)/)
const username = usernameMatch?.[1]?.replace(/[()]/g, "") ?? null
const scopesMatch = output.match(/Token scopes?:\s*(.+)/i)
const scopes = scopesMatch?.[1]
? scopesMatch[1]
.split(/,\s*/)
.map((s) => s.replace(/['"]/g, "").trim())
.filter(Boolean)
: []
return { authenticated: true, username, scopes, error: null }
}
const errorMatch = output.match(/error[:\s]+(.+)/i)
return {
authenticated: false,
username: null,
scopes: [],
error: errorMatch?.[1]?.trim() ?? "Not authenticated",
}
} catch (err) {
return {
authenticated: false,
username: null,
scopes: [],
error: err instanceof Error ? err.message : "Failed to check auth status",
}
}
}
export async function getGhCliInfo(): Promise<GhCliInfo> {
const binaryCheck = await checkBinaryExists("gh")
if (!binaryCheck.exists) {
return {
installed: false,
version: null,
path: null,
authenticated: false,
username: null,
scopes: [],
error: null,
}
}
const [version, authStatus] = await Promise.all([getGhVersion(), getGhAuthStatus()])
return {
installed: true,
version,
path: binaryCheck.path,
authenticated: authStatus.authenticated,
username: authStatus.username,
scopes: authStatus.scopes,
error: authStatus.error,
}
}
export async function checkGhCli(): Promise<CheckResult> {
const info = await getGhCliInfo()
const name = CHECK_NAMES[CHECK_IDS.GH_CLI]
if (!info.installed) {
return {
name,
status: "warn",
message: "Not installed (optional)",
details: [
"GitHub CLI is used by librarian agent and scripts",
"Install: https://cli.github.com/",
],
}
}
if (!info.authenticated) {
return {
name,
status: "warn",
message: `${info.version ?? "installed"} - not authenticated`,
details: [
info.path ? `Path: ${info.path}` : null,
"Authenticate: gh auth login",
info.error ? `Error: ${info.error}` : null,
].filter((d): d is string => d !== null),
}
}
const details: string[] = []
if (info.path) details.push(`Path: ${info.path}`)
if (info.username) details.push(`Account: ${info.username}`)
if (info.scopes.length > 0) details.push(`Scopes: ${info.scopes.join(", ")}`)
return {
name,
status: "pass",
message: `${info.version ?? "installed"} - authenticated as ${info.username ?? "unknown"}`,
details: details.length > 0 ? details : undefined,
}
}
export function getGhCliCheckDefinition(): CheckDefinition {
return {
id: CHECK_IDS.GH_CLI,
name: CHECK_NAMES[CHECK_IDS.GH_CLI],
category: "tools",
check: checkGhCli,
critical: false,
}
}

View File

@@ -4,6 +4,7 @@ import { getPluginCheckDefinition } from "./plugin"
import { getConfigCheckDefinition } from "./config"
import { getAuthCheckDefinitions } from "./auth"
import { getDependencyCheckDefinitions } from "./dependencies"
import { getGhCliCheckDefinition } from "./gh"
import { getLspCheckDefinition } from "./lsp"
import { getMcpCheckDefinitions } from "./mcp"
import { getVersionCheckDefinition } from "./version"
@@ -13,6 +14,7 @@ export * from "./plugin"
export * from "./config"
export * from "./auth"
export * from "./dependencies"
export * from "./gh"
export * from "./lsp"
export * from "./mcp"
export * from "./version"
@@ -24,6 +26,7 @@ export function getAllCheckDefinitions(): CheckDefinition[] {
getConfigCheckDefinition(),
...getAuthCheckDefinitions(),
...getDependencyCheckDefinitions(),
getGhCliCheckDefinition(),
getLspCheckDefinition(),
...getMcpCheckDefinitions(),
getVersionCheckDefinition(),

View File

@@ -1,76 +1,11 @@
import { existsSync, readFileSync } from "node:fs"
import { homedir } from "node:os"
import { join } from "node:path"
import type { CheckResult, CheckDefinition, VersionCheckInfo } 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_PACKAGE_JSON = join(OPENCODE_CONFIG_DIR, "package.json")
const OPENCODE_JSON = join(OPENCODE_CONFIG_DIR, "opencode.json")
const OPENCODE_JSONC = join(OPENCODE_CONFIG_DIR, "opencode.jsonc")
async function fetchLatestVersion(): Promise<string | null> {
try {
const res = await fetch(`https://registry.npmjs.org/${PACKAGE_NAME}/latest`, {
signal: AbortSignal.timeout(5000),
})
if (!res.ok) return null
const data = (await res.json()) as { version: string }
return data.version
} catch {
return null
}
}
function getCurrentVersion(): {
version: string | null
isLocalDev: boolean
isPinned: boolean
pinnedVersion: string | null
} {
const configPath = existsSync(OPENCODE_JSONC) ? OPENCODE_JSONC : OPENCODE_JSON
if (!existsSync(configPath)) {
return { version: null, isLocalDev: false, isPinned: false, pinnedVersion: null }
}
try {
const content = readFileSync(configPath, "utf-8")
const config = parseJsonc<{ plugin?: string[] }>(content)
const plugins = config.plugin ?? []
for (const plugin of plugins) {
if (plugin.startsWith("file:") && plugin.includes(PACKAGE_NAME)) {
return { version: "local-dev", isLocalDev: true, isPinned: false, pinnedVersion: null }
}
if (plugin.startsWith(`${PACKAGE_NAME}@`)) {
const pinnedVersion = plugin.split("@")[1]
return { version: pinnedVersion, isLocalDev: false, isPinned: true, pinnedVersion }
}
if (plugin === PACKAGE_NAME) {
if (existsSync(OPENCODE_PACKAGE_JSON)) {
try {
const pkgContent = readFileSync(OPENCODE_PACKAGE_JSON, "utf-8")
const pkg = JSON.parse(pkgContent) as { dependencies?: Record<string, string> }
const depVersion = pkg.dependencies?.[PACKAGE_NAME]
if (depVersion) {
const cleanVersion = depVersion.replace(/^[\^~]/, "")
return { version: cleanVersion, isLocalDev: false, isPinned: false, pinnedVersion: null }
}
} catch {
// intentionally empty - parse errors ignored
}
}
return { version: null, isLocalDev: false, isPinned: false, pinnedVersion: null }
}
}
return { version: null, isLocalDev: false, isPinned: false, pinnedVersion: null }
} catch {
return { version: null, isLocalDev: false, isPinned: false, pinnedVersion: null }
}
}
import { CHECK_IDS, CHECK_NAMES } from "../constants"
import {
getCachedVersion,
getLatestVersion,
isLocalDevMode,
findPluginEntry,
} from "../../../hooks/auto-update-checker/checker"
function compareVersions(current: string, latest: string): boolean {
const parseVersion = (v: string): number[] => {
@@ -91,22 +26,43 @@ function compareVersions(current: string, latest: string): boolean {
}
export async function getVersionInfo(): Promise<VersionCheckInfo> {
const current = getCurrentVersion()
const latestVersion = await fetchLatestVersion()
const cwd = process.cwd()
if (isLocalDevMode(cwd)) {
return {
currentVersion: "local-dev",
latestVersion: null,
isUpToDate: true,
isLocalDev: true,
isPinned: false,
}
}
const pluginInfo = findPluginEntry(cwd)
if (pluginInfo?.isPinned) {
return {
currentVersion: pluginInfo.pinnedVersion,
latestVersion: null,
isUpToDate: true,
isLocalDev: false,
isPinned: true,
}
}
const currentVersion = getCachedVersion()
const latestVersion = await getLatestVersion()
const isUpToDate =
current.isLocalDev ||
current.isPinned ||
!current.version ||
!currentVersion ||
!latestVersion ||
compareVersions(current.version, latestVersion)
compareVersions(currentVersion, latestVersion)
return {
currentVersion: current.version,
currentVersion,
latestVersion,
isUpToDate,
isLocalDev: current.isLocalDev,
isPinned: current.isPinned,
isLocalDev: false,
isPinned: false,
}
}

View File

@@ -27,6 +27,7 @@ export const CHECK_IDS = {
DEP_AST_GREP_CLI: "dep-ast-grep-cli",
DEP_AST_GREP_NAPI: "dep-ast-grep-napi",
DEP_COMMENT_CHECKER: "dep-comment-checker",
GH_CLI: "gh-cli",
LSP_SERVERS: "lsp-servers",
MCP_BUILTIN: "mcp-builtin",
MCP_USER: "mcp-user",
@@ -43,6 +44,7 @@ export const CHECK_NAMES: Record<string, string> = {
[CHECK_IDS.DEP_AST_GREP_CLI]: "AST-Grep CLI",
[CHECK_IDS.DEP_AST_GREP_NAPI]: "AST-Grep NAPI",
[CHECK_IDS.DEP_COMMENT_CHECKER]: "Comment Checker",
[CHECK_IDS.GH_CLI]: "GitHub CLI",
[CHECK_IDS.LSP_SERVERS]: "LSP Servers",
[CHECK_IDS.MCP_BUILTIN]: "Built-in MCP Servers",
[CHECK_IDS.MCP_USER]: "User MCP Configuration",

View File

@@ -331,6 +331,17 @@ async function runNonTuiInstall(args: InstallArgs): Promise<number> {
console.log(`${SYMBOLS.star} ${color.bold(color.green(isUpdate ? "Configuration updated!" : "Installation complete!"))}`)
console.log(` Run ${color.cyan("opencode")} to start!`)
console.log()
printBox(
`${color.bold("Pro Tip:")} Include ${color.cyan("ultrawork")} (or ${color.cyan("ulw")}) in your prompt.\n` +
`All features work like magic—parallel agents, background tasks,\n` +
`deep exploration, and relentless execution until completion.`,
"🪄 The Magic Word"
)
console.log(`${SYMBOLS.star} ${color.yellow("If you found this helpful, consider starring the repo!")}`)
console.log(` ${color.dim("gh repo star code-yeongyu/oh-my-opencode")}`)
console.log()
console.log(color.dim("oMoMoMoMo... Enjoy!"))
console.log()
@@ -450,6 +461,16 @@ export async function install(args: InstallArgs): Promise<number> {
p.log.success(color.bold(isUpdate ? "Configuration updated!" : "Installation complete!"))
p.log.message(`Run ${color.cyan("opencode")} to start!`)
p.note(
`Include ${color.cyan("ultrawork")} (or ${color.cyan("ulw")}) in your prompt.\n` +
`All features work like magic—parallel agents, background tasks,\n` +
`deep exploration, and relentless execution until completion.`,
"🪄 The Magic Word"
)
p.log.message(`${color.yellow("★")} If you found this helpful, consider starring the repo!`)
p.log.message(` ${color.dim("gh repo star code-yeongyu/oh-my-opencode")}`)
p.outro(color.green("oMoMoMoMo... Enjoy!"))
return 0

View File

@@ -26,6 +26,10 @@ export const BuiltinAgentNameSchema = z.enum([
"multimodal-looker",
])
export const BuiltinSkillNameSchema = z.enum([
"playwright",
])
export const OverridableAgentNameSchema = z.enum([
"build",
"plan",
@@ -66,6 +70,10 @@ export const HookNameSchema = z.enum([
"empty-message-sanitizer",
"thinking-block-validator",
"ralph-loop",
"preemptive-compaction",
"compaction-context-injector",
"claude-code-hooks",
"auto-slash-command",
])
export const BuiltinCommandNameSchema = z.enum([
@@ -164,7 +172,7 @@ export const DynamicContextPruningConfigSchema = z.object({
export const ExperimentalConfigSchema = z.object({
aggressive_truncation: z.boolean().optional(),
auto_resume: z.boolean().optional(),
/** Enable preemptive compaction at threshold (default: false) */
/** Enable preemptive compaction at threshold (default: true since v2.9.0) */
preemptive_compaction: z.boolean().optional(),
/** Threshold percentage to trigger preemptive compaction (default: 0.80) */
preemptive_compaction_threshold: z.number().min(0.5).max(0.95).optional(),
@@ -227,6 +235,7 @@ export const OhMyOpenCodeConfigSchema = z.object({
$schema: z.string().optional(),
disabled_mcps: z.array(McpNameSchema).optional(),
disabled_agents: z.array(BuiltinAgentNameSchema).optional(),
disabled_skills: z.array(BuiltinSkillNameSchema).optional(),
disabled_hooks: z.array(HookNameSchema).optional(),
disabled_commands: z.array(BuiltinCommandNameSchema).optional(),
agents: AgentOverridesSchema.optional(),
@@ -246,6 +255,7 @@ export type AgentOverrides = z.infer<typeof AgentOverridesSchema>
export type AgentName = z.infer<typeof AgentNameSchema>
export type HookName = z.infer<typeof HookNameSchema>
export type BuiltinCommandName = z.infer<typeof BuiltinCommandNameSchema>
export type BuiltinSkillName = z.infer<typeof BuiltinSkillNameSchema>
export type SisyphusAgentConfig = z.infer<typeof SisyphusAgentConfigSchema>
export type CommentCheckerConfig = z.infer<typeof CommentCheckerConfigSchema>
export type ExperimentalConfig = z.infer<typeof ExperimentalConfigSchema>

View File

@@ -2,79 +2,65 @@
## OVERVIEW
Claude Code compatibility layer and core feature modules. Enables Claude Code configs/commands/skills/MCPs/hooks to work seamlessly in OpenCode.
Claude Code compatibility layer + core feature modules. Commands, skills, agents, MCPs, hooks from Claude Code work seamlessly.
## STRUCTURE
```
features/
├── background-agent/ # Background task management
│ ├── manager.ts # Task lifecycle, notifications
│ ├── manager.test.ts
│ └── types.ts
├── builtin-commands/ # Built-in slash command definitions
├── claude-code-agent-loader/ # Load agents from ~/.claude/agents/*.md
├── claude-code-command-loader/ # Load commands from ~/.claude/commands/*.md
├── claude-code-mcp-loader/ # Load MCPs from .mcp.json
├── background-agent/ # Task lifecycle, notifications (460 lines)
├── builtin-commands/ # Built-in slash commands
├── builtin-skills/ # Built-in skills (playwright)
├── claude-code-agent-loader/ # ~/.claude/agents/*.md
├── claude-code-command-loader/ # ~/.claude/commands/*.md
├── claude-code-mcp-loader/ # .mcp.json files
│ └── env-expander.ts # ${VAR} expansion
├── claude-code-plugin-loader/ # Load external plugins from installed_plugins.json
├── claude-code-plugin-loader/ # installed_plugins.json (484 lines)
├── claude-code-session-state/ # Session state persistence
├── opencode-skill-loader/ # Load skills from OpenCode and Claude paths
├── opencode-skill-loader/ # Skills from OpenCode + Claude paths
├── skill-mcp-manager/ # MCP servers in skill YAML
└── hook-message-injector/ # Inject messages into conversation
```
## LOADER PRIORITY
Each loader reads from multiple directories (highest priority first):
| Loader | Priority Order |
|--------|---------------|
| Loader | Priority (highest first) |
|--------|--------------------------|
| Commands | `.opencode/command/` > `~/.config/opencode/command/` > `.claude/commands/` > `~/.claude/commands/` |
| Skills | `.opencode/skill/` > `~/.config/opencode/skill/` > `.claude/skills/` > `~/.claude/skills/` |
| Agents | `.claude/agents/` > `~/.claude/agents/` |
| MCPs | `.claude/.mcp.json` > `.mcp.json` > `~/.claude/.mcp.json` |
## HOW TO ADD A LOADER
1. Create directory: `src/features/claude-code-my-loader/`
2. Create files:
- `loader.ts`: Main loader logic with `load()` function
- `types.ts`: TypeScript interfaces
- `index.ts`: Barrel export
3. Pattern: Read from multiple dirs, merge with priority, return normalized config
## BACKGROUND AGENT SPECIFICS
- **Task lifecycle**: pending → running → completed/failed
- **Notifications**: OS notification on task complete (configurable)
- **Result retrieval**: `background_output` tool with task_id
- **Cancellation**: `background_cancel` with task_id or all=true
## CONFIG TOGGLES
Disable features in `oh-my-opencode.json`:
```json
{
"claude_code": {
"mcp": false, // Skip .mcp.json loading
"commands": false, // Skip commands/*.md loading
"skills": false, // Skip skills/*/SKILL.md loading
"agents": false, // Skip agents/*.md loading
"mcp": false, // Skip .mcp.json
"commands": false, // Skip commands/*.md
"skills": false, // Skip skills/*/SKILL.md
"agents": false, // Skip agents/*.md
"hooks": false // Skip settings.json hooks
}
}
```
## HOOK MESSAGE INJECTOR
## BACKGROUND AGENT
- **Purpose**: Inject system messages into conversation at specific points
- **Timing**: PreToolUse, PostToolUse, UserPromptSubmit, Stop
- **Format**: Returns `{ messages: [{ role: "user", content: "..." }] }`
- Lifecycle: pending → running → completed/failed
- OS notification on complete
- `background_output` to retrieve results
- `background_cancel` with task_id or all=true
## ANTI-PATTERNS (FEATURES)
## SKILL MCP
- **Blocking on load**: Loaders run at startup, keep them fast
- **No error handling**: Always try/catch, log failures, return empty on error
- **Ignoring priority**: Higher priority dirs must override lower
- **Modifying user files**: Loaders read-only, never write to ~/.claude/
- MCP servers embedded in skill YAML frontmatter
- Lazy client loading, session-scoped cleanup
- `skill_mcp` tool exposes capabilities
## ANTI-PATTERNS
- Blocking on load (loaders run at startup)
- No error handling (always try/catch)
- Ignoring priority order
- Writing to ~/.claude/ (read-only)

View File

@@ -42,10 +42,8 @@ export function loadBuiltinCommands(
for (const [name, definition] of Object.entries(BUILTIN_COMMAND_DEFINITIONS)) {
if (!disabled.has(name as BuiltinCommandName)) {
commands[name] = {
name,
...definition,
}
const { argumentHint: _argumentHint, ...openCodeCompatible } = definition
commands[name] = openCodeCompatible as CommandDefinition
}
}

View File

@@ -1,205 +1,191 @@
export const INIT_DEEP_TEMPLATE = `# Initialize Deep Knowledge Base
export const INIT_DEEP_TEMPLATE = `# /init-deep
Generate comprehensive AGENTS.md files across project hierarchy. Combines root-level project knowledge (gen-knowledge) with complexity-based subdirectory documentation (gen-knowledge-deep).
Generate hierarchical AGENTS.md files. Root + complexity-scored subdirectories.
## Usage
\`\`\`
/init-deep # Analyze and generate hierarchical AGENTS.md
/init-deep --create-new # Force create from scratch (ignore existing)
/init-deep --max-depth=2 # Limit to N directory levels (default: 3)
/init-deep # Update mode: modify existing + create new where warranted
/init-deep --create-new # Read existing → remove all → regenerate from scratch
/init-deep --max-depth=2 # Limit directory depth (default: 3)
\`\`\`
---
## Core Principles
## Workflow (High-Level)
- **Telegraphic Style**: Sacrifice grammar for concision ("Project uses React" → "React 18")
- **Predict-then-Compare**: Predict standard → find actual → document ONLY deviations
- **Hierarchy Aware**: Parent covers general, children cover specific
- **No Redundancy**: Child AGENTS.md NEVER repeats parent content
- **LSP-First**: Use LSP tools for accurate code intelligence when available (semantic > text search)
---
## Process
1. **Discovery + Analysis** (concurrent)
- Fire background explore agents immediately
- Main session: bash structure + LSP codemap + read existing AGENTS.md
2. **Score & Decide** - Determine AGENTS.md locations from merged findings
3. **Generate** - Root first, then subdirs in parallel
4. **Review** - Deduplicate, trim, validate
<critical>
**MANDATORY: TodoWrite for ALL phases. Mark in_progress → completed in real-time.**
</critical>
### Phase 0: Initialize
**TodoWrite ALL phases. Mark in_progress → completed in real-time.**
\`\`\`
TodoWrite([
{ id: "p1-analysis", content: "Parallel project structure & complexity analysis", status: "pending", priority: "high" },
{ id: "p2-scoring", content: "Score directories, determine AGENTS.md locations", status: "pending", priority: "high" },
{ id: "p3-root", content: "Generate root AGENTS.md with Predict-then-Compare", status: "pending", priority: "high" },
{ id: "p4-subdirs", content: "Generate subdirectory AGENTS.md files in parallel", status: "pending", priority: "high" },
{ id: "p5-review", content: "Review, deduplicate, validate all files", status: "pending", priority: "medium" }
{ id: "discovery", content: "Fire explore agents + LSP codemap + read existing", status: "pending", priority: "high" },
{ id: "scoring", content: "Score directories, determine locations", status: "pending", priority: "high" },
{ id: "generate", content: "Generate AGENTS.md files (root + subdirs)", status: "pending", priority: "high" },
{ id: "review", content: "Deduplicate, validate, trim", status: "pending", priority: "medium" }
])
\`\`\`
</critical>
---
## Phase 1: Parallel Project Analysis
## Phase 1: Discovery + Analysis (Concurrent)
**Mark "p1-analysis" as in_progress.**
**Mark "discovery" as in_progress.**
Launch **ALL tasks simultaneously**:
### Fire Background Explore Agents IMMEDIATELY
<parallel-tasks>
Don't wait—these run async while main session works.
\`\`\`
// Fire all at once, collect results later
background_task(agent="explore", prompt="Project structure: PREDICT standard patterns for detected language → REPORT deviations only")
background_task(agent="explore", prompt="Entry points: FIND main files → REPORT non-standard organization")
background_task(agent="explore", prompt="Conventions: FIND config files (.eslintrc, pyproject.toml, .editorconfig) → REPORT project-specific rules")
background_task(agent="explore", prompt="Anti-patterns: FIND 'DO NOT', 'NEVER', 'ALWAYS', 'DEPRECATED' comments → LIST forbidden patterns")
background_task(agent="explore", prompt="Build/CI: FIND .github/workflows, Makefile → REPORT non-standard patterns")
background_task(agent="explore", prompt="Test patterns: FIND test configs, test structure → REPORT unique conventions")
\`\`\`
<dynamic-agents>
**DYNAMIC AGENT SPAWNING**: After bash analysis, spawn ADDITIONAL explore agents based on project scale:
| Factor | Threshold | Additional Agents |
|--------|-----------|-------------------|
| **Total files** | >100 | +1 per 100 files |
| **Total lines** | >10k | +1 per 10k lines |
| **Directory depth** | ≥4 | +2 for deep exploration |
| **Large files (>500 lines)** | >10 files | +1 for complexity hotspots |
| **Monorepo** | detected | +1 per package/workspace |
| **Multiple languages** | >1 | +1 per language |
### Structural Analysis (bash - run in parallel)
\`\`\`bash
# Task A: Directory depth analysis
find . -type d -not -path '*/\\.*' -not -path '*/node_modules/*' -not -path '*/venv/*' -not -path '*/__pycache__/*' -not -path '*/dist/*' -not -path '*/build/*' | awk -F/ '{print NF-1}' | sort -n | uniq -c
# Measure project scale first
total_files=$(find . -type f -not -path '*/node_modules/*' -not -path '*/.git/*' | wc -l)
total_lines=$(find . -type f \\( -name "*.ts" -o -name "*.py" -o -name "*.go" \\) -not -path '*/node_modules/*' -exec wc -l {} + 2>/dev/null | tail -1 | awk '{print $1}')
large_files=$(find . -type f \\( -name "*.ts" -o -name "*.py" \\) -not -path '*/node_modules/*' -exec wc -l {} + 2>/dev/null | awk '$1 > 500 {count++} END {print count+0}')
max_depth=$(find . -type d -not -path '*/node_modules/*' -not -path '*/.git/*' | awk -F/ '{print NF}' | sort -rn | head -1)
\`\`\`
# Task B: File count per directory
find . -type f -not -path '*/\\.*' -not -path '*/node_modules/*' -not -path '*/venv/*' -not -path '*/__pycache__/*' | sed 's|/[^/]*$||' | sort | uniq -c | sort -rn | head -30
Example spawning:
\`\`\`
// 500 files, 50k lines, depth 6, 15 large files → spawn 5+5+2+1 = 13 additional agents
background_task(agent="explore", prompt="Large file analysis: FIND files >500 lines, REPORT complexity hotspots")
background_task(agent="explore", prompt="Deep modules at depth 4+: FIND hidden patterns, internal conventions")
background_task(agent="explore", prompt="Cross-cutting concerns: FIND shared utilities across directories")
// ... more based on calculation
\`\`\`
</dynamic-agents>
# Task C: Code concentration
find . -type f \\( -name "*.py" -o -name "*.ts" -o -name "*.tsx" -o -name "*.js" -o -name "*.jsx" -o -name "*.go" -o -name "*.rs" -o -name "*.java" \\) -not -path '*/node_modules/*' -not -path '*/venv/*' | sed 's|/[^/]*$||' | sort | uniq -c | sort -rn | head -20
### Main Session: Concurrent Analysis
# Task D: Existing knowledge files
**While background agents run**, main session does:
#### 1. Bash Structural Analysis
\`\`\`bash
# Directory depth + file counts
find . -type d -not -path '*/\\.*' -not -path '*/node_modules/*' -not -path '*/venv/*' -not -path '*/dist/*' -not -path '*/build/*' | awk -F/ '{print NF-1}' | sort -n | uniq -c
# Files per directory (top 30)
find . -type f -not -path '*/\\.*' -not -path '*/node_modules/*' | sed 's|/[^/]*$||' | sort | uniq -c | sort -rn | head -30
# Code concentration by extension
find . -type f \\( -name "*.py" -o -name "*.ts" -o -name "*.tsx" -o -name "*.js" -o -name "*.go" -o -name "*.rs" \\) -not -path '*/node_modules/*' | sed 's|/[^/]*$||' | sort | uniq -c | sort -rn | head -20
# Existing AGENTS.md / CLAUDE.md
find . -type f \\( -name "AGENTS.md" -o -name "CLAUDE.md" \\) -not -path '*/node_modules/*' 2>/dev/null
\`\`\`
### Context Gathering (Explore agents - background_task in parallel)
#### 2. Read Existing AGENTS.md
\`\`\`
background_task(agent="explore", prompt="Project structure: PREDICT standard {lang} patterns → FIND package.json/pyproject.toml/go.mod → REPORT deviations only")
background_task(agent="explore", prompt="Entry points: PREDICT typical (main.py, index.ts) → FIND actual → REPORT non-standard organization")
background_task(agent="explore", prompt="Conventions: FIND .cursor/rules, .cursorrules, eslintrc, pyproject.toml → REPORT project-specific rules DIFFERENT from defaults")
background_task(agent="explore", prompt="Anti-patterns: FIND comments with 'DO NOT', 'NEVER', 'ALWAYS', 'LEGACY', 'DEPRECATED' → REPORT forbidden patterns")
background_task(agent="explore", prompt="Build/CI: FIND .github/workflows, Makefile, justfile → REPORT non-standard build/deploy patterns")
background_task(agent="explore", prompt="Test patterns: FIND pytest.ini, jest.config, test structure → REPORT unique testing conventions")
For each existing file found:
Read(filePath=file)
Extract: key insights, conventions, anti-patterns
Store in EXISTING_AGENTS map
\`\`\`
### Code Intelligence Analysis (LSP tools - run in parallel)
LSP provides semantic understanding beyond text search. Use for accurate code mapping.
If \`--create-new\`: Read all existing first (preserve context) → then delete all → regenerate.
#### 3. LSP Codemap (if available)
\`\`\`
# Step 1: Check LSP availability
lsp_servers() # Verify language server is available
lsp_servers() # Check availability
# Step 2: Analyze entry point files (run in parallel)
# Find entry points first, then analyze each with lsp_document_symbols
lsp_document_symbols(filePath="src/index.ts") # Main entry
lsp_document_symbols(filePath="src/main.py") # Python entry
lsp_document_symbols(filePath="cmd/main.go") # Go entry
# Entry points (parallel)
lsp_document_symbols(filePath="src/index.ts")
lsp_document_symbols(filePath="main.py")
# Step 3: Discover key symbols across workspace (run in parallel)
lsp_workspace_symbols(filePath=".", query="class") # All classes
lsp_workspace_symbols(filePath=".", query="interface") # All interfaces
lsp_workspace_symbols(filePath=".", query="function") # Top-level functions
lsp_workspace_symbols(filePath=".", query="type") # Type definitions
# Key symbols (parallel)
lsp_workspace_symbols(filePath=".", query="class")
lsp_workspace_symbols(filePath=".", query="interface")
lsp_workspace_symbols(filePath=".", query="function")
# Step 4: Analyze symbol centrality (for top 5-10 key symbols)
# High reference count = central/important concept
lsp_find_references(filePath="src/index.ts", line=X, character=Y) # Main export
# Centrality for top exports
lsp_find_references(filePath="...", line=X, character=Y)
\`\`\`
#### LSP Analysis Output Format
**LSP Fallback**: If unavailable, rely on explore agents + AST-grep.
### Collect Background Results
\`\`\`
CODE_INTELLIGENCE = {
entry_points: [
{ file: "src/index.ts", exports: ["Plugin", "createHook"], symbol_count: 12 }
],
key_symbols: [
{ name: "Plugin", type: "class", file: "src/index.ts", refs: 45, role: "Central orchestrator" },
{ name: "createHook", type: "function", file: "src/utils.ts", refs: 23, role: "Hook factory" }
],
module_boundaries: [
{ dir: "src/hooks", exports: 21, imports_from: ["shared/"] },
{ dir: "src/tools", exports: 15, imports_from: ["shared/", "hooks/"] }
]
}
// After main session analysis done, collect all task results
for each task_id: background_output(task_id="...")
\`\`\`
<critical>
**LSP Fallback**: If LSP unavailable (no server installed), skip this section and rely on explore agents + AST-grep patterns.
</critical>
</parallel-tasks>
**Collect all results. Mark "p1-analysis" as completed.**
**Merge: bash + LSP + existing + explore findings. Mark "discovery" as completed.**
---
## Phase 2: Complexity Scoring & Location Decision
## Phase 2: Scoring & Location Decision
**Mark "p2-scoring" as in_progress.**
**Mark "scoring" as in_progress.**
### Scoring Matrix
| Factor | Weight | Threshold | Source |
|--------|--------|-----------|--------|
| File count | 3x | >20 files = high | bash |
| Subdirectory count | 2x | >5 subdirs = high | bash |
| Code file ratio | 2x | >70% code = high | bash |
| Factor | Weight | High Threshold | Source |
|--------|--------|----------------|--------|
| File count | 3x | >20 | bash |
| Subdir count | 2x | >5 | bash |
| Code ratio | 2x | >70% | bash |
| Unique patterns | 1x | Has own config | explore |
| Module boundary | 2x | Has __init__.py/index.ts | bash |
| **Symbol density** | 2x | >30 symbols = high | LSP |
| **Export count** | 2x | >10 exports = high | LSP |
| **Reference centrality** | 3x | Symbols with >20 refs | LSP |
<lsp-scoring>
**LSP-Enhanced Scoring** (if available):
\`\`\`
For each directory in candidates:
symbols = lsp_document_symbols(dir/index.ts or dir/__init__.py)
symbol_score = len(symbols) > 30 ? 6 : len(symbols) > 15 ? 3 : 0
export_score = count(exported symbols) > 10 ? 4 : 0
# Check if this module is central (many things depend on it)
for each exported symbol:
refs = lsp_find_references(symbol)
if refs > 20: centrality_score += 3
total_score += symbol_score + export_score + centrality_score
\`\`\`
</lsp-scoring>
| Module boundary | 2x | Has index.ts/__init__.py | bash |
| Symbol density | 2x | >30 symbols | LSP |
| Export count | 2x | >10 exports | LSP |
| Reference centrality | 3x | >20 refs | LSP |
### Decision Rules
| Score | Action |
|-------|--------|
| **Root (.)** | ALWAYS create AGENTS.md |
| **High (>15)** | Create dedicated AGENTS.md |
| **Medium (8-15)** | Create if distinct domain |
| **Low (<8)** | Skip, parent sufficient |
### Output Format
| **Root (.)** | ALWAYS create |
| **>15** | Create AGENTS.md |
| **8-15** | Create if distinct domain |
| **<8** | Skip (parent covers) |
### Output
\`\`\`
AGENTS_LOCATIONS = [
{ path: ".", type: "root" },
{ path: "src/api", score: 18, reason: "high complexity, 45 files" },
{ path: "src/hooks", score: 12, reason: "distinct domain, unique patterns" },
{ path: "src/hooks", score: 18, reason: "high complexity" },
{ path: "src/api", score: 12, reason: "distinct domain" }
]
\`\`\`
**Mark "p2-scoring" as completed.**
**Mark "scoring" as completed.**
---
## Phase 3: Generate Root AGENTS.md
## Phase 3: Generate AGENTS.md
**Mark "p3-root" as in_progress.**
**Mark "generate" as in_progress.**
Root AGENTS.md gets **full treatment** with Predict-then-Compare synthesis.
### Required Sections
### Root AGENTS.md (Full Treatment)
\`\`\`markdown
# PROJECT KNOWLEDGE BASE
@@ -209,153 +195,75 @@ Root AGENTS.md gets **full treatment** with Predict-then-Compare synthesis.
**Branch:** {BRANCH}
## OVERVIEW
{1-2 sentences: what project does, core tech stack}
{1-2 sentences: what + core stack}
## STRUCTURE
\\\`\\\`\\\`
{project-root}/
├── {dir}/ # {non-obvious purpose only}
└── {entry} # entry point
{root}/
├── {dir}/ # {non-obvious purpose only}
└── {entry}
\\\`\\\`\\\`
## WHERE TO LOOK
| Task | Location | Notes |
|------|----------|-------|
| Add feature X | \\\`src/x/\\\` | {pattern hint} |
## CODE MAP
{Generated from LSP analysis - shows key symbols and their relationships}
{From LSP - skip if unavailable or project <10 files}
| Symbol | Type | Location | Refs | Role |
|--------|------|----------|------|------|
| {MainClass} | Class | \\\`src/index.ts\\\` | {N} | {Central orchestrator} |
| {createX} | Function | \\\`src/utils.ts\\\` | {N} | {Factory pattern} |
| {Config} | Interface | \\\`src/types.ts\\\` | {N} | {Configuration contract} |
### Module Dependencies
\\\`\\\`\\\`
{entry} ──imports──> {core/}
│ │
└──imports──> {utils/} <──imports── {features/}
\\\`\\\`\\\`
<code-map-note>
**Skip CODE MAP if**: LSP unavailable OR project too small (<10 files) OR no clear module boundaries.
</code-map-note>
## CONVENTIONS
{ONLY deviations from standard - skip generic advice}
- **{rule}**: {specific detail}
{ONLY deviations from standard}
## ANTI-PATTERNS (THIS PROJECT)
{Things explicitly forbidden HERE}
- **{pattern}**: {why} → {alternative}
{Explicitly forbidden here}
## UNIQUE STYLES
{Project-specific coding styles}
- **{style}**: {how different}
{Project-specific}
## COMMANDS
\\\`\\\`\\\`bash
{dev-command}
{test-command}
{build-command}
{dev/test/build}
\\\`\\\`\\\`
## NOTES
{Gotchas, non-obvious info}
{Gotchas}
\`\`\`
### Quality Gates
**Quality gates**: 50-150 lines, no generic advice, no obvious info.
- [ ] Size: 50-150 lines
- [ ] No generic advice ("write clean code")
- [ ] No obvious info ("tests/ has tests")
- [ ] Every item is project-specific
### Subdirectory AGENTS.md (Parallel)
**Mark "p3-root" as completed.**
Launch document-writer agents for each location:
\`\`\`
for loc in AGENTS_LOCATIONS (except root):
background_task(agent="document-writer", prompt=\\\`
Generate AGENTS.md for: \${loc.path}
- Reason: \${loc.reason}
- 30-80 lines max
- NEVER repeat parent content
- Sections: OVERVIEW (1 line), STRUCTURE (if >5 subdirs), WHERE TO LOOK, CONVENTIONS (if different), ANTI-PATTERNS
\\\`)
\`\`\`
**Wait for all. Mark "generate" as completed.**
---
## Phase 4: Generate Subdirectory AGENTS.md
## Phase 4: Review & Deduplicate
**Mark "p4-subdirs" as in_progress.**
**Mark "review" as in_progress.**
For each location in AGENTS_LOCATIONS (except root), launch **parallel document-writer agents**:
For each generated file:
- Remove generic advice
- Remove parent duplicates
- Trim to size limits
- Verify telegraphic style
\`\`\`typescript
for (const loc of AGENTS_LOCATIONS.filter(l => l.path !== ".")) {
background_task({
agent: "document-writer",
prompt: \\\`
Generate AGENTS.md for: \${loc.path}
CONTEXT:
- Complexity reason: \${loc.reason}
- Parent AGENTS.md: ./AGENTS.md (already covers project overview)
CRITICAL RULES:
1. Focus ONLY on this directory's specific context
2. NEVER repeat parent AGENTS.md content
3. Shorter is better - 30-80 lines max
4. Telegraphic style - sacrifice grammar
REQUIRED SECTIONS:
- OVERVIEW (1 line: what this directory does)
- STRUCTURE (only if >5 subdirs)
- WHERE TO LOOK (directory-specific tasks)
- CONVENTIONS (only if DIFFERENT from root)
- ANTI-PATTERNS (directory-specific only)
OUTPUT: Write to \${loc.path}/AGENTS.md
\\\`
})
}
\`\`\`
**Wait for all agents. Mark "p4-subdirs" as completed.**
---
## Phase 5: Review & Deduplicate
**Mark "p5-review" as in_progress.**
### Validation Checklist
For EACH generated AGENTS.md:
| Check | Action if Fail |
|-------|----------------|
| Contains generic advice | REMOVE the line |
| Repeats parent content | REMOVE the line |
| Missing required section | ADD it |
| Over 150 lines (root) / 80 lines (subdir) | TRIM |
| Verbose explanations | REWRITE telegraphic |
### Cross-Reference Validation
\`\`\`
For each child AGENTS.md:
For each line in child:
If similar line exists in parent:
REMOVE from child (parent already covers)
\`\`\`
**Mark "p5-review" as completed.**
**Mark "review" as completed.**
---
@@ -364,31 +272,29 @@ For each child AGENTS.md:
\`\`\`
=== init-deep Complete ===
Files Generated:
Mode: {update | create-new}
Files:
✓ ./AGENTS.md (root, {N} lines)
✓ ./src/hooks/AGENTS.md ({N} lines)
✓ ./src/tools/AGENTS.md ({N} lines)
Directories Analyzed: {N}
Dirs Analyzed: {N}
AGENTS.md Created: {N}
Total Lines: {N}
AGENTS.md Updated: {N}
Hierarchy:
./AGENTS.md
── src/hooks/AGENTS.md
└── src/tools/AGENTS.md
── src/hooks/AGENTS.md
\`\`\`
---
## Anti-Patterns for THIS Command
## Anti-Patterns
- **Over-documenting**: Not every directory needs AGENTS.md
- **Redundancy**: Child must NOT repeat parent
- **Static agent count**: MUST vary agents based on project size/depth
- **Sequential execution**: MUST parallel (explore + LSP concurrent)
- **Ignoring existing**: ALWAYS read existing first, even with --create-new
- **Over-documenting**: Not every dir needs AGENTS.md
- **Redundancy**: Child never repeats parent
- **Generic content**: Remove anything that applies to ALL projects
- **Sequential execution**: MUST use parallel agents
- **Deep nesting**: Rarely need AGENTS.md at depth 4+
- **Verbose style**: "This directory contains..." → just list it
- **Ignoring LSP**: If LSP available, USE IT - semantic analysis > text grep
- **LSP without fallback**: Always have explore agent backup if LSP unavailable
- **Over-referencing**: Don't trace refs for EVERY symbol - focus on exports only`
- **Verbose style**: Telegraphic or die`

View File

@@ -1,5 +1,19 @@
import type { BuiltinSkill } from "./types"
export function createBuiltinSkills(): BuiltinSkill[] {
return []
const playwrightSkill: BuiltinSkill = {
name: "playwright",
description: "Browser automation with Playwright MCP. Use for web scraping, testing, screenshots, and browser interactions.",
template: `# Playwright Browser Automation
This skill provides browser automation capabilities via the Playwright MCP server.`,
mcpConfig: {
playwright: {
command: "npx",
args: ["@playwright/mcp@latest"],
},
},
}
export function createBuiltinSkills(): BuiltinSkill[] {
return [playwrightSkill]
}

View File

@@ -1,3 +1,5 @@
import type { SkillMcpConfig } from "../skill-mcp-manager/types"
export interface BuiltinSkill {
name: string
description: string
@@ -10,4 +12,5 @@ export interface BuiltinSkill {
model?: string
subtask?: boolean
argumentHint?: string
mcpConfig?: SkillMcpConfig
}

View File

@@ -1,24 +1,59 @@
import { existsSync, readdirSync, readFileSync } from "fs"
import { existsSync, readdirSync, readFileSync, realpathSync, type Dirent } from "fs"
import { join, basename } from "path"
import { parseFrontmatter } from "../../shared/frontmatter"
import { sanitizeModelField } from "../../shared/model-sanitizer"
import { isMarkdownFile } from "../../shared/file-utils"
import { getClaudeConfigDir } from "../../shared"
import { log } from "../../shared/logger"
import type { CommandScope, CommandDefinition, CommandFrontmatter, LoadedCommand } from "./types"
function loadCommandsFromDir(commandsDir: string, scope: CommandScope): LoadedCommand[] {
function loadCommandsFromDir(
commandsDir: string,
scope: CommandScope,
visited: Set<string> = new Set(),
prefix: string = ""
): LoadedCommand[] {
if (!existsSync(commandsDir)) {
return []
}
const entries = readdirSync(commandsDir, { withFileTypes: true })
let realPath: string
try {
realPath = realpathSync(commandsDir)
} catch (error) {
log(`Failed to resolve command directory: ${commandsDir}`, error)
return []
}
if (visited.has(realPath)) {
return []
}
visited.add(realPath)
let entries: Dirent[]
try {
entries = readdirSync(commandsDir, { withFileTypes: true })
} catch (error) {
log(`Failed to read command directory: ${commandsDir}`, error)
return []
}
const commands: LoadedCommand[] = []
for (const entry of entries) {
if (entry.isDirectory()) {
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))
continue
}
if (!isMarkdownFile(entry)) continue
const commandPath = join(commandsDir, entry.name)
const commandName = basename(entry.name, ".md")
const baseCommandName = basename(entry.name, ".md")
const commandName = prefix ? `${prefix}:${baseCommandName}` : baseCommandName
try {
const content = readFileSync(commandPath, "utf-8")
@@ -43,6 +78,7 @@ $ARGUMENTS
model: sanitizeModelField(data.model, isOpencodeSource ? "opencode" : "claude-code"),
subtask: data.subtask,
argumentHint: data["argument-hint"],
handoffs: data.handoffs,
}
commands.push({
@@ -51,7 +87,8 @@ $ARGUMENTS
definition,
scope,
})
} catch {
} catch (error) {
log(`Failed to parse command: ${commandPath}`, error)
continue
}
}
@@ -62,7 +99,8 @@ $ARGUMENTS
function commandsToRecord(commands: LoadedCommand[]): Record<string, CommandDefinition> {
const result: Record<string, CommandDefinition> = {}
for (const cmd of commands) {
result[cmd.name] = cmd.definition
const { name: _name, argumentHint: _argumentHint, ...openCodeCompatible } = cmd.definition
result[cmd.name] = openCodeCompatible as CommandDefinition
}
return result
}

View File

@@ -1,5 +1,21 @@
export type CommandScope = "user" | "project" | "opencode" | "opencode-project"
/**
* Handoff definition for command workflows.
* Based on speckit's handoff pattern for multi-agent orchestration.
* @see https://github.com/github/spec-kit
*/
export interface HandoffDefinition {
/** Human-readable label for the handoff action */
label: string
/** Target agent/command identifier (e.g., "speckit.tasks") */
agent: string
/** Pre-filled prompt text for the handoff */
prompt: string
/** If true, automatically executes after command completion; if false, shows as suggestion */
send?: boolean
}
export interface CommandDefinition {
name: string
description?: string
@@ -8,6 +24,8 @@ export interface CommandDefinition {
model?: string
subtask?: boolean
argumentHint?: string
/** Handoff definitions for workflow transitions */
handoffs?: HandoffDefinition[]
}
export interface CommandFrontmatter {
@@ -16,6 +34,8 @@ export interface CommandFrontmatter {
agent?: string
model?: string
subtask?: boolean
/** Handoff definitions for workflow transitions */
handoffs?: HandoffDefinition[]
}
export interface LoadedCommand {

View File

@@ -0,0 +1,162 @@
import { describe, it, expect, beforeEach, afterEach } from "bun:test"
import { mkdirSync, writeFileSync, rmSync } from "fs"
import { join } from "path"
import { tmpdir } from "os"
const TEST_DIR = join(tmpdir(), "mcp-loader-test-" + Date.now())
describe("getSystemMcpServerNames", () => {
beforeEach(() => {
mkdirSync(TEST_DIR, { recursive: true })
})
afterEach(() => {
rmSync(TEST_DIR, { recursive: true, force: true })
})
it("returns empty set when no .mcp.json files exist", async () => {
// #given
const originalCwd = process.cwd()
process.chdir(TEST_DIR)
try {
// #when
const { getSystemMcpServerNames } = await import("./loader")
const names = getSystemMcpServerNames()
// #then
expect(names).toBeInstanceOf(Set)
expect(names.size).toBe(0)
} finally {
process.chdir(originalCwd)
}
})
it("returns server names from project .mcp.json", async () => {
// #given
const mcpConfig = {
mcpServers: {
playwright: {
command: "npx",
args: ["@playwright/mcp@latest"],
},
sqlite: {
command: "uvx",
args: ["mcp-server-sqlite"],
},
},
}
writeFileSync(join(TEST_DIR, ".mcp.json"), JSON.stringify(mcpConfig))
const originalCwd = process.cwd()
process.chdir(TEST_DIR)
try {
// #when
const { getSystemMcpServerNames } = await import("./loader")
const names = getSystemMcpServerNames()
// #then
expect(names.has("playwright")).toBe(true)
expect(names.has("sqlite")).toBe(true)
expect(names.size).toBe(2)
} finally {
process.chdir(originalCwd)
}
})
it("returns server names from .claude/.mcp.json", async () => {
// #given
mkdirSync(join(TEST_DIR, ".claude"), { recursive: true })
const mcpConfig = {
mcpServers: {
memory: {
command: "npx",
args: ["-y", "@anthropic-ai/mcp-server-memory"],
},
},
}
writeFileSync(join(TEST_DIR, ".claude", ".mcp.json"), JSON.stringify(mcpConfig))
const originalCwd = process.cwd()
process.chdir(TEST_DIR)
try {
// #when
const { getSystemMcpServerNames } = await import("./loader")
const names = getSystemMcpServerNames()
// #then
expect(names.has("memory")).toBe(true)
} finally {
process.chdir(originalCwd)
}
})
it("excludes disabled MCP servers", async () => {
// #given
const mcpConfig = {
mcpServers: {
playwright: {
command: "npx",
args: ["@playwright/mcp@latest"],
disabled: true,
},
active: {
command: "npx",
args: ["some-mcp"],
},
},
}
writeFileSync(join(TEST_DIR, ".mcp.json"), JSON.stringify(mcpConfig))
const originalCwd = process.cwd()
process.chdir(TEST_DIR)
try {
// #when
const { getSystemMcpServerNames } = await import("./loader")
const names = getSystemMcpServerNames()
// #then
expect(names.has("playwright")).toBe(false)
expect(names.has("active")).toBe(true)
} finally {
process.chdir(originalCwd)
}
})
it("merges server names from multiple .mcp.json files", async () => {
// #given
mkdirSync(join(TEST_DIR, ".claude"), { recursive: true })
const projectMcp = {
mcpServers: {
playwright: { command: "npx", args: ["@playwright/mcp@latest"] },
},
}
const localMcp = {
mcpServers: {
memory: { command: "npx", args: ["-y", "@anthropic-ai/mcp-server-memory"] },
},
}
writeFileSync(join(TEST_DIR, ".mcp.json"), JSON.stringify(projectMcp))
writeFileSync(join(TEST_DIR, ".claude", ".mcp.json"), JSON.stringify(localMcp))
const originalCwd = process.cwd()
process.chdir(TEST_DIR)
try {
// #when
const { getSystemMcpServerNames } = await import("./loader")
const names = getSystemMcpServerNames()
// #then
expect(names.has("playwright")).toBe(true)
expect(names.has("memory")).toBe(true)
} finally {
process.chdir(originalCwd)
}
})
})

View File

@@ -1,4 +1,4 @@
import { existsSync } from "fs"
import { existsSync, readFileSync } from "fs"
import { join } from "path"
import { getClaudeConfigDir } from "../../shared"
import type {
@@ -42,6 +42,30 @@ async function loadMcpConfigFile(
}
}
export function getSystemMcpServerNames(): Set<string> {
const names = new Set<string>()
const paths = getMcpConfigPaths()
for (const { path } of paths) {
if (!existsSync(path)) continue
try {
const content = readFileSync(path, "utf-8")
const config = JSON.parse(content) as ClaudeCodeMcpConfig
if (!config?.mcpServers) continue
for (const [name, serverConfig] of Object.entries(config.mcpServers)) {
if (serverConfig.disabled) continue
names.add(name)
}
} catch {
continue
}
}
return names
}
export async function loadMcpConfigs(): Promise<McpLoadResult> {
const servers: McpLoadResult["servers"] = {}
const loadedServers: LoadedMcpServer[] = []

View File

@@ -246,7 +246,7 @@ $ARGUMENTS
const formattedDescription = `(plugin: ${plugin.name}) ${data.description || ""}`
commands[namespacedName] = {
const definition = {
name: namespacedName,
description: formattedDescription,
template: wrappedTemplate,
@@ -255,6 +255,8 @@ $ARGUMENTS
subtask: data.subtask,
argumentHint: data["argument-hint"],
}
const { name: _name, argumentHint: _argumentHint, ...openCodeCompatible } = definition
commands[namespacedName] = openCodeCompatible as CommandDefinition
log(`Loaded plugin command: ${namespacedName}`, { path: commandPath })
} catch (error) {
@@ -306,12 +308,14 @@ ${body.trim()}
$ARGUMENTS
</user-request>`
skills[namespacedName] = {
const definition = {
name: namespacedName,
description: formattedDescription,
template: wrappedTemplate,
model: sanitizeModelField(data.model),
}
const { name: _name, ...openCodeCompatible } = definition
skills[namespacedName] = openCodeCompatible as CommandDefinition
log(`Loaded plugin skill: ${namespacedName}`, { path: resolvedPath })
} catch (error) {

View File

@@ -0,0 +1,273 @@
import { describe, it, expect, beforeEach, afterEach } from "bun:test"
import { mkdirSync, writeFileSync, rmSync } from "fs"
import { join } from "path"
import { tmpdir } from "os"
const TEST_DIR = join(tmpdir(), "skill-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
}
describe("skill loader MCP parsing", () => {
beforeEach(() => {
mkdirSync(TEST_DIR, { recursive: true })
})
afterEach(() => {
rmSync(TEST_DIR, { recursive: true, force: true })
})
describe("parseSkillMcpConfig", () => {
it("parses skill with nested MCP config", async () => {
// #given
const skillContent = `---
name: test-skill
description: A test skill with MCP
mcp:
sqlite:
command: uvx
args:
- mcp-server-sqlite
- --db-path
- ./data.db
memory:
command: npx
args: [-y, "@anthropic-ai/mcp-server-memory"]
---
This is the skill body.
`
createTestSkill("test-mcp-skill", skillContent)
// #when
const { discoverSkills } = await import("./loader")
const originalCwd = process.cwd()
process.chdir(TEST_DIR)
try {
const skills = discoverSkills({ includeClaudeCodePaths: false })
const skill = skills.find(s => s.name === "test-skill")
// #then
expect(skill).toBeDefined()
expect(skill?.mcpConfig).toBeDefined()
expect(skill?.mcpConfig?.sqlite).toBeDefined()
expect(skill?.mcpConfig?.sqlite?.command).toBe("uvx")
expect(skill?.mcpConfig?.sqlite?.args).toEqual([
"mcp-server-sqlite",
"--db-path",
"./data.db"
])
expect(skill?.mcpConfig?.memory).toBeDefined()
expect(skill?.mcpConfig?.memory?.command).toBe("npx")
} finally {
process.chdir(originalCwd)
}
})
it("returns undefined mcpConfig for skill without MCP", async () => {
// #given
const skillContent = `---
name: simple-skill
description: A simple skill without MCP
---
This is a simple skill.
`
createTestSkill("simple-skill", skillContent)
// #when
const { discoverSkills } = await import("./loader")
const originalCwd = process.cwd()
process.chdir(TEST_DIR)
try {
const skills = discoverSkills({ includeClaudeCodePaths: false })
const skill = skills.find(s => s.name === "simple-skill")
// #then
expect(skill).toBeDefined()
expect(skill?.mcpConfig).toBeUndefined()
} finally {
process.chdir(originalCwd)
}
})
it("preserves env var placeholders without expansion", async () => {
// #given
const skillContent = `---
name: env-skill
mcp:
api-server:
command: node
args: [server.js]
env:
API_KEY: "\${API_KEY}"
DB_PATH: "\${HOME}/data.db"
---
Skill with env vars.
`
createTestSkill("env-skill", skillContent)
// #when
const { discoverSkills } = await import("./loader")
const originalCwd = process.cwd()
process.chdir(TEST_DIR)
try {
const skills = discoverSkills({ includeClaudeCodePaths: false })
const skill = skills.find(s => s.name === "env-skill")
// #then
expect(skill?.mcpConfig?.["api-server"]?.env?.API_KEY).toBe("${API_KEY}")
expect(skill?.mcpConfig?.["api-server"]?.env?.DB_PATH).toBe("${HOME}/data.db")
} finally {
process.chdir(originalCwd)
}
})
it("handles malformed YAML gracefully", async () => {
// #given - malformed YAML causes entire frontmatter to fail parsing
const skillContent = `---
name: bad-yaml
mcp: [this is not valid yaml for mcp
---
Skill body.
`
createTestSkill("bad-yaml-skill", skillContent)
// #when
const { discoverSkills } = await import("./loader")
const originalCwd = process.cwd()
process.chdir(TEST_DIR)
try {
const skills = discoverSkills({ includeClaudeCodePaths: false })
// #then - when YAML fails, skill uses directory name as fallback
const skill = skills.find(s => s.name === "bad-yaml-skill")
expect(skill).toBeDefined()
expect(skill?.mcpConfig).toBeUndefined()
} finally {
process.chdir(originalCwd)
}
})
})
describe("mcp.json file loading (AmpCode compat)", () => {
it("loads MCP config from mcp.json with mcpServers format", async () => {
// #given
const skillContent = `---
name: ampcode-skill
description: Skill with mcp.json
---
Skill body.
`
const mcpJson = {
mcpServers: {
playwright: {
command: "npx",
args: ["@playwright/mcp@latest"]
}
}
}
createTestSkill("ampcode-skill", skillContent, mcpJson)
// #when
const { discoverSkills } = await import("./loader")
const originalCwd = process.cwd()
process.chdir(TEST_DIR)
try {
const skills = discoverSkills({ includeClaudeCodePaths: false })
const skill = skills.find(s => s.name === "ampcode-skill")
// #then
expect(skill).toBeDefined()
expect(skill?.mcpConfig).toBeDefined()
expect(skill?.mcpConfig?.playwright).toBeDefined()
expect(skill?.mcpConfig?.playwright?.command).toBe("npx")
expect(skill?.mcpConfig?.playwright?.args).toEqual(["@playwright/mcp@latest"])
} finally {
process.chdir(originalCwd)
}
})
it("mcp.json takes priority over YAML frontmatter", async () => {
// #given
const skillContent = `---
name: priority-skill
mcp:
from-yaml:
command: yaml-cmd
args: [yaml-arg]
---
Skill body.
`
const mcpJson = {
mcpServers: {
"from-json": {
command: "json-cmd",
args: ["json-arg"]
}
}
}
createTestSkill("priority-skill", skillContent, mcpJson)
// #when
const { discoverSkills } = await import("./loader")
const originalCwd = process.cwd()
process.chdir(TEST_DIR)
try {
const skills = discoverSkills({ includeClaudeCodePaths: false })
const skill = skills.find(s => s.name === "priority-skill")
// #then - mcp.json should take priority
expect(skill?.mcpConfig?.["from-json"]).toBeDefined()
expect(skill?.mcpConfig?.["from-yaml"]).toBeUndefined()
} finally {
process.chdir(originalCwd)
}
})
it("supports direct format without mcpServers wrapper", async () => {
// #given
const skillContent = `---
name: direct-format
---
Skill body.
`
const mcpJson = {
sqlite: {
command: "uvx",
args: ["mcp-server-sqlite"]
}
}
createTestSkill("direct-format", skillContent, mcpJson)
// #when
const { discoverSkills } = await import("./loader")
const originalCwd = process.cwd()
process.chdir(TEST_DIR)
try {
const skills = discoverSkills({ includeClaudeCodePaths: false })
const skill = skills.find(s => s.name === "direct-format")
// #then
expect(skill?.mcpConfig?.sqlite).toBeDefined()
expect(skill?.mcpConfig?.sqlite?.command).toBe("uvx")
} finally {
process.chdir(originalCwd)
}
})
})
})

View File

@@ -1,21 +1,58 @@
import { existsSync, readdirSync, readFileSync } from "fs"
import { join, basename, dirname } 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 { getClaudeConfigDir } from "../../shared"
import type { CommandDefinition } from "../claude-code-command-loader/types"
import type { SkillScope, SkillMetadata, LoadedSkill } from "./types"
import type { SkillMcpConfig } from "../skill-mcp-manager/types"
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
}
function loadMcpJsonFromDir(skillDir: string): SkillMcpConfig | undefined {
const mcpJsonPath = join(skillDir, "mcp.json")
if (!existsSync(mcpJsonPath)) return undefined
try {
const content = readFileSync(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>)
)
if (hasCommandField) {
return parsed as SkillMcpConfig
}
}
} catch {
return undefined
}
return undefined
}
/**
* Load a skill from a markdown file path.
*
* @param skillPath - Path to the skill file (SKILL.md or {name}.md)
* @param resolvedPath - Directory for file reference resolution (@path references)
* @param defaultName - Fallback name if not specified in frontmatter
* @param scope - Source scope for priority ordering
*/
function parseAllowedTools(allowedTools: string | undefined): string[] | undefined {
if (!allowedTools) return undefined
return allowedTools.split(/\s+/).filter(Boolean)
@@ -30,6 +67,9 @@ function loadSkillFromPath(
try {
const content = readFileSync(skillPath, "utf-8")
const { data, body } = parseFrontmatter<SkillMetadata>(content)
const frontmatterMcp = parseSkillMcpConfigFromFrontmatter(content)
const mcpJsonMcp = loadMcpJsonFromDir(resolvedPath)
const mcpConfig = mcpJsonMcp || frontmatterMcp
const skillName = data.name || defaultName
const originalDescription = data.description || ""
@@ -67,6 +107,7 @@ $ARGUMENTS
compatibility: data.compatibility,
metadata: data.metadata,
allowedTools: parseAllowedTools(data["allowed-tools"]),
mcpConfig,
}
} catch {
return null
@@ -126,7 +167,8 @@ function loadSkillsFromDir(skillsDir: string, scope: SkillScope): LoadedSkill[]
function skillsToRecord(skills: LoadedSkill[]): Record<string, CommandDefinition> {
const result: Record<string, CommandDefinition> = {}
for (const skill of skills) {
result[skill.name] = skill.definition
const { name: _name, argumentHint: _argumentHint, ...openCodeCompatible } = skill.definition
result[skill.name] = openCodeCompatible as CommandDefinition
}
return result
}

View File

@@ -21,7 +21,7 @@ const SCOPE_PRIORITY: Record<SkillScope, number> = {
function builtinToLoaded(builtin: BuiltinSkill): LoadedSkill {
const definition: CommandDefinition = {
name: builtin.name,
description: `(builtin - Skill) ${builtin.description}`,
description: `(opencode - Skill) ${builtin.description}`,
template: builtin.template,
model: builtin.model,
agent: builtin.agent,
@@ -37,6 +37,7 @@ function builtinToLoaded(builtin: BuiltinSkill): LoadedSkill {
compatibility: builtin.compatibility,
metadata: builtin.metadata as Record<string, string> | undefined,
allowedTools: builtin.allowedTools,
mcpConfig: builtin.mcpConfig,
}
}

View File

@@ -1,4 +1,5 @@
import type { CommandDefinition } from "../claude-code-command-loader/types"
import type { SkillMcpConfig } from "../skill-mcp-manager/types"
export type SkillScope = "builtin" | "config" | "user" | "project" | "opencode" | "opencode-project"
@@ -13,6 +14,7 @@ export interface SkillMetadata {
compatibility?: string
metadata?: Record<string, string>
"allowed-tools"?: string
mcp?: SkillMcpConfig
}
export interface LoadedSkill {
@@ -25,4 +27,5 @@ export interface LoadedSkill {
compatibility?: string
metadata?: Record<string, string>
allowedTools?: string[]
mcpConfig?: SkillMcpConfig
}

View File

@@ -0,0 +1,2 @@
export * from "./types"
export { SkillMcpManager } from "./manager"

View File

@@ -0,0 +1,159 @@
import { describe, it, expect, beforeEach, afterEach, mock, spyOn } from "bun:test"
import { SkillMcpManager } from "./manager"
import type { SkillMcpClientInfo, SkillMcpServerContext } from "./types"
import type { ClaudeCodeMcpServer } from "../claude-code-mcp-loader/types"
describe("SkillMcpManager", () => {
let manager: SkillMcpManager
beforeEach(() => {
manager = new SkillMcpManager()
})
afterEach(async () => {
await manager.disconnectAll()
})
describe("getOrCreateClient", () => {
it("throws error when command is missing", async () => {
// #given
const info: SkillMcpClientInfo = {
serverName: "test-server",
skillName: "test-skill",
sessionID: "session-1",
}
const config: ClaudeCodeMcpServer = {}
// #when / #then
await expect(manager.getOrCreateClient(info, config)).rejects.toThrow(
/missing required 'command' field/
)
})
it("includes helpful error message with example when command is missing", async () => {
// #given
const info: SkillMcpClientInfo = {
serverName: "my-mcp",
skillName: "data-skill",
sessionID: "session-1",
}
const config: ClaudeCodeMcpServer = {}
// #when / #then
await expect(manager.getOrCreateClient(info, config)).rejects.toThrow(
/my-mcp[\s\S]*data-skill[\s\S]*Example/
)
})
})
describe("disconnectSession", () => {
it("removes all clients for a specific session", async () => {
// #given
const session1Info: SkillMcpClientInfo = {
serverName: "server1",
skillName: "skill1",
sessionID: "session-1",
}
const session2Info: SkillMcpClientInfo = {
serverName: "server1",
skillName: "skill1",
sessionID: "session-2",
}
// #when
await manager.disconnectSession("session-1")
// #then
expect(manager.isConnected(session1Info)).toBe(false)
expect(manager.isConnected(session2Info)).toBe(false)
})
it("does not throw when session has no clients", async () => {
// #given / #when / #then
await expect(manager.disconnectSession("nonexistent")).resolves.toBeUndefined()
})
})
describe("disconnectAll", () => {
it("clears all clients", async () => {
// #given - no actual clients connected (would require real MCP server)
// #when
await manager.disconnectAll()
// #then
expect(manager.getConnectedServers()).toEqual([])
})
})
describe("isConnected", () => {
it("returns false for unconnected server", () => {
// #given
const info: SkillMcpClientInfo = {
serverName: "unknown",
skillName: "test",
sessionID: "session-1",
}
// #when / #then
expect(manager.isConnected(info)).toBe(false)
})
})
describe("getConnectedServers", () => {
it("returns empty array when no servers connected", () => {
// #given / #when / #then
expect(manager.getConnectedServers()).toEqual([])
})
})
describe("environment variable handling", () => {
it("always inherits process.env even when config.env is undefined", async () => {
// #given
const info: SkillMcpClientInfo = {
serverName: "test-server",
skillName: "test-skill",
sessionID: "session-1",
}
const configWithoutEnv: ClaudeCodeMcpServer = {
command: "node",
args: ["-e", "process.exit(0)"],
}
// #when - attempt connection (will fail but exercises env merging code path)
// #then - should not throw "undefined" related errors for env
try {
await manager.getOrCreateClient(info, configWithoutEnv)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
expect(message).not.toContain("env")
expect(message).not.toContain("undefined")
}
})
it("overlays config.env on top of inherited process.env", async () => {
// #given
const info: SkillMcpClientInfo = {
serverName: "test-server",
skillName: "test-skill",
sessionID: "session-2",
}
const configWithEnv: ClaudeCodeMcpServer = {
command: "node",
args: ["-e", "process.exit(0)"],
env: {
CUSTOM_VAR: "custom_value",
},
}
// #when - attempt connection
// #then - should not throw, env merging should work
try {
await manager.getOrCreateClient(info, configWithEnv)
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
expect(message).toContain("Failed to connect")
}
})
})
})

View File

@@ -0,0 +1,213 @@
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
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 type { SkillMcpClientInfo, SkillMcpServerContext } from "./types"
interface ManagedClient {
client: Client
transport: StdioClientTransport
skillName: string
}
export class SkillMcpManager {
private clients: Map<string, ManagedClient> = new Map()
private getClientKey(info: SkillMcpClientInfo): string {
return `${info.sessionID}:${info.skillName}:${info.serverName}`
}
async getOrCreateClient(
info: SkillMcpClientInfo,
config: ClaudeCodeMcpServer
): Promise<Client> {
const key = this.getClientKey(info)
const existing = this.clients.get(key)
if (existing) {
return existing.client
}
const expandedConfig = expandEnvVarsInObject(config)
const client = await this.createClient(info, expandedConfig)
return client
}
private async createClient(
info: SkillMcpClientInfo,
config: ClaudeCodeMcpServer
): Promise<Client> {
const key = this.getClientKey(info)
if (!config.command) {
throw new Error(
`MCP server "${info.serverName}" is missing required 'command' field.\n\n` +
`The MCP configuration in skill "${info.skillName}" must specify a command to execute.\n\n` +
`Example:\n` +
` mcp:\n` +
` ${info.serverName}:\n` +
` command: npx\n` +
` args: [-y, @some/mcp-server]`
)
}
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 transport = new StdioClientTransport({
command,
args,
env: mergedEnv,
stderr: "ignore",
})
const client = new Client(
{ name: `skill-mcp-${info.skillName}-${info.serverName}`, version: "1.0.0" },
{ capabilities: {} }
)
try {
await client.connect(transport)
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
throw new Error(
`Failed to connect to MCP server "${info.serverName}".\n\n` +
`Command: ${command} ${args.join(" ")}\n` +
`Reason: ${errorMessage}\n\n` +
`Hints:\n` +
` - Ensure the command is installed and available in PATH\n` +
` - Check if the MCP server package exists\n` +
` - Verify the args are correct for this server`
)
}
this.clients.set(key, { client, transport, skillName: info.skillName })
return client
}
async disconnectSession(sessionID: string): Promise<void> {
const keysToRemove: string[] = []
for (const [key, managed] of this.clients.entries()) {
if (key.startsWith(`${sessionID}:`)) {
keysToRemove.push(key)
try {
await managed.client.close()
} catch {
// Ignore close errors - process may already be terminated
}
}
}
for (const key of keysToRemove) {
this.clients.delete(key)
}
}
async disconnectAll(): Promise<void> {
for (const [, managed] of this.clients.entries()) {
try {
await managed.client.close()
} catch { /* process may already be terminated */ }
}
this.clients.clear()
}
async listTools(
info: SkillMcpClientInfo,
context: SkillMcpServerContext
): Promise<Tool[]> {
const client = await this.getOrCreateClientWithRetry(info, context.config)
const result = await client.listTools()
return result.tools
}
async listResources(
info: SkillMcpClientInfo,
context: SkillMcpServerContext
): Promise<Resource[]> {
const client = await this.getOrCreateClientWithRetry(info, context.config)
const result = await client.listResources()
return result.resources
}
async listPrompts(
info: SkillMcpClientInfo,
context: SkillMcpServerContext
): Promise<Prompt[]> {
const client = await this.getOrCreateClientWithRetry(info, context.config)
const result = await client.listPrompts()
return result.prompts
}
async callTool(
info: SkillMcpClientInfo,
context: SkillMcpServerContext,
name: string,
args: Record<string, unknown>
): Promise<unknown> {
const client = await this.getOrCreateClientWithRetry(info, context.config)
const result = await client.callTool({ name, arguments: args })
return result.content
}
async readResource(
info: SkillMcpClientInfo,
context: SkillMcpServerContext,
uri: string
): Promise<unknown> {
const client = await this.getOrCreateClientWithRetry(info, context.config)
const result = await client.readResource({ uri })
return result.contents
}
async getPrompt(
info: SkillMcpClientInfo,
context: SkillMcpServerContext,
name: string,
args: Record<string, string>
): Promise<unknown> {
const client = await this.getOrCreateClientWithRetry(info, context.config)
const result = await client.getPrompt({ name, arguments: args })
return result.messages
}
private async getOrCreateClientWithRetry(
info: SkillMcpClientInfo,
config: ClaudeCodeMcpServer
): Promise<Client> {
try {
return await this.getOrCreateClient(info, config)
} catch (error) {
const key = this.getClientKey(info)
const existing = this.clients.get(key)
if (existing) {
try {
await existing.client.close()
} catch { /* process may already be terminated */ }
this.clients.delete(key)
return await this.getOrCreateClient(info, config)
}
throw error
}
}
getConnectedServers(): string[] {
return Array.from(this.clients.keys())
}
isConnected(info: SkillMcpClientInfo): boolean {
return this.clients.has(this.getClientKey(info))
}
}

View File

@@ -0,0 +1,14 @@
import type { ClaudeCodeMcpServer } from "../claude-code-mcp-loader/types"
export type SkillMcpConfig = Record<string, ClaudeCodeMcpServer>
export interface SkillMcpClientInfo {
serverName: string
skillName: string
sessionID: string
}
export interface SkillMcpServerContext {
config: ClaudeCodeMcpServer
skillName: string
}

View File

@@ -2,83 +2,65 @@
## OVERVIEW
Lifecycle hooks that intercept/modify agent behavior. Inject context, enforce rules, recover from errors, notify on events.
22 lifecycle hooks intercepting/modifying agent behavior. Context injection, error recovery, output control, notifications.
## STRUCTURE
```
hooks/
├── agent-usage-reminder/ # Remind to use specialized agents
├── anthropic-context-window-limit-recovery/ # Auto-compact Claude at token limit
├── auto-update-checker/ # Version update notifications
├── background-notification/ # OS notify on background task complete
├── claude-code-hooks/ # Claude Code settings.json integration
├── anthropic-context-window-limit-recovery/ # Auto-compact at token limit (554 lines)
├── auto-slash-command/ # Detect and execute /command patterns
├── auto-update-checker/ # Version notifications, startup toast
├── background-notification/ # OS notify on task complete
├── claude-code-hooks/ # settings.json PreToolUse/PostToolUse/etc
├── comment-checker/ # Prevent excessive AI comments
── filters/ # Filtering rules (docstring, directive, bdd, etc.)
│ └── output/ # Output formatting
├── compaction-context-injector/ # Inject context during compaction
├── directory-agents-injector/ # Auto-inject AGENTS.md files
├── directory-readme-injector/ # Auto-inject README.md files
── filters/ # docstring, directive, bdd, etc
├── compaction-context-injector/ # Preserve context during compaction
├── directory-agents-injector/ # Auto-inject AGENTS.md
├── directory-readme-injector/ # Auto-inject README.md
├── empty-message-sanitizer/ # Sanitize empty messages
├── interactive-bash-session/ # Tmux session management
├── keyword-detector/ # Detect ultrawork/search keywords
├── non-interactive-env/ # CI/headless environment handling
├── preemptive-compaction/ # Pre-emptive session compaction
├── keyword-detector/ # ultrawork/search keyword activation
├── non-interactive-env/ # CI/headless handling
├── preemptive-compaction/ # Pre-emptive at 85% usage
├── ralph-loop/ # Self-referential dev loop
├── rules-injector/ # Conditional rules from .claude/rules/
├── session-recovery/ # Recover from session errors
├── session-recovery/ # Recover from errors (430 lines)
├── think-mode/ # Auto-detect thinking triggers
├── thinking-block-validator/ # Validate thinking blocks in messages
├── context-window-monitor.ts # Monitor context usage (standalone)
├── empty-task-response-detector.ts
├── session-notification.ts # OS notify on idle (standalone)
── todo-continuation-enforcer.ts # Force TODO completion (standalone)
└── tool-output-truncator.ts # Truncate verbose outputs (standalone)
├── agent-usage-reminder/ # Remind to use specialists
├── context-window-monitor.ts # Monitor usage (standalone)
├── session-notification.ts # OS notify on idle
├── todo-continuation-enforcer.ts # Force TODO completion
── tool-output-truncator.ts # Truncate verbose outputs
```
## HOOK CATEGORIES
| Category | Hooks | Purpose |
|----------|-------|---------|
| Context Injection | directory-agents-injector, directory-readme-injector, rules-injector, compaction-context-injector | Auto-inject relevant context |
| Session Management | session-recovery, anthropic-context-window-limit-recovery, preemptive-compaction, empty-message-sanitizer | Handle session lifecycle |
| Output Control | comment-checker, tool-output-truncator | Control agent output quality |
| Notifications | session-notification, background-notification, auto-update-checker | OS/user notifications |
| Behavior Enforcement | todo-continuation-enforcer, keyword-detector, think-mode, agent-usage-reminder | Enforce agent behavior |
| Environment | non-interactive-env, interactive-bash-session, context-window-monitor | Adapt to runtime environment |
| Compatibility | claude-code-hooks | Claude Code settings.json support |
## HOW TO ADD A HOOK
1. Create directory: `src/hooks/my-hook/`
2. Create files:
- `index.ts`: Export `createMyHook(input: PluginInput)`
- `constants.ts`: Hook name constant
- `types.ts`: TypeScript interfaces (optional)
- `storage.ts`: Persistent state (optional)
3. Return event handlers: `{ PreToolUse?, PostToolUse?, UserPromptSubmit?, Stop?, onSummarize? }`
4. Export from `src/hooks/index.ts`
5. Register in main plugin
## HOOK EVENTS
| Event | Timing | Can Block | Use Case |
|-------|--------|-----------|----------|
| PreToolUse | Before tool exec | Yes | Validate, modify input |
| PostToolUse | After tool exec | No | Add context, warnings |
| UserPromptSubmit | On user prompt | Yes | Inject messages, block |
| PreToolUse | Before tool | Yes | Validate, modify input |
| PostToolUse | After tool | No | Add context, warnings |
| UserPromptSubmit | On prompt | Yes | Inject messages, block |
| Stop | Session idle | No | Inject follow-ups |
| onSummarize | During compaction | No | Preserve critical context |
| onSummarize | Compaction | No | Preserve context |
## COMMON PATTERNS
## HOW TO ADD
- **Storage**: Use `storage.ts` with JSON file for persistent state across sessions
- **Once-per-session**: Track injected paths in Set to avoid duplicate injection
- **Message injection**: Return `{ messages: [...] }` from event handlers
- **Blocking**: Return `{ blocked: true, message: "reason" }` from PreToolUse
1. Create `src/hooks/my-hook/`
2. Files: `index.ts` (createMyHook), `constants.ts`, `types.ts` (optional)
3. Return: `{ PreToolUse?, PostToolUse?, UserPromptSubmit?, Stop?, onSummarize? }`
4. Export from `src/hooks/index.ts`
## ANTI-PATTERNS (HOOKS)
## PATTERNS
- **Heavy computation** in PreToolUse: Slows every tool call
- **Blocking without clear reason**: Always provide actionable message
- **Duplicate injection**: Track what's already injected per session
- **Ignoring errors**: Always try/catch, log failures, don't crash session
- **Storage**: JSON file for persistent state across sessions
- **Once-per-session**: Track injected paths in Set
- **Message injection**: Return `{ messages: [...] }`
- **Blocking**: Return `{ blocked: true, message: "..." }` from PreToolUse
## ANTI-PATTERNS
- Heavy computation in PreToolUse (slows every tool call)
- Blocking without actionable message
- Duplicate injection (track what's injected)
- Missing try/catch (don't crash session)

View File

@@ -1,6 +1,7 @@
import { describe, test, expect, mock, beforeEach } from "bun:test"
import { describe, test, expect, mock, beforeEach, spyOn } from "bun:test"
import { executeCompact } from "./executor"
import type { AutoCompactState } from "./types"
import * as storage from "./storage"
describe("executeCompact lock management", () => {
let autoCompactState: AutoCompactState
@@ -15,7 +16,6 @@ describe("executeCompact lock management", () => {
pendingCompact: new Set<string>(),
errorDataBySession: new Map(),
retryStateBySession: new Map(),
fallbackStateBySession: new Map(),
truncateStateBySession: new Map(),
dcpStateBySession: new Map(),
emptyContentAttemptBySession: new Map(),
@@ -68,38 +68,6 @@ describe("executeCompact lock management", () => {
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false)
})
test("clears lock when revert throws exception", async () => {
// #given: Force revert path by exhausting retry attempts and making revert fail
mockClient.session.revert = mock(() =>
Promise.reject(new Error("Revert failed")),
)
mockClient.session.messages = mock(() =>
Promise.resolve({
data: [
{ info: { id: "msg1", role: "user" } },
{ info: { id: "msg2", role: "assistant" } },
],
}),
)
// Exhaust retry attempts
autoCompactState.retryStateBySession.set(sessionID, {
attempt: 5,
lastAttemptTime: Date.now(),
})
autoCompactState.errorDataBySession.set(sessionID, {
errorType: "token_limit",
currentTokens: 100000,
maxTokens: 200000,
})
// #when: Execute compaction
await executeCompact(sessionID, msg, autoCompactState, mockClient, directory)
// #then: Lock cleared even though revert failed
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false)
})
test("shows toast when lock already held", async () => {
// #given: Lock already held
autoCompactState.compactionInProgress.add(sessionID)
@@ -195,9 +163,6 @@ describe("executeCompact lock management", () => {
attempt: 5,
lastAttemptTime: Date.now(),
})
autoCompactState.fallbackStateBySession.set(sessionID, {
revertAttempt: 5,
})
autoCompactState.truncateStateBySession.set(sessionID, {
truncateAttempt: 5,
})
@@ -260,4 +225,86 @@ describe("executeCompact lock management", () => {
// The continuation happens in setTimeout, but lock is cleared in finally before that
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false)
})
test("falls through to summarize when truncation is insufficient", async () => {
// #given: Over token limit with truncation returning insufficient
autoCompactState.errorDataBySession.set(sessionID, {
errorType: "token_limit",
currentTokens: 250000,
maxTokens: 200000,
})
const truncateSpy = spyOn(storage, "truncateUntilTargetTokens").mockReturnValue({
success: true,
sufficient: false,
truncatedCount: 3,
totalBytesRemoved: 10000,
targetBytesToRemove: 50000,
truncatedTools: [
{ toolName: "Grep", originalSize: 5000 },
{ toolName: "Read", originalSize: 3000 },
{ toolName: "Bash", originalSize: 2000 },
],
})
// #when: Execute compaction
await executeCompact(sessionID, msg, autoCompactState, mockClient, directory)
// #then: Truncation was attempted
expect(truncateSpy).toHaveBeenCalled()
// #then: Summarize should be called (fall through from insufficient truncation)
expect(mockClient.session.summarize).toHaveBeenCalledWith(
expect.objectContaining({
path: { id: sessionID },
body: { providerID: "anthropic", modelID: "claude-opus-4-5" },
}),
)
// #then: Lock should be cleared
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false)
truncateSpy.mockRestore()
})
test("does NOT call summarize when truncation is sufficient", async () => {
// #given: Over token limit with truncation returning sufficient
autoCompactState.errorDataBySession.set(sessionID, {
errorType: "token_limit",
currentTokens: 250000,
maxTokens: 200000,
})
const truncateSpy = spyOn(storage, "truncateUntilTargetTokens").mockReturnValue({
success: true,
sufficient: true,
truncatedCount: 5,
totalBytesRemoved: 60000,
targetBytesToRemove: 50000,
truncatedTools: [
{ toolName: "Grep", originalSize: 30000 },
{ toolName: "Read", originalSize: 30000 },
],
})
// #when: Execute compaction
await executeCompact(sessionID, msg, autoCompactState, mockClient, directory)
// Wait for setTimeout callback
await new Promise((resolve) => setTimeout(resolve, 600))
// #then: Truncation was attempted
expect(truncateSpy).toHaveBeenCalled()
// #then: Summarize should NOT be called (early return from sufficient truncation)
expect(mockClient.session.summarize).not.toHaveBeenCalled()
// #then: prompt_async should be called (Continue after successful truncation)
expect(mockClient.session.prompt_async).toHaveBeenCalled()
// #then: Lock should be cleared
expect(autoCompactState.compactionInProgress.has(sessionID)).toBe(false)
truncateSpy.mockRestore()
})
})

View File

@@ -1,12 +1,11 @@
import type {
AutoCompactState,
DcpState,
FallbackState,
RetryState,
TruncateState,
} from "./types";
import type { ExperimentalConfig } from "../../config";
import { FALLBACK_CONFIG, RETRY_CONFIG, TRUNCATE_CONFIG } from "./types";
import { RETRY_CONFIG, TRUNCATE_CONFIG } from "./types";
import { executeDynamicContextPruning } from "./pruning-executor";
import {
findLargestToolResult,
@@ -69,17 +68,7 @@ function getOrCreateRetryState(
return state;
}
function getOrCreateFallbackState(
autoCompactState: AutoCompactState,
sessionID: string,
): FallbackState {
let state = autoCompactState.fallbackStateBySession.get(sessionID);
if (!state) {
state = { revertAttempt: 0 };
autoCompactState.fallbackStateBySession.set(sessionID, state);
}
return state;
}
function getOrCreateTruncateState(
autoCompactState: AutoCompactState,
@@ -135,58 +124,6 @@ function sanitizeEmptyMessagesBeforeSummarize(sessionID: string): number {
return fixedCount;
}
async function getLastMessagePair(
sessionID: string,
client: Client,
directory: string,
): Promise<{ userMessageID: string; assistantMessageID?: string } | null> {
try {
const resp = await client.session.messages({
path: { id: sessionID },
query: { directory },
});
const data = (resp as { data?: unknown[] }).data;
if (
!Array.isArray(data) ||
data.length < FALLBACK_CONFIG.minMessagesRequired
) {
return null;
}
const reversed = [...data].reverse();
const lastAssistant = reversed.find((m) => {
const msg = m as Record<string, unknown>;
const info = msg.info as Record<string, unknown> | undefined;
return info?.role === "assistant";
});
const lastUser = reversed.find((m) => {
const msg = m as Record<string, unknown>;
const info = msg.info as Record<string, unknown> | undefined;
return info?.role === "user";
});
if (!lastUser) return null;
const userInfo = (lastUser as { info?: Record<string, unknown> }).info;
const userMessageID = userInfo?.id as string | undefined;
if (!userMessageID) return null;
let assistantMessageID: string | undefined;
if (lastAssistant) {
const assistantInfo = (
lastAssistant as { info?: Record<string, unknown> }
).info;
assistantMessageID = assistantInfo?.id as string | undefined;
}
return { userMessageID, assistantMessageID };
} catch {
return null;
}
}
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes}B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
@@ -221,6 +158,8 @@ export async function getLastAssistant(
}
}
function clearSessionState(
autoCompactState: AutoCompactState,
sessionID: string,
@@ -228,7 +167,6 @@ function clearSessionState(
autoCompactState.pendingCompact.delete(sessionID);
autoCompactState.errorDataBySession.delete(sessionID);
autoCompactState.retryStateBySession.delete(sessionID);
autoCompactState.fallbackStateBySession.delete(sessionID);
autoCompactState.truncateStateBySession.delete(sessionID);
autoCompactState.dcpStateBySession.delete(sessionID);
autoCompactState.emptyContentAttemptBySession.delete(sessionID);
@@ -359,17 +297,16 @@ export async function executeCompact(
const errorData = autoCompactState.errorDataBySession.get(sessionID);
const truncateState = getOrCreateTruncateState(autoCompactState, sessionID);
// DCP FIRST - run before any other recovery attempts when token limit exceeded (controlled by dcp-for-compaction hook)
const dcpState = getOrCreateDcpState(autoCompactState, sessionID);
if (
dcpForCompaction !== false &&
!dcpState.attempted &&
const isOverLimit =
errorData?.currentTokens &&
errorData?.maxTokens &&
errorData.currentTokens > errorData.maxTokens
) {
errorData.currentTokens > errorData.maxTokens;
// PHASE 1: DCP (Dynamic Context Pruning) - prune duplicate tool calls first
const dcpState = getOrCreateDcpState(autoCompactState, sessionID);
if (dcpForCompaction !== false && !dcpState.attempted && isOverLimit) {
dcpState.attempted = true;
log("[auto-compact] DCP triggered FIRST on token limit error", {
log("[auto-compact] PHASE 1: DCP triggered on token limit error", {
sessionID,
currentTokens: errorData.currentTokens,
maxTokens: errorData.maxTokens,
@@ -378,19 +315,25 @@ export async function executeCompact(
const dcpConfig = experimental?.dynamic_context_pruning ?? {
enabled: true,
notification: "detailed" as const,
protected_tools: ["task", "todowrite", "todoread", "lsp_rename", "lsp_code_action_resolve"],
protected_tools: [
"task",
"todowrite",
"todoread",
"lsp_rename",
"lsp_code_action_resolve",
],
};
try {
const pruningResult = await executeDynamicContextPruning(
sessionID,
dcpConfig,
client
client,
);
if (pruningResult.itemsPruned > 0) {
dcpState.itemsPruned = pruningResult.itemsPruned;
log("[auto-compact] DCP successful, proceeding to compaction", {
log("[auto-compact] DCP successful, proceeding to truncation", {
itemsPruned: pruningResult.itemsPruned,
tokensSaved: pruningResult.totalTokensSaved,
});
@@ -399,56 +342,13 @@ export async function executeCompact(
.showToast({
body: {
title: "Dynamic Context Pruning",
message: `Pruned ${pruningResult.itemsPruned} items (~${Math.round(pruningResult.totalTokensSaved / 1000)}k tokens). Running compaction...`,
message: `Pruned ${pruningResult.itemsPruned} items (~${Math.round(pruningResult.totalTokensSaved / 1000)}k tokens). Proceeding to truncation...`,
variant: "success",
duration: 3000,
},
})
.catch(() => {});
// After DCP, immediately try summarize
const providerID = msg.providerID as string | undefined;
const modelID = msg.modelID as string | undefined;
if (providerID && modelID) {
try {
sanitizeEmptyMessagesBeforeSummarize(sessionID);
await (client as Client).tui
.showToast({
body: {
title: "Auto Compact",
message: "Summarizing session after DCP...",
variant: "warning",
duration: 3000,
},
})
.catch(() => {});
await (client as Client).session.summarize({
path: { id: sessionID },
body: { providerID, modelID },
query: { directory },
});
clearSessionState(autoCompactState, sessionID);
setTimeout(async () => {
try {
await (client as Client).session.prompt_async({
path: { sessionID },
body: { parts: [{ type: "text", text: "Continue" }] },
query: { directory },
});
} catch {}
}, 500);
return;
} catch (summarizeError) {
log("[auto-compact] summarize after DCP failed, continuing recovery", {
error: String(summarizeError),
});
}
}
// Continue to PHASE 2 (truncation) instead of summarizing immediately
} else {
log("[auto-compact] DCP did not prune any items", { sessionID });
}
@@ -457,14 +357,12 @@ export async function executeCompact(
}
}
// PHASE 2: Aggressive Truncation - always try when over limit (not experimental-only)
if (
experimental?.aggressive_truncation &&
errorData?.currentTokens &&
errorData?.maxTokens &&
errorData.currentTokens > errorData.maxTokens &&
isOverLimit &&
truncateState.truncateAttempt < TRUNCATE_CONFIG.maxTruncateAttempts
) {
log("[auto-compact] aggressive truncation triggered (experimental)", {
log("[auto-compact] PHASE 2: aggressive truncation triggered", {
currentTokens: errorData.currentTokens,
maxTokens: errorData.maxTokens,
targetRatio: TRUNCATE_CONFIG.targetTokenRatio,
@@ -486,16 +384,16 @@ export async function executeCompact(
.join(", ");
const statusMsg = aggressiveResult.sufficient
? `Truncated ${aggressiveResult.truncatedCount} outputs (${formatBytes(aggressiveResult.totalBytesRemoved)})`
: `Truncated ${aggressiveResult.truncatedCount} outputs (${formatBytes(aggressiveResult.totalBytesRemoved)}) but need ${formatBytes(aggressiveResult.targetBytesToRemove)}. Falling back to summarize/revert...`;
: `Truncated ${aggressiveResult.truncatedCount} outputs (${formatBytes(aggressiveResult.totalBytesRemoved)}) - continuing to summarize...`;
await (client as Client).tui
.showToast({
body: {
title: aggressiveResult.sufficient
? "Aggressive Truncation"
? "Truncation Complete"
: "Partial Truncation",
message: `${statusMsg}: ${toolNames}`,
variant: "warning",
variant: aggressiveResult.sufficient ? "success" : "warning",
duration: 4000,
},
})
@@ -503,7 +401,10 @@ export async function executeCompact(
log("[auto-compact] aggressive truncation completed", aggressiveResult);
// Only return early if truncation was sufficient to get under token limit
// Otherwise fall through to PHASE 3 (Summarize)
if (aggressiveResult.sufficient) {
clearSessionState(autoCompactState, sessionID);
setTimeout(async () => {
try {
await (client as Client).session.prompt_async({
@@ -515,87 +416,16 @@ export async function executeCompact(
}, 500);
return;
}
} else {
await (client as Client).tui
.showToast({
body: {
title: "Truncation Skipped",
message: "No tool outputs found to truncate.",
variant: "warning",
duration: 3000,
},
})
.catch(() => {});
}
}
let skipSummarize = false;
if (truncateState.truncateAttempt < TRUNCATE_CONFIG.maxTruncateAttempts) {
const largest = findLargestToolResult(sessionID);
if (
largest &&
largest.outputSize >= TRUNCATE_CONFIG.minOutputSizeToTruncate
) {
const result = truncateToolResult(largest.partPath);
if (result.success) {
truncateState.truncateAttempt++;
truncateState.lastTruncatedPartId = largest.partId;
await (client as Client).tui
.showToast({
body: {
title: "Truncating Large Output",
message: `Truncated ${result.toolName} (${formatBytes(result.originalSize ?? 0)}). Retrying...`,
variant: "warning",
duration: 3000,
},
})
.catch(() => {});
setTimeout(async () => {
try {
await (client as Client).session.prompt_async({
path: { sessionID },
body: { parts: [{ type: "text", text: "Continue" }] },
query: { directory },
});
} catch {}
}, 500);
return;
}
} else if (
errorData?.currentTokens &&
errorData?.maxTokens &&
errorData.currentTokens > errorData.maxTokens
) {
skipSummarize = true;
await (client as Client).tui
.showToast({
body: {
title: "Summarize Skipped",
message: `Over token limit (${errorData.currentTokens}/${errorData.maxTokens}) with nothing to truncate. Going to revert...`,
variant: "warning",
duration: 3000,
},
})
.catch(() => {});
} else if (!errorData?.currentTokens) {
await (client as Client).tui
.showToast({
body: {
title: "Truncation Skipped",
message: "No large tool outputs found.",
variant: "warning",
duration: 3000,
},
})
.catch(() => {});
// Truncation was insufficient - fall through to Summarize
log("[auto-compact] truncation insufficient, falling through to summarize", {
sessionID,
truncatedCount: aggressiveResult.truncatedCount,
sufficient: aggressiveResult.sufficient,
});
}
}
// PHASE 3: Summarize - fallback when truncation insufficient or no tool outputs
const retryState = getOrCreateRetryState(autoCompactState, sessionID);
if (errorData?.errorType?.includes("non-empty content")) {
@@ -642,11 +472,10 @@ export async function executeCompact(
if (Date.now() - retryState.lastAttemptTime > 300000) {
retryState.attempt = 0;
autoCompactState.fallbackStateBySession.delete(sessionID);
autoCompactState.truncateStateBySession.delete(sessionID);
}
if (!skipSummarize && retryState.attempt < RETRY_CONFIG.maxAttempts) {
if (retryState.attempt < RETRY_CONFIG.maxAttempts) {
retryState.attempt++;
retryState.lastAttemptTime = Date.now();
@@ -708,75 +537,7 @@ export async function executeCompact(
.showToast({
body: {
title: "Summarize Skipped",
message: "Missing providerID or modelID. Skipping to revert...",
variant: "warning",
duration: 3000,
},
})
.catch(() => {});
}
}
const fallbackState = getOrCreateFallbackState(autoCompactState, sessionID);
if (fallbackState.revertAttempt < FALLBACK_CONFIG.maxRevertAttempts) {
const pair = await getLastMessagePair(
sessionID,
client as Client,
directory,
);
if (pair) {
try {
await (client as Client).tui
.showToast({
body: {
title: "Emergency Recovery",
message: "Removing last message pair...",
variant: "warning",
duration: 3000,
},
})
.catch(() => {});
if (pair.assistantMessageID) {
await (client as Client).session.revert({
path: { id: sessionID },
body: { messageID: pair.assistantMessageID },
query: { directory },
});
}
await (client as Client).session.revert({
path: { id: sessionID },
body: { messageID: pair.userMessageID },
query: { directory },
});
fallbackState.revertAttempt++;
fallbackState.lastRevertedMessageID = pair.userMessageID;
// Clear all state after successful revert - don't recurse
clearSessionState(autoCompactState, sessionID);
// Send "Continue" prompt to resume session
setTimeout(async () => {
try {
await (client as Client).session.prompt_async({
path: { sessionID },
body: { parts: [{ type: "text", text: "Continue" }] },
query: { directory },
});
} catch {}
}, 500);
return;
} catch {}
} else {
await (client as Client).tui
.showToast({
body: {
title: "Revert Skipped",
message: "Could not find last message pair to revert.",
message: "Missing providerID or modelID.",
variant: "warning",
duration: 3000,
},

View File

@@ -15,7 +15,6 @@ function createRecoveryState(): AutoCompactState {
pendingCompact: new Set<string>(),
errorDataBySession: new Map<string, ParsedTokenLimitError>(),
retryStateBySession: new Map(),
fallbackStateBySession: new Map(),
truncateStateBySession: new Map(),
dcpStateBySession: new Map(),
emptyContentAttemptBySession: new Map(),
@@ -37,7 +36,6 @@ export function createAnthropicContextWindowLimitRecoveryHook(ctx: PluginInput,
autoCompactState.pendingCompact.delete(sessionInfo.id)
autoCompactState.errorDataBySession.delete(sessionInfo.id)
autoCompactState.retryStateBySession.delete(sessionInfo.id)
autoCompactState.fallbackStateBySession.delete(sessionInfo.id)
autoCompactState.truncateStateBySession.delete(sessionInfo.id)
autoCompactState.dcpStateBySession.delete(sessionInfo.id)
autoCompactState.emptyContentAttemptBySession.delete(sessionInfo.id)
@@ -154,6 +152,6 @@ export function createAnthropicContextWindowLimitRecoveryHook(ctx: PluginInput,
}
}
export type { AutoCompactState, DcpState, FallbackState, ParsedTokenLimitError, TruncateState } from "./types"
export type { AutoCompactState, DcpState, ParsedTokenLimitError, TruncateState } from "./types"
export { parseAnthropicTokenLimitError } from "./parser"
export { executeCompact, getLastAssistant } from "./executor"

View File

@@ -26,9 +26,23 @@ const TOKEN_LIMIT_KEYWORDS = [
"context length",
"too many tokens",
"non-empty content",
"invalid_request_error",
]
// Patterns that indicate thinking block structure errors (NOT token limit errors)
// These should be handled by session-recovery hook, not compaction
const THINKING_BLOCK_ERROR_PATTERNS = [
/thinking.*first block/i,
/first block.*thinking/i,
/must.*start.*thinking/i,
/thinking.*redacted_thinking/i,
/expected.*thinking.*found/i,
/thinking.*disabled.*cannot.*contain/i,
]
function isThinkingBlockError(text: string): boolean {
return THINKING_BLOCK_ERROR_PATTERNS.some((pattern) => pattern.test(text))
}
const MESSAGE_INDEX_PATTERN = /messages\.(\d+)/
function extractTokensFromMessage(message: string): { current: number; max: number } | null {
@@ -52,6 +66,9 @@ function extractMessageIndex(text: string): number | undefined {
}
function isTokenLimitError(text: string): boolean {
if (isThinkingBlockError(text)) {
return false
}
const lower = text.toLowerCase()
return TOKEN_LIMIT_KEYWORDS.some((kw) => lower.includes(kw.toLowerCase()))
}

View File

@@ -0,0 +1,77 @@
import { describe, test, expect, mock, beforeEach } from "bun:test"
import { truncateUntilTargetTokens } from "./storage"
import * as storage from "./storage"
// Mock the entire module
mock.module("./storage", () => {
return {
...storage,
findToolResultsBySize: mock(() => []),
truncateToolResult: mock(() => ({ success: false })),
}
})
describe("truncateUntilTargetTokens", () => {
const sessionID = "test-session"
beforeEach(() => {
// Reset mocks
const { findToolResultsBySize, truncateToolResult } = require("./storage")
findToolResultsBySize.mockReset()
truncateToolResult.mockReset()
})
test("truncates only until target is reached", () => {
const { findToolResultsBySize, truncateToolResult } = require("./storage")
// #given: Two tool results, each 1000 chars. Target reduction is 500 chars.
const results = [
{ partPath: "path1", partId: "id1", messageID: "m1", toolName: "tool1", outputSize: 1000 },
{ partPath: "path2", partId: "id2", messageID: "m2", toolName: "tool2", outputSize: 1000 },
]
findToolResultsBySize.mockReturnValue(results)
truncateToolResult.mockImplementation((path: string) => ({
success: true,
toolName: path === "path1" ? "tool1" : "tool2",
originalSize: 1000
}))
// #when: currentTokens=1000, maxTokens=1000, targetRatio=0.5 (target=500, reduce=500)
// charsPerToken=1 for simplicity in test
const result = truncateUntilTargetTokens(sessionID, 1000, 1000, 0.5, 1)
// #then: Should only truncate the first tool
expect(result.truncatedCount).toBe(1)
expect(truncateToolResult).toHaveBeenCalledTimes(1)
expect(truncateToolResult).toHaveBeenCalledWith("path1")
expect(result.totalBytesRemoved).toBe(1000)
expect(result.sufficient).toBe(true)
})
test("truncates all if target not reached", () => {
const { findToolResultsBySize, truncateToolResult } = require("./storage")
// #given: Two tool results, each 100 chars. Target reduction is 500 chars.
const results = [
{ partPath: "path1", partId: "id1", messageID: "m1", toolName: "tool1", outputSize: 100 },
{ partPath: "path2", partId: "id2", messageID: "m2", toolName: "tool2", outputSize: 100 },
]
findToolResultsBySize.mockReturnValue(results)
truncateToolResult.mockImplementation((path: string) => ({
success: true,
toolName: path === "path1" ? "tool1" : "tool2",
originalSize: 100
}))
// #when: reduce 500 chars
const result = truncateUntilTargetTokens(sessionID, 1000, 1000, 0.5, 1)
// #then: Should truncate both
expect(result.truncatedCount).toBe(2)
expect(truncateToolResult).toHaveBeenCalledTimes(2)
expect(result.totalBytesRemoved).toBe(200)
expect(result.sufficient).toBe(false)
})
})

View File

@@ -230,6 +230,10 @@ export function truncateUntilTargetTokens(
toolName: truncateResult.toolName ?? result.toolName,
originalSize: removedSize,
})
if (totalRemoved >= charsToReduce) {
break
}
}
}

View File

@@ -13,11 +13,6 @@ export interface RetryState {
lastAttemptTime: number
}
export interface FallbackState {
revertAttempt: number
lastRevertedMessageID?: string
}
export interface TruncateState {
truncateAttempt: number
lastTruncatedPartId?: string
@@ -32,7 +27,6 @@ export interface AutoCompactState {
pendingCompact: Set<string>
errorDataBySession: Map<string, ParsedTokenLimitError>
retryStateBySession: Map<string, RetryState>
fallbackStateBySession: Map<string, FallbackState>
truncateStateBySession: Map<string, TruncateState>
dcpStateBySession: Map<string, DcpState>
emptyContentAttemptBySession: Map<string, number>
@@ -46,11 +40,6 @@ export const RETRY_CONFIG = {
maxDelayMs: 30000,
} as const
export const FALLBACK_CONFIG = {
maxRevertAttempts: 3,
minMessagesRequired: 2,
} as const
export const TRUNCATE_CONFIG = {
maxTruncateAttempts: 20,
minOutputSizeToTruncate: 500,

View File

@@ -0,0 +1,11 @@
export const HOOK_NAME = "auto-slash-command" as const
export const AUTO_SLASH_COMMAND_TAG_OPEN = "<auto-slash-command>"
export const AUTO_SLASH_COMMAND_TAG_CLOSE = "</auto-slash-command>"
export const SLASH_COMMAND_PATTERN = /^\/([a-zA-Z][\w-]*)\s*(.*)/
export const EXCLUDED_COMMANDS = new Set([
"ralph-loop",
"cancel-ralph",
])

View File

@@ -0,0 +1,296 @@
import { describe, expect, it } from "bun:test"
import {
parseSlashCommand,
detectSlashCommand,
isExcludedCommand,
removeCodeBlocks,
extractPromptText,
} from "./detector"
describe("auto-slash-command detector", () => {
describe("removeCodeBlocks", () => {
it("should remove markdown code blocks", () => {
// #given text with code blocks
const text = "Hello ```code here``` world"
// #when removing code blocks
const result = removeCodeBlocks(text)
// #then code blocks should be removed
expect(result).toBe("Hello world")
})
it("should remove multiline code blocks", () => {
// #given text with multiline code blocks
const text = `Before
\`\`\`javascript
/command-inside-code
\`\`\`
After`
// #when removing code blocks
const result = removeCodeBlocks(text)
// #then code blocks should be removed
expect(result).toContain("Before")
expect(result).toContain("After")
expect(result).not.toContain("/command-inside-code")
})
it("should handle text without code blocks", () => {
// #given text without code blocks
const text = "Just regular text"
// #when removing code blocks
const result = removeCodeBlocks(text)
// #then text should remain unchanged
expect(result).toBe("Just regular text")
})
})
describe("parseSlashCommand", () => {
it("should parse simple command without args", () => {
// #given a simple slash command
const text = "/commit"
// #when parsing
const result = parseSlashCommand(text)
// #then should extract command correctly
expect(result).not.toBeNull()
expect(result?.command).toBe("commit")
expect(result?.args).toBe("")
})
it("should parse command with arguments", () => {
// #given a slash command with arguments
const text = "/plan create a new feature for auth"
// #when parsing
const result = parseSlashCommand(text)
// #then should extract command and args
expect(result).not.toBeNull()
expect(result?.command).toBe("plan")
expect(result?.args).toBe("create a new feature for auth")
})
it("should parse command with quoted arguments", () => {
// #given a slash command with quoted arguments
const text = '/execute "build the API"'
// #when parsing
const result = parseSlashCommand(text)
// #then should extract command and args
expect(result).not.toBeNull()
expect(result?.command).toBe("execute")
expect(result?.args).toBe('"build the API"')
})
it("should parse command with hyphen in name", () => {
// #given a slash command with hyphen
const text = "/frontend-template-creator project"
// #when parsing
const result = parseSlashCommand(text)
// #then should extract full command name
expect(result).not.toBeNull()
expect(result?.command).toBe("frontend-template-creator")
expect(result?.args).toBe("project")
})
it("should return null for non-slash text", () => {
// #given text without slash
const text = "regular text"
// #when parsing
const result = parseSlashCommand(text)
// #then should return null
expect(result).toBeNull()
})
it("should return null for slash not at start", () => {
// #given text with slash in middle
const text = "some text /command"
// #when parsing
const result = parseSlashCommand(text)
// #then should return null (slash not at start)
expect(result).toBeNull()
})
it("should return null for just a slash", () => {
// #given just a slash
const text = "/"
// #when parsing
const result = parseSlashCommand(text)
// #then should return null
expect(result).toBeNull()
})
it("should return null for slash followed by number", () => {
// #given slash followed by number
const text = "/123"
// #when parsing
const result = parseSlashCommand(text)
// #then should return null (command must start with letter)
expect(result).toBeNull()
})
it("should handle whitespace before slash", () => {
// #given command with leading whitespace
const text = " /commit"
// #when parsing
const result = parseSlashCommand(text)
// #then should parse after trimming
expect(result).not.toBeNull()
expect(result?.command).toBe("commit")
})
})
describe("isExcludedCommand", () => {
it("should exclude ralph-loop", () => {
// #given ralph-loop command
// #when checking exclusion
// #then should be excluded
expect(isExcludedCommand("ralph-loop")).toBe(true)
})
it("should exclude cancel-ralph", () => {
// #given cancel-ralph command
// #when checking exclusion
// #then should be excluded
expect(isExcludedCommand("cancel-ralph")).toBe(true)
})
it("should be case-insensitive for exclusion", () => {
// #given uppercase variants
// #when checking exclusion
// #then should still be excluded
expect(isExcludedCommand("RALPH-LOOP")).toBe(true)
expect(isExcludedCommand("Cancel-Ralph")).toBe(true)
})
it("should not exclude regular commands", () => {
// #given regular commands
// #when checking exclusion
// #then should not be excluded
expect(isExcludedCommand("commit")).toBe(false)
expect(isExcludedCommand("plan")).toBe(false)
expect(isExcludedCommand("execute")).toBe(false)
})
})
describe("detectSlashCommand", () => {
it("should detect slash command in plain text", () => {
// #given plain text with slash command
const text = "/commit fix typo"
// #when detecting
const result = detectSlashCommand(text)
// #then should detect
expect(result).not.toBeNull()
expect(result?.command).toBe("commit")
expect(result?.args).toBe("fix typo")
})
it("should NOT detect slash command inside code block", () => {
// #given slash command inside code block
const text = "```bash\n/command\n```"
// #when detecting
const result = detectSlashCommand(text)
// #then should not detect (only code block content)
expect(result).toBeNull()
})
it("should detect command when text has code blocks elsewhere", () => {
// #given slash command before code block
const text = "/commit fix\n```code```"
// #when detecting
const result = detectSlashCommand(text)
// #then should detect the command
expect(result).not.toBeNull()
expect(result?.command).toBe("commit")
})
it("should NOT detect excluded commands", () => {
// #given excluded command
const text = "/ralph-loop do something"
// #when detecting
const result = detectSlashCommand(text)
// #then should not detect
expect(result).toBeNull()
})
it("should return null for non-command text", () => {
// #given regular text
const text = "Just some regular text"
// #when detecting
const result = detectSlashCommand(text)
// #then should return null
expect(result).toBeNull()
})
})
describe("extractPromptText", () => {
it("should extract text from parts", () => {
// #given message parts
const parts = [
{ type: "text", text: "Hello " },
{ type: "tool_use", id: "123" },
{ type: "text", text: "world" },
]
// #when extracting
const result = extractPromptText(parts)
// #then should join text parts
expect(result).toBe("Hello world")
})
it("should handle empty parts", () => {
// #given empty parts
const parts: Array<{ type: string; text?: string }> = []
// #when extracting
const result = extractPromptText(parts)
// #then should return empty string
expect(result).toBe("")
})
it("should handle parts without text", () => {
// #given parts without text content
const parts = [
{ type: "tool_use", id: "123" },
{ type: "tool_result", output: "result" },
]
// #when extracting
const result = extractPromptText(parts)
// #then should return empty string
expect(result).toBe("")
})
})
})

View File

@@ -0,0 +1,65 @@
import {
SLASH_COMMAND_PATTERN,
EXCLUDED_COMMANDS,
} from "./constants"
import type { ParsedSlashCommand } from "./types"
const CODE_BLOCK_PATTERN = /```[\s\S]*?```/g
export function removeCodeBlocks(text: string): string {
return text.replace(CODE_BLOCK_PATTERN, "")
}
export function parseSlashCommand(text: string): ParsedSlashCommand | null {
const trimmed = text.trim()
if (!trimmed.startsWith("/")) {
return null
}
const match = trimmed.match(SLASH_COMMAND_PATTERN)
if (!match) {
return null
}
const [raw, command, args] = match
return {
command: command.toLowerCase(),
args: args.trim(),
raw,
}
}
export function isExcludedCommand(command: string): boolean {
return EXCLUDED_COMMANDS.has(command.toLowerCase())
}
export function detectSlashCommand(text: string): ParsedSlashCommand | null {
const textWithoutCodeBlocks = removeCodeBlocks(text)
const trimmed = textWithoutCodeBlocks.trim()
if (!trimmed.startsWith("/")) {
return null
}
const parsed = parseSlashCommand(trimmed)
if (!parsed) {
return null
}
if (isExcludedCommand(parsed.command)) {
return null
}
return parsed
}
export function extractPromptText(
parts: Array<{ type: string; text?: string }>
): string {
return parts
.filter((p) => p.type === "text")
.map((p) => p.text || "")
.join(" ")
}

View File

@@ -0,0 +1,194 @@
import { existsSync, readdirSync, readFileSync } from "fs"
import { join, basename, dirname } from "path"
import { homedir } from "os"
import {
parseFrontmatter,
resolveCommandsInText,
resolveFileReferencesInText,
sanitizeModelField,
getClaudeConfigDir,
} from "../../shared"
import type { CommandFrontmatter } from "../../features/claude-code-command-loader/types"
import { isMarkdownFile } from "../../shared/file-utils"
import { discoverAllSkills, type LoadedSkill } from "../../features/opencode-skill-loader"
import type { ParsedSlashCommand } from "./types"
interface CommandScope {
type: "user" | "project" | "opencode" | "opencode-project" | "skill"
}
interface CommandMetadata {
name: string
description: string
argumentHint?: string
model?: string
agent?: string
subtask?: boolean
}
interface CommandInfo {
name: string
path?: string
metadata: CommandMetadata
content?: string
scope: CommandScope["type"]
}
function discoverCommandsFromDir(commandsDir: string, scope: CommandScope["type"]): CommandInfo[] {
if (!existsSync(commandsDir)) {
return []
}
const entries = readdirSync(commandsDir, { withFileTypes: true })
const commands: CommandInfo[] = []
for (const entry of entries) {
if (!isMarkdownFile(entry)) continue
const commandPath = join(commandsDir, entry.name)
const commandName = basename(entry.name, ".md")
try {
const content = readFileSync(commandPath, "utf-8")
const { data, body } = parseFrontmatter<CommandFrontmatter>(content)
const isOpencodeSource = scope === "opencode" || scope === "opencode-project"
const metadata: CommandMetadata = {
name: commandName,
description: data.description || "",
argumentHint: data["argument-hint"],
model: sanitizeModelField(data.model, isOpencodeSource ? "opencode" : "claude-code"),
agent: data.agent,
subtask: Boolean(data.subtask),
}
commands.push({
name: commandName,
path: commandPath,
metadata,
content: body,
scope,
})
} catch {
continue
}
}
return commands
}
function skillToCommandInfo(skill: LoadedSkill): CommandInfo {
return {
name: skill.name,
path: skill.path,
metadata: {
name: skill.name,
description: skill.definition.description || "",
argumentHint: skill.definition.argumentHint,
model: skill.definition.model,
agent: skill.definition.agent,
subtask: skill.definition.subtask,
},
content: skill.definition.template,
scope: "skill",
}
}
function discoverAllCommands(): CommandInfo[] {
const userCommandsDir = join(getClaudeConfigDir(), "commands")
const projectCommandsDir = join(process.cwd(), ".claude", "commands")
const opencodeGlobalDir = join(homedir(), ".config", "opencode", "command")
const opencodeProjectDir = join(process.cwd(), ".opencode", "command")
const userCommands = discoverCommandsFromDir(userCommandsDir, "user")
const opencodeGlobalCommands = discoverCommandsFromDir(opencodeGlobalDir, "opencode")
const projectCommands = discoverCommandsFromDir(projectCommandsDir, "project")
const opencodeProjectCommands = discoverCommandsFromDir(opencodeProjectDir, "opencode-project")
const skills = discoverAllSkills()
const skillCommands = skills.map(skillToCommandInfo)
return [
...opencodeProjectCommands,
...projectCommands,
...opencodeGlobalCommands,
...userCommands,
...skillCommands,
]
}
function findCommand(commandName: string): CommandInfo | null {
const allCommands = discoverAllCommands()
return allCommands.find(
(cmd) => cmd.name.toLowerCase() === commandName.toLowerCase()
) ?? null
}
async function formatCommandTemplate(cmd: CommandInfo, args: string): Promise<string> {
const sections: string[] = []
sections.push(`# /${cmd.name} Command\n`)
if (cmd.metadata.description) {
sections.push(`**Description**: ${cmd.metadata.description}\n`)
}
if (args) {
sections.push(`**User Arguments**: ${args}\n`)
}
if (cmd.metadata.model) {
sections.push(`**Model**: ${cmd.metadata.model}\n`)
}
if (cmd.metadata.agent) {
sections.push(`**Agent**: ${cmd.metadata.agent}\n`)
}
sections.push(`**Scope**: ${cmd.scope}\n`)
sections.push("---\n")
sections.push("## Command Instructions\n")
const commandDir = cmd.path ? dirname(cmd.path) : process.cwd()
const withFileRefs = await resolveFileReferencesInText(cmd.content || "", commandDir)
const resolvedContent = await resolveCommandsInText(withFileRefs)
sections.push(resolvedContent.trim())
if (args) {
sections.push("\n\n---\n")
sections.push("## User Request\n")
sections.push(args)
}
return sections.join("\n")
}
export interface ExecuteResult {
success: boolean
replacementText?: string
error?: string
}
export async function executeSlashCommand(parsed: ParsedSlashCommand): Promise<ExecuteResult> {
const command = findCommand(parsed.command)
if (!command) {
return {
success: false,
error: `Command "/${parsed.command}" not found. Use the slashcommand tool to list available commands.`,
}
}
try {
const template = await formatCommandTemplate(command, parsed.args)
return {
success: true,
replacementText: template,
}
} catch (err) {
return {
success: false,
error: `Failed to load command "/${parsed.command}": ${err instanceof Error ? err.message : String(err)}`,
}
}
}

View File

@@ -0,0 +1,258 @@
import { describe, expect, it, beforeEach, mock, spyOn } from "bun:test"
import type {
AutoSlashCommandHookInput,
AutoSlashCommandHookOutput,
} from "./types"
// Import real shared module to avoid mock leaking to other test files
import * as shared from "../../shared"
// Spy on log instead of mocking the entire module
const logMock = spyOn(shared, "log").mockImplementation(() => {})
const { createAutoSlashCommandHook } = await import("./index")
function createMockInput(sessionID: string, messageID?: string): AutoSlashCommandHookInput {
return {
sessionID,
messageID: messageID ?? `msg-${Date.now()}-${Math.random()}`,
agent: "test-agent",
model: { providerID: "anthropic", modelID: "claude-sonnet-4-5" },
}
}
function createMockOutput(text: string): AutoSlashCommandHookOutput {
return {
message: {
agent: "test-agent",
model: { providerID: "anthropic", modelID: "claude-sonnet-4-5" },
path: { cwd: "/test", root: "/test" },
tools: {},
},
parts: [{ type: "text", text }],
}
}
describe("createAutoSlashCommandHook", () => {
beforeEach(() => {
logMock.mockClear()
})
describe("slash command replacement", () => {
it("should replace message with error when command not found", async () => {
// #given a slash command that doesn't exist
const hook = createAutoSlashCommandHook()
const sessionID = `test-session-notfound-${Date.now()}`
const input = createMockInput(sessionID)
const output = createMockOutput("/nonexistent-command args")
// #when hook is called
await hook["chat.message"](input, output)
// #then should replace with error message
const textPart = output.parts.find((p) => p.type === "text")
expect(textPart?.text).toContain("<auto-slash-command>")
expect(textPart?.text).toContain("not found")
})
it("should wrap replacement in auto-slash-command tags", async () => {
// #given any slash command
const hook = createAutoSlashCommandHook()
const sessionID = `test-session-tags-${Date.now()}`
const input = createMockInput(sessionID)
const output = createMockOutput("/some-command")
// #when hook is called
await hook["chat.message"](input, output)
// #then should wrap in tags
const textPart = output.parts.find((p) => p.type === "text")
expect(textPart?.text).toContain("<auto-slash-command>")
expect(textPart?.text).toContain("</auto-slash-command>")
})
it("should completely replace original message text", async () => {
// #given slash command
const hook = createAutoSlashCommandHook()
const sessionID = `test-session-replace-${Date.now()}`
const input = createMockInput(sessionID)
const output = createMockOutput("/test-cmd some args")
// #when hook is called
await hook["chat.message"](input, output)
// #then original text should be replaced, not prepended
const textPart = output.parts.find((p) => p.type === "text")
expect(textPart?.text).not.toContain("/test-cmd some args\n<auto-slash-command>")
expect(textPart?.text?.startsWith("<auto-slash-command>")).toBe(true)
})
})
describe("no slash command", () => {
it("should do nothing for regular text", async () => {
// #given regular text without slash
const hook = createAutoSlashCommandHook()
const sessionID = `test-session-regular-${Date.now()}`
const input = createMockInput(sessionID)
const output = createMockOutput("Just regular text")
const originalText = output.parts[0].text
// #when hook is called
await hook["chat.message"](input, output)
// #then should not modify
expect(output.parts[0].text).toBe(originalText)
})
it("should do nothing for slash in middle of text", async () => {
// #given slash in middle
const hook = createAutoSlashCommandHook()
const sessionID = `test-session-middle-${Date.now()}`
const input = createMockInput(sessionID)
const output = createMockOutput("Please run /commit later")
const originalText = output.parts[0].text
// #when hook is called
await hook["chat.message"](input, output)
// #then should not detect (not at start)
expect(output.parts[0].text).toBe(originalText)
})
})
describe("excluded commands", () => {
it("should NOT trigger for ralph-loop command", async () => {
// #given ralph-loop command
const hook = createAutoSlashCommandHook()
const sessionID = `test-session-ralph-${Date.now()}`
const input = createMockInput(sessionID)
const output = createMockOutput("/ralph-loop do something")
const originalText = output.parts[0].text
// #when hook is called
await hook["chat.message"](input, output)
// #then should not modify (excluded command)
expect(output.parts[0].text).toBe(originalText)
})
it("should NOT trigger for cancel-ralph command", async () => {
// #given cancel-ralph command
const hook = createAutoSlashCommandHook()
const sessionID = `test-session-cancel-${Date.now()}`
const input = createMockInput(sessionID)
const output = createMockOutput("/cancel-ralph")
const originalText = output.parts[0].text
// #when hook is called
await hook["chat.message"](input, output)
// #then should not modify
expect(output.parts[0].text).toBe(originalText)
})
})
describe("already processed", () => {
it("should skip if auto-slash-command tags already present", async () => {
// #given text with existing tags
const hook = createAutoSlashCommandHook()
const sessionID = `test-session-existing-${Date.now()}`
const input = createMockInput(sessionID)
const output = createMockOutput(
"<auto-slash-command>/commit</auto-slash-command>"
)
const originalText = output.parts[0].text
// #when hook is called
await hook["chat.message"](input, output)
// #then should not modify
expect(output.parts[0].text).toBe(originalText)
})
})
describe("code blocks", () => {
it("should NOT detect command inside code block", async () => {
// #given command inside code block
const hook = createAutoSlashCommandHook()
const sessionID = `test-session-codeblock-${Date.now()}`
const input = createMockInput(sessionID)
const output = createMockOutput("```\n/commit\n```")
const originalText = output.parts[0].text
// #when hook is called
await hook["chat.message"](input, output)
// #then should not detect
expect(output.parts[0].text).toBe(originalText)
})
})
describe("edge cases", () => {
it("should handle empty text", async () => {
// #given empty text
const hook = createAutoSlashCommandHook()
const sessionID = `test-session-empty-${Date.now()}`
const input = createMockInput(sessionID)
const output = createMockOutput("")
// #when hook is called
// #then should not throw
await expect(hook["chat.message"](input, output)).resolves.toBeUndefined()
})
it("should handle just slash", async () => {
// #given just slash
const hook = createAutoSlashCommandHook()
const sessionID = `test-session-slash-only-${Date.now()}`
const input = createMockInput(sessionID)
const output = createMockOutput("/")
const originalText = output.parts[0].text
// #when hook is called
await hook["chat.message"](input, output)
// #then should not modify
expect(output.parts[0].text).toBe(originalText)
})
it("should handle command with special characters in args", async () => {
// #given command with special characters
const hook = createAutoSlashCommandHook()
const sessionID = `test-session-special-${Date.now()}`
const input = createMockInput(sessionID)
const output = createMockOutput('/execute "test & stuff <tag>"')
// #when hook is called
await hook["chat.message"](input, output)
// #then should handle gracefully (not found, but processed)
const textPart = output.parts.find((p) => p.type === "text")
expect(textPart?.text).toContain("<auto-slash-command>")
expect(textPart?.text).toContain("/execute")
})
it("should handle multiple text parts", async () => {
// #given multiple text parts
const hook = createAutoSlashCommandHook()
const sessionID = `test-session-multi-${Date.now()}`
const input = createMockInput(sessionID)
const output: AutoSlashCommandHookOutput = {
message: {},
parts: [
{ type: "text", text: "/commit " },
{ type: "text", text: "fix bug" },
],
}
// #when hook is called
await hook["chat.message"](input, output)
// #then should detect from combined text and modify first text part
const firstTextPart = output.parts.find((p) => p.type === "text")
expect(firstTextPart?.text).toContain("<auto-slash-command>")
})
})
})

View File

@@ -0,0 +1,82 @@
import {
detectSlashCommand,
extractPromptText,
} from "./detector"
import { executeSlashCommand } from "./executor"
import { log } from "../../shared"
import {
AUTO_SLASH_COMMAND_TAG_OPEN,
AUTO_SLASH_COMMAND_TAG_CLOSE,
} from "./constants"
import type {
AutoSlashCommandHookInput,
AutoSlashCommandHookOutput,
} from "./types"
export * from "./detector"
export * from "./executor"
export * from "./constants"
export * from "./types"
const sessionProcessedCommands = new Set<string>()
export function createAutoSlashCommandHook() {
return {
"chat.message": async (
input: AutoSlashCommandHookInput,
output: AutoSlashCommandHookOutput
): Promise<void> => {
const promptText = extractPromptText(output.parts)
if (
promptText.includes(AUTO_SLASH_COMMAND_TAG_OPEN) ||
promptText.includes(AUTO_SLASH_COMMAND_TAG_CLOSE)
) {
return
}
const parsed = detectSlashCommand(promptText)
if (!parsed) {
return
}
const commandKey = `${input.sessionID}:${input.messageID}:${parsed.command}`
if (sessionProcessedCommands.has(commandKey)) {
return
}
sessionProcessedCommands.add(commandKey)
log(`[auto-slash-command] Detected: /${parsed.command}`, {
sessionID: input.sessionID,
args: parsed.args,
})
const result = await executeSlashCommand(parsed)
const idx = output.parts.findIndex((p) => p.type === "text" && p.text)
if (idx < 0) {
return
}
if (result.success && result.replacementText) {
const taggedContent = `${AUTO_SLASH_COMMAND_TAG_OPEN}\n${result.replacementText}\n${AUTO_SLASH_COMMAND_TAG_CLOSE}`
output.parts[idx].text = taggedContent
log(`[auto-slash-command] Replaced message with command template`, {
sessionID: input.sessionID,
command: parsed.command,
})
} else {
const errorMessage = `${AUTO_SLASH_COMMAND_TAG_OPEN}\n[AUTO-SLASH-COMMAND ERROR]\n${result.error}\n\nOriginal input: ${parsed.raw}\n${AUTO_SLASH_COMMAND_TAG_CLOSE}`
output.parts[idx].text = errorMessage
log(`[auto-slash-command] Command not found, showing error`, {
sessionID: input.sessionID,
command: parsed.command,
error: result.error,
})
}
},
}
}

View File

@@ -0,0 +1,23 @@
export interface AutoSlashCommandHookInput {
sessionID: string
agent?: string
model?: { providerID: string; modelID: string }
messageID?: string
}
export interface AutoSlashCommandHookOutput {
message: Record<string, unknown>
parts: Array<{ type: string; text?: string; [key: string]: unknown }>
}
export interface ParsedSlashCommand {
command: string
args: string
raw: string
}
export interface AutoSlashCommandResult {
detected: boolean
parsedCommand?: ParsedSlashCommand
injectedMessage?: string
}

View File

@@ -9,7 +9,10 @@ import {
INSTALLED_PACKAGE_JSON,
USER_OPENCODE_CONFIG,
USER_OPENCODE_CONFIG_JSONC,
USER_CONFIG_DIR,
getWindowsAppdataDir,
} from "./constants"
import * as os from "node:os"
import { log } from "../../shared/logger"
export function isLocalDevMode(directory: string): boolean {
@@ -23,12 +26,32 @@ function stripJsonComments(json: string): string {
}
function getConfigPaths(directory: string): string[] {
return [
const paths = [
path.join(directory, ".opencode", "opencode.json"),
path.join(directory, ".opencode", "opencode.jsonc"),
USER_OPENCODE_CONFIG,
USER_OPENCODE_CONFIG_JSONC,
]
if (process.platform === "win32") {
const crossPlatformDir = path.join(os.homedir(), ".config")
const appdataDir = getWindowsAppdataDir()
if (appdataDir) {
const alternateDir = USER_CONFIG_DIR === crossPlatformDir ? appdataDir : crossPlatformDir
const alternateConfig = path.join(alternateDir, "opencode", "opencode.json")
const alternateConfigJsonc = path.join(alternateDir, "opencode", "opencode.jsonc")
if (!paths.includes(alternateConfig)) {
paths.push(alternateConfig)
}
if (!paths.includes(alternateConfigJsonc)) {
paths.push(alternateConfigJsonc)
}
}
}
return paths
}
export function getLocalDevPath(directory: string): string | null {

View File

@@ -1,5 +1,6 @@
import * as path from "node:path"
import * as os from "node:os"
import * as fs from "node:fs"
export const PACKAGE_NAME = "oh-my-opencode"
export const NPM_REGISTRY_URL = `https://registry.npmjs.org/-/package/${PACKAGE_NAME}/dist-tags`
@@ -28,14 +29,36 @@ export const INSTALLED_PACKAGE_JSON = path.join(
/**
* OpenCode config file locations (priority order)
* On Windows, checks ~/.config first (cross-platform), then %APPDATA% (fallback)
* This matches shared/config-path.ts behavior for consistency
*/
function getUserConfigDir(): string {
if (process.platform === "win32") {
return process.env.APPDATA ?? path.join(os.homedir(), "AppData", "Roaming")
const crossPlatformDir = path.join(os.homedir(), ".config")
const appdataDir = process.env.APPDATA ?? path.join(os.homedir(), "AppData", "Roaming")
// Check cross-platform path first (~/.config)
const crossPlatformConfig = path.join(crossPlatformDir, "opencode", "opencode.json")
const crossPlatformConfigJsonc = path.join(crossPlatformDir, "opencode", "opencode.jsonc")
if (fs.existsSync(crossPlatformConfig) || fs.existsSync(crossPlatformConfigJsonc)) {
return crossPlatformDir
}
// Fall back to %APPDATA%
return appdataDir
}
return process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config")
}
/**
* Get the Windows-specific APPDATA directory (for fallback checks)
*/
export function getWindowsAppdataDir(): string | null {
if (process.platform !== "win32") return null
return process.env.APPDATA ?? path.join(os.homedir(), "AppData", "Roaming")
}
export const USER_CONFIG_DIR = getUserConfigDir()
export const USER_OPENCODE_CONFIG = path.join(USER_CONFIG_DIR, "opencode", "opencode.json")
export const USER_OPENCODE_CONFIG_JSONC = path.join(USER_CONFIG_DIR, "opencode", "opencode.jsonc")

View File

@@ -4,6 +4,7 @@ import { invalidatePackage } from "./cache"
import { PACKAGE_NAME } from "./constants"
import { log } from "../../shared/logger"
import { getConfigLoadErrors, clearConfigLoadErrors } from "../../shared/config-errors"
import { runBunInstall } from "../../cli/config-manager"
import type { AutoUpdateCheckerOptions } from "./types"
const SISYPHUS_SPINNER = ["·", "•", "●", "○", "◌", "◦", " "]
@@ -100,16 +101,34 @@ async function runBackgroundUpdateCheck(
if (pluginInfo.isPinned) {
const updated = updatePinnedVersion(pluginInfo.configPath, pluginInfo.entry, latestVersion)
if (updated) {
invalidatePackage(PACKAGE_NAME)
await showAutoUpdatedToast(ctx, currentVersion, latestVersion)
log(`[auto-update-checker] Config updated: ${pluginInfo.entry}${PACKAGE_NAME}@${latestVersion}`)
} else {
if (!updated) {
await showUpdateAvailableToast(ctx, latestVersion, getToastMessage)
log("[auto-update-checker] Failed to update pinned version in config")
return
}
log(`[auto-update-checker] Config updated: ${pluginInfo.entry}${PACKAGE_NAME}@${latestVersion}`)
}
invalidatePackage(PACKAGE_NAME)
const installSuccess = await runBunInstallSafe()
if (installSuccess) {
await showAutoUpdatedToast(ctx, currentVersion, latestVersion)
log(`[auto-update-checker] Update installed: ${currentVersion}${latestVersion}`)
} else {
invalidatePackage(PACKAGE_NAME)
await showUpdateAvailableToast(ctx, latestVersion, getToastMessage)
log("[auto-update-checker] bun install failed; update not installed (falling back to notification-only)")
}
}
async function runBunInstallSafe(): Promise<boolean> {
try {
return await runBunInstall()
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err)
log("[auto-update-checker] bun install error:", errorMessage)
return false
}
}

View File

@@ -112,11 +112,6 @@ export function createClaudeCodeHooksHook(ctx: PluginInput, config: PluginConfig
const isFirstMessage = !sessionFirstMessageProcessed.has(input.sessionID)
sessionFirstMessageProcessed.add(input.sessionID)
if (isFirstMessage) {
log("Skipping UserPromptSubmit hooks on first message for title generation", { sessionID: input.sessionID })
return
}
if (!isHookDisabled(config, "UserPromptSubmit")) {
const userPromptCtx: UserPromptSubmitContext = {
sessionId: input.sessionID,
@@ -144,24 +139,33 @@ export function createClaudeCodeHooksHook(ctx: PluginInput, config: PluginConfig
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 })
const message = output.message as {
agent?: string
model?: { modelID?: string; providerID?: string }
path?: { cwd?: string; root?: string }
tools?: Record<string, boolean>
log(`[claude-code-hooks] Injecting ${result.messages.length} hook messages`, { sessionID: input.sessionID, contentLength: hookContent.length, isFirstMessage })
if (isFirstMessage) {
const idx = output.parts.findIndex((p) => p.type === "text" && p.text)
if (idx >= 0) {
output.parts[idx].text = `${hookContent}\n\n${output.parts[idx].text ?? ""}`
log("UserPromptSubmit hooks prepended to first message parts directly", { sessionID: input.sessionID })
}
} else {
const message = output.message as {
agent?: string
model?: { modelID?: string; providerID?: string }
path?: { cwd?: string; root?: string }
tools?: Record<string, boolean>
}
const success = injectHookMessage(input.sessionID, hookContent, {
agent: message.agent,
model: message.model,
path: message.path ?? { cwd: ctx.directory, root: "/" },
tools: message.tools,
})
log(success ? "Hook message injected via file system" : "File injection failed", {
sessionID: input.sessionID,
})
}
const success = injectHookMessage(input.sessionID, hookContent, {
agent: message.agent,
model: message.model,
path: message.path ?? { cwd: ctx.directory, root: "/" },
tools: message.tools,
})
log(success ? "Hook message injected via file system" : "File injection failed", {
sessionID: input.sessionID,
})
}
}
},

View File

@@ -60,12 +60,17 @@ export function createDirectoryAgentsInjectorHook(ctx: PluginInput) {
let current = startDir;
while (true) {
const agentsPath = join(current, AGENTS_FILENAME);
if (existsSync(agentsPath)) {
found.push(agentsPath);
// Skip root AGENTS.md - OpenCode's system.ts already loads it via custom()
// See: https://github.com/code-yeongyu/oh-my-opencode/issues/379
const isRootDir = current === ctx.directory;
if (!isRootDir) {
const agentsPath = join(current, AGENTS_FILENAME);
if (existsSync(agentsPath)) {
found.push(agentsPath);
}
}
if (current === ctx.directory) break;
if (isRootDir) break;
const parent = dirname(current);
if (parent === current) break;
if (!parent.startsWith(ctx.directory)) break;

View File

@@ -23,3 +23,4 @@ export { createInteractiveBashSessionHook } from "./interactive-bash-session";
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";

View File

@@ -4,7 +4,7 @@ export const INLINE_CODE_PATTERN = /`[^`]+`/g
export const KEYWORD_DETECTORS: Array<{ pattern: RegExp; message: string }> = [
// ULTRAWORK: ulw, ultrawork
{
pattern: /\b(ultrawork|ulw)\b/i,
pattern: /(ultrawork|ulw)/i,
message: `<ultrawork-mode>
[CODE RED] Maximum precision required. Ultrathink before acting.

View File

@@ -4,6 +4,11 @@ import {
INLINE_CODE_PATTERN,
} from "./constants"
export interface DetectedKeyword {
type: "ultrawork" | "search" | "analyze"
message: string
}
export function removeCodeBlocks(text: string): string {
return text.replace(CODE_BLOCK_PATTERN, "").replace(INLINE_CODE_PATTERN, "")
}
@@ -15,6 +20,18 @@ export function detectKeywords(text: string): string[] {
).map(({ message }) => message)
}
export function detectKeywordsWithType(text: 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,
}))
.filter((result) => result.matches)
.map(({ type, message }) => ({ type, message }))
}
export function extractPromptText(
parts: Array<{ type: string; text?: string }>
): string {

View File

@@ -1,4 +1,5 @@
import { detectKeywords, extractPromptText } from "./detector"
import type { PluginInput } from "@opencode-ai/plugin"
import { detectKeywords, detectKeywordsWithType, extractPromptText } from "./detector"
import { log } from "../../shared"
import { injectHookMessage } from "../../features/hook-message-injector"
@@ -7,8 +8,9 @@ export * from "./constants"
export * from "./types"
const sessionFirstMessageProcessed = new Set<string>()
const sessionUltraworkNotified = new Set<string>()
export function createKeywordDetectorHook() {
export function createKeywordDetectorHook(ctx: PluginInput) {
return {
"chat.message": async (
input: {
@@ -26,12 +28,28 @@ export function createKeywordDetectorHook() {
sessionFirstMessageProcessed.add(input.sessionID)
const promptText = extractPromptText(output.parts)
const messages = detectKeywords(promptText)
const detectedKeywords = detectKeywordsWithType(promptText)
const messages = detectedKeywords.map((k) => k.message)
if (messages.length === 0) {
return
}
const hasUltrawork = detectedKeywords.some((k) => k.type === "ultrawork")
if (hasUltrawork && !sessionUltraworkNotified.has(input.sessionID)) {
sessionUltraworkNotified.add(input.sessionID)
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 }))
}
const context = messages.join("\n")
// First message: transform parts directly (for title generation compatibility)

View File

@@ -7,10 +7,11 @@ export const NON_INTERACTIVE_ENV: Record<string, string> = {
GCM_INTERACTIVE: "never",
HOMEBREW_NO_AUTO_UPDATE: "1",
// Block interactive editors - git rebase, commit, etc.
GIT_EDITOR: "true",
EDITOR: "true",
VISUAL: "true",
GIT_SEQUENCE_EDITOR: "true",
GIT_EDITOR: ":",
EDITOR: ":",
VISUAL: "",
GIT_SEQUENCE_EDITOR: ":",
GIT_MERGE_AUTOEDIT: "no",
// Block pagers
GIT_PAGER: "cat",
PAGER: "cat",

View File

@@ -35,6 +35,7 @@ export function createNonInteractiveEnvHook(_ctx: PluginInput) {
}
output.args.env = {
...process.env,
...(output.args.env as Record<string, string> | undefined),
...NON_INTERACTIVE_ENV,
}

View File

@@ -82,10 +82,12 @@ export function createPreemptiveCompactionHook(
const experimental = options?.experimental
const onBeforeSummarize = options?.onBeforeSummarize
const getModelLimit = options?.getModelLimit
const enabled = experimental?.preemptive_compaction === true
// Preemptive compaction is now enabled by default.
// Backward compatibility: explicit false in experimental config disables the hook.
const explicitlyDisabled = experimental?.preemptive_compaction === false
const threshold = experimental?.preemptive_compaction_threshold ?? DEFAULT_THRESHOLD
if (!enabled) {
if (explicitlyDisabled) {
return { event: async () => {} }
}

View File

@@ -10,6 +10,8 @@ describe("ralph-loop", () => {
const TEST_DIR = join(tmpdir(), "ralph-loop-test-" + Date.now())
let promptCalls: Array<{ sessionID: string; text: string }>
let toastCalls: Array<{ title: string; message: string; variant: string }>
let messagesCalls: Array<{ sessionID: string }>
let mockSessionMessages: Array<{ info?: { role?: string }; parts?: Array<{ type: string; text?: string }> }>
function createMockPluginInput() {
return {
@@ -22,6 +24,10 @@ describe("ralph-loop", () => {
})
return {}
},
messages: async (opts: { path: { id: string } }) => {
messagesCalls.push({ sessionID: opts.path.id })
return { data: mockSessionMessages }
},
},
tui: {
showToast: async (opts: { body: { title: string; message: string; variant: string } }) => {
@@ -35,12 +41,14 @@ describe("ralph-loop", () => {
},
},
directory: TEST_DIR,
} as Parameters<typeof createRalphLoopHook>[0]
} as unknown as Parameters<typeof createRalphLoopHook>[0]
}
beforeEach(() => {
promptCalls = []
toastCalls = []
messagesCalls = []
mockSessionMessages = []
if (!existsSync(TEST_DIR)) {
mkdirSync(TEST_DIR, { recursive: true })
@@ -351,6 +359,35 @@ describe("ralph-loop", () => {
expect(hook.getState()).toBeNull()
})
test("should detect completion promise via session messages API", async () => {
// #given - active loop with assistant message containing completion promise
mockSessionMessages = [
{ info: { role: "user" }, parts: [{ type: "text", text: "Build something" }] },
{ info: { role: "assistant" }, parts: [{ type: "text", text: "I have completed the task. <promise>API_DONE</promise>" }] },
]
const hook = createRalphLoopHook(createMockPluginInput(), {
getTranscriptPath: () => join(TEST_DIR, "nonexistent.jsonl"),
})
hook.startLoop("session-123", "Build something", { completionPromise: "API_DONE" })
// #when - session goes idle
await hook.event({
event: {
type: "session.idle",
properties: { sessionID: "session-123" },
},
})
// #then - loop completed via API detection, no continuation
expect(promptCalls.length).toBe(0)
expect(toastCalls.some((t) => t.title === "Ralph Loop Complete!")).toBe(true)
expect(hook.getState()).toBeNull()
// #then - messages API was called with correct session ID
expect(messagesCalls.length).toBe(1)
expect(messagesCalls[0].sessionID).toBe("session-123")
})
test("should handle multiple iterations correctly", async () => {
// #given - active loop
const hook = createRalphLoopHook(createMockPluginInput())

View File

@@ -18,6 +18,17 @@ interface SessionState {
isRecovering?: boolean
}
interface OpenCodeSessionMessage {
info?: {
role?: string
}
parts?: Array<{
type: string
text?: string
[key: string]: unknown
}>
}
const CONTINUATION_PROMPT = `[RALPH LOOP - ITERATION {{ITERATION}}/{{MAX}}]
Your previous attempt did not output the completion promise. Continue working on the task.
@@ -81,6 +92,41 @@ export function createRalphLoopHook(
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
}
async function detectCompletionInSessionMessages(
sessionID: string,
promise: string
): Promise<boolean> {
try {
const response = await ctx.client.session.messages({
path: { id: sessionID },
query: { directory: ctx.directory },
})
const messages = (response as { data?: unknown[] }).data ?? []
if (!Array.isArray(messages)) return false
const pattern = new RegExp(`<promise>\\s*${escapeRegex(promise)}\\s*</promise>`, "is")
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
} catch (err) {
log(`[${HOOK_NAME}] Failed to fetch session messages`, { sessionID, error: String(err) })
return false
}
}
const startLoop = (
sessionID: string,
prompt: string,
@@ -151,14 +197,20 @@ export function createRalphLoopHook(
return
}
// Generate transcript path from sessionID - OpenCode doesn't pass it in event properties
const transcriptPath = getTranscriptPath(sessionID)
const completionDetectedViaApi = await detectCompletionInSessionMessages(
sessionID,
state.completion_promise
)
if (detectCompletionPromise(transcriptPath, state.completion_promise)) {
const transcriptPath = getTranscriptPath(sessionID)
const completionDetectedViaTranscript = detectCompletionPromise(transcriptPath, state.completion_promise)
if (completionDetectedViaApi || completionDetectedViaTranscript) {
log(`[${HOOK_NAME}] Completion detected!`, {
sessionID,
iteration: state.iteration,
promise: state.completion_promise,
detectedVia: completionDetectedViaApi ? "session_messages_api" : "transcript_file",
})
clearState(ctx.directory, stateDir)

View File

@@ -14,10 +14,17 @@ export const PROJECT_MARKERS = [
];
export const PROJECT_RULE_SUBDIRS: [string, string][] = [
[".github", "instructions"],
[".cursor", "rules"],
[".claude", "rules"],
];
export const PROJECT_RULE_FILES: string[] = [
".github/copilot-instructions.md",
];
export const GITHUB_INSTRUCTIONS_PATTERN = /\.instructions\.md$/;
export const USER_RULE_DIR = ".claude/rules";
export const RULE_EXTENSIONS = [".md", ".mdc"];

View File

@@ -0,0 +1,381 @@
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { findProjectRoot, findRuleFiles } from "./finder";
describe("findRuleFiles", () => {
const TEST_DIR = join(tmpdir(), `rules-injector-test-${Date.now()}`);
const homeDir = join(TEST_DIR, "home");
beforeEach(() => {
mkdirSync(TEST_DIR, { recursive: true });
mkdirSync(homeDir, { recursive: true });
mkdirSync(join(TEST_DIR, ".git"), { recursive: true });
});
afterEach(() => {
if (existsSync(TEST_DIR)) {
rmSync(TEST_DIR, { recursive: true, force: true });
}
});
describe(".github/instructions/ discovery", () => {
it("should discover .github/instructions/*.instructions.md files", () => {
// #given .github/instructions/ with valid files
const instructionsDir = join(TEST_DIR, ".github", "instructions");
mkdirSync(instructionsDir, { recursive: true });
writeFileSync(
join(instructionsDir, "typescript.instructions.md"),
"TS rules"
);
writeFileSync(
join(instructionsDir, "python.instructions.md"),
"PY rules"
);
const srcDir = join(TEST_DIR, "src");
mkdirSync(srcDir, { recursive: true });
const currentFile = join(srcDir, "index.ts");
writeFileSync(currentFile, "code");
// #when finding rules for a file
const candidates = findRuleFiles(TEST_DIR, homeDir, currentFile);
// #then should find both instruction files
const paths = candidates.map((c) => c.path);
expect(
paths.some((p) => p.includes("typescript.instructions.md"))
).toBe(true);
expect(paths.some((p) => p.includes("python.instructions.md"))).toBe(
true
);
});
it("should ignore non-.instructions.md files in .github/instructions/", () => {
// #given .github/instructions/ with invalid files
const instructionsDir = join(TEST_DIR, ".github", "instructions");
mkdirSync(instructionsDir, { recursive: true });
writeFileSync(
join(instructionsDir, "valid.instructions.md"),
"valid"
);
writeFileSync(join(instructionsDir, "invalid.md"), "invalid");
writeFileSync(join(instructionsDir, "readme.txt"), "readme");
const currentFile = join(TEST_DIR, "index.ts");
writeFileSync(currentFile, "code");
// #when finding rules
const candidates = findRuleFiles(TEST_DIR, homeDir, currentFile);
// #then should only find .instructions.md file
const paths = candidates.map((c) => c.path);
expect(paths.some((p) => p.includes("valid.instructions.md"))).toBe(
true
);
expect(paths.some((p) => p.endsWith("invalid.md"))).toBe(false);
expect(paths.some((p) => p.includes("readme.txt"))).toBe(false);
});
it("should discover nested .instructions.md files in subdirectories", () => {
// #given nested .github/instructions/ structure
const instructionsDir = join(TEST_DIR, ".github", "instructions");
const frontendDir = join(instructionsDir, "frontend");
mkdirSync(frontendDir, { recursive: true });
writeFileSync(
join(frontendDir, "react.instructions.md"),
"React rules"
);
const currentFile = join(TEST_DIR, "app.tsx");
writeFileSync(currentFile, "code");
// #when finding rules
const candidates = findRuleFiles(TEST_DIR, homeDir, currentFile);
// #then should find nested instruction file
const paths = candidates.map((c) => c.path);
expect(paths.some((p) => p.includes("react.instructions.md"))).toBe(
true
);
});
});
describe(".github/copilot-instructions.md (single file)", () => {
it("should discover copilot-instructions.md at project root", () => {
// #given .github/copilot-instructions.md at root
const githubDir = join(TEST_DIR, ".github");
mkdirSync(githubDir, { recursive: true });
writeFileSync(
join(githubDir, "copilot-instructions.md"),
"Global instructions"
);
const currentFile = join(TEST_DIR, "index.ts");
writeFileSync(currentFile, "code");
// #when finding rules
const candidates = findRuleFiles(TEST_DIR, homeDir, currentFile);
// #then should find the single file rule
const singleFile = candidates.find((c) =>
c.path.includes("copilot-instructions.md")
);
expect(singleFile).toBeDefined();
expect(singleFile?.isSingleFile).toBe(true);
});
it("should mark single file rules with isSingleFile: true", () => {
// #given copilot-instructions.md
const githubDir = join(TEST_DIR, ".github");
mkdirSync(githubDir, { recursive: true });
writeFileSync(
join(githubDir, "copilot-instructions.md"),
"Instructions"
);
const currentFile = join(TEST_DIR, "file.ts");
writeFileSync(currentFile, "code");
// #when finding rules
const candidates = findRuleFiles(TEST_DIR, homeDir, currentFile);
// #then isSingleFile should be true
const copilotFile = candidates.find((c) => c.isSingleFile);
expect(copilotFile).toBeDefined();
expect(copilotFile?.path).toContain("copilot-instructions.md");
});
it("should set distance to 0 for single file rules", () => {
// #given copilot-instructions.md at project root
const githubDir = join(TEST_DIR, ".github");
mkdirSync(githubDir, { recursive: true });
writeFileSync(
join(githubDir, "copilot-instructions.md"),
"Instructions"
);
const srcDir = join(TEST_DIR, "src", "deep", "nested");
mkdirSync(srcDir, { recursive: true });
const currentFile = join(srcDir, "file.ts");
writeFileSync(currentFile, "code");
// #when finding rules from deeply nested file
const candidates = findRuleFiles(TEST_DIR, homeDir, currentFile);
// #then single file should have distance 0
const copilotFile = candidates.find((c) => c.isSingleFile);
expect(copilotFile?.distance).toBe(0);
});
});
describe("backward compatibility", () => {
it("should still discover .claude/rules/ files", () => {
// #given .claude/rules/ directory
const rulesDir = join(TEST_DIR, ".claude", "rules");
mkdirSync(rulesDir, { recursive: true });
writeFileSync(join(rulesDir, "typescript.md"), "TS rules");
const currentFile = join(TEST_DIR, "index.ts");
writeFileSync(currentFile, "code");
// #when finding rules
const candidates = findRuleFiles(TEST_DIR, homeDir, currentFile);
// #then should find claude rules
const paths = candidates.map((c) => c.path);
expect(paths.some((p) => p.includes(".claude/rules/"))).toBe(true);
});
it("should still discover .cursor/rules/ files", () => {
// #given .cursor/rules/ directory
const rulesDir = join(TEST_DIR, ".cursor", "rules");
mkdirSync(rulesDir, { recursive: true });
writeFileSync(join(rulesDir, "python.md"), "PY rules");
const currentFile = join(TEST_DIR, "main.py");
writeFileSync(currentFile, "code");
// #when finding rules
const candidates = findRuleFiles(TEST_DIR, homeDir, currentFile);
// #then should find cursor rules
const paths = candidates.map((c) => c.path);
expect(paths.some((p) => p.includes(".cursor/rules/"))).toBe(true);
});
it("should discover .mdc files in rule directories", () => {
// #given .mdc file in .claude/rules/
const rulesDir = join(TEST_DIR, ".claude", "rules");
mkdirSync(rulesDir, { recursive: true });
writeFileSync(join(rulesDir, "advanced.mdc"), "MDC rules");
const currentFile = join(TEST_DIR, "app.ts");
writeFileSync(currentFile, "code");
// #when finding rules
const candidates = findRuleFiles(TEST_DIR, homeDir, currentFile);
// #then should find .mdc file
const paths = candidates.map((c) => c.path);
expect(paths.some((p) => p.endsWith("advanced.mdc"))).toBe(true);
});
});
describe("mixed sources", () => {
it("should discover rules from all sources", () => {
// #given rules in multiple directories
const claudeRules = join(TEST_DIR, ".claude", "rules");
const cursorRules = join(TEST_DIR, ".cursor", "rules");
const githubInstructions = join(TEST_DIR, ".github", "instructions");
const githubDir = join(TEST_DIR, ".github");
mkdirSync(claudeRules, { recursive: true });
mkdirSync(cursorRules, { recursive: true });
mkdirSync(githubInstructions, { recursive: true });
writeFileSync(join(claudeRules, "claude.md"), "claude");
writeFileSync(join(cursorRules, "cursor.md"), "cursor");
writeFileSync(
join(githubInstructions, "copilot.instructions.md"),
"copilot"
);
writeFileSync(join(githubDir, "copilot-instructions.md"), "global");
const currentFile = join(TEST_DIR, "index.ts");
writeFileSync(currentFile, "code");
// #when finding rules
const candidates = findRuleFiles(TEST_DIR, homeDir, currentFile);
// #then should find all rules
expect(candidates.length).toBeGreaterThanOrEqual(4);
const paths = candidates.map((c) => c.path);
expect(paths.some((p) => p.includes(".claude/rules/"))).toBe(true);
expect(paths.some((p) => p.includes(".cursor/rules/"))).toBe(true);
expect(paths.some((p) => p.includes(".github/instructions/"))).toBe(
true
);
expect(paths.some((p) => p.includes("copilot-instructions.md"))).toBe(
true
);
});
it("should not duplicate single file rules", () => {
// #given copilot-instructions.md
const githubDir = join(TEST_DIR, ".github");
mkdirSync(githubDir, { recursive: true });
writeFileSync(
join(githubDir, "copilot-instructions.md"),
"Instructions"
);
const currentFile = join(TEST_DIR, "file.ts");
writeFileSync(currentFile, "code");
// #when finding rules
const candidates = findRuleFiles(TEST_DIR, homeDir, currentFile);
// #then should only have one copilot-instructions.md entry
const copilotFiles = candidates.filter((c) =>
c.path.includes("copilot-instructions.md")
);
expect(copilotFiles.length).toBe(1);
});
});
describe("user-level rules", () => {
it("should discover user-level .claude/rules/ files", () => {
// #given user-level rules
const userRulesDir = join(homeDir, ".claude", "rules");
mkdirSync(userRulesDir, { recursive: true });
writeFileSync(join(userRulesDir, "global.md"), "Global user rules");
const currentFile = join(TEST_DIR, "app.ts");
writeFileSync(currentFile, "code");
// #when finding rules
const candidates = findRuleFiles(TEST_DIR, homeDir, currentFile);
// #then should find user-level rules
const userRule = candidates.find((c) => c.isGlobal);
expect(userRule).toBeDefined();
expect(userRule?.path).toContain("global.md");
});
it("should mark user-level rules as isGlobal: true", () => {
// #given user-level rules
const userRulesDir = join(homeDir, ".claude", "rules");
mkdirSync(userRulesDir, { recursive: true });
writeFileSync(join(userRulesDir, "user.md"), "User rules");
const currentFile = join(TEST_DIR, "app.ts");
writeFileSync(currentFile, "code");
// #when finding rules
const candidates = findRuleFiles(TEST_DIR, homeDir, currentFile);
// #then isGlobal should be true
const userRule = candidates.find((c) => c.path.includes("user.md"));
expect(userRule?.isGlobal).toBe(true);
expect(userRule?.distance).toBe(9999);
});
});
});
describe("findProjectRoot", () => {
const TEST_DIR = join(tmpdir(), `project-root-test-${Date.now()}`);
beforeEach(() => {
mkdirSync(TEST_DIR, { recursive: true });
});
afterEach(() => {
if (existsSync(TEST_DIR)) {
rmSync(TEST_DIR, { recursive: true, force: true });
}
});
it("should find project root with .git directory", () => {
// #given directory with .git
mkdirSync(join(TEST_DIR, ".git"), { recursive: true });
const nestedFile = join(TEST_DIR, "src", "components", "Button.tsx");
mkdirSync(join(TEST_DIR, "src", "components"), { recursive: true });
writeFileSync(nestedFile, "code");
// #when finding project root from nested file
const root = findProjectRoot(nestedFile);
// #then should return the directory with .git
expect(root).toBe(TEST_DIR);
});
it("should find project root with package.json", () => {
// #given directory with package.json
writeFileSync(join(TEST_DIR, "package.json"), "{}");
const nestedFile = join(TEST_DIR, "lib", "index.js");
mkdirSync(join(TEST_DIR, "lib"), { recursive: true });
writeFileSync(nestedFile, "code");
// #when finding project root
const root = findProjectRoot(nestedFile);
// #then should find the package.json directory
expect(root).toBe(TEST_DIR);
});
it("should return null when no project markers found", () => {
// #given directory without any project markers
const isolatedDir = join(TEST_DIR, "isolated");
mkdirSync(isolatedDir, { recursive: true });
const file = join(isolatedDir, "file.txt");
writeFileSync(file, "content");
// #when finding project root
const root = findProjectRoot(file);
// #then should return null
expect(root).toBeNull();
});
});

View File

@@ -6,24 +6,24 @@ import {
} from "node:fs";
import { dirname, join, relative } from "node:path";
import {
GITHUB_INSTRUCTIONS_PATTERN,
PROJECT_MARKERS,
PROJECT_RULE_FILES,
PROJECT_RULE_SUBDIRS,
RULE_EXTENSIONS,
USER_RULE_DIR,
} from "./constants";
import type { RuleFileCandidate } from "./types";
/**
* Candidate rule file with metadata for filtering and sorting
*/
export interface RuleFileCandidate {
/** Absolute path to the rule file */
path: string;
/** Real path after symlink resolution (for duplicate detection) */
realPath: string;
/** Whether this is a global/user-level rule */
isGlobal: boolean;
/** Directory distance from current file (9999 for global rules) */
distance: number;
function isGitHubInstructionsDir(dir: string): boolean {
return dir.includes(".github/instructions") || dir.endsWith(".github/instructions");
}
function isValidRuleFile(fileName: string, dir: string): boolean {
if (isGitHubInstructionsDir(dir)) {
return GITHUB_INSTRUCTIONS_PATTERN.test(fileName);
}
return RULE_EXTENSIONS.some((ext) => fileName.endsWith(ext));
}
/**
@@ -76,10 +76,7 @@ function findRuleFilesRecursive(dir: string, results: string[]): void {
if (entry.isDirectory()) {
findRuleFilesRecursive(fullPath, results);
} else if (entry.isFile()) {
const isRuleFile = RULE_EXTENSIONS.some((ext) =>
entry.name.endsWith(ext),
);
if (isRuleFile) {
if (isValidRuleFile(entry.name, dir)) {
results.push(fullPath);
}
}
@@ -133,8 +130,10 @@ export function calculateDistance(
return 9999;
}
const ruleParts = ruleRel ? ruleRel.split("/") : [];
const currentParts = currentRel ? currentRel.split("/") : [];
// Split by both forward and back slashes for cross-platform compatibility
// path.relative() returns OS-native separators (backslashes on Windows)
const ruleParts = ruleRel ? ruleRel.split(/[/\\]/) : [];
const currentParts = currentRel ? currentRel.split(/[/\\]/) : [];
// Find common prefix length
let common = 0;
@@ -207,6 +206,33 @@ export function findRuleFiles(
distance++;
}
// Check for single-file rules at project root (e.g., .github/copilot-instructions.md)
if (projectRoot) {
for (const ruleFile of PROJECT_RULE_FILES) {
const filePath = join(projectRoot, ruleFile);
if (existsSync(filePath)) {
try {
const stat = statSync(filePath);
if (stat.isFile()) {
const realPath = safeRealpathSync(filePath);
if (!seenRealPaths.has(realPath)) {
seenRealPaths.add(realPath);
candidates.push({
path: filePath,
realPath,
isGlobal: false,
distance: 0,
isSingleFile: true,
});
}
}
} catch {
// Skip if file can't be read
}
}
}
}
// Search user-level rule directory (~/.claude/rules)
const userRuleDir = join(homeDir, USER_RULE_DIR);
const userFiles: string[] = [];

View File

@@ -100,8 +100,14 @@ export function createRulesInjectorHook(ctx: PluginInput) {
const rawContent = readFileSync(candidate.path, "utf-8");
const { metadata, body } = parseRuleFrontmatter(rawContent);
const matchResult = shouldApplyRule(metadata, resolved, projectRoot);
if (!matchResult.applies) continue;
let matchReason: string;
if (candidate.isSingleFile) {
matchReason = "copilot-instructions (always apply)";
} else {
const matchResult = shouldApplyRule(metadata, resolved, projectRoot);
if (!matchResult.applies) continue;
matchReason = matchResult.reason ?? "matched";
}
const contentHash = createContentHash(body);
if (isDuplicateByContentHash(contentHash, cache.contentHashes)) continue;
@@ -112,7 +118,7 @@ export function createRulesInjectorHook(ctx: PluginInput) {
toInject.push({
relativePath,
matchReason: matchResult.reason ?? "matched",
matchReason,
content: body,
distance: candidate.distance,
});

View File

@@ -0,0 +1,226 @@
import { describe, expect, it } from "bun:test";
import { parseRuleFrontmatter } from "./parser";
describe("parseRuleFrontmatter", () => {
describe("applyTo field (GitHub Copilot format)", () => {
it("should parse applyTo as single string", () => {
// #given frontmatter with applyTo as single string
const content = `---
applyTo: "*.ts"
---
Rule content here`;
// #when parsing
const result = parseRuleFrontmatter(content);
// #then globs should contain the pattern
expect(result.metadata.globs).toBe("*.ts");
expect(result.body).toBe("Rule content here");
});
it("should parse applyTo as inline array", () => {
// #given frontmatter with applyTo as inline array
const content = `---
applyTo: ["*.ts", "*.tsx"]
---
Rule content`;
// #when parsing
const result = parseRuleFrontmatter(content);
// #then globs should be array
expect(result.metadata.globs).toEqual(["*.ts", "*.tsx"]);
});
it("should parse applyTo as multi-line array", () => {
// #given frontmatter with applyTo as multi-line array
const content = `---
applyTo:
- "*.ts"
- "src/**/*.js"
---
Content`;
// #when parsing
const result = parseRuleFrontmatter(content);
// #then globs should be array
expect(result.metadata.globs).toEqual(["*.ts", "src/**/*.js"]);
});
it("should parse applyTo as comma-separated string", () => {
// #given frontmatter with comma-separated applyTo
const content = `---
applyTo: "*.ts, *.js"
---
Content`;
// #when parsing
const result = parseRuleFrontmatter(content);
// #then globs should be array
expect(result.metadata.globs).toEqual(["*.ts", "*.js"]);
});
it("should merge applyTo and globs when both present", () => {
// #given frontmatter with both applyTo and globs
const content = `---
globs: "*.md"
applyTo: "*.ts"
---
Content`;
// #when parsing
const result = parseRuleFrontmatter(content);
// #then should merge both into globs array
expect(result.metadata.globs).toEqual(["*.md", "*.ts"]);
});
it("should parse applyTo without quotes", () => {
// #given frontmatter with unquoted applyTo
const content = `---
applyTo: **/*.py
---
Python rules`;
// #when parsing
const result = parseRuleFrontmatter(content);
// #then should parse correctly
expect(result.metadata.globs).toBe("**/*.py");
});
it("should parse applyTo with description", () => {
// #given frontmatter with applyTo and description (GitHub Copilot style)
const content = `---
applyTo: "**/*.ts,**/*.tsx"
description: "TypeScript coding standards"
---
# TypeScript Guidelines`;
// #when parsing
const result = parseRuleFrontmatter(content);
// #then should parse both fields
expect(result.metadata.globs).toEqual(["**/*.ts", "**/*.tsx"]);
expect(result.metadata.description).toBe("TypeScript coding standards");
});
});
describe("existing globs/paths parsing (backward compatibility)", () => {
it("should still parse globs field correctly", () => {
// #given existing globs format
const content = `---
globs: ["*.py", "**/*.ts"]
---
Python/TypeScript rules`;
// #when parsing
const result = parseRuleFrontmatter(content);
// #then should work as before
expect(result.metadata.globs).toEqual(["*.py", "**/*.ts"]);
});
it("should still parse paths field as alias", () => {
// #given paths field (Claude Code style)
const content = `---
paths: ["src/**"]
---
Source rules`;
// #when parsing
const result = parseRuleFrontmatter(content);
// #then should map to globs
expect(result.metadata.globs).toEqual(["src/**"]);
});
it("should parse alwaysApply correctly", () => {
// #given frontmatter with alwaysApply
const content = `---
alwaysApply: true
---
Always apply this rule`;
// #when parsing
const result = parseRuleFrontmatter(content);
// #then should recognize alwaysApply
expect(result.metadata.alwaysApply).toBe(true);
});
});
describe("no frontmatter", () => {
it("should return empty metadata and full body for plain markdown", () => {
// #given markdown without frontmatter
const content = `# Instructions
This is a plain rule file without frontmatter.`;
// #when parsing
const result = parseRuleFrontmatter(content);
// #then should have empty metadata
expect(result.metadata).toEqual({});
expect(result.body).toBe(content);
});
it("should handle empty content", () => {
// #given empty content
const content = "";
// #when parsing
const result = parseRuleFrontmatter(content);
// #then should return empty metadata and body
expect(result.metadata).toEqual({});
expect(result.body).toBe("");
});
});
describe("edge cases", () => {
it("should handle frontmatter with only applyTo", () => {
// #given minimal GitHub Copilot format
const content = `---
applyTo: "**"
---
Apply to all files`;
// #when parsing
const result = parseRuleFrontmatter(content);
// #then should parse correctly
expect(result.metadata.globs).toBe("**");
expect(result.body).toBe("Apply to all files");
});
it("should handle mixed array formats", () => {
// #given globs as multi-line and applyTo as inline
const content = `---
globs:
- "*.md"
applyTo: ["*.ts", "*.js"]
---
Mixed format`;
// #when parsing
const result = parseRuleFrontmatter(content);
// #then should merge both
expect(result.metadata.globs).toEqual(["*.md", "*.ts", "*.js"]);
});
it("should handle Windows-style line endings", () => {
// #given content with CRLF
const content = "---\r\napplyTo: \"*.ts\"\r\n---\r\nWindows content";
// #when parsing
const result = parseRuleFrontmatter(content);
// #then should parse correctly
expect(result.metadata.globs).toBe("*.ts");
expect(result.body).toBe("Windows content");
});
});
});

View File

@@ -60,7 +60,7 @@ function parseYamlContent(yamlContent: string): RuleMetadata {
metadata.description = parseStringValue(rawValue);
} else if (key === "alwaysApply") {
metadata.alwaysApply = rawValue === "true";
} else if (key === "globs" || key === "paths") {
} else if (key === "globs" || key === "paths" || key === "applyTo") {
const { value, consumed } = parseArrayOrStringValue(rawValue, lines, i);
// Merge paths into globs (Claude Code compatibility)
if (key === "paths") {

View File

@@ -1,6 +1,8 @@
/**
* Rule file metadata (Claude Code style frontmatter)
* Supports both Claude Code format (globs, paths) and GitHub Copilot format (applyTo)
* @see https://docs.anthropic.com/en/docs/claude-code/settings#rule-files
* @see https://docs.github.com/en/copilot/customizing-copilot/adding-repository-custom-instructions-for-github-copilot
*/
export interface RuleMetadata {
description?: string;
@@ -30,6 +32,18 @@ export interface RuleInfo {
realPath: string;
}
/**
* Rule file candidate with discovery context
*/
export interface RuleFileCandidate {
path: string;
realPath: string;
isGlobal: boolean;
distance: number;
/** Single-file rules (e.g., .github/copilot-instructions.md) always apply without frontmatter */
isSingleFile?: boolean;
}
/**
* Session storage for injected rules tracking
*/

View File

@@ -1,12 +1,6 @@
import { describe, expect, it, beforeEach, mock } from "bun:test"
import { describe, expect, it, beforeEach } from "bun:test"
import type { ThinkModeInput } from "./types"
const logMock = mock(() => {})
mock.module("../../shared", () => ({
log: logMock,
}))
const { createThinkModeHook, clearThinkModeState } = await import("./index")
/**

View File

@@ -1,11 +1,11 @@
import { describe, expect, test, beforeEach, afterEach, mock } from "bun:test"
import { afterEach, beforeEach, describe, expect, test } from "bun:test"
import { createTodoContinuationEnforcer } from "./todo-continuation-enforcer"
import { setMainSession, subagentSessions } from "../features/claude-code-session-state"
import type { BackgroundManager } from "../features/background-agent"
import { setMainSession, subagentSessions } from "../features/claude-code-session-state"
import { createTodoContinuationEnforcer } from "./todo-continuation-enforcer"
describe("todo-continuation-enforcer", () => {
let promptCalls: Array<{ sessionID: string; agent?: string; text: string }>
let promptCalls: Array<{ sessionID: string; agent?: string; model?: { providerID?: string; modelID?: string }; text: string }>
let toastCalls: Array<{ title: string; message: string }>
function createMockPluginInput() {
@@ -20,6 +20,7 @@ describe("todo-continuation-enforcer", () => {
promptCalls.push({
sessionID: opts.path.id,
agent: opts.body.agent,
model: opts.body.model,
text: opts.body.parts[0].text,
})
return {}
@@ -41,8 +42,8 @@ describe("todo-continuation-enforcer", () => {
function createMockBackgroundManager(runningTasks: boolean = false): BackgroundManager {
return {
getTasksByParentSession: () => runningTasks
? [{ status: "running" }]
getTasksByParentSession: () => runningTasks
? [{ status: "running" }]
: [],
} as any
}
@@ -164,42 +165,42 @@ describe("todo-continuation-enforcer", () => {
expect(promptCalls[0].sessionID).toBe(bgTaskSession)
})
test("should skip injection after recent error", async () => {
// #given - session that just had an error
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 - session error occurs
// #when - abort error occurs
await hook.handler({
event: { type: "session.error", properties: { sessionID, error: new Error("test") } },
event: { type: "session.error", properties: { sessionID, error: { name: "AbortError", message: "aborted" } } },
})
// #when - session goes idle immediately after
// #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 (error cooldown)
// #then - no continuation injected (abort was immediately before idle)
expect(promptCalls).toHaveLength(0)
})
test("should clear error state on user message and allow injection", async () => {
// #given - session with error, then user clears it
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 - error occurs
// #when - abort error occurs
await hook.handler({
event: { type: "session.error", properties: { sessionID } },
event: { type: "session.error", properties: { sessionID, error: { message: "aborted" } } },
})
// #when - user sends message (clears error immediately)
// #when - user sends message (clears abort state)
await hook.handler({
event: { type: "message.updated", properties: { info: { sessionID, role: "user" } } },
})
@@ -211,7 +212,7 @@ describe("todo-continuation-enforcer", () => {
await new Promise(r => setTimeout(r, 2500))
// #then - continuation injected (error was cleared by user message)
// #then - continuation injected (abort state was cleared by user message)
expect(promptCalls.length).toBe(1)
})
@@ -229,9 +230,9 @@ describe("todo-continuation-enforcer", () => {
// #when - user sends message immediately (before 2s countdown)
await hook.handler({
event: {
type: "message.updated",
properties: { info: { sessionID, role: "user" } }
event: {
type: "message.updated",
properties: { info: { sessionID, role: "user" } }
},
})
@@ -255,9 +256,9 @@ describe("todo-continuation-enforcer", () => {
// #when - assistant starts responding
await new Promise(r => setTimeout(r, 500))
await hook.handler({
event: {
type: "message.part.updated",
properties: { info: { sessionID, role: "assistant" } }
event: {
type: "message.part.updated",
properties: { info: { sessionID, role: "assistant" } }
},
})
@@ -401,4 +402,211 @@ describe("todo-continuation-enforcer", () => {
// #then - second injection also happened (no throttle blocking)
expect(promptCalls.length).toBe(2)
}, { timeout: 10000 })
// ============================================================
// 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
const sessionID = "main-noabort-error"
setMainSession(sessionID)
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
// #when - non-abort error occurs (e.g., network error, API error)
await hook.handler({
event: {
type: "session.error",
properties: {
sessionID,
error: { name: "NetworkError", message: "Connection failed" }
}
},
})
// #when - session goes idle immediately after
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
await new Promise(r => setTimeout(r, 2500))
// #then - continuation injected (non-abort errors don't block)
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"
setMainSession(sessionID)
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
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
await new Promise(r => setTimeout(r, 3000))
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"
setMainSession(sessionID)
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
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
await new Promise(r => setTimeout(r, 3000))
// #then - no continuation (abort was immediately before)
expect(promptCalls).toHaveLength(0)
})
})

View File

@@ -1,12 +1,12 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { existsSync, readdirSync } from "node:fs"
import { join } from "node:path"
import type { PluginInput } from "@opencode-ai/plugin"
import type { BackgroundManager } from "../features/background-agent"
import { getMainSessionID, subagentSessions } from "../features/claude-code-session-state"
import {
findNearestMessageWithFields,
MESSAGE_STORAGE,
findNearestMessageWithFields,
MESSAGE_STORAGE,
} from "../features/hook-message-injector"
import type { BackgroundManager } from "../features/background-agent"
import { log } from "../shared/logger"
const HOOK_NAME = "todo-continuation-enforcer"
@@ -29,7 +29,7 @@ interface Todo {
}
interface SessionState {
lastErrorAt?: number
lastEventWasAbortError?: boolean
countdownTimer?: ReturnType<typeof setTimeout>
countdownInterval?: ReturnType<typeof setInterval>
isRecovering?: boolean
@@ -45,7 +45,6 @@ Incomplete tasks remain in your todo list. Continue working on the next pending
const COUNTDOWN_SECONDS = 2
const TOAST_DURATION_MS = 900
const ERROR_COOLDOWN_MS = 3_000
function getMessageDir(sessionID: string): string | null {
if (!existsSync(MESSAGE_STORAGE)) return null
@@ -63,22 +62,22 @@ function getMessageDir(sessionID: string): string | 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
}
@@ -105,7 +104,7 @@ export function createTodoContinuationEnforcer(
function cancelCountdown(sessionID: string): void {
const state = sessions.get(sessionID)
if (!state) return
if (state.countdownTimer) {
clearTimeout(state.countdownTimer)
state.countdownTimer = undefined
@@ -149,16 +148,13 @@ export function createTodoContinuationEnforcer(
async function injectContinuation(sessionID: string, incompleteCount: number, total: number): Promise<void> {
const state = sessions.get(sessionID)
if (state?.isRecovering) {
log(`[${HOOK_NAME}] Skipped injection: in recovery`, { sessionID })
return
}
if (state?.lastErrorAt && Date.now() - state.lastErrorAt < ERROR_COOLDOWN_MS) {
log(`[${HOOK_NAME}] Skipped injection: recent error`, { sessionID })
return
}
const hasRunningBgTasks = backgroundManager
? backgroundManager.getTasksByParentSession(sessionID).some(t => t.status === "running")
@@ -187,9 +183,9 @@ export function createTodoContinuationEnforcer(
const messageDir = getMessageDir(sessionID)
const prevMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
const hasWritePermission = !prevMessage?.tools ||
const hasWritePermission = !prevMessage?.tools ||
(prevMessage.tools.write !== false && prevMessage.tools.edit !== false)
if (!hasWritePermission) {
log(`[${HOOK_NAME}] Skipped: agent lacks write permission`, { sessionID, agent: prevMessage?.agent })
return
@@ -203,18 +199,23 @@ export function createTodoContinuationEnforcer(
const prompt = `${CONTINUATION_PROMPT}\n\n[Status: ${todos.length - freshIncompleteCount}/${todos.length} completed, ${freshIncompleteCount} remaining]`
const modelField = prevMessage?.model?.providerID && prevMessage?.model?.modelID
? { providerID: prevMessage.model.providerID, modelID: prevMessage.model.modelID }
: undefined
try {
log(`[${HOOK_NAME}] Injecting continuation`, { sessionID, agent: prevMessage?.agent, incompleteCount: freshIncompleteCount })
log(`[${HOOK_NAME}] Injecting continuation`, { sessionID, agent: prevMessage?.agent, model: modelField, incompleteCount: freshIncompleteCount })
await ctx.client.session.prompt({
path: { id: sessionID },
body: {
agent: prevMessage?.agent,
model: modelField,
parts: [{ type: "text", text: prompt }],
},
query: { directory: ctx.directory },
})
log(`[${HOOK_NAME}] Injection successful`, { sessionID })
} catch (err) {
log(`[${HOOK_NAME}] Injection failed`, { sessionID, error: String(err) })
@@ -251,10 +252,11 @@ export function createTodoContinuationEnforcer(
if (!sessionID) return
const state = getState(sessionID)
state.lastErrorAt = Date.now()
const isAbort = isAbortError(props?.error)
state.lastEventWasAbortError = isAbort
cancelCountdown(sessionID)
log(`[${HOOK_NAME}] session.error`, { sessionID, isAbort: isAbortError(props?.error) })
log(`[${HOOK_NAME}] session.error`, { sessionID, isAbort })
return
}
@@ -267,7 +269,7 @@ export function createTodoContinuationEnforcer(
const mainSessionID = getMainSessionID()
const isMainSession = sessionID === mainSessionID
const isBackgroundTaskSession = subagentSessions.has(sessionID)
if (mainSessionID && !isMainSession && !isBackgroundTaskSession) {
log(`[${HOOK_NAME}] Skipped: not main or background task session`, { sessionID })
return
@@ -280,8 +282,9 @@ export function createTodoContinuationEnforcer(
return
}
if (state.lastErrorAt && Date.now() - state.lastErrorAt < ERROR_COOLDOWN_MS) {
log(`[${HOOK_NAME}] Skipped: recent error (cooldown)`, { sessionID })
if (state.lastEventWasAbortError) {
state.lastEventWasAbortError = false
log(`[${HOOK_NAME}] Skipped: abort error immediately before idle`, { sessionID })
return
}
@@ -325,13 +328,14 @@ 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) {
state.lastErrorAt = undefined
}
cancelCountdown(sessionID)
log(`[${HOOK_NAME}] User message: cleared error state`, { sessionID })
log(`[${HOOK_NAME}] User message: cleared abort state`, { sessionID })
}
if (role === "assistant") {
@@ -346,6 +350,10 @@ 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
@@ -354,6 +362,10 @@ 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

@@ -16,6 +16,9 @@ const TRUNCATABLE_TOOLS = [
"ast_grep_search",
"interactive_bash",
"Interactive_bash",
"skill_mcp",
"webfetch",
"WebFetch",
]
interface ToolOutputTruncatorOptions {

View File

@@ -1,5 +1,4 @@
import type { Plugin } from "@opencode-ai/plugin";
import { createBuiltinAgents } from "./agents";
import {
createTodoContinuationEnforcer,
createContextWindowMonitorHook,
@@ -25,20 +24,10 @@ import {
createEmptyMessageSanitizerHook,
createThinkingBlockValidatorHook,
createRalphLoopHook,
createAutoSlashCommandHook,
} from "./hooks";
import { createGoogleAntigravityAuthPlugin } from "./auth/antigravity";
import {
loadUserCommands,
loadProjectCommands,
loadOpencodeGlobalCommands,
loadOpencodeProjectCommands,
} from "./features/claude-code-command-loader";
import { loadBuiltinCommands } from "./features/builtin-commands";
import {
loadUserSkills,
loadProjectSkills,
loadOpencodeGlobalSkills,
loadOpencodeProjectSkills,
discoverUserClaudeSkills,
discoverProjectClaudeSkills,
discoverOpencodeGlobalSkills,
@@ -46,201 +35,35 @@ import {
mergeSkills,
} from "./features/opencode-skill-loader";
import { createBuiltinSkills } from "./features/builtin-skills";
import {
loadUserAgents,
loadProjectAgents,
} from "./features/claude-code-agent-loader";
import { loadMcpConfigs } from "./features/claude-code-mcp-loader";
import { loadAllPluginComponents } from "./features/claude-code-plugin-loader";
import { getSystemMcpServerNames } from "./features/claude-code-mcp-loader";
import {
setMainSession,
getMainSessionID,
} from "./features/claude-code-session-state";
import { builtinTools, createCallOmoAgent, createBackgroundTools, createLookAt, createSkillTool, interactive_bash, getTmuxPath } from "./tools";
import {
builtinTools,
createCallOmoAgent,
createBackgroundTools,
createLookAt,
createSkillTool,
createSkillMcpTool,
interactive_bash,
getTmuxPath,
} from "./tools";
import { BackgroundManager } from "./features/background-agent";
import { createBuiltinMcps } from "./mcp";
import { OhMyOpenCodeConfigSchema, type OhMyOpenCodeConfig, type HookName } from "./config";
import { log, deepMerge, getUserConfigDir, addConfigLoadError, parseJsonc, detectConfigFile } from "./shared";
import { PLAN_SYSTEM_PROMPT, PLAN_PERMISSION } from "./agents/plan-prompt";
import * as fs from "fs";
import * as path from "path";
// Migration map: old keys → new keys (for backward compatibility)
const AGENT_NAME_MAP: Record<string, string> = {
// Legacy names (backward compatibility)
omo: "Sisyphus",
"OmO": "Sisyphus",
"OmO-Plan": "Planner-Sisyphus",
"omo-plan": "Planner-Sisyphus",
// Current names
sisyphus: "Sisyphus",
"planner-sisyphus": "Planner-Sisyphus",
build: "build",
oracle: "oracle",
librarian: "librarian",
explore: "explore",
"frontend-ui-ux-engineer": "frontend-ui-ux-engineer",
"document-writer": "document-writer",
"multimodal-looker": "multimodal-looker",
};
function migrateAgentNames(agents: Record<string, unknown>): { migrated: Record<string, unknown>; changed: boolean } {
const migrated: Record<string, unknown> = {};
let changed = false;
for (const [key, value] of Object.entries(agents)) {
const newKey = AGENT_NAME_MAP[key.toLowerCase()] ?? AGENT_NAME_MAP[key] ?? key;
if (newKey !== key) {
changed = true;
}
migrated[newKey] = value;
}
return { migrated, changed };
}
function migrateConfigFile(configPath: string, rawConfig: Record<string, unknown>): boolean {
let needsWrite = false;
if (rawConfig.agents && typeof rawConfig.agents === "object") {
const { migrated, changed } = migrateAgentNames(rawConfig.agents as Record<string, unknown>);
if (changed) {
rawConfig.agents = migrated;
needsWrite = true;
}
}
if (rawConfig.omo_agent) {
rawConfig.sisyphus_agent = rawConfig.omo_agent;
delete rawConfig.omo_agent;
needsWrite = true;
}
if (needsWrite) {
try {
fs.writeFileSync(configPath, JSON.stringify(rawConfig, null, 2) + "\n", "utf-8");
log(`Migrated config file: ${configPath} (OmO → Sisyphus)`);
} catch (err) {
log(`Failed to write migrated config to ${configPath}:`, err);
}
}
return needsWrite;
}
function loadConfigFromPath(configPath: string, ctx: any): OhMyOpenCodeConfig | null {
try {
if (fs.existsSync(configPath)) {
const content = fs.readFileSync(configPath, "utf-8");
const rawConfig = parseJsonc<Record<string, unknown>>(content);
migrateConfigFile(configPath, rawConfig);
const result = OhMyOpenCodeConfigSchema.safeParse(rawConfig);
if (!result.success) {
const errorMsg = result.error.issues.map(i => `${i.path.join(".")}: ${i.message}`).join(", ");
log(`Config validation error in ${configPath}:`, result.error.issues);
addConfigLoadError({ path: configPath, error: `Validation error: ${errorMsg}` });
return null;
}
log(`Config loaded from ${configPath}`, { agents: result.data.agents });
return result.data;
}
} catch (err) {
const errorMsg = err instanceof Error ? err.message : String(err);
log(`Error loading config from ${configPath}:`, err);
addConfigLoadError({ path: configPath, error: errorMsg });
}
return null;
}
function mergeConfigs(
base: OhMyOpenCodeConfig,
override: OhMyOpenCodeConfig
): OhMyOpenCodeConfig {
return {
...base,
...override,
agents: deepMerge(base.agents, override.agents),
disabled_agents: [
...new Set([
...(base.disabled_agents ?? []),
...(override.disabled_agents ?? []),
]),
],
disabled_mcps: [
...new Set([
...(base.disabled_mcps ?? []),
...(override.disabled_mcps ?? []),
]),
],
disabled_hooks: [
...new Set([
...(base.disabled_hooks ?? []),
...(override.disabled_hooks ?? []),
]),
],
disabled_commands: [
...new Set([
...(base.disabled_commands ?? []),
...(override.disabled_commands ?? []),
]),
],
claude_code: deepMerge(base.claude_code, override.claude_code),
};
}
function loadPluginConfig(directory: string, ctx: any): OhMyOpenCodeConfig {
// User-level config path (OS-specific) - prefer .jsonc over .json
const userBasePath = path.join(getUserConfigDir(), "opencode", "oh-my-opencode");
const userDetected = detectConfigFile(userBasePath);
const userConfigPath = userDetected.format !== "none" ? userDetected.path : userBasePath + ".json";
// Project-level config path - prefer .jsonc over .json
const projectBasePath = path.join(directory, ".opencode", "oh-my-opencode");
const projectDetected = detectConfigFile(projectBasePath);
const projectConfigPath = projectDetected.format !== "none" ? projectDetected.path : projectBasePath + ".json";
// Load user config first (base)
let config: OhMyOpenCodeConfig = loadConfigFromPath(userConfigPath, ctx) ?? {};
// Override with project config
const projectConfig = loadConfigFromPath(projectConfigPath, ctx);
if (projectConfig) {
config = mergeConfigs(config, projectConfig);
}
log("Final merged config", {
agents: config.agents,
disabled_agents: config.disabled_agents,
disabled_mcps: config.disabled_mcps,
disabled_hooks: config.disabled_hooks,
claude_code: config.claude_code,
});
return config;
}
import { SkillMcpManager } from "./features/skill-mcp-manager";
import { type OhMyOpenCodeConfig, 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) => {
const pluginConfig = loadPluginConfig(ctx.directory, ctx);
const disabledHooks = new Set(pluginConfig.disabled_hooks ?? []);
const isHookEnabled = (hookName: HookName) => !disabledHooks.has(hookName);
const modelContextLimitsCache = new Map<string, number>();
let anthropicContext1MEnabled = false;
const getModelLimit = (providerID: string, modelID: string): number | undefined => {
const key = `${providerID}/${modelID}`;
const cached = modelContextLimitsCache.get(key);
if (cached) return cached;
if (providerID === "anthropic" && anthropicContext1MEnabled && modelID.includes("sonnet")) {
return 1_000_000;
}
return undefined;
};
const modelCacheState = createModelCacheState();
const contextWindowMonitor = isHookEnabled("context-window-monitor")
? createContextWindowMonitorHook(ctx)
@@ -256,7 +79,9 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
? createCommentCheckerHooks(pluginConfig.comment_checker)
: null;
const toolOutputTruncator = isHookEnabled("tool-output-truncator")
? createToolOutputTruncatorHook(ctx, { experimental: pluginConfig.experimental })
? createToolOutputTruncatorHook(ctx, {
experimental: pluginConfig.experimental,
})
: null;
const directoryAgentsInjector = isHookEnabled("directory-agents-injector")
? createDirectoryAgentsInjectorHook(ctx)
@@ -267,24 +92,29 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
const emptyTaskResponseDetector = isHookEnabled("empty-task-response-detector")
? createEmptyTaskResponseDetectorHook(ctx)
: null;
const thinkMode = isHookEnabled("think-mode")
? createThinkModeHook()
: null;
const thinkMode = isHookEnabled("think-mode") ? createThinkModeHook() : null;
const claudeCodeHooks = createClaudeCodeHooksHook(ctx, {
disabledHooks: (pluginConfig.claude_code?.hooks ?? true) ? undefined : true,
});
const anthropicContextWindowLimitRecovery = isHookEnabled("anthropic-context-window-limit-recovery")
const anthropicContextWindowLimitRecovery = isHookEnabled(
"anthropic-context-window-limit-recovery"
)
? createAnthropicContextWindowLimitRecoveryHook(ctx, {
experimental: pluginConfig.experimental,
dcpForCompaction: pluginConfig.experimental?.dcp_for_compaction,
})
: null;
const compactionContextInjector = createCompactionContextInjector();
const preemptiveCompaction = createPreemptiveCompactionHook(ctx, {
experimental: pluginConfig.experimental,
onBeforeSummarize: compactionContextInjector,
getModelLimit,
});
const compactionContextInjector = isHookEnabled("compaction-context-injector")
? createCompactionContextInjector()
: undefined;
const preemptiveCompaction = isHookEnabled("preemptive-compaction")
? createPreemptiveCompactionHook(ctx, {
experimental: pluginConfig.experimental,
onBeforeSummarize: compactionContextInjector,
getModelLimit: (providerID, modelID) =>
getModelLimit(modelCacheState, providerID, modelID),
})
: null;
const rulesInjector = isHookEnabled("rules-injector")
? createRulesInjectorHook(ctx)
: null;
@@ -296,7 +126,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
})
: null;
const keywordDetector = isHookEnabled("keyword-detector")
? createKeywordDetectorHook()
? createKeywordDetectorHook(ctx)
: null;
const agentUsageReminder = isHookEnabled("agent-usage-reminder")
? createAgentUsageReminderHook(ctx)
@@ -318,6 +148,10 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
? createRalphLoopHook(ctx, { config: pluginConfig.ralph_loop })
: null;
const autoSlashCommand = isHookEnabled("auto-slash-command")
? createAutoSlashCommandHook()
: null;
const backgroundManager = new BackgroundManager(ctx);
const todoContinuationEnforcer = isHookEnabled("todo-continuation-enforcer")
@@ -326,7 +160,9 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
if (sessionRecovery && todoContinuationEnforcer) {
sessionRecovery.setOnAbortCallback(todoContinuationEnforcer.markRecovering);
sessionRecovery.setOnRecoveryCompleteCallback(todoContinuationEnforcer.markRecoveryComplete);
sessionRecovery.setOnRecoveryCompleteCallback(
todoContinuationEnforcer.markRecoveryComplete
);
}
const backgroundNotificationHook = isHookEnabled("background-notification")
@@ -336,7 +172,17 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
const callOmoAgent = createCallOmoAgent(ctx, backgroundManager);
const lookAt = createLookAt(ctx);
const builtinSkills = createBuiltinSkills();
const disabledSkills = new Set(pluginConfig.disabled_skills ?? []);
const systemMcpNames = getSystemMcpServerNames();
const builtinSkills = createBuiltinSkills().filter((skill) => {
if (disabledSkills.has(skill.name as never)) return false;
if (skill.mcpConfig) {
for (const mcpName of Object.keys(skill.mcpConfig)) {
if (systemMcpNames.has(mcpName)) return false;
}
}
return true;
});
const includeClaudeSkills = pluginConfig.claude_code?.skills !== false;
const mergedSkills = mergeSkills(
builtinSkills,
@@ -344,16 +190,34 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
includeClaudeSkills ? discoverUserClaudeSkills() : [],
discoverOpencodeGlobalSkills(),
includeClaudeSkills ? discoverProjectClaudeSkills() : [],
discoverOpencodeProjectSkills(),
discoverOpencodeProjectSkills()
);
const skillTool = createSkillTool({ skills: mergedSkills });
const skillMcpManager = new SkillMcpManager();
const getSessionIDForMcp = () => getMainSessionID() || "";
const skillTool = createSkillTool({
skills: mergedSkills,
mcpManager: skillMcpManager,
getSessionID: getSessionIDForMcp,
});
const skillMcpTool = createSkillMcpTool({
manager: skillMcpManager,
getLoadedSkills: () => mergedSkills,
getSessionID: getSessionIDForMcp,
});
const googleAuthHooks = pluginConfig.google_auth !== false
? await createGoogleAntigravityAuthPlugin(ctx)
: null;
const googleAuthHooks =
pluginConfig.google_auth !== false
? await createGoogleAntigravityAuthPlugin(ctx)
: null;
const tmuxAvailable = await getTmuxPath();
const configHandler = createConfigHandler({
ctx,
pluginConfig,
modelCacheState,
});
return {
...(googleAuthHooks ? { auth: googleAuthHooks.auth } : {}),
@@ -363,42 +227,64 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
call_omo_agent: callOmoAgent,
look_at: lookAt,
skill: skillTool,
skill_mcp: skillMcpTool,
...(tmuxAvailable ? { interactive_bash } : {}),
},
"chat.message": async (input, output) => {
await claudeCodeHooks["chat.message"]?.(input, output);
await keywordDetector?.["chat.message"]?.(input, output);
await autoSlashCommand?.["chat.message"]?.(input, output);
if (ralphLoop) {
const parts = (output as { parts?: Array<{ type: string; text?: string }> }).parts;
const promptText = parts
?.filter((p) => p.type === "text" && p.text)
.map((p) => p.text)
.join("\n")
.trim() || "";
const parts = (
output as { parts?: Array<{ type: string; text?: string }> }
).parts;
const promptText =
parts
?.filter((p) => p.type === "text" && p.text)
.map((p) => p.text)
.join("\n")
.trim() || "";
const isRalphLoopTemplate = promptText.includes("You are starting a Ralph Loop") &&
const isRalphLoopTemplate =
promptText.includes("You are starting a Ralph Loop") &&
promptText.includes("<user-task>");
const isCancelRalphTemplate = promptText.includes("Cancel the currently active Ralph Loop");
const isCancelRalphTemplate = promptText.includes(
"Cancel the currently active Ralph Loop"
);
if (isRalphLoopTemplate) {
const taskMatch = promptText.match(/<user-task>\s*([\s\S]*?)\s*<\/user-task>/i);
const taskMatch = promptText.match(
/<user-task>\s*([\s\S]*?)\s*<\/user-task>/i
);
const rawTask = taskMatch?.[1]?.trim() || "";
const quotedMatch = rawTask.match(/^["'](.+?)["']/);
const prompt = quotedMatch?.[1] || rawTask.split(/\s+--/)[0]?.trim() || "Complete the task as instructed";
const prompt =
quotedMatch?.[1] ||
rawTask.split(/\s+--/)[0]?.trim() ||
"Complete the task as instructed";
const maxIterMatch = rawTask.match(/--max-iterations=(\d+)/i);
const promiseMatch = rawTask.match(/--completion-promise=["']?([^"'\s]+)["']?/i);
const promiseMatch = rawTask.match(
/--completion-promise=["']?([^"'\s]+)["']?/i
);
log("[ralph-loop] Starting loop from chat.message", { sessionID: input.sessionID, prompt });
log("[ralph-loop] Starting loop from chat.message", {
sessionID: input.sessionID,
prompt,
});
ralphLoop.startLoop(input.sessionID, prompt, {
maxIterations: maxIterMatch ? parseInt(maxIterMatch[1], 10) : undefined,
maxIterations: maxIterMatch
? parseInt(maxIterMatch[1], 10)
: undefined,
completionPromise: promiseMatch?.[1],
});
} else if (isCancelRalphTemplate) {
log("[ralph-loop] Cancelling loop from chat.message", { sessionID: input.sessionID });
log("[ralph-loop] Cancelling loop from chat.message", {
sessionID: input.sessionID,
});
ralphLoop.cancelLoop(input.sessionID);
}
}
@@ -408,207 +294,17 @@ 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 thinkingBlockValidator?.["experimental.chat.messages.transform"]?.(input, output as any);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await emptyMessageSanitizer?.["experimental.chat.messages.transform"]?.(input, output as any);
await thinkingBlockValidator?.[
"experimental.chat.messages.transform"
// eslint-disable-next-line @typescript-eslint/no-explicit-any
]?.(input, output as any);
await emptyMessageSanitizer?.[
"experimental.chat.messages.transform"
// eslint-disable-next-line @typescript-eslint/no-explicit-any
]?.(input, output as any);
},
config: async (config) => {
type ProviderConfig = {
options?: { headers?: Record<string, string> }
models?: Record<string, { limit?: { context?: number } }>
}
const providers = config.provider as Record<string, ProviderConfig> | undefined;
const anthropicBeta = providers?.anthropic?.options?.headers?.["anthropic-beta"];
anthropicContext1MEnabled = anthropicBeta?.includes("context-1m") ?? false;
if (providers) {
for (const [providerID, providerConfig] of Object.entries(providers)) {
const models = providerConfig?.models;
if (models) {
for (const [modelID, modelConfig] of Object.entries(models)) {
const contextLimit = modelConfig?.limit?.context;
if (contextLimit) {
modelContextLimitsCache.set(`${providerID}/${modelID}`, contextLimit);
}
}
}
}
}
const pluginComponents = (pluginConfig.claude_code?.plugins ?? true)
? await loadAllPluginComponents({
enabledPluginsOverride: pluginConfig.claude_code?.plugins_override,
})
: { commands: {}, skills: {}, agents: {}, mcpServers: {}, hooksConfigs: [], plugins: [], errors: [] };
if (pluginComponents.plugins.length > 0) {
log(`Loaded ${pluginComponents.plugins.length} Claude Code plugins`, {
plugins: pluginComponents.plugins.map(p => `${p.name}@${p.version}`),
});
}
if (pluginComponents.errors.length > 0) {
log(`Plugin load errors`, { errors: pluginComponents.errors });
}
const builtinAgents = createBuiltinAgents(
pluginConfig.disabled_agents,
pluginConfig.agents,
ctx.directory,
config.model,
);
const userAgents = (pluginConfig.claude_code?.agents ?? true) ? loadUserAgents() : {};
const projectAgents = (pluginConfig.claude_code?.agents ?? true) ? loadProjectAgents() : {};
const pluginAgents = pluginComponents.agents;
const isSisyphusEnabled = pluginConfig.sisyphus_agent?.disabled !== true;
const builderEnabled = pluginConfig.sisyphus_agent?.default_builder_enabled ?? false;
const plannerEnabled = pluginConfig.sisyphus_agent?.planner_enabled ?? true;
const replacePlan = pluginConfig.sisyphus_agent?.replace_plan ?? true;
if (isSisyphusEnabled && builtinAgents.Sisyphus) {
// Set Sisyphus as default agent (feature added in OpenCode PR #5843)
(config as { default_agent?: string }).default_agent = "Sisyphus";
const agentConfig: Record<string, unknown> = {
Sisyphus: builtinAgents.Sisyphus,
};
if (builderEnabled) {
const { name: _buildName, ...buildConfigWithoutName } = config.agent?.build ?? {};
const openCodeBuilderOverride = pluginConfig.agents?.["OpenCode-Builder"];
const openCodeBuilderBase = {
...buildConfigWithoutName,
description: `${config.agent?.build?.description ?? "Build agent"} (OpenCode default)`,
};
agentConfig["OpenCode-Builder"] = openCodeBuilderOverride
? { ...openCodeBuilderBase, ...openCodeBuilderOverride }
: openCodeBuilderBase;
}
if (plannerEnabled) {
const { name: _planName, ...planConfigWithoutName } = config.agent?.plan ?? {};
const plannerSisyphusOverride = pluginConfig.agents?.["Planner-Sisyphus"];
const plannerSisyphusBase = {
...planConfigWithoutName,
prompt: PLAN_SYSTEM_PROMPT,
permission: PLAN_PERMISSION,
description: `${config.agent?.plan?.description ?? "Plan agent"} (OhMyOpenCode version)`,
color: config.agent?.plan?.color ?? "#6495ED",
};
agentConfig["Planner-Sisyphus"] = plannerSisyphusOverride
? { ...plannerSisyphusBase, ...plannerSisyphusOverride }
: plannerSisyphusBase;
}
// Filter out build/plan from config.agent - they'll be re-added as subagents if replaced
const filteredConfigAgents = config.agent ?
Object.fromEntries(
Object.entries(config.agent).filter(([key]) => {
if (key === "build") return false;
if (key === "plan" && replacePlan) return false;
return true;
})
) : {};
config.agent = {
...agentConfig,
...Object.fromEntries(Object.entries(builtinAgents).filter(([k]) => k !== "Sisyphus")),
...userAgents,
...projectAgents,
...pluginAgents,
...filteredConfigAgents, // Filtered config agents (excludes build/plan if replaced)
// Demote build/plan to subagent mode when replaced
build: { ...config.agent?.build, mode: "subagent" },
...(replacePlan ? { plan: { ...config.agent?.plan, mode: "subagent" } } : {}),
};
} else {
config.agent = {
...builtinAgents,
...userAgents,
...projectAgents,
...pluginAgents,
...config.agent,
};
}
config.tools = {
...config.tools,
};
if (config.agent.explore) {
config.agent.explore.tools = {
...config.agent.explore.tools,
call_omo_agent: false,
};
}
if (config.agent.librarian) {
config.agent.librarian.tools = {
...config.agent.librarian.tools,
call_omo_agent: false,
};
}
if (config.agent["multimodal-looker"]) {
config.agent["multimodal-looker"].tools = {
...config.agent["multimodal-looker"].tools,
task: false,
call_omo_agent: false,
look_at: false,
};
}
config.permission = {
...config.permission,
webfetch: "allow",
external_directory: "allow",
}
const mcpResult = (pluginConfig.claude_code?.mcp ?? true)
? await loadMcpConfigs()
: { servers: {} };
config.mcp = {
...config.mcp,
...createBuiltinMcps(pluginConfig.disabled_mcps),
...mcpResult.servers,
...pluginComponents.mcpServers,
};
const builtinCommands = loadBuiltinCommands(pluginConfig.disabled_commands);
const userCommands = (pluginConfig.claude_code?.commands ?? true) ? loadUserCommands() : {};
const opencodeGlobalCommands = loadOpencodeGlobalCommands();
const systemCommands = config.command ?? {};
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();
config.command = {
...builtinCommands,
...userCommands,
...userSkills,
...opencodeGlobalCommands,
...opencodeGlobalSkills,
...systemCommands,
...projectCommands,
...projectSkills,
...opencodeProjectCommands,
...opencodeProjectSkills,
...pluginComponents.commands,
...pluginComponents.skills,
};
},
config: configHandler,
event: async (input) => {
await autoUpdateChecker?.event(input);
@@ -644,6 +340,9 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
if (sessionInfo?.id === getMainSessionID()) {
setMainSession(undefined);
}
if (sessionInfo?.id) {
await skillMcpManager.disconnectSession(sessionInfo.id);
}
}
if (event.type === "session.error") {
@@ -684,7 +383,9 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
if (input.tool === "task") {
const args = output.args as Record<string, unknown>;
const subagentType = args.subagent_type as string;
const isExploreOrLibrarian = ["explore", "librarian"].includes(subagentType);
const isExploreOrLibrarian = ["explore", "librarian"].includes(
subagentType
);
args.tools = {
...(args.tools as Record<string, boolean> | undefined),
@@ -699,15 +400,23 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
const sessionID = input.sessionID || getMainSessionID();
if (command === "ralph-loop" && sessionID) {
const rawArgs = args?.command?.replace(/^\/?(ralph-loop)\s*/i, "") || "";
const rawArgs =
args?.command?.replace(/^\/?(ralph-loop)\s*/i, "") || "";
const taskMatch = rawArgs.match(/^["'](.+?)["']/);
const prompt = taskMatch?.[1] || rawArgs.split(/\s+--/)[0]?.trim() || "Complete the task as instructed";
const prompt =
taskMatch?.[1] ||
rawArgs.split(/\s+--/)[0]?.trim() ||
"Complete the task as instructed";
const maxIterMatch = rawArgs.match(/--max-iterations=(\d+)/i);
const promiseMatch = rawArgs.match(/--completion-promise=["']?([^"'\s]+)["']?/i);
const promiseMatch = rawArgs.match(
/--completion-promise=["']?([^"'\s]+)["']?/i
);
ralphLoop.startLoop(sessionID, prompt, {
maxIterations: maxIterMatch ? parseInt(maxIterMatch[1], 10) : undefined,
maxIterations: maxIterMatch
? parseInt(maxIterMatch[1], 10)
: undefined,
completionPromise: promiseMatch?.[1],
});
} else if (command === "cancel-ralph" && sessionID) {

134
src/plugin-config.ts Normal file
View File

@@ -0,0 +1,134 @@
import * as fs from "fs";
import * as path from "path";
import { OhMyOpenCodeConfigSchema, type OhMyOpenCodeConfig } from "./config";
import {
log,
deepMerge,
getUserConfigDir,
addConfigLoadError,
parseJsonc,
detectConfigFile,
migrateConfigFile,
} from "./shared";
export function loadConfigFromPath(
configPath: string,
ctx: unknown
): OhMyOpenCodeConfig | null {
try {
if (fs.existsSync(configPath)) {
const content = fs.readFileSync(configPath, "utf-8");
const rawConfig = parseJsonc<Record<string, unknown>>(content);
migrateConfigFile(configPath, rawConfig);
const result = OhMyOpenCodeConfigSchema.safeParse(rawConfig);
if (!result.success) {
const errorMsg = result.error.issues
.map((i) => `${i.path.join(".")}: ${i.message}`)
.join(", ");
log(`Config validation error in ${configPath}:`, result.error.issues);
addConfigLoadError({
path: configPath,
error: `Validation error: ${errorMsg}`,
});
return null;
}
log(`Config loaded from ${configPath}`, { agents: result.data.agents });
return result.data;
}
} catch (err) {
const errorMsg = err instanceof Error ? err.message : String(err);
log(`Error loading config from ${configPath}:`, err);
addConfigLoadError({ path: configPath, error: errorMsg });
}
return null;
}
export function mergeConfigs(
base: OhMyOpenCodeConfig,
override: OhMyOpenCodeConfig
): OhMyOpenCodeConfig {
return {
...base,
...override,
agents: deepMerge(base.agents, override.agents),
disabled_agents: [
...new Set([
...(base.disabled_agents ?? []),
...(override.disabled_agents ?? []),
]),
],
disabled_mcps: [
...new Set([
...(base.disabled_mcps ?? []),
...(override.disabled_mcps ?? []),
]),
],
disabled_hooks: [
...new Set([
...(base.disabled_hooks ?? []),
...(override.disabled_hooks ?? []),
]),
],
disabled_commands: [
...new Set([
...(base.disabled_commands ?? []),
...(override.disabled_commands ?? []),
]),
],
disabled_skills: [
...new Set([
...(base.disabled_skills ?? []),
...(override.disabled_skills ?? []),
]),
],
claude_code: deepMerge(base.claude_code, override.claude_code),
};
}
export function loadPluginConfig(
directory: string,
ctx: unknown
): OhMyOpenCodeConfig {
// User-level config path (OS-specific) - prefer .jsonc over .json
const userBasePath = path.join(
getUserConfigDir(),
"opencode",
"oh-my-opencode"
);
const userDetected = detectConfigFile(userBasePath);
const userConfigPath =
userDetected.format !== "none"
? userDetected.path
: userBasePath + ".json";
// Project-level config path - prefer .jsonc over .json
const projectBasePath = path.join(directory, ".opencode", "oh-my-opencode");
const projectDetected = detectConfigFile(projectBasePath);
const projectConfigPath =
projectDetected.format !== "none"
? projectDetected.path
: projectBasePath + ".json";
// Load user config first (base)
let config: OhMyOpenCodeConfig =
loadConfigFromPath(userConfigPath, ctx) ?? {};
// Override with project config
const projectConfig = loadConfigFromPath(projectConfigPath, ctx);
if (projectConfig) {
config = mergeConfigs(config, projectConfig);
}
log("Final merged config", {
agents: config.agents,
disabled_agents: config.disabled_agents,
disabled_mcps: config.disabled_mcps,
disabled_hooks: config.disabled_hooks,
claude_code: config.claude_code,
});
return config;
}

View File

@@ -0,0 +1,280 @@
import { createBuiltinAgents } from "../agents";
import {
loadUserCommands,
loadProjectCommands,
loadOpencodeGlobalCommands,
loadOpencodeProjectCommands,
} from "../features/claude-code-command-loader";
import { loadBuiltinCommands } from "../features/builtin-commands";
import {
loadUserSkills,
loadProjectSkills,
loadOpencodeGlobalSkills,
loadOpencodeProjectSkills,
} from "../features/opencode-skill-loader";
import {
loadUserAgents,
loadProjectAgents,
} from "../features/claude-code-agent-loader";
import { loadMcpConfigs } from "../features/claude-code-mcp-loader";
import { loadAllPluginComponents } from "../features/claude-code-plugin-loader";
import { createBuiltinMcps } from "../mcp";
import type { OhMyOpenCodeConfig } from "../config";
import { log } from "../shared";
import { PLAN_SYSTEM_PROMPT, PLAN_PERMISSION } from "../agents/plan-prompt";
import type { ModelCacheState } from "../plugin-state";
export interface ConfigHandlerDeps {
ctx: { directory: string };
pluginConfig: OhMyOpenCodeConfig;
modelCacheState: ModelCacheState;
}
export function createConfigHandler(deps: ConfigHandlerDeps) {
const { ctx, pluginConfig, modelCacheState } = deps;
return async (config: Record<string, unknown>) => {
type ProviderConfig = {
options?: { headers?: Record<string, string> };
models?: Record<string, { limit?: { context?: number } }>;
};
const providers = config.provider as
| Record<string, ProviderConfig>
| undefined;
const anthropicBeta =
providers?.anthropic?.options?.headers?.["anthropic-beta"];
modelCacheState.anthropicContext1MEnabled =
anthropicBeta?.includes("context-1m") ?? false;
if (providers) {
for (const [providerID, providerConfig] of Object.entries(providers)) {
const models = providerConfig?.models;
if (models) {
for (const [modelID, modelConfig] of Object.entries(models)) {
const contextLimit = modelConfig?.limit?.context;
if (contextLimit) {
modelCacheState.modelContextLimitsCache.set(
`${providerID}/${modelID}`,
contextLimit
);
}
}
}
}
}
const pluginComponents = (pluginConfig.claude_code?.plugins ?? true)
? await loadAllPluginComponents({
enabledPluginsOverride: pluginConfig.claude_code?.plugins_override,
})
: {
commands: {},
skills: {},
agents: {},
mcpServers: {},
hooksConfigs: [],
plugins: [],
errors: [],
};
if (pluginComponents.plugins.length > 0) {
log(`Loaded ${pluginComponents.plugins.length} Claude Code plugins`, {
plugins: pluginComponents.plugins.map((p) => `${p.name}@${p.version}`),
});
}
if (pluginComponents.errors.length > 0) {
log(`Plugin load errors`, { errors: pluginComponents.errors });
}
const builtinAgents = createBuiltinAgents(
pluginConfig.disabled_agents,
pluginConfig.agents,
ctx.directory,
config.model as string | undefined
);
const userAgents = (pluginConfig.claude_code?.agents ?? true)
? loadUserAgents()
: {};
const projectAgents = (pluginConfig.claude_code?.agents ?? true)
? loadProjectAgents()
: {};
const pluginAgents = pluginComponents.agents;
const isSisyphusEnabled = pluginConfig.sisyphus_agent?.disabled !== true;
const builderEnabled =
pluginConfig.sisyphus_agent?.default_builder_enabled ?? false;
const plannerEnabled =
pluginConfig.sisyphus_agent?.planner_enabled ?? true;
const replacePlan = pluginConfig.sisyphus_agent?.replace_plan ?? true;
type AgentConfig = Record<
string,
Record<string, unknown> | undefined
> & {
build?: Record<string, unknown>;
plan?: Record<string, unknown>;
explore?: { tools?: Record<string, unknown> };
librarian?: { tools?: Record<string, unknown> };
"multimodal-looker"?: { tools?: Record<string, unknown> };
};
const configAgent = config.agent as AgentConfig | undefined;
if (isSisyphusEnabled && builtinAgents.Sisyphus) {
(config as { default_agent?: string }).default_agent = "Sisyphus";
const agentConfig: Record<string, unknown> = {
Sisyphus: builtinAgents.Sisyphus,
};
if (builderEnabled) {
const { name: _buildName, ...buildConfigWithoutName } =
configAgent?.build ?? {};
const openCodeBuilderOverride =
pluginConfig.agents?.["OpenCode-Builder"];
const openCodeBuilderBase = {
...buildConfigWithoutName,
description: `${configAgent?.build?.description ?? "Build agent"} (OpenCode default)`,
};
agentConfig["OpenCode-Builder"] = openCodeBuilderOverride
? { ...openCodeBuilderBase, ...openCodeBuilderOverride }
: openCodeBuilderBase;
}
if (plannerEnabled) {
const { name: _planName, ...planConfigWithoutName } =
configAgent?.plan ?? {};
const plannerSisyphusOverride =
pluginConfig.agents?.["Planner-Sisyphus"];
const plannerSisyphusBase = {
...planConfigWithoutName,
prompt: PLAN_SYSTEM_PROMPT,
permission: PLAN_PERMISSION,
description: `${configAgent?.plan?.description ?? "Plan agent"} (OhMyOpenCode version)`,
color: (configAgent?.plan?.color as string) ?? "#6495ED",
};
agentConfig["Planner-Sisyphus"] = plannerSisyphusOverride
? { ...plannerSisyphusBase, ...plannerSisyphusOverride }
: plannerSisyphusBase;
}
const filteredConfigAgents = configAgent
? Object.fromEntries(
Object.entries(configAgent).filter(([key]) => {
if (key === "build") return false;
if (key === "plan" && replacePlan) return false;
return true;
})
)
: {};
config.agent = {
...agentConfig,
...Object.fromEntries(
Object.entries(builtinAgents).filter(([k]) => k !== "Sisyphus")
),
...userAgents,
...projectAgents,
...pluginAgents,
...filteredConfigAgents,
build: { ...configAgent?.build, mode: "subagent" },
...(replacePlan
? { plan: { ...configAgent?.plan, mode: "subagent" } }
: {}),
};
} else {
config.agent = {
...builtinAgents,
...userAgents,
...projectAgents,
...pluginAgents,
...configAgent,
};
}
const agentResult = config.agent as AgentConfig;
config.tools = {
...(config.tools as Record<string, unknown>),
"grep_app_*": false,
};
if (agentResult.explore) {
agentResult.explore.tools = {
...agentResult.explore.tools,
call_omo_agent: false,
};
}
if (agentResult.librarian) {
agentResult.librarian.tools = {
...agentResult.librarian.tools,
call_omo_agent: false,
"grep_app_*": true,
};
}
if (agentResult["multimodal-looker"]) {
agentResult["multimodal-looker"].tools = {
...agentResult["multimodal-looker"].tools,
task: false,
call_omo_agent: false,
look_at: false,
};
}
config.permission = {
...(config.permission as Record<string, unknown>),
webfetch: "allow",
external_directory: "allow",
};
const mcpResult = (pluginConfig.claude_code?.mcp ?? true)
? await loadMcpConfigs()
: { servers: {} };
config.mcp = {
...(config.mcp as Record<string, unknown>),
...createBuiltinMcps(pluginConfig.disabled_mcps),
...mcpResult.servers,
...pluginComponents.mcpServers,
};
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();
config.command = {
...builtinCommands,
...userCommands,
...userSkills,
...opencodeGlobalCommands,
...opencodeGlobalSkills,
...systemCommands,
...projectCommands,
...projectSkills,
...opencodeProjectCommands,
...opencodeProjectSkills,
...pluginComponents.commands,
...pluginComponents.skills,
};
};
}

View File

@@ -0,0 +1 @@
export { createConfigHandler, type ConfigHandlerDeps } from "./config-handler";

30
src/plugin-state.ts Normal file
View File

@@ -0,0 +1,30 @@
export interface ModelCacheState {
modelContextLimitsCache: Map<string, number>;
anthropicContext1MEnabled: boolean;
}
export function createModelCacheState(): ModelCacheState {
return {
modelContextLimitsCache: new Map<string, number>(),
anthropicContext1MEnabled: false,
};
}
export function getModelLimit(
state: ModelCacheState,
providerID: string,
modelID: string
): number | undefined {
const key = `${providerID}/${modelID}`;
const cached = state.modelContextLimitsCache.get(key);
if (cached) return cached;
if (
providerID === "anthropic" &&
state.anthropicContext1MEnabled &&
modelID.includes("sonnet")
) {
return 1_000_000;
}
return undefined;
}

63
src/shared/AGENTS.md Normal file
View File

@@ -0,0 +1,63 @@
# SHARED UTILITIES KNOWLEDGE BASE
## OVERVIEW
Cross-cutting utilities: path resolution, config management, text processing, Claude Code compatibility helpers.
## STRUCTURE
```
shared/
├── index.ts # Barrel export
├── claude-config-dir.ts # ~/.claude resolution
├── command-executor.ts # Shell exec with variable expansion
├── config-errors.ts # Global error tracking
├── config-path.ts # User/project config paths
├── data-path.ts # XDG data directory
├── deep-merge.ts # Type-safe recursive merge
├── dynamic-truncator.ts # Token-aware truncation
├── file-reference-resolver.ts # @filename syntax
├── file-utils.ts # Symlink, markdown detection
├── frontmatter.ts # YAML frontmatter parsing
├── hook-disabled.ts # Check if hook disabled
├── jsonc-parser.ts # JSON with Comments
├── logger.ts # File-based logging
├── migration.ts # Legacy name compat (omo → Sisyphus)
├── model-sanitizer.ts # Normalize model names
├── pattern-matcher.ts # Tool name matching
├── snake-case.ts # Case conversion
└── tool-name.ts # PascalCase normalization
```
## WHEN TO USE
| Task | Utility |
|------|---------|
| Find ~/.claude | `getClaudeConfigDir()` |
| Merge configs | `deepMerge(base, override)` |
| Parse user files | `parseJsonc()` |
| Check hook enabled | `isHookDisabled(name, list)` |
| Truncate output | `dynamicTruncate(text, budget)` |
| Resolve @file | `resolveFileReferencesInText()` |
| Execute shell | `resolveCommandsInText()` |
| Legacy names | `migrateLegacyAgentNames()` |
## CRITICAL PATTERNS
```typescript
// Dynamic truncation
const output = dynamicTruncate(result, remainingTokens, 0.5)
// Deep merge priority
const final = deepMerge(deepMerge(defaults, userConfig), projectConfig)
// Safe JSONC
const { config, error } = parseJsoncSafe(content)
```
## ANTI-PATTERNS
- Hardcoding paths (use getClaudeConfigDir, getUserConfigPath)
- JSON.parse for user files (use parseJsonc)
- Ignoring truncation (large outputs MUST use dynamicTruncate)
- Direct string concat for configs (use deepMerge)

View File

@@ -0,0 +1,262 @@
import { describe, test, expect } from "bun:test"
import { parseFrontmatter } from "./frontmatter"
describe("parseFrontmatter", () => {
// #region backward compatibility
test("parses simple key-value frontmatter", () => {
// #given
const content = `---
description: Test command
agent: build
---
Body content`
// #when
const result = parseFrontmatter(content)
// #then
expect(result.data.description).toBe("Test command")
expect(result.data.agent).toBe("build")
expect(result.body).toBe("Body content")
})
test("parses boolean values", () => {
// #given
const content = `---
subtask: true
enabled: false
---
Body`
// #when
const result = parseFrontmatter<{ subtask: boolean; enabled: boolean }>(content)
// #then
expect(result.data.subtask).toBe(true)
expect(result.data.enabled).toBe(false)
})
// #endregion
// #region complex YAML (handoffs support)
test("parses complex array frontmatter (speckit handoffs)", () => {
// #given
const content = `---
description: Execute planning workflow
handoffs:
- label: Create Tasks
agent: speckit.tasks
prompt: Break the plan into tasks
send: true
- label: Create Checklist
agent: speckit.checklist
prompt: Create a checklist
---
Workflow instructions`
interface TestMeta {
description: string
handoffs: Array<{ label: string; agent: string; prompt: string; send?: boolean }>
}
// #when
const result = parseFrontmatter<TestMeta>(content)
// #then
expect(result.data.description).toBe("Execute planning workflow")
expect(result.data.handoffs).toHaveLength(2)
expect(result.data.handoffs[0].label).toBe("Create Tasks")
expect(result.data.handoffs[0].agent).toBe("speckit.tasks")
expect(result.data.handoffs[0].send).toBe(true)
expect(result.data.handoffs[1].agent).toBe("speckit.checklist")
expect(result.data.handoffs[1].send).toBeUndefined()
})
test("parses nested objects in frontmatter", () => {
// #given
const content = `---
name: test
config:
timeout: 5000
retry: true
options:
verbose: false
---
Content`
interface TestMeta {
name: string
config: {
timeout: number
retry: boolean
options: { verbose: boolean }
}
}
// #when
const result = parseFrontmatter<TestMeta>(content)
// #then
expect(result.data.name).toBe("test")
expect(result.data.config.timeout).toBe(5000)
expect(result.data.config.retry).toBe(true)
expect(result.data.config.options.verbose).toBe(false)
})
// #endregion
// #region edge cases
test("handles content without frontmatter", () => {
// #given
const content = "Just body content"
// #when
const result = parseFrontmatter(content)
// #then
expect(result.data).toEqual({})
expect(result.body).toBe("Just body content")
})
test("handles empty frontmatter", () => {
// #given
const content = `---
---
Body`
// #when
const result = parseFrontmatter(content)
// #then
expect(result.data).toEqual({})
expect(result.body).toBe("Body")
})
test("handles invalid YAML gracefully", () => {
// #given
const content = `---
invalid: yaml: syntax: here
bad indentation
---
Body`
// #when
const result = parseFrontmatter(content)
// #then - should not throw, return empty data
expect(result.data).toEqual({})
expect(result.body).toBe("Body")
})
test("handles frontmatter with only whitespace", () => {
// #given
const content = `---
---
Body with whitespace-only frontmatter`
// #when
const result = parseFrontmatter(content)
// #then
expect(result.data).toEqual({})
expect(result.body).toBe("Body with whitespace-only frontmatter")
})
// #endregion
// #region mixed content
test("preserves multiline body content", () => {
// #given
const content = `---
title: Test
---
Line 1
Line 2
Line 4 after blank`
// #when
const result = parseFrontmatter<{ title: string }>(content)
// #then
expect(result.data.title).toBe("Test")
expect(result.body).toBe("Line 1\nLine 2\n\nLine 4 after blank")
})
test("handles CRLF line endings", () => {
// #given
const content = "---\r\ndescription: Test\r\n---\r\nBody"
// #when
const result = parseFrontmatter<{ description: string }>(content)
// #then
expect(result.data.description).toBe("Test")
expect(result.body).toBe("Body")
})
// #endregion
// #region extra fields tolerance
test("allows extra fields beyond typed interface", () => {
// #given
const content = `---
description: Test command
agent: build
extra_field: should not fail
another_extra:
nested: value
array:
- item1
- item2
custom_boolean: true
custom_number: 42
---
Body content`
interface MinimalMeta {
description: string
agent: string
}
// #when
const result = parseFrontmatter<MinimalMeta>(content)
// #then
expect(result.data.description).toBe("Test command")
expect(result.data.agent).toBe("build")
expect(result.body).toBe("Body content")
// @ts-expect-error - accessing extra field not in MinimalMeta
expect(result.data.extra_field).toBe("should not fail")
// @ts-expect-error - accessing extra field not in MinimalMeta
expect(result.data.another_extra).toEqual({ nested: "value", array: ["item1", "item2"] })
// @ts-expect-error - accessing extra field not in MinimalMeta
expect(result.data.custom_boolean).toBe(true)
// @ts-expect-error - accessing extra field not in MinimalMeta
expect(result.data.custom_number).toBe(42)
})
test("extra fields do not interfere with expected fields", () => {
// #given
const content = `---
description: Original description
unknown_field: extra value
handoffs:
- label: Task 1
agent: test.agent
---
Content`
interface HandoffMeta {
description: string
handoffs: Array<{ label: string; agent: string }>
}
// #when
const result = parseFrontmatter<HandoffMeta>(content)
// #then
expect(result.data.description).toBe("Original description")
expect(result.data.handoffs).toHaveLength(1)
expect(result.data.handoffs[0].label).toBe("Task 1")
expect(result.data.handoffs[0].agent).toBe("test.agent")
})
// #endregion
})

View File

@@ -1,12 +1,14 @@
export interface FrontmatterResult<T = Record<string, string>> {
import yaml from "js-yaml"
export interface FrontmatterResult<T = Record<string, unknown>> {
data: T
body: string
}
export function parseFrontmatter<T = Record<string, string>>(
export function parseFrontmatter<T = Record<string, unknown>>(
content: string
): FrontmatterResult<T> {
const frontmatterRegex = /^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/
const frontmatterRegex = /^---\r?\n([\s\S]*?)\r?\n?---\r?\n([\s\S]*)$/
const match = content.match(frontmatterRegex)
if (!match) {
@@ -16,19 +18,12 @@ export function parseFrontmatter<T = Record<string, string>>(
const yamlContent = match[1]
const body = match[2]
const data: Record<string, string | boolean> = {}
for (const line of yamlContent.split("\n")) {
const colonIndex = line.indexOf(":")
if (colonIndex !== -1) {
const key = line.slice(0, colonIndex).trim()
let value: string | boolean = line.slice(colonIndex + 1).trim()
if (value === "true") value = true
else if (value === "false") value = false
data[key] = value
}
try {
// 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 }
} catch {
return { data: {} as T, body }
}
return { data: data as T, body }
}

View File

@@ -15,3 +15,4 @@ export * from "./data-path"
export * from "./config-errors"
export * from "./claude-config-dir"
export * from "./jsonc-parser"
export * from "./migration"

View File

@@ -0,0 +1,243 @@
import { describe, test, expect } from "bun:test"
import {
AGENT_NAME_MAP,
HOOK_NAME_MAP,
migrateAgentNames,
migrateHookNames,
migrateConfigFile,
} from "./migration"
describe("migrateAgentNames", () => {
test("migrates legacy OmO names to Sisyphus", () => {
// #given: Config with legacy OmO agent names
const agents = {
omo: { model: "anthropic/claude-opus-4-5" },
OmO: { temperature: 0.5 },
"OmO-Plan": { prompt: "custom prompt" },
}
// #when: Migrate agent names
const { migrated, changed } = migrateAgentNames(agents)
// #then: Legacy names should be migrated to Sisyphus
expect(changed).toBe(true)
expect(migrated["Sisyphus"]).toEqual({ temperature: 0.5 })
expect(migrated["Planner-Sisyphus"]).toEqual({ prompt: "custom prompt" })
expect(migrated["omo"]).toBeUndefined()
expect(migrated["OmO"]).toBeUndefined()
expect(migrated["OmO-Plan"]).toBeUndefined()
})
test("preserves current agent names unchanged", () => {
// #given: Config with current agent names
const agents = {
oracle: { model: "openai/gpt-5.2" },
librarian: { model: "google/gemini-3-flash" },
explore: { model: "opencode/grok-code" },
}
// #when: Migrate agent names
const { migrated, changed } = migrateAgentNames(agents)
// #then: Current names should remain unchanged
expect(changed).toBe(false)
expect(migrated["oracle"]).toEqual({ model: "openai/gpt-5.2" })
expect(migrated["librarian"]).toEqual({ model: "google/gemini-3-flash" })
expect(migrated["explore"]).toEqual({ model: "opencode/grok-code" })
})
test("handles case-insensitive migration", () => {
// #given: Config with mixed case agent names
const agents = {
SISYPHUS: { model: "test" },
"PLANNER-SISYPHUS": { prompt: "test" },
}
// #when: Migrate agent names
const { migrated, changed } = migrateAgentNames(agents)
// #then: Case-insensitive lookup should migrate correctly
expect(migrated["Sisyphus"]).toEqual({ model: "test" })
expect(migrated["Planner-Sisyphus"]).toEqual({ prompt: "test" })
})
test("passes through unknown agent names unchanged", () => {
// #given: Config with unknown agent name
const agents = {
"custom-agent": { model: "custom/model" },
}
// #when: Migrate agent names
const { migrated, changed } = migrateAgentNames(agents)
// #then: Unknown names should pass through
expect(changed).toBe(false)
expect(migrated["custom-agent"]).toEqual({ model: "custom/model" })
})
})
describe("migrateHookNames", () => {
test("migrates anthropic-auto-compact to anthropic-context-window-limit-recovery", () => {
// #given: Config with legacy hook name
const hooks = ["anthropic-auto-compact", "comment-checker"]
// #when: Migrate hook names
const { migrated, changed } = migrateHookNames(hooks)
// #then: Legacy hook name should be migrated
expect(changed).toBe(true)
expect(migrated).toContain("anthropic-context-window-limit-recovery")
expect(migrated).toContain("comment-checker")
expect(migrated).not.toContain("anthropic-auto-compact")
})
test("preserves current hook names unchanged", () => {
// #given: Config with current hook names
const hooks = [
"anthropic-context-window-limit-recovery",
"todo-continuation-enforcer",
"session-recovery",
]
// #when: Migrate hook names
const { migrated, changed } = migrateHookNames(hooks)
// #then: Current names should remain unchanged
expect(changed).toBe(false)
expect(migrated).toEqual(hooks)
})
test("handles empty hooks array", () => {
// #given: Empty hooks array
const hooks: string[] = []
// #when: Migrate hook names
const { migrated, changed } = migrateHookNames(hooks)
// #then: Should return empty array with no changes
expect(changed).toBe(false)
expect(migrated).toEqual([])
})
test("migrates multiple legacy hook names", () => {
// #given: Multiple legacy hook names (if more are added in future)
const hooks = ["anthropic-auto-compact"]
// #when: Migrate hook names
const { migrated, changed } = migrateHookNames(hooks)
// #then: All legacy names should be migrated
expect(changed).toBe(true)
expect(migrated).toEqual(["anthropic-context-window-limit-recovery"])
})
})
describe("migrateConfigFile", () => {
const testConfigPath = "/tmp/nonexistent-path-for-test.json"
test("migrates omo_agent to sisyphus_agent", () => {
// #given: Config with legacy omo_agent key
const rawConfig: Record<string, unknown> = {
omo_agent: { disabled: false },
}
// #when: Migrate config file
const needsWrite = migrateConfigFile(testConfigPath, rawConfig)
// #then: omo_agent should be migrated to sisyphus_agent
expect(needsWrite).toBe(true)
expect(rawConfig.sisyphus_agent).toEqual({ disabled: false })
expect(rawConfig.omo_agent).toBeUndefined()
})
test("migrates legacy agent names in agents object", () => {
// #given: Config with legacy agent names
const rawConfig: Record<string, unknown> = {
agents: {
omo: { model: "test" },
OmO: { temperature: 0.5 },
},
}
// #when: Migrate config file
const needsWrite = migrateConfigFile(testConfigPath, rawConfig)
// #then: Agent names should be migrated
expect(needsWrite).toBe(true)
const agents = rawConfig.agents as Record<string, unknown>
expect(agents["Sisyphus"]).toBeDefined()
})
test("migrates legacy hook names in disabled_hooks", () => {
// #given: Config with legacy hook names
const rawConfig: Record<string, unknown> = {
disabled_hooks: ["anthropic-auto-compact", "comment-checker"],
}
// #when: Migrate config file
const needsWrite = migrateConfigFile(testConfigPath, rawConfig)
// #then: Hook names should be migrated
expect(needsWrite).toBe(true)
expect(rawConfig.disabled_hooks).toContain("anthropic-context-window-limit-recovery")
expect(rawConfig.disabled_hooks).not.toContain("anthropic-auto-compact")
})
test("does not write if no migration needed", () => {
// #given: Config with current names
const rawConfig: Record<string, unknown> = {
sisyphus_agent: { disabled: false },
agents: {
Sisyphus: { model: "test" },
},
disabled_hooks: ["anthropic-context-window-limit-recovery"],
}
// #when: Migrate config file
const needsWrite = migrateConfigFile(testConfigPath, rawConfig)
// #then: No write should be needed
expect(needsWrite).toBe(false)
})
test("handles migration of all legacy items together", () => {
// #given: Config with all legacy items
const rawConfig: Record<string, unknown> = {
omo_agent: { disabled: false },
agents: {
omo: { model: "test" },
"OmO-Plan": { prompt: "custom" },
},
disabled_hooks: ["anthropic-auto-compact"],
}
// #when: Migrate config file
const needsWrite = migrateConfigFile(testConfigPath, rawConfig)
// #then: All legacy items should be migrated
expect(needsWrite).toBe(true)
expect(rawConfig.sisyphus_agent).toEqual({ disabled: false })
expect(rawConfig.omo_agent).toBeUndefined()
const agents = rawConfig.agents as Record<string, unknown>
expect(agents["Sisyphus"]).toBeDefined()
expect(agents["Planner-Sisyphus"]).toBeDefined()
expect(rawConfig.disabled_hooks).toContain("anthropic-context-window-limit-recovery")
})
})
describe("migration maps", () => {
test("AGENT_NAME_MAP contains all expected legacy mappings", () => {
// #given/#when: Check AGENT_NAME_MAP
// #then: Should contain all legacy → current mappings
expect(AGENT_NAME_MAP["omo"]).toBe("Sisyphus")
expect(AGENT_NAME_MAP["OmO"]).toBe("Sisyphus")
expect(AGENT_NAME_MAP["OmO-Plan"]).toBe("Planner-Sisyphus")
expect(AGENT_NAME_MAP["omo-plan"]).toBe("Planner-Sisyphus")
})
test("HOOK_NAME_MAP contains anthropic-auto-compact migration", () => {
// #given/#when: Check HOOK_NAME_MAP
// #then: Should contain the legacy hook name mapping
expect(HOOK_NAME_MAP["anthropic-auto-compact"]).toBe("anthropic-context-window-limit-recovery")
})
})

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