Compare commits

..

66 Commits

Author SHA1 Message Date
github-actions[bot]
30e0cc6ef1 release: v2.12.2 2026-01-03 01:02:03 +00:00
Sisyphus
f345101f91 fix(ralph-loop): adopt OContinue patterns for better performance and abort handling (#431) 2026-01-03 09:55:12 +09:00
Sisyphus
d09c994b91 fix(session-recovery): detect 'final block cannot be thinking' error pattern (#420) 2026-01-03 09:46:45 +09:00
sisyphus-dev-ai
8c30974c18 fix: address review feedback - fix typos and wording consistency 2026-01-03 00:43:09 +00:00
Sisyphus
c341c156ec docs: update Discord invite link in all README files (#429) 2026-01-03 09:35:35 +09:00
github-actions[bot]
b1528c590d release: v2.12.1 2026-01-02 17:42:01 +00:00
Jeon Suyeol
8b9913345b fix(todo-continuation-enforcer): add 500ms grace period to prevent false countdown cancellation (#424) 2026-01-03 02:22:55 +09:00
YeonGyu-Kim
fa204d8af0 chore: remove dead code - unused imports and variables
- Remove unused import OhMyOpenCodeConfig from src/index.ts
- Remove unused import dirname from src/features/opencode-skill-loader/loader.ts
- Remove unused import detectKeywords from src/hooks/keyword-detector/index.ts
- Remove unused import CliMatch from src/tools/ast-grep/utils.ts
- Prefix unused parameter _original in src/tools/ast-grep/utils.ts

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

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

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

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

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

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

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

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2026-01-02 21:16:23 +09:00
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
101 changed files with 6915 additions and 1145 deletions

View File

@@ -316,8 +316,8 @@ jobs:
---
Write everything using the todo tools.
Then investigate and satisfy the request. Only if user requested to you to work explicitely, then use plan agent to plan, todo obsessivley then create a PR to `BRANCH_PLACEHOLDER` branch.
Plan everything using todo tools.
Then investigate and satisfy the request. Only if user requested to you to work explicitly, then use plan agent to plan, todo obsessively then create a PR to `BRANCH_PLACEHOLDER` branch.
When done, report the result to the issue/PR with `gh issue comment NUMBER_PLACEHOLDER` or `gh pr comment NUMBER_PLACEHOLDER`.
PROMPT_EOF
)

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>

129
AGENTS.md
View File

@@ -1,30 +1,29 @@
# PROJECT KNOWLEDGE BASE
**Generated:** 2025-12-31T14:05:00+09:00
**Commit:** 502e9f5
**Generated:** 2026-01-02T22:41:22+09:00
**Commit:** d0694e5
**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. - see src/shared/AGENTS.md
│ ├── cli/ # CLI installer, doctor, run - see src/cli/AGENTS.md
│ └── index.ts # Main plugin entry (OhMyOpenCodePlugin)
── index.ts # Main plugin entry (464 lines)
├── script/ # build-schema.ts, publish.ts, generate-changelog.ts
├── assets/ # JSON schema
└── dist/ # Build output (ESM + .d.ts)
```
@@ -32,63 +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 MCP | `src/mcp/` | Create config, add to index.ts and types.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 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 |
| 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 |
| CLI installer | `src/cli/install.ts` | Interactive TUI installation |
| Doctor checks | `src/cli/doctor/checks/` | Health checks for environment |
| Shared utilities | `src/shared/` | Cross-cutting utilities |
## 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
- **High temperature**: Don't use >0.3 for code-related agents
- **Broad tool access**: Prefer explicit `include` over unrestricted access
## 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 |
@@ -99,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
@@ -108,32 +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 PIPELINE
- **ci.yml**: Parallel test/typecheck, build verification, auto-commit schema on master, rolling `next` draft release
- **publish.yml**: Manual workflow_dispatch, version bump, changelog, OIDC npm publish
- **sisyphus-agent.yml**: Agent-in-CI for automated issue handling via `@sisyphus-dev-ai` mentions
CI auto-commits schema changes on master, maintains rolling `next` draft release on dev.
## COMPLEXITY HOTSPOTS
| File | Lines | Description |
|------|-------|-------------|
| `src/index.ts` | 690 | Main plugin orchestration, all hook/tool initialization |
| `src/hooks/anthropic-context-window-limit-recovery/executor.ts` | 670 | Session compaction, multi-stage recovery pipeline |
| `src/cli/config-manager.ts` | 669 | JSONC parsing, environment detection, installation |
| `src/auth/antigravity/fetch.ts` | 621 | Token refresh, URL rewriting, endpoint fallbacks |
| `src/tools/lsp/client.ts` | 611 | LSP protocol, stdin/stdout buffering, JSON-RPC |
| `src/index.ts` | 464 | Main plugin, all hook/tool init |
| `src/cli/config-manager.ts` | 669 | JSONC parsing, env detection |
| `src/auth/antigravity/fetch.ts` | 621 | Token refresh, URL rewriting |
| `src/tools/lsp/client.ts` | 611 | LSP protocol, JSON-RPC |
| `src/hooks/anthropic-context-window-limit-recovery/executor.ts` | 564 | 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
- **JSONC support**: Config files support comments (`// comment`, `/* block */`) and trailing commas
- **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

@@ -4,7 +4,7 @@
>
> 一緒に歩みましょう!
>
> | [<img alt="Discord link" src="https://img.shields.io/discord/1452487457085063218?color=5865F2&label=discord&labelColor=black&logo=discord&logoColor=white&style=flat-square" width="156px" />](https://discord.gg/PWpXmbhF) | [Discordコミュニティ](https://discord.gg/PWpXmbhF)に参加して、コントリビューターや`oh-my-opencode`仲間とつながりましょう。 |
> | [<img alt="Discord link" src="https://img.shields.io/discord/1452487457085063218?color=5865F2&label=discord&labelColor=black&logo=discord&logoColor=white&style=flat-square" width="156px" />](https://discord.gg/aSfGzWtYxM) | [Discordコミュニティ](https://discord.gg/aSfGzWtYxM)に参加して、コントリビューターや`oh-my-opencode`仲間とつながりましょう。 |
> | :-----| :----- |
> | [<img alt="X link" src="https://img.shields.io/badge/Follow-%40justsisyphus-00CED1?style=flat-square&logo=x&labelColor=black" width="156px" />](https://x.com/justsisyphus) | `oh-my-opencode`に関するニュースは私のXアカウントで投稿していましたが、無実の罪で凍結されたため、<br />[@justsisyphus](https://x.com/justsisyphus)が代わりに更新を投稿しています。 |
> | [<img alt="Sponsor" src="https://img.shields.io/badge/Sponsor-❤-ff69b4?style=flat-square&logo=github-sponsors&labelColor=black" width="156px" />](https://github.com/sponsors/code-yeongyu) | [スポンサーになって](https://github.com/sponsors/code-yeongyu) `oh-my-opencode` の開発を応援してください。皆さまのご支援がこのプロジェクトを成長させます。 |
@@ -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`

View File

@@ -4,7 +4,7 @@
>
> 함께해주세요!
>
> | [<img alt="Discord link" src="https://img.shields.io/discord/1452487457085063218?color=5865F2&label=discord&labelColor=black&logo=discord&logoColor=white&style=flat-square" width="156px" />](https://discord.gg/PWpXmbhF) | [Discord 커뮤니티](https://discord.gg/PWpXmbhF)에서 기여자들과 `oh-my-opencode` 사용자들을 만나보세요. |
> | [<img alt="Discord link" src="https://img.shields.io/discord/1452487457085063218?color=5865F2&label=discord&labelColor=black&logo=discord&logoColor=white&style=flat-square" width="156px" />](https://discord.gg/aSfGzWtYxM) | [Discord 커뮤니티](https://discord.gg/aSfGzWtYxM)에서 기여자들과 `oh-my-opencode` 사용자들을 만나보세요. |
> | :-----| :----- |
> | [<img alt="X link" src="https://img.shields.io/badge/Follow-%40justsisyphus-00CED1?style=flat-square&logo=x&labelColor=black" width="156px" />](https://x.com/justsisyphus) | `oh-my-opencode` 관련 소식은 제 X 계정에서 올렸었는데, 억울하게 정지당해서 <br />[@justsisyphus](https://x.com/justsisyphus)가 대신 소식을 전하고 있습니다. |
> | [<img alt="Sponsor" src="https://img.shields.io/badge/Sponsor-❤-ff69b4?style=flat-square&logo=github-sponsors&labelColor=black" width="156px" />](https://github.com/sponsors/code-yeongyu) | [스폰서가 되어](https://github.com/sponsors/code-yeongyu) `oh-my-opencode` 개발을 응원해주세요. 여러분의 후원이 이 프로젝트를 계속 성장시킵니다. |
@@ -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`

View File

@@ -7,7 +7,7 @@
>
> Be with us!
>
> | [<img alt="Discord link" src="https://img.shields.io/discord/1452487457085063218?color=5865F2&label=discord&labelColor=black&logo=discord&logoColor=white&style=flat-square" width="156px" />](https://discord.gg/PWpXmbhF) | Join our [Discord community](https://discord.gg/PWpXmbhF) to connect with contributors and fellow `oh-my-opencode` users. |
> | [<img alt="Discord link" src="https://img.shields.io/discord/1452487457085063218?color=5865F2&label=discord&labelColor=black&logo=discord&logoColor=white&style=flat-square" width="156px" />](https://discord.gg/aSfGzWtYxM) | Join our [Discord community](https://discord.gg/aSfGzWtYxM) to connect with contributors and fellow `oh-my-opencode` users. |
> | :-----| :----- |
> | [<img alt="X link" src="https://img.shields.io/badge/Follow-%40justsisyphus-00CED1?style=flat-square&logo=x&labelColor=black" width="156px" />](https://x.com/justsisyphus) | News and updates for `oh-my-opencode` used to be posted on my X account. <br /> Since it was suspended mistakenly, [@justsisyphus](https://x.com/justsisyphus) now posts updates on my behalf. |
> | [<img alt="Sponsor" src="https://img.shields.io/badge/Sponsor-❤-ff69b4?style=flat-square&logo=github-sponsors&labelColor=black" width="156px" />](https://github.com/sponsors/code-yeongyu) | Support the development of `oh-my-opencode` by [becoming a sponsor](https://github.com/sponsors/code-yeongyu). Your contribution helps keep this project alive and growing. |
@@ -152,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)
@@ -204,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**
@@ -577,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.
@@ -846,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:

View File

@@ -4,7 +4,7 @@
>
> 与我们同行!
>
> | [<img alt="Discord link" src="https://img.shields.io/discord/1452487457085063218?color=5865F2&label=discord&labelColor=black&logo=discord&logoColor=white&style=flat-square" width="156px" />](https://discord.gg/PWpXmbhF) | 加入我们的 [Discord 社区](https://discord.gg/PWpXmbhF),和贡献者们、`oh-my-opencode` 用户们一起交流。 |
> | [<img alt="Discord link" src="https://img.shields.io/discord/1452487457085063218?color=5865F2&label=discord&labelColor=black&logo=discord&logoColor=white&style=flat-square" width="156px" />](https://discord.gg/aSfGzWtYxM) | 加入我们的 [Discord 社区](https://discord.gg/aSfGzWtYxM),和贡献者们、`oh-my-opencode` 用户们一起交流。 |
> | :-----| :----- |
> | [<img alt="X link" src="https://img.shields.io/badge/Follow-%40justsisyphus-00CED1?style=flat-square&logo=x&labelColor=black" width="156px" />](https://x.com/justsisyphus) | `oh-my-opencode` 的消息之前在我的 X 账号发,但账号被无辜封了,<br />现在 [@justsisyphus](https://x.com/justsisyphus) 替我发更新。 |
> | [<img alt="Sponsor" src="https://img.shields.io/badge/Sponsor-❤-ff69b4?style=flat-square&logo=github-sponsors&labelColor=black" width="156px" />](https://github.com/sponsors/code-yeongyu) | [成为赞助者](https://github.com/sponsors/code-yeongyu),支持 `oh-my-opencode` 的开发。您的支持让这个项目持续成长。 |
@@ -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`

View File

@@ -34,6 +34,15 @@
]
}
},
"disabled_skills": {
"type": "array",
"items": {
"type": "string",
"enum": [
"playwright"
]
}
},
"disabled_hooks": {
"type": "array",
"items": {
@@ -64,7 +73,9 @@
"ralph-loop",
"preemptive-compaction",
"compaction-context-injector",
"claude-code-hooks"
"claude-code-hooks",
"auto-slash-command",
"edit-error-recovery"
]
}
},

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.9.1",
"version": "2.12.2",
"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

@@ -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

@@ -108,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,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)

View File

@@ -2,92 +2,67 @@
## OVERVIEW
Command-line interface for oh-my-opencode. Interactive installer, health diagnostics (doctor), and runtime commands. Entry point: `bunx oh-my-opencode`.
CLI for oh-my-opencode: interactive installer, health diagnostics (doctor), runtime launcher. Entry: `bunx oh-my-opencode`.
## STRUCTURE
```
cli/
├── index.ts # Commander.js entry point, subcommand routing
├── install.ts # Interactive TUI installer
├── config-manager.ts # Config detection, parsing, merging (669 lines)
├── 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, descriptions
│ ├── constants.ts # Check categories
│ ├── types.ts # Check result interfaces
│ └── checks/ # 17 individual health checks
├── get-local-version/ # Version detection utility
│ ├── index.ts
│ └── formatter.ts
│ └── checks/ # 17+ individual checks
├── get-local-version/ # Version detection
└── run/ # OpenCode session launcher
├── index.ts
└── completion.test.ts
```
## CLI COMMANDS
| Command | Purpose | Key File |
|---------|---------|----------|
| `install` | Interactive setup wizard | `install.ts` |
| `doctor` | Environment health checks | `doctor/index.ts` |
| `run` | Launch OpenCode session | `run/index.ts` |
| Command | Purpose |
|---------|---------|
| `install` | Interactive setup wizard |
| `doctor` | Environment health checks |
| `run` | Launch OpenCode session |
## DOCTOR CHECKS
17 checks in `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
| Check | Validates |
|-------|-----------|
| `version.ts` | OpenCode version >= 1.0.150 |
| `config.ts` | Plugin registered in opencode.json |
| `bun.ts` | Bun runtime available |
| `node.ts` | Node.js version compatibility |
| `git.ts` | Git installed |
| `anthropic-auth.ts` | Claude authentication |
| `openai-auth.ts` | OpenAI authentication |
| `google-auth.ts` | Google/Gemini authentication |
| `lsp-*.ts` | Language server availability |
| `mcp-*.ts` | MCP server connectivity |
## CONFIG-MANAGER (669 lines)
## INSTALLATION FLOW
- JSONC support (comments, trailing commas)
- Multi-source: User (~/.config/opencode/) + Project (.opencode/)
- Zod validation
- Legacy format migration
- Error aggregation for doctor
1. **Detection**: Find existing `opencode.json` / `opencode.jsonc`
2. **TUI Prompts**: Claude subscription? ChatGPT? Gemini?
3. **Config Generation**: Build `oh-my-opencode.json` based on answers
4. **Plugin Registration**: Add to `plugin` array in opencode.json
5. **Auth Guidance**: Instructions for `opencode auth login`
## CONFIG-MANAGER
The largest file (669 lines) handles:
- **JSONC support**: Parses comments and trailing commas
- **Multi-source detection**: User (~/.config/opencode/) + Project (.opencode/)
- **Schema validation**: Zod-based config validation
- **Migration**: Handles legacy config formats
- **Error collection**: Aggregates parsing errors for doctor
## HOW TO ADD A DOCTOR CHECK
## HOW TO ADD CHECK
1. Create `src/cli/doctor/checks/my-check.ts`:
```typescript
import type { DoctorCheck } from "../types"
export const myCheck: DoctorCheck = {
name: "my-check",
category: "environment",
check: async () => {
// Return { status: "pass" | "warn" | "fail", message: string }
return { status: "pass" | "warn" | "fail", message: "..." }
}
}
```
2. Add to `src/cli/doctor/checks/index.ts`
3. Update `constants.ts` if new category
## ANTI-PATTERNS (CLI)
## ANTI-PATTERNS
- **Blocking prompts in non-TTY**: Check `process.stdout.isTTY` before TUI
- **Hardcoded paths**: Use shared utilities for config paths
- **Ignoring JSONC**: User configs may have comments
- **Silent failures**: Doctor checks must return clear status/message
- 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

@@ -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",
@@ -69,6 +73,8 @@ export const HookNameSchema = z.enum([
"preemptive-compaction",
"compaction-context-injector",
"claude-code-hooks",
"auto-slash-command",
"edit-error-recovery",
])
export const BuiltinCommandNameSchema = z.enum([
@@ -167,7 +173,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(),
@@ -230,6 +236,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(),
@@ -249,6 +256,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

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

View File

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

View File

@@ -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
}
}

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

@@ -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 { join, basename } from "path"
import { homedir } from "os"
import yaml from "js-yaml"
import { parseFrontmatter } from "../../shared/frontmatter"
import { sanitizeModelField } from "../../shared/model-sanitizer"
import { resolveSymlink, isMarkdownFile } from "../../shared/file-utils"
import { 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

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
@@ -224,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

@@ -39,7 +39,7 @@ type Client = {
query: { directory: string };
}) => Promise<unknown>;
prompt_async: (opts: {
path: { sessionID: string };
path: { id: string };
body: { parts: Array<{ type: string; text: string }> };
query: { directory: string };
}) => Promise<unknown>;
@@ -158,6 +158,8 @@ export async function getLastAssistant(
}
}
function clearSessionState(
autoCompactState: AutoCompactState,
sessionID: string,
@@ -295,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,
@@ -314,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,
});
@@ -335,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 });
}
@@ -393,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,
@@ -422,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,
},
})
@@ -439,11 +401,14 @@ 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({
path: { sessionID },
path: { id: sessionID },
body: { parts: [{ type: "text", text: "Continue" }] },
query: { directory },
});
@@ -451,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")) {
@@ -581,7 +475,7 @@ export async function executeCompact(
autoCompactState.truncateStateBySession.delete(sessionID);
}
if (!skipSummarize && retryState.attempt < RETRY_CONFIG.maxAttempts) {
if (retryState.attempt < RETRY_CONFIG.maxAttempts) {
retryState.attempt++;
retryState.lastAttemptTime = Date.now();
@@ -612,7 +506,7 @@ export async function executeCompact(
setTimeout(async () => {
try {
await (client as Client).session.prompt_async({
path: { sessionID },
path: { id: sessionID },
body: { parts: [{ type: "text", text: "Continue" }] },
query: { directory },
});

View File

@@ -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

@@ -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

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

View File

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

View File

@@ -23,3 +23,5 @@ 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";
export { createEditErrorRecoveryHook } from "./edit-error-recovery";

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 { 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

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

View File

@@ -19,6 +19,33 @@ function detectBannedCommand(command: string): string | undefined {
return undefined
}
/**
* Shell-escape a value for use in VAR=value prefix.
* Wraps in single quotes if contains special chars.
*/
function shellEscape(value: string): string {
// Empty string needs quotes
if (value === "") return "''"
// If contains special chars, wrap in single quotes (escape existing single quotes)
if (/[^a-zA-Z0-9_\-.:\/]/.test(value)) {
return `'${value.replace(/'/g, "'\\''")}'`
}
return value
}
/**
* Build env prefix string with line continuation for readability:
* VAR1=val1 \
* VAR2=val2 \
* ...
* OpenCode's bash tool ignores args.env, so we must prepend to command.
*/
function buildEnvPrefix(env: Record<string, string>): string {
return Object.entries(env)
.map(([key, value]) => `${key}=${shellEscape(value)}`)
.join(" \\\n")
}
export function createNonInteractiveEnvHook(_ctx: PluginInput) {
return {
"tool.execute.before": async (
@@ -34,19 +61,25 @@ export function createNonInteractiveEnvHook(_ctx: PluginInput) {
return
}
output.args.env = {
...(output.args.env as Record<string, string> | undefined),
...NON_INTERACTIVE_ENV,
}
const bannedCmd = detectBannedCommand(command)
if (bannedCmd) {
output.message = `⚠️ Warning: '${bannedCmd}' is an interactive command that may hang in non-interactive environments.`
}
log(`[${HOOK_NAME}] Set non-interactive environment variables`, {
// Only prepend env vars for git commands (editor blocking, pager, etc.)
const isGitCommand = /\bgit\b/.test(command)
if (!isGitCommand) {
return
}
// OpenCode's bash tool uses hardcoded `...process.env` in spawn(),
// ignoring any args.env we might set. Prepend to command instead.
const envPrefix = buildEnvPrefix(NON_INTERACTIVE_ENV)
output.args.command = `${envPrefix} ${command}`
log(`[${HOOK_NAME}] Prepended non-interactive env vars to git command`, {
sessionID: input.sessionID,
env: NON_INTERACTIVE_ENV,
envPrefix,
})
},
}

View File

@@ -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())
@@ -386,5 +423,162 @@ describe("ralph-loop", () => {
expect(promptCalls[0].text).toContain("Create a calculator app")
expect(promptCalls[0].text).toContain("<promise>CALCULATOR_DONE</promise>")
})
test("should clear loop state on user abort (MessageAbortedError)", async () => {
// #given - active loop
const hook = createRalphLoopHook(createMockPluginInput())
hook.startLoop("session-123", "Build something")
expect(hook.getState()).not.toBeNull()
// #when - user aborts (Ctrl+C)
await hook.event({
event: {
type: "session.error",
properties: {
sessionID: "session-123",
error: { name: "MessageAbortedError", message: "User aborted" },
},
},
})
// #then - loop state should be cleared immediately
expect(hook.getState()).toBeNull()
})
test("should NOT set recovery mode on user abort", async () => {
// #given - active loop
const hook = createRalphLoopHook(createMockPluginInput())
hook.startLoop("session-123", "Build something")
// #when - user aborts (Ctrl+C)
await hook.event({
event: {
type: "session.error",
properties: {
sessionID: "session-123",
error: { name: "MessageAbortedError" },
},
},
})
// Start a new loop
hook.startLoop("session-123", "New task")
// #when - session goes idle immediately (should work, no recovery mode)
await hook.event({
event: { type: "session.idle", properties: { sessionID: "session-123" } },
})
// #then - continuation should be injected (not blocked by recovery)
expect(promptCalls.length).toBe(1)
})
test("should only check LAST assistant message for completion", async () => {
// #given - multiple assistant messages, only first has completion promise
mockSessionMessages = [
{ info: { role: "user" }, parts: [{ type: "text", text: "Start task" }] },
{ info: { role: "assistant" }, parts: [{ type: "text", text: "I'll work on it. <promise>DONE</promise>" }] },
{ info: { role: "user" }, parts: [{ type: "text", text: "Continue" }] },
{ info: { role: "assistant" }, parts: [{ type: "text", text: "Working on more features..." }] },
]
const hook = createRalphLoopHook(createMockPluginInput(), {
getTranscriptPath: () => join(TEST_DIR, "nonexistent.jsonl"),
})
hook.startLoop("session-123", "Build something", { completionPromise: "DONE" })
// #when - session goes idle
await hook.event({
event: { type: "session.idle", properties: { sessionID: "session-123" } },
})
// #then - loop should continue (last message has no completion promise)
expect(promptCalls.length).toBe(1)
expect(hook.getState()?.iteration).toBe(2)
})
test("should detect completion only in LAST assistant message", async () => {
// #given - last assistant message has completion promise
mockSessionMessages = [
{ info: { role: "user" }, parts: [{ type: "text", text: "Start task" }] },
{ info: { role: "assistant" }, parts: [{ type: "text", text: "Starting work..." }] },
{ info: { role: "user" }, parts: [{ type: "text", text: "Continue" }] },
{ info: { role: "assistant" }, parts: [{ type: "text", text: "Task complete! <promise>DONE</promise>" }] },
]
const hook = createRalphLoopHook(createMockPluginInput(), {
getTranscriptPath: () => join(TEST_DIR, "nonexistent.jsonl"),
})
hook.startLoop("session-123", "Build something", { completionPromise: "DONE" })
// #when - session goes idle
await hook.event({
event: { type: "session.idle", properties: { sessionID: "session-123" } },
})
// #then - loop should complete (last message has completion promise)
expect(promptCalls.length).toBe(0)
expect(toastCalls.some((t) => t.title === "Ralph Loop Complete!")).toBe(true)
expect(hook.getState()).toBeNull()
})
test("should check transcript BEFORE API to optimize performance", async () => {
// #given - transcript has completion promise
const transcriptPath = join(TEST_DIR, "transcript.jsonl")
writeFileSync(transcriptPath, JSON.stringify({ content: "<promise>DONE</promise>" }))
mockSessionMessages = [
{ info: { role: "assistant" }, parts: [{ type: "text", text: "No promise here" }] },
]
const hook = createRalphLoopHook(createMockPluginInput(), {
getTranscriptPath: () => transcriptPath,
})
hook.startLoop("session-123", "Build something", { completionPromise: "DONE" })
// #when - session goes idle
await hook.event({
event: { type: "session.idle", properties: { sessionID: "session-123" } },
})
// #then - should complete via transcript (API not called when transcript succeeds)
expect(promptCalls.length).toBe(0)
expect(hook.getState()).toBeNull()
// API should NOT be called since transcript found completion
expect(messagesCalls.length).toBe(0)
})
})
describe("API timeout protection", () => {
test("should not hang when session.messages() times out", async () => {
// #given - slow API that takes longer than timeout
const slowMock = {
...createMockPluginInput(),
client: {
...createMockPluginInput().client,
session: {
...createMockPluginInput().client.session,
messages: async () => {
// Simulate slow API (would hang without timeout)
await new Promise((resolve) => setTimeout(resolve, 10000))
return { data: [] }
},
},
},
}
const hook = createRalphLoopHook(slowMock as any, {
getTranscriptPath: () => join(TEST_DIR, "nonexistent.jsonl"),
apiTimeout: 100, // 100ms timeout for test
})
hook.startLoop("session-123", "Build something")
// #when - session goes idle (API will timeout)
const startTime = Date.now()
await hook.event({
event: { type: "session.idle", properties: { sessionID: "session-123" } },
})
const elapsed = Date.now() - startTime
// #then - should complete within timeout + buffer (not hang for 10s)
expect(elapsed).toBeLessThan(500)
// #then - loop should continue (API timeout = no completion detected)
expect(promptCalls.length).toBe(1)
})
})
})

View File

@@ -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.
@@ -42,6 +53,8 @@ export interface RalphLoopHook {
getState: () => RalphLoopState | null
}
const DEFAULT_API_TIMEOUT = 3000
export function createRalphLoopHook(
ctx: PluginInput,
options?: RalphLoopOptions
@@ -50,6 +63,7 @@ export function createRalphLoopHook(
const config = options?.config
const stateDir = config?.state_dir
const getTranscriptPath = options?.getTranscriptPath ?? getDefaultTranscriptPath
const apiTimeout = options?.apiTimeout ?? DEFAULT_API_TIMEOUT
function getSessionState(sessionID: string): SessionState {
let state = sessions.get(sessionID)
@@ -81,6 +95,43 @@ export function createRalphLoopHook(
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
}
async function detectCompletionInSessionMessages(
sessionID: string,
promise: string
): Promise<boolean> {
try {
const response = await Promise.race([
ctx.client.session.messages({
path: { id: sessionID },
query: { directory: ctx.directory },
}),
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error("API timeout")), apiTimeout)
),
])
const messages = (response as { data?: unknown[] }).data ?? []
if (!Array.isArray(messages)) return false
const assistantMessages = (messages as OpenCodeSessionMessage[]).filter(
(msg) => msg.info?.role === "assistant"
)
const lastAssistant = assistantMessages[assistantMessages.length - 1]
if (!lastAssistant?.parts) return false
const pattern = new RegExp(`<promise>\\s*${escapeRegex(promise)}\\s*</promise>`, "is")
const responseText = lastAssistant.parts
.filter((p) => p.type === "text")
.map((p) => p.text ?? "")
.join("\n")
return pattern.test(responseText)
} catch (err) {
log(`[${HOOK_NAME}] Session messages check failed`, { sessionID, error: String(err) })
return false
}
}
const startLoop = (
sessionID: string,
prompt: string,
@@ -151,14 +202,19 @@ export function createRalphLoopHook(
return
}
// Generate transcript path from sessionID - OpenCode doesn't pass it in event properties
const transcriptPath = getTranscriptPath(sessionID)
const completionDetectedViaTranscript = detectCompletionPromise(transcriptPath, state.completion_promise)
if (detectCompletionPromise(transcriptPath, state.completion_promise)) {
const completionDetectedViaApi = completionDetectedViaTranscript
? false
: await detectCompletionInSessionMessages(sessionID, state.completion_promise)
if (completionDetectedViaTranscript || completionDetectedViaApi) {
log(`[${HOOK_NAME}] Completion detected!`, {
sessionID,
iteration: state.iteration,
promise: state.completion_promise,
detectedVia: completionDetectedViaTranscript ? "transcript_file" : "session_messages_api",
})
clearState(ctx.directory, stateDir)
@@ -256,6 +312,20 @@ export function createRalphLoopHook(
if (event.type === "session.error") {
const sessionID = props?.sessionID as string | undefined
const error = props?.error as { name?: string } | undefined
if (error?.name === "MessageAbortedError") {
if (sessionID) {
const state = readState(ctx.directory, stateDir)
if (state?.session_id === sessionID) {
clearState(ctx.directory, stateDir)
log(`[${HOOK_NAME}] User aborted, loop cleared`, { sessionID })
}
sessions.delete(sessionID)
}
return
}
if (sessionID) {
const sessionState = getSessionState(sessionID)
sessionState.isRecovering = true

View File

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

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

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

View File

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

View File

@@ -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,11 +212,11 @@ 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)
})
test("should cancel countdown on user message", async () => {
test("should cancel countdown on user message after grace period", async () => {
// #given - session starting countdown
const sessionID = "main-cancel"
setMainSession(sessionID)
@@ -227,19 +228,46 @@ describe("todo-continuation-enforcer", () => {
event: { type: "session.idle", properties: { sessionID } },
})
// #when - user sends message immediately (before 2s countdown)
// #when - wait past grace period (500ms), then user sends message
await new Promise(r => setTimeout(r, 600))
await hook.handler({
event: {
type: "message.updated",
properties: { info: { sessionID, role: "user" } }
event: {
type: "message.updated",
properties: { info: { sessionID, role: "user" } }
},
})
// #then - wait past countdown time and verify no injection
// #then - wait past countdown time and verify no injection (countdown was cancelled)
await new Promise(r => setTimeout(r, 2500))
expect(promptCalls).toHaveLength(0)
})
test("should ignore user message within grace period", async () => {
// #given - session starting countdown
const sessionID = "main-grace"
setMainSession(sessionID)
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
// #when - session goes idle
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
// #when - user message arrives within grace period (immediately)
await hook.handler({
event: {
type: "message.updated",
properties: { info: { sessionID, role: "user" } }
},
})
// #then - countdown should continue (message was ignored)
// wait past 2s countdown and verify injection happens
await new Promise(r => setTimeout(r, 2500))
expect(promptCalls).toHaveLength(1)
})
test("should cancel countdown on assistant activity", async () => {
// #given - session starting countdown
const sessionID = "main-assistant"
@@ -255,9 +283,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 +429,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,10 +29,11 @@ interface Todo {
}
interface SessionState {
lastErrorAt?: number
lastEventWasAbortError?: boolean
countdownTimer?: ReturnType<typeof setTimeout>
countdownInterval?: ReturnType<typeof setInterval>
isRecovering?: boolean
countdownStartedAt?: number
}
const CONTINUATION_PROMPT = `[SYSTEM REMINDER - TODO CONTINUATION]
@@ -45,7 +46,7 @@ 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
const COUNTDOWN_GRACE_PERIOD_MS = 500
function getMessageDir(sessionID: string): string | null {
if (!existsSync(MESSAGE_STORAGE)) return null
@@ -63,22 +64,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 +106,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
@@ -114,6 +115,7 @@ export function createTodoContinuationEnforcer(
clearInterval(state.countdownInterval)
state.countdownInterval = undefined
}
state.countdownStartedAt = undefined
}
function cleanup(sessionID: string): void {
@@ -149,16 +151,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 +186,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 +202,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) })
@@ -227,6 +231,7 @@ export function createTodoContinuationEnforcer(
let secondsRemaining = COUNTDOWN_SECONDS
showCountdownToast(secondsRemaining, incompleteCount)
state.countdownStartedAt = Date.now()
state.countdownInterval = setInterval(() => {
secondsRemaining--
@@ -251,10 +256,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 +273,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 +286,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 +332,21 @@ 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
if (state?.countdownStartedAt) {
const elapsed = Date.now() - state.countdownStartedAt
if (elapsed < COUNTDOWN_GRACE_PERIOD_MS) {
log(`[${HOOK_NAME}] Ignoring user message in grace period`, { sessionID, elapsed })
return
}
}
cancelCountdown(sessionID)
log(`[${HOOK_NAME}] User message: cleared error state`, { sessionID })
log(`[${HOOK_NAME}] User message: cleared abort state`, { sessionID })
}
if (role === "assistant") {
@@ -346,6 +361,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 +373,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,11 @@ import {
createEmptyMessageSanitizerHook,
createThinkingBlockValidatorHook,
createRalphLoopHook,
createAutoSlashCommandHook,
createEditErrorRecoveryHook,
} 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,138 +36,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, migrateConfigFile } from "./shared";
import { PLAN_SYSTEM_PROMPT, PLAN_PERMISSION } from "./agents/plan-prompt";
import * as fs from "fs";
import * as path from "path";
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 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)
@@ -193,7 +80,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)
@@ -204,13 +93,13 @@ 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,
@@ -223,7 +112,8 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
? createPreemptiveCompactionHook(ctx, {
experimental: pluginConfig.experimental,
onBeforeSummarize: compactionContextInjector,
getModelLimit,
getModelLimit: (providerID, modelID) =>
getModelLimit(modelCacheState, providerID, modelID),
})
: null;
const rulesInjector = isHookEnabled("rules-injector")
@@ -237,7 +127,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
})
: null;
const keywordDetector = isHookEnabled("keyword-detector")
? createKeywordDetectorHook()
? createKeywordDetectorHook(ctx)
: null;
const agentUsageReminder = isHookEnabled("agent-usage-reminder")
? createAgentUsageReminderHook(ctx)
@@ -259,6 +149,14 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
? createRalphLoopHook(ctx, { config: pluginConfig.ralph_loop })
: null;
const autoSlashCommand = isHookEnabled("auto-slash-command")
? createAutoSlashCommandHook()
: null;
const editErrorRecovery = isHookEnabled("edit-error-recovery")
? createEditErrorRecoveryHook(ctx)
: null;
const backgroundManager = new BackgroundManager(ctx);
const todoContinuationEnforcer = isHookEnabled("todo-continuation-enforcer")
@@ -267,7 +165,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")
@@ -277,7 +177,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,
@@ -285,16 +195,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 } : {}),
@@ -304,42 +232,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);
}
}
@@ -349,207 +299,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);
@@ -585,6 +345,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") {
@@ -625,7 +388,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),
@@ -640,15 +405,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) {
@@ -668,6 +441,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
await emptyTaskResponseDetector?.["tool.execute.after"](input, output);
await agentUsageReminder?.["tool.execute.after"](input, output);
await interactiveBashSession?.["tool.execute.after"](input, output);
await editErrorRecovery?.["tool.execute.after"](input, output);
},
};
};

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;
}

View File

@@ -2,81 +2,62 @@
## OVERVIEW
Cross-cutting utility functions used across agents, hooks, tools, and features. Path resolution, config management, text processing, and Claude Code compatibility helpers.
Cross-cutting utilities: path resolution, config management, text processing, Claude Code compatibility helpers.
## STRUCTURE
```
shared/
├── index.ts # Barrel export (import { x } from "../shared")
├── claude-config-dir.ts # Resolve ~/.claude directory
├── command-executor.ts # Shell command execution with variable expansion
├── config-errors.ts # Global config error tracking
├── config-path.ts # User/project config path resolution
├── data-path.ts # XDG data directory resolution
├── deep-merge.ts # Type-safe recursive object merging
├── dynamic-truncator.ts # Token-aware output truncation
├── file-reference-resolver.ts # @filename syntax resolution
├── file-utils.ts # Symlink resolution, markdown detection
├── 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 is disabled in config
├── jsonc-parser.ts # JSON with Comments parsing
├── logger.ts # File-based logging to OS temp
├── migration.ts # Legacy name compatibility (omo -> Sisyphus)
├── 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 with wildcards
├── snake-case.ts # Case conversion for objects
└── tool-name.ts # Normalize tool names to PascalCase
├── pattern-matcher.ts # Tool name matching
├── snake-case.ts # Case conversion
└── tool-name.ts # PascalCase normalization
```
## UTILITY CATEGORIES
## WHEN TO USE
| Category | Utilities | Used By |
|----------|-----------|---------|
| Path Resolution | `getClaudeConfigDir`, `getUserConfigPath`, `getProjectConfigPath`, `getDataDir` | Features, Hooks |
| Config Management | `deepMerge`, `parseJsonc`, `isHookDisabled`, `configErrors` | index.ts, CLI |
| Text Processing | `resolveCommandsInText`, `resolveFileReferencesInText`, `parseFrontmatter` | Commands, Rules |
| Output Control | `dynamicTruncate` | Tools (Grep, LSP) |
| Normalization | `transformToolName`, `objectToSnakeCase`, `sanitizeModelName` | Hooks, Agents |
| Compatibility | `migration.ts` | Config loading |
## WHEN TO USE WHAT
| Task | Utility | Notes |
|------|---------|-------|
| Find Claude Code configs | `getClaudeConfigDir()` | Never hardcode `~/.claude` |
| Merge settings (default → user → project) | `deepMerge(base, override)` | Arrays replaced, objects merged |
| Parse user config files | `parseJsonc()` | Supports comments and trailing commas |
| Check if hook should run | `isHookDisabled(name, disabledHooks)` | Respects `disabled_hooks` config |
| Truncate large tool output | `dynamicTruncate(text, budget, reserved)` | Token-aware, prevents overflow |
| Resolve `@file` references | `resolveFileReferencesInText()` | maxDepth=3 prevents infinite loops |
| Execute shell commands | `resolveCommandsInText()` | Supports `!`\`command\`\` syntax |
| Handle legacy agent names | `migrateLegacyAgentNames()` | `omo``Sisyphus` |
| 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
### Dynamic Truncation
```typescript
import { dynamicTruncate } from "../shared"
// Keep 50% headroom, max 50k tokens
// Dynamic truncation
const output = dynamicTruncate(result, remainingTokens, 0.5)
```
### Deep Merge Priority
```typescript
const final = deepMerge(defaults, userConfig)
final = deepMerge(final, projectConfig) // Project wins
```
// Deep merge priority
const final = deepMerge(deepMerge(defaults, userConfig), projectConfig)
### Safe JSONC Parsing
```typescript
// Safe JSONC
const { config, error } = parseJsoncSafe(content)
if (error) return fallback
```
## ANTI-PATTERNS (SHARED)
## ANTI-PATTERNS
- **Hardcoding paths**: Use `getClaudeConfigDir()`, `getUserConfigPath()`
- **Manual JSON.parse**: Use `parseJsonc()` for user files (comments allowed)
- **Ignoring truncation**: Large outputs MUST use `dynamicTruncate`
- **Direct string concat for configs**: Use `deepMerge` for proper priority
- 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

@@ -2,79 +2,67 @@
## OVERVIEW
Custom tools extending agent capabilities: LSP integration (11 tools), AST-aware code search/replace, file operations with timeouts, background task management.
Custom tools: 11 LSP tools, AST-aware search/replace, file ops with timeouts, background task management, session navigation.
## STRUCTURE
```
tools/
├── ast-grep/ # AST-aware code search/replace (25 languages)
│ ├── cli.ts # @ast-grep/cli subprocess
── napi.ts # @ast-grep/napi native binding (preferred)
│ ├── constants.ts, types.ts, tools.ts, utils.ts
│ ├── napi.ts # @ast-grep/napi binding (preferred)
── cli.ts # @ast-grep/cli fallback
├── background-task/ # Async agent task management
├── call-omo-agent/ # Spawn explore/librarian agents
├── glob/ # File pattern matching (timeout-safe)
├── grep/ # Content search (timeout-safe)
├── glob/ # File pattern matching (60s timeout)
├── grep/ # Content search (60s timeout)
├── interactive-bash/ # Tmux session management
├── look-at/ # Multimodal analysis (PDF, images)
├── lsp/ # 11 LSP tools
├── lsp/ # 11 LSP tools (611 lines client.ts)
│ ├── client.ts # LSP connection lifecycle
│ ├── config.ts # Server configurations
│ ├── tools.ts # Tool implementations
│ └── types.ts
├── session-manager/ # OpenCode session file management
│ ├── constants.ts # Storage paths, descriptions
│ ├── types.ts # Session data interfaces
│ ├── storage.ts # File I/O operations
│ ├── utils.ts # Formatting, filtering
│ └── tools.ts # Tool implementations
├── session-manager/ # OpenCode session file ops
├── skill/ # Skill loading and execution
├── skill-mcp/ # Skill-embedded MCP invocation
├── slashcommand/ # Slash command execution
└── index.ts # builtinTools export
```
## TOOL CATEGORIES
| Category | Tools | Purpose |
|----------|-------|---------|
| LSP | lsp_hover, lsp_goto_definition, lsp_find_references, lsp_document_symbols, lsp_workspace_symbols, lsp_diagnostics, lsp_servers, lsp_prepare_rename, lsp_rename, lsp_code_actions, lsp_code_action_resolve | IDE-like code intelligence |
| AST | ast_grep_search, ast_grep_replace | Pattern-based code search/replace |
| File Search | grep, glob | Content and file pattern matching |
| Session | session_list, session_read, session_search, session_info | OpenCode session file management |
| Background | background_task, background_output, background_cancel | Async agent orchestration |
| Multimodal | look_at | PDF/image analysis via Gemini |
| Terminal | interactive_bash | Tmux session control |
| Commands | slashcommand | Execute slash commands |
| Agents | call_omo_agent | Spawn explore/librarian |
| Category | Tools |
|----------|-------|
| LSP | lsp_hover, lsp_goto_definition, lsp_find_references, lsp_document_symbols, lsp_workspace_symbols, lsp_diagnostics, lsp_servers, lsp_prepare_rename, lsp_rename, lsp_code_actions, lsp_code_action_resolve |
| AST | ast_grep_search, ast_grep_replace |
| File Search | grep, glob |
| Session | session_list, session_read, session_search, session_info |
| Background | background_task, background_output, background_cancel |
| Multimodal | look_at |
| Terminal | interactive_bash |
| Skills | skill, skill_mcp |
| Agents | call_omo_agent |
## HOW TO ADD A TOOL
## HOW TO ADD
1. Create directory: `src/tools/my-tool/`
2. Create files:
- `constants.ts`: `TOOL_NAME`, `TOOL_DESCRIPTION`
- `types.ts`: Parameter/result interfaces
- `tools.ts`: Tool implementation (returns OpenCode tool object)
- `index.ts`: Barrel export
- `utils.ts`: Helpers (optional)
1. Create `src/tools/my-tool/`
2. Files: `constants.ts`, `types.ts`, `tools.ts`, `index.ts`
3. Add to `builtinTools` in `src/tools/index.ts`
## LSP SPECIFICS
- **Client lifecycle**: Lazy init on first use, auto-shutdown on idle
- **Config priority**: opencode.json > oh-my-opencode.json > defaults
- **Supported servers**: typescript-language-server, pylsp, gopls, rust-analyzer, etc.
- **Custom servers**: Add via `lsp` config in oh-my-opencode.json
- Lazy init on first use, auto-shutdown on idle
- Config priority: opencode.json > oh-my-opencode.json > defaults
- Servers: typescript-language-server, pylsp, gopls, rust-analyzer
## AST-GREP SPECIFICS
- **Meta-variables**: `$VAR` (single node), `$$$` (multiple nodes)
- **Languages**: 25 supported (typescript, tsx, python, rust, go, etc.)
- **Binding**: Prefers @ast-grep/napi (native), falls back to @ast-grep/cli
- **Pattern must be valid AST**: `export async function $NAME($$$) { $$$ }` not fragments
- Meta-variables: `$VAR` (single), `$$$` (multiple)
- Pattern must be valid AST node, not fragment
- Prefers napi binding for performance
## ANTI-PATTERNS (TOOLS)
## ANTI-PATTERNS
- **No timeout**: Always use timeout for file operations (default 60s)
- **Blocking main thread**: Use async/await, never sync file ops
- **Ignoring LSP errors**: Gracefully handle server not found/crashed
- **Raw subprocess for ast-grep**: Prefer napi binding for performance
- No timeout on file ops (always use, default 60s)
- Sync file operations (use async/await)
- Ignoring LSP errors (graceful handling required)
- Raw subprocess for ast-grep (prefer napi)

View File

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

View File

@@ -31,6 +31,7 @@ import {
export { interactive_bash, startBackgroundCheck as startTmuxCheck } from "./interactive-bash"
export { createSkillTool } from "./skill"
export { getTmuxPath } from "./interactive-bash/utils"
export { createSkillMcpTool } from "./skill-mcp"
import {
createBackgroundTask,

View File

@@ -0,0 +1,3 @@
export const SKILL_MCP_TOOL_NAME = "skill_mcp"
export const SKILL_MCP_DESCRIPTION = `Invoke MCP server operations from skill-embedded MCPs. Requires mcp_name plus exactly one of: tool_name, resource_name, or prompt_name.`

View File

@@ -0,0 +1,3 @@
export * from "./constants"
export * from "./types"
export { createSkillMcpTool } from "./tools"

View File

@@ -0,0 +1,215 @@
import { describe, it, expect, beforeEach, mock } from "bun:test"
import { createSkillMcpTool, applyGrepFilter } from "./tools"
import { SkillMcpManager } from "../../features/skill-mcp-manager"
import type { LoadedSkill } from "../../features/opencode-skill-loader/types"
function createMockSkillWithMcp(name: string, mcpServers: Record<string, unknown>): LoadedSkill {
return {
name,
path: `/test/skills/${name}/SKILL.md`,
resolvedPath: `/test/skills/${name}`,
definition: {
name,
description: `Test skill ${name}`,
template: "Test template",
},
scope: "opencode-project",
mcpConfig: mcpServers as LoadedSkill["mcpConfig"],
}
}
const mockContext = {
sessionID: "test-session",
messageID: "msg-1",
agent: "test-agent",
abort: new AbortController().signal,
}
describe("skill_mcp tool", () => {
let manager: SkillMcpManager
let loadedSkills: LoadedSkill[]
let sessionID: string
beforeEach(() => {
manager = new SkillMcpManager()
loadedSkills = []
sessionID = "test-session-1"
})
describe("parameter validation", () => {
it("throws when no operation specified", async () => {
// #given
const tool = createSkillMcpTool({
manager,
getLoadedSkills: () => loadedSkills,
getSessionID: () => sessionID,
})
// #when / #then
await expect(
tool.execute({ mcp_name: "test-server" }, mockContext)
).rejects.toThrow(/Missing operation/)
})
it("throws when multiple operations specified", async () => {
// #given
const tool = createSkillMcpTool({
manager,
getLoadedSkills: () => loadedSkills,
getSessionID: () => sessionID,
})
// #when / #then
await expect(
tool.execute({
mcp_name: "test-server",
tool_name: "some-tool",
resource_name: "some://resource",
}, mockContext)
).rejects.toThrow(/Multiple operations/)
})
it("throws when mcp_name not found in any skill", async () => {
// #given
loadedSkills = [
createMockSkillWithMcp("test-skill", {
"known-server": { command: "echo", args: ["test"] },
}),
]
const tool = createSkillMcpTool({
manager,
getLoadedSkills: () => loadedSkills,
getSessionID: () => sessionID,
})
// #when / #then
await expect(
tool.execute({ mcp_name: "unknown-server", tool_name: "some-tool" }, mockContext)
).rejects.toThrow(/not found/)
})
it("includes available MCP servers in error message", async () => {
// #given
loadedSkills = [
createMockSkillWithMcp("db-skill", {
sqlite: { command: "uvx", args: ["mcp-server-sqlite"] },
}),
createMockSkillWithMcp("api-skill", {
"rest-api": { command: "node", args: ["server.js"] },
}),
]
const tool = createSkillMcpTool({
manager,
getLoadedSkills: () => loadedSkills,
getSessionID: () => sessionID,
})
// #when / #then
await expect(
tool.execute({ mcp_name: "missing", tool_name: "test" }, mockContext)
).rejects.toThrow(/sqlite.*db-skill|rest-api.*api-skill/s)
})
it("throws on invalid JSON arguments", async () => {
// #given
loadedSkills = [
createMockSkillWithMcp("test-skill", {
"test-server": { command: "echo" },
}),
]
const tool = createSkillMcpTool({
manager,
getLoadedSkills: () => loadedSkills,
getSessionID: () => sessionID,
})
// #when / #then
await expect(
tool.execute({
mcp_name: "test-server",
tool_name: "some-tool",
arguments: "not valid json",
}, mockContext)
).rejects.toThrow(/Invalid arguments JSON/)
})
})
describe("tool description", () => {
it("has concise description", () => {
// #given / #when
const tool = createSkillMcpTool({
manager,
getLoadedSkills: () => [],
getSessionID: () => "session",
})
// #then
expect(tool.description.length).toBeLessThan(200)
expect(tool.description).toContain("mcp_name")
})
it("includes grep parameter in schema", () => {
// #given / #when
const tool = createSkillMcpTool({
manager,
getLoadedSkills: () => [],
getSessionID: () => "session",
})
// #then
expect(tool.description).toBeDefined()
})
})
})
describe("applyGrepFilter", () => {
it("filters lines matching pattern", () => {
// #given
const output = `line1: hello world
line2: foo bar
line3: hello again
line4: baz qux`
// #when
const result = applyGrepFilter(output, "hello")
// #then
expect(result).toContain("line1: hello world")
expect(result).toContain("line3: hello again")
expect(result).not.toContain("foo bar")
expect(result).not.toContain("baz qux")
})
it("returns original output when pattern is undefined", () => {
// #given
const output = "some output"
// #when
const result = applyGrepFilter(output, undefined)
// #then
expect(result).toBe(output)
})
it("returns message when no lines match", () => {
// #given
const output = "line1\nline2\nline3"
// #when
const result = applyGrepFilter(output, "xyz")
// #then
expect(result).toContain("[grep] No lines matched pattern")
})
it("handles invalid regex gracefully", () => {
// #given
const output = "some output"
// #when
const result = applyGrepFilter(output, "[invalid")
// #then
expect(result).toBe(output)
})
})

View File

@@ -0,0 +1,169 @@
import { tool, type ToolDefinition } from "@opencode-ai/plugin"
import { SKILL_MCP_DESCRIPTION } from "./constants"
import type { SkillMcpArgs } from "./types"
import type { SkillMcpManager, SkillMcpClientInfo, SkillMcpServerContext } from "../../features/skill-mcp-manager"
import type { LoadedSkill } from "../../features/opencode-skill-loader/types"
interface SkillMcpToolOptions {
manager: SkillMcpManager
getLoadedSkills: () => LoadedSkill[]
getSessionID: () => string
}
type OperationType = { type: "tool" | "resource" | "prompt"; name: string }
function validateOperationParams(args: SkillMcpArgs): OperationType {
const operations: OperationType[] = []
if (args.tool_name) operations.push({ type: "tool", name: args.tool_name })
if (args.resource_name) operations.push({ type: "resource", name: args.resource_name })
if (args.prompt_name) operations.push({ type: "prompt", name: args.prompt_name })
if (operations.length === 0) {
throw new Error(
`Missing operation. Exactly one of tool_name, resource_name, or prompt_name must be specified.\n\n` +
`Examples:\n` +
` skill_mcp(mcp_name="sqlite", tool_name="query", arguments='{"sql": "SELECT * FROM users"}')\n` +
` skill_mcp(mcp_name="memory", resource_name="memory://notes")\n` +
` skill_mcp(mcp_name="helper", prompt_name="summarize", arguments='{"text": "..."}')`
)
}
if (operations.length > 1) {
const provided = [
args.tool_name && `tool_name="${args.tool_name}"`,
args.resource_name && `resource_name="${args.resource_name}"`,
args.prompt_name && `prompt_name="${args.prompt_name}"`,
].filter(Boolean).join(", ")
throw new Error(
`Multiple operations specified. Exactly one of tool_name, resource_name, or prompt_name must be provided.\n\n` +
`Received: ${provided}\n\n` +
`Use separate calls for each operation.`
)
}
return operations[0]
}
function findMcpServer(
mcpName: string,
skills: LoadedSkill[]
): { skill: LoadedSkill; config: NonNullable<LoadedSkill["mcpConfig"]>[string] } | null {
for (const skill of skills) {
if (skill.mcpConfig && mcpName in skill.mcpConfig) {
return { skill, config: skill.mcpConfig[mcpName] }
}
}
return null
}
function formatAvailableMcps(skills: LoadedSkill[]): string {
const mcps: string[] = []
for (const skill of skills) {
if (skill.mcpConfig) {
for (const serverName of Object.keys(skill.mcpConfig)) {
mcps.push(` - "${serverName}" from skill "${skill.name}"`)
}
}
}
return mcps.length > 0 ? mcps.join("\n") : " (none found)"
}
function parseArguments(argsJson: string | undefined): Record<string, unknown> {
if (!argsJson) return {}
try {
const parsed = JSON.parse(argsJson)
if (typeof parsed !== "object" || parsed === null) {
throw new Error("Arguments must be a JSON object")
}
return parsed as Record<string, unknown>
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
throw new Error(
`Invalid arguments JSON: ${errorMessage}\n\n` +
`Expected a valid JSON object, e.g.: '{"key": "value"}'\n` +
`Received: ${argsJson}`
)
}
}
export function applyGrepFilter(output: string, pattern: string | undefined): string {
if (!pattern) return output
try {
const regex = new RegExp(pattern, "i")
const lines = output.split("\n")
const filtered = lines.filter(line => regex.test(line))
return filtered.length > 0
? filtered.join("\n")
: `[grep] No lines matched pattern: ${pattern}`
} catch {
return output
}
}
export function createSkillMcpTool(options: SkillMcpToolOptions): ToolDefinition {
const { manager, getLoadedSkills, getSessionID } = options
return tool({
description: SKILL_MCP_DESCRIPTION,
args: {
mcp_name: tool.schema.string().describe("Name of the MCP server from skill config"),
tool_name: tool.schema.string().optional().describe("MCP tool to call"),
resource_name: tool.schema.string().optional().describe("MCP resource URI to read"),
prompt_name: tool.schema.string().optional().describe("MCP prompt to get"),
arguments: tool.schema.string().optional().describe("JSON string of arguments"),
grep: tool.schema.string().optional().describe("Regex pattern to filter output lines (only matching lines returned)"),
},
async execute(args: SkillMcpArgs) {
const operation = validateOperationParams(args)
const skills = getLoadedSkills()
const found = findMcpServer(args.mcp_name, skills)
if (!found) {
throw new Error(
`MCP server "${args.mcp_name}" not found.\n\n` +
`Available MCP servers in loaded skills:\n` +
formatAvailableMcps(skills) + `\n\n` +
`Hint: Load the skill first using the 'skill' tool, then call skill_mcp.`
)
}
const info: SkillMcpClientInfo = {
serverName: args.mcp_name,
skillName: found.skill.name,
sessionID: getSessionID(),
}
const context: SkillMcpServerContext = {
config: found.config,
skillName: found.skill.name,
}
const parsedArgs = parseArguments(args.arguments)
let output: string
switch (operation.type) {
case "tool": {
const result = await manager.callTool(info, context, operation.name, parsedArgs)
output = JSON.stringify(result, null, 2)
break
}
case "resource": {
const result = await manager.readResource(info, context, operation.name)
output = JSON.stringify(result, null, 2)
break
}
case "prompt": {
const stringArgs: Record<string, string> = {}
for (const [key, value] of Object.entries(parsedArgs)) {
stringArgs[key] = String(value)
}
const result = await manager.getPrompt(info, context, operation.name, stringArgs)
output = JSON.stringify(result, null, 2)
break
}
}
return applyGrepFilter(output, args.grep)
},
})
}

View File

@@ -0,0 +1,8 @@
export interface SkillMcpArgs {
mcp_name: string
tool_name?: string
resource_name?: string
prompt_name?: string
arguments?: string
grep?: string
}

View File

@@ -0,0 +1,239 @@
import { describe, it, expect, beforeEach, mock, spyOn } from "bun:test"
import * as fs from "node:fs"
import { createSkillTool } from "./tools"
import { SkillMcpManager } from "../../features/skill-mcp-manager"
import type { LoadedSkill } from "../../features/opencode-skill-loader/types"
import type { Tool as McpTool } from "@modelcontextprotocol/sdk/types.js"
const originalReadFileSync = fs.readFileSync.bind(fs)
mock.module("node:fs", () => ({
...fs,
readFileSync: (path: string, encoding?: string) => {
if (typeof path === "string" && path.includes("/skills/")) {
return `---
description: Test skill description
---
Test skill body content`
}
return originalReadFileSync(path, encoding as BufferEncoding)
},
}))
function createMockSkillWithMcp(name: string, mcpServers: Record<string, unknown>): LoadedSkill {
return {
name,
path: `/test/skills/${name}/SKILL.md`,
resolvedPath: `/test/skills/${name}`,
definition: {
name,
description: `Test skill ${name}`,
template: "Test template",
},
scope: "opencode-project",
mcpConfig: mcpServers as LoadedSkill["mcpConfig"],
}
}
const mockContext = {
sessionID: "test-session",
messageID: "msg-1",
agent: "test-agent",
abort: new AbortController().signal,
}
describe("skill tool - MCP schema display", () => {
let manager: SkillMcpManager
let loadedSkills: LoadedSkill[]
let sessionID: string
beforeEach(() => {
manager = new SkillMcpManager()
loadedSkills = []
sessionID = "test-session-1"
})
describe("formatMcpCapabilities with inputSchema", () => {
it("displays tool inputSchema when available", async () => {
// #given
const mockToolsWithSchema: McpTool[] = [
{
name: "browser_type",
description: "Type text into an element",
inputSchema: {
type: "object",
properties: {
element: { type: "string", description: "Human-readable element description" },
ref: { type: "string", description: "Element reference from page snapshot" },
text: { type: "string", description: "Text to type into the element" },
submit: { type: "boolean", description: "Submit form after typing" },
},
required: ["element", "ref", "text"],
},
},
]
loadedSkills = [
createMockSkillWithMcp("test-skill", {
playwright: { command: "npx", args: ["-y", "@anthropic-ai/mcp-playwright"] },
}),
]
// Mock manager.listTools to return our mock tools
spyOn(manager, "listTools").mockResolvedValue(mockToolsWithSchema)
spyOn(manager, "listResources").mockResolvedValue([])
spyOn(manager, "listPrompts").mockResolvedValue([])
const tool = createSkillTool({
skills: loadedSkills,
mcpManager: manager,
getSessionID: () => sessionID,
})
// #when
const result = await tool.execute({ name: "test-skill" }, mockContext)
// #then
// Should include inputSchema details
expect(result).toContain("browser_type")
expect(result).toContain("inputSchema")
expect(result).toContain("element")
expect(result).toContain("ref")
expect(result).toContain("text")
expect(result).toContain("submit")
expect(result).toContain("required")
})
it("displays multiple tools with their schemas", async () => {
// #given
const mockToolsWithSchema: McpTool[] = [
{
name: "browser_navigate",
description: "Navigate to a URL",
inputSchema: {
type: "object",
properties: {
url: { type: "string", description: "URL to navigate to" },
},
required: ["url"],
},
},
{
name: "browser_click",
description: "Click an element",
inputSchema: {
type: "object",
properties: {
element: { type: "string" },
ref: { type: "string" },
},
required: ["element", "ref"],
},
},
]
loadedSkills = [
createMockSkillWithMcp("playwright-skill", {
playwright: { command: "npx", args: ["-y", "@anthropic-ai/mcp-playwright"] },
}),
]
spyOn(manager, "listTools").mockResolvedValue(mockToolsWithSchema)
spyOn(manager, "listResources").mockResolvedValue([])
spyOn(manager, "listPrompts").mockResolvedValue([])
const tool = createSkillTool({
skills: loadedSkills,
mcpManager: manager,
getSessionID: () => sessionID,
})
// #when
const result = await tool.execute({ name: "playwright-skill" }, mockContext)
// #then
expect(result).toContain("browser_navigate")
expect(result).toContain("browser_click")
expect(result).toContain("url")
expect(result).toContain("Navigate to a URL")
})
it("handles tools without inputSchema gracefully", async () => {
// #given
const mockToolsMinimal: McpTool[] = [
{
name: "simple_tool",
inputSchema: { type: "object" },
},
]
loadedSkills = [
createMockSkillWithMcp("simple-skill", {
simple: { command: "echo", args: ["test"] },
}),
]
spyOn(manager, "listTools").mockResolvedValue(mockToolsMinimal)
spyOn(manager, "listResources").mockResolvedValue([])
spyOn(manager, "listPrompts").mockResolvedValue([])
const tool = createSkillTool({
skills: loadedSkills,
mcpManager: manager,
getSessionID: () => sessionID,
})
// #when
const result = await tool.execute({ name: "simple-skill" }, mockContext)
// #then
expect(result).toContain("simple_tool")
// Should not throw, should handle gracefully
})
it("formats schema in a way LLM can understand for skill_mcp calls", async () => {
// #given
const mockTools: McpTool[] = [
{
name: "query",
description: "Execute SQL query",
inputSchema: {
type: "object",
properties: {
sql: { type: "string", description: "SQL query to execute" },
params: { type: "array", description: "Query parameters" },
},
required: ["sql"],
},
},
]
loadedSkills = [
createMockSkillWithMcp("db-skill", {
sqlite: { command: "uvx", args: ["mcp-server-sqlite"] },
}),
]
spyOn(manager, "listTools").mockResolvedValue(mockTools)
spyOn(manager, "listResources").mockResolvedValue([])
spyOn(manager, "listPrompts").mockResolvedValue([])
const tool = createSkillTool({
skills: loadedSkills,
mcpManager: manager,
getSessionID: () => sessionID,
})
// #when
const result = await tool.execute({ name: "db-skill" }, mockContext)
// #then
// Should provide enough info for LLM to construct valid skill_mcp call
expect(result).toContain("sqlite")
expect(result).toContain("query")
expect(result).toContain("sql")
expect(result).toContain("required")
expect(result).toMatch(/sql[\s\S]*string/i)
})
})
})

View File

@@ -5,6 +5,8 @@ import { TOOL_DESCRIPTION_NO_SKILLS, TOOL_DESCRIPTION_PREFIX } from "./constants
import type { SkillArgs, SkillInfo, SkillLoadOptions } from "./types"
import { discoverSkills, type LoadedSkill } from "../../features/opencode-skill-loader"
import { parseFrontmatter } from "../../shared/frontmatter"
import type { SkillMcpManager, SkillMcpClientInfo, SkillMcpServerContext } from "../../features/skill-mcp-manager"
import type { Tool, Resource, Prompt } from "@modelcontextprotocol/sdk/types.js"
function loadedSkillToInfo(skill: LoadedSkill): SkillInfo {
return {
@@ -49,6 +51,77 @@ function extractSkillBody(skill: LoadedSkill): string {
return templateMatch ? templateMatch[1].trim() : skill.definition.template || ""
}
async function formatMcpCapabilities(
skill: LoadedSkill,
manager: SkillMcpManager,
sessionID: string
): Promise<string | null> {
if (!skill.mcpConfig || Object.keys(skill.mcpConfig).length === 0) {
return null
}
const sections: string[] = ["", "## Available MCP Servers", ""]
for (const [serverName, config] of Object.entries(skill.mcpConfig)) {
const info: SkillMcpClientInfo = {
serverName,
skillName: skill.name,
sessionID,
}
const context: SkillMcpServerContext = {
config,
skillName: skill.name,
}
sections.push(`### ${serverName}`)
sections.push("")
try {
const [tools, resources, prompts] = await Promise.all([
manager.listTools(info, context).catch(() => []),
manager.listResources(info, context).catch(() => []),
manager.listPrompts(info, context).catch(() => []),
])
if (tools.length > 0) {
sections.push("**Tools:**")
sections.push("")
for (const t of tools as Tool[]) {
sections.push(`#### \`${t.name}\``)
if (t.description) {
sections.push(t.description)
}
sections.push("")
sections.push("**inputSchema:**")
sections.push("```json")
sections.push(JSON.stringify(t.inputSchema, null, 2))
sections.push("```")
sections.push("")
}
}
if (resources.length > 0) {
sections.push(`**Resources**: ${resources.map((r: Resource) => r.uri).join(", ")}`)
}
if (prompts.length > 0) {
sections.push(`**Prompts**: ${prompts.map((p: Prompt) => p.name).join(", ")}`)
}
if (tools.length === 0 && resources.length === 0 && prompts.length === 0) {
sections.push("*No capabilities discovered*")
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error)
sections.push(`*Failed to connect: ${errorMessage.split("\n")[0]}*`)
}
sections.push("")
sections.push(`Use \`skill_mcp\` tool with \`mcp_name="${serverName}"\` to invoke.`)
sections.push("")
}
return sections.join("\n")
}
export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition {
const skills = options.skills ?? discoverSkills({ includeClaudeCodePaths: !options.opencodeOnly })
const skillInfos = skills.map(loadedSkillToInfo)
@@ -75,13 +148,26 @@ export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition
const body = extractSkillBody(skill)
const dir = skill.path ? dirname(skill.path) : skill.resolvedPath || process.cwd()
return [
const output = [
`## Skill: ${skill.name}`,
"",
`**Base directory**: ${dir}`,
"",
body,
].join("\n")
]
if (options.mcpManager && options.getSessionID && skill.mcpConfig) {
const mcpInfo = await formatMcpCapabilities(
skill,
options.mcpManager,
options.getSessionID()
)
if (mcpInfo) {
output.push(mcpInfo)
}
}
return output.join("\n")
},
})
}

View File

@@ -1,4 +1,5 @@
import type { SkillScope, LoadedSkill } from "../../features/opencode-skill-loader/types"
import type { SkillMcpManager } from "../../features/skill-mcp-manager"
export interface SkillArgs {
name: string
@@ -20,4 +21,8 @@ export interface SkillLoadOptions {
opencodeOnly?: boolean
/** Pre-merged skills to use instead of discovering */
skills?: LoadedSkill[]
/** MCP manager for querying skill-embedded MCP servers */
mcpManager?: SkillMcpManager
/** Session ID getter for MCP client identification */
getSessionID?: () => string
}

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