Compare commits

...

25 Commits

Author SHA1 Message Date
github-actions[bot]
40d4673201 release: v0.3.1 2025-12-10 14:44:44 +00:00
YeonGyu-Kim
cf33fc5da1 docs(readme): sync English version with Korean improvements on setup and configuration clarity 2025-12-10 23:43:35 +09:00
YeonGyu-Kim
407786978a chore: remove test files (test-rule.yml, test.js) 2025-12-10 23:43:35 +09:00
YeonGyu-Kim
15454f1d81 chore: remove test files and temporary notepad 2025-12-10 23:43:35 +09:00
YeonGyu-Kim
56160d17f8 docs(readme.ko): improve clarity on setup configuration paths and MCP/LSP explanations 2025-12-10 23:43:35 +09:00
YeonGyu-Kim
61bbbcb577 feat(hooks): integrate anthropic-auto-compact hook for automatic context summarization
Enables automatic session summarization when Anthropic token limits are exceeded.
The hook detects token limit errors and triggers compact operation on session idle.

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-10 23:43:35 +09:00
YeonGyu-Kim
adabace02d improve(hooks): refine context window reminder message for better clarity and guidance 2025-12-10 15:45:45 +09:00
YeonGyu-Kim
41f93c9f8b docs(readme): add warning for LLM agents on oh-my-opencode.json setup and sync English tone with Korean version 2025-12-10 15:45:45 +09:00
YeonGyu-Kim
8102d178cb fix(hooks): fix TODO continuation abort handling with timer-based approach
Replace blocking await with non-blocking timer scheduling to handle race
condition between session.idle and session.error events. When ESC abort
occurs, session.error immediately cancels the pending timer, preventing
unwanted continuation prompts.

Changes:
- Add pendingTimers Map to track scheduled continuation checks
- Cancel timer on session.error (especially abort cases)
- Cancel timer on message.updated and session.deleted for cleanup
- Reduce delay to 200ms for faster response
- Maintain existing Set-based flag logic for compatibility

This fixes the issue where ESC abort would not prevent continuation
prompts due to event ordering (idle before error)
2025-12-10 15:45:45 +09:00
YeonGyu-Kim
4f019f8fe5 fix(hooks): improve session recovery for empty content messages
- Extract message index from Anthropic error messages (messages.N format)
- Sort messages by time.created instead of id for accurate ordering
- Remove last message skip logic that prevented recovery
- Prioritize recovery targets: index-matched > failedMsg > all empty
- Add error logging for debugging recovery failures

Fixes issue where 'messages.83: all messages must have non-empty content' errors were not being recovered properly due to incorrect message ordering and overly restrictive filtering.

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-10 15:45:45 +09:00
YeonGyu-Kim
7b19177c8a Revert "fix(hooks): improve TODO continuation race condition handling with state machine pattern"
This reverts commit e59b0be6cc380a3750e2d56c4c7ba553feef2c40.
2025-12-10 15:45:45 +09:00
YeonGyu-Kim
e8f59cbbf8 fix(hooks): improve TODO continuation race condition handling with state machine pattern
- Replace multiple Set-based tracking with explicit SessionStatus state machine
- Implement setTimeout+clearTimeout pattern for robust race condition handling
- SessionStatus tracks: idle → continuation-sent or aborted states
- Increase grace period to 500ms to accommodate event ordering delays
- Add cleanupSession utility for proper resource cleanup

This addresses ESC abort not canceling continuation prompts when session.idle
arrives before session.error event, which can occur due to async event processing
in OpenCode plugin system
2025-12-10 15:45:45 +09:00
github-actions[bot]
2d23a81926 release: v0.3.0 2025-12-09 12:51:28 +00:00
YeonGyu-Kim
31cb8616c2 chore: bump version to 0.2.0
🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-09 21:47:21 +09:00
YeonGyu-Kim
1932257f82 docs(readme): add comprehensive Claude Code compatibility documentation
- Add new 'Claude Code Compatibility' section to both README.md and README.ko.md
- Document hooks integration (PreToolUse, PostToolUse, UserPromptSubmit, Stop)
- Document configuration loaders (Command, Skill, Agent, MCP loaders)
- Document data storage (Todo management, Transcript logging)
- Simplify 'Other Features' section by moving loaders to new section
- Clean up temporary planning files in local-ignore/

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-09 21:47:01 +09:00
YeonGyu-Kim
5a793bb526 fix(hooks): align Claude Code hooks with opencode-cc-plugin reference
100% port verification via Oracle agent parallel checks:
- PreToolUse: recordToolUse(), isHookDisabled(), Object.assign(), error message
- PostToolUse: recordToolResult(), isHookDisabled(), permissionMode, title field
- Stop: isHookDisabled(), parentSessionId, error/interrupt state tracking
- UserPromptSubmit: interrupt checks, recordUserMessage(), log messages

All four hooks now match opencode-cc-plugin/src/plugin/*.ts exactly.

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-09 19:00:01 +09:00
YeonGyu-Kim
2ec351d0d8 feat(hooks): implement UserPromptSubmit with chat.message hook and injectHookMessage
- Add chat.message handler to createClaudeCodeHooksHook factory
- Integrate executeUserPromptSubmitHooks() for user prompt processing
- Use injectHookMessage() for file system based message injection
- Add sessionFirstMessageProcessed tracking for title generation skip
- Register chat.message hook in plugin entry point

This completes 100% port of Claude Code hooks from opencode-cc-plugin.

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-09 18:20:13 +09:00
YeonGyu-Kim
441fc1a219 feat(hooks): integrate Claude Code hooks with plugin system
- Create factory function createClaudeCodeHooksHook()
- Wire tool.execute.before → executePreToolUseHooks
- Wire tool.execute.after → executePostToolUseHooks
- Wire event (session.idle) → executeStopHooks
- Register hooks in src/index.ts
- Claude hooks execute first in handler chain

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-09 18:10:30 +09:00
YeonGyu-Kim
bd67419d1d feat(features): add hook message injector
- Port hook-message-injector from opencode-cc-plugin (4 files)
- constants.ts: XDG-based path definitions (MESSAGE_STORAGE, PART_STORAGE)
- types.ts: MessageMeta, OriginalMessageContext, TextPart interfaces
- injector.ts: injectHookMessage() implementation with message/part storage
- index.ts: Barrel export
- Self-contained module with no import path changes needed
- Preserves XDG_DATA_HOME environment variable support
- Preserves message fallback logic for incomplete originalMessage

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-09 18:02:45 +09:00
YeonGyu-Kim
dca98121ac feat(hooks): add UserPromptSubmit and Stop executors
- Port user-prompt-submit.ts from opencode-cc-plugin (118 lines)
- Port stop.ts from opencode-cc-plugin (119 lines)
- Preserve recursion prevention logic (<user-prompt-submit-hook> tags)
- Preserve inject_prompt support (message injection, stop prompt injection)
- Preserve stopHookActiveState management (per-session state)
- Import path adjustments: ../types → ./types, ../../config → ./plugin-config
- All exit code handling preserved (exitCode 2 → block, etc.)

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-09 18:00:16 +09:00
YeonGyu-Kim
3fcfedcec0 feat(hooks): add PostToolUse hook executor
- Port post-tool-use.ts from opencode-cc-plugin (200 lines)
- Implement executePostToolUseHooks() with full transcript support
- Include temp file cleanup in finally block
- Preserve all exit code handling and output fields
- Update notepad.md with Task 5 completion log

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-09 17:55:06 +09:00
YeonGyu-Kim
530c4d63d5 feat(hooks): add PreToolUse hook executor
🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-09 17:48:28 +09:00
YeonGyu-Kim
e0b43380cc feat(hooks): add Claude hooks config, transcript, and todo
🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-09 17:41:39 +09:00
YeonGyu-Kim
a27cac96d5 feat(hooks): add Claude Code hooks type definitions
🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-09 17:36:12 +09:00
YeonGyu-Kim
fef7f4ca03 feat(shared): add Claude hooks command executor and utilities
- Add snake-case.ts: objectToSnakeCase, objectToCamelCase utilities
- Add tool-name.ts: transformToolName with PascalCase conversion
- Add pattern-matcher.ts: findMatchingHooks for hook config matching
- Add hook-disabled.ts: isHookDisabled for hook config validation
- Add temporary stub types at src/hooks/claude-code-hooks/types.ts
- Export all new utilities from src/shared/index.ts

Stub types will be replaced with full implementation in Task 1.
Import paths adjusted from opencode-cc-plugin structure to oh-my-opencode.

🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-09 17:31:15 +09:00
40 changed files with 2378 additions and 729 deletions

View File

@@ -15,6 +15,7 @@
- [내장 AST-Grep Tools](#내장-ast-grep-tools)
- [Grep](#grep)
- [내장 MCPs](#내장-mcps)
- [Claude Code 호환성](#claude-code-호환성)
- [기타 편의 기능](#기타-편의-기능)
- [설정](#설정)
- [작성자의 노트](#작성자의-노트)
@@ -105,7 +106,19 @@ opencode --version # 1.0.132 이상이어야 함
cat ~/.config/opencode/opencode.json # plugin 배열에 "oh-my-opencode"가 있어야 함
```
> **참고**: 모델 인증이 필요하면, `opencode` 실행 후 `/connect` 명령어를 사용하거나, tmux를 통해 `opencode auth login`을 안내하세요.
### 4단계: 인증정보 설정
plugin 에 oh my opencode 를 추가했다면, tmux 를 사용하여 다음의 내용을 진행주세요.
1. tmux 를 사용하여 셸 열기
2. opencode auth login > Provider Anthropic > Login method Claude Pro/Max > 사용자에게 oauth 화면에서 인증하라고 안내
3. 사용자에게 macOS 일 경우 VibeProxy, 아닐 경우 CLIProxyAPI 를 사용하여 OpenAI / Google 인증을 완료하라고 안내
### ⚠️ 주의
**사용자가 명시적으로 요청하지 않는 한, `oh-my-opencode.json` 설정 파일을 생성하거나 수정하지 마세요.**
플러그인은 설정 파일 없이도 기본값으로 완벽하게 동작합니다. 설정 파일은 사용자가 커스터마이징을 원할 때만 필요합니다.
</details>
@@ -224,17 +237,72 @@ OpenCode 는 아주 확장가능하고 아주 커스터마이저블합니다.
}
```
### Claude Code 호환성
Oh My OpenCode는 Claude Code 설정과 완벽하게 호환됩니다. Claude Code를 사용하셨다면, 기존 설정을 그대로 사용할 수 있습니다.
#### Hooks 통합
Claude Code의 `settings.json` 훅 시스템을 통해 커스텀 스크립트를 실행합니다. Oh My OpenCode는 다음 위치의 훅을 읽고 실행합니다:
- `~/.claude/settings.json` (사용자)
- `./.claude/settings.json` (프로젝트)
- `./.claude/settings.local.json` (로컬, git-ignored)
지원되는 훅 이벤트:
- **PreToolUse**: 도구 실행 전에 실행. 차단하거나 도구 입력을 수정할 수 있습니다.
- **PostToolUse**: 도구 실행 후에 실행. 경고나 컨텍스트를 추가할 수 있습니다.
- **UserPromptSubmit**: 사용자가 프롬프트를 제출할 때 실행. 차단하거나 메시지를 주입할 수 있습니다.
- **Stop**: 세션이 유휴 상태가 될 때 실행. 후속 프롬프트를 주입할 수 있습니다.
`settings.json` 예시:
```json
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [{ "type": "command", "command": "eslint --fix $FILE" }]
}
]
}
}
```
#### 설정 로더
**Command Loader**: 4개 디렉토리에서 마크다운 기반 슬래시 명령어를 로드합니다:
- `~/.claude/commands/` (사용자)
- `./.claude/commands/` (프로젝트)
- `~/.config/opencode/command/` (opencode 전역)
- `./.opencode/command/` (opencode 프로젝트)
**Skill Loader**: `SKILL.md`가 있는 디렉토리 기반 스킬을 로드합니다:
- `~/.claude/skills/` (사용자)
- `./.claude/skills/` (프로젝트)
**Agent Loader**: 마크다운 파일에서 커스텀 에이전트 정의를 로드합니다:
- `~/.claude/agents/*.md` (사용자)
- `./.claude/agents/*.md` (프로젝트)
**MCP Loader**: `.mcp.json` 파일에서 MCP 서버 설정을 로드합니다:
- `~/.claude/.mcp.json` (사용자)
- `./.mcp.json` (프로젝트)
- `./.claude/.mcp.json` (로컬)
- 환경변수 확장 지원 (`${VAR}` 문법)
#### 데이터 저장소
**Todo 관리**: 세션 todo가 `~/.claude/todos/`에 Claude Code 호환 형식으로 저장됩니다.
**Transcript**: 세션 활동이 `~/.claude/transcripts/`에 JSONL 형식으로 기록되어 재생 및 분석이 가능합니다.
> **`claude-code-*` 네이밍에 대해**: `src/features/claude-code-*/` 아래의 기능들은 Claude Code의 설정 시스템에서 마이그레이션되었습니다. 이 네이밍 규칙은 어떤 기능이 Claude Code에서 유래했는지 명확히 식별합니다.
### 기타 편의 기능
- **Terminal Title**: 세션 상태에 따라 터미널 타이틀을 자동 업데이트합니다 (유휴 ○, 처리중 ◐, 도구 ⚡, 에러 ✖). tmux를 지원합니다.
- **Command Loader**: 다음 디렉토리들에서 마크다운 기반의 커스텀 명령어들을 로드합니다:
- User scope: `~/.claude/commands/`
- Project scope: `./.claude/commands/`
- OpenCode global: `~/.config/opencode/command/`
- OpenCode project: `./.opencode/command/`
- **Skill Loader**: 다음 디렉토리들에서 디렉토리 기반의 스킬들을 실행 가능한 명령어로 로드합니다:
- User scope: `~/.claude/skills/`
- Project scope: `./.claude/skills/`
- **Session State**: 이벤트 훅과 터미널 타이틀 업데이트에 사용되는 중앙집중식 세션 추적 모듈입니다.
## 설정
@@ -270,7 +338,7 @@ Schema 자동 완성이 지원됩니다:
각 에이전트에서 지원하는 옵션: `model`, `temperature`, `top_p`, `prompt`, `tools`, `disable`, `description`, `mode`, `color`, `permission`.
또는 `disabled_agents` 비활성화할 수 있습니다:
또는 ~/.config/opencode/oh-my-opencode.json 혹은 .opencode/oh-my-opencode.json 의 `disabled_agents` 를 사용하여 비활성화할 수 있습니다:
```json
{
@@ -282,7 +350,9 @@ Schema 자동 완성이 지원됩니다:
### MCPs
내장된 MCP를 비활성화합니다:
기본적으로 Context7, Exa MCP 지원합니다.
이것이 마음에 들지 않는다면, ~/.config/opencode/oh-my-opencode.json 혹은 .opencode/oh-my-opencode.json 의 `disabled_mcps` 를 사용하여 비활성화할 수 있습니다:
```json
{
@@ -290,13 +360,13 @@ Schema 자동 완성이 지원됩니다:
}
```
더 자세한 내용은 [OpenCode MCP Servers](https://opencode.ai/docs/mcp-servers)를 참조하세요.
### LSP
Oh My OpenCode의 LSP 도구는 오직 **리팩토링(이름 변경, 코드 액션)만을 위한 것**입니다. 분석용 LSP는 OpenCode 자체에서 처리합니다.
OpenCode 는 분석을 위해 LSP 도구를 제공합니다.
Oh My OpenCode 에서는 LSP 의 리팩토링(이름 변경, 코드 액션) 도구를 제공합니다.
OpenCode 에서 지원하는 모든 LSP 구성 및 커스텀 설정 (opencode.json 에 설정 된 것) 을 그대로 지원하고, Oh My OpenCode 만을 위한 추가적인 설정도 아래와 같이 설정 할 수 있습니다.
`lsp` 옵션을 통해 LSP 서버를 설정합니다:
~/.config/opencode/oh-my-opencode.json 혹은 .opencode/oh-my-opencode.json 의 `lsp` 옵션을 통해 LSP 서버를 추가로 설정 할 수 있습니다:
```json
{

127
README.md
View File

@@ -15,6 +15,7 @@ English | [한국어](README.ko.md)
- [Built-in AST-Grep Tools](#built-in-ast-grep-tools)
- [Grep](#grep)
- [Built-in MCPs](#built-in-mcps)
- [Claude Code Compatibility](#claude-code-compatibility)
- [Other Features](#other-features)
- [Configuration](#configuration)
- [Author's Note](#authors-note)
@@ -27,11 +28,12 @@ Oh My OpenCode
oMoMoMoMoMo···
If you work in tech, you likely appreciated [Claude Code](https://www.claude.com/product/claude-code).
If you are a hacker, you will fucking falling in love with [OpenCode](https://github.com/sst/opencode).
If you are a hacker, you will fucking fall in love with [OpenCode](https://github.com/sst/opencode).
You don't write code just for a paycheck? You write because you genuinely love it?
To you, OpenCode will feel like the paradigm shift from Windows to Linux. Not you? It's still worth the investment. Give it 10 minutes. Your work and life will improve. I promise.
Do you remember the first time you switched from Windows to Linux? The excitement of configuring everything just the way you wanted?
In an era where that romance feels lost, OpenCode will be that project for you.
If you love coding and computers, OpenCode will feel exactly like that moment you first touched Linux after years of Windows.
Even if you don't, invest a little time. Boost your skills and productivity by multipliers.
## TL;DR
@@ -41,7 +43,7 @@ To you, OpenCode will feel like the paradigm shift from Windows to Linux. Not yo
- For OpenAI models, I recommend the [OpenCode-OpenAI-Codex-Auth plugin](https://github.com/numman-ali/opencode-openai-codex-auth).
- For Gemini and GPT, use [VibeProxy (macOS Only)](https://github.com/automazeio/vibeproxy) or [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI).
- **Truth be told, the rest is optional. Just install it. Once set up, no further learning is required. It simply evolves.**
- **Paste this document into Claude Code, OpenCode, or Cursor, instruct it to install, and ask for assistance if needed. It works seamlessly.**
- **Paste this document into Claude Code, OpenCode, or Cursor, instruct it to install, and ask for assistance if needed. It just installs.**
- If you are an LLM Agent, install the required plugins and dependencies, then run `opencode auth login` via tmux to assist the user with configuration.
## Installation
@@ -103,7 +105,19 @@ opencode --version # Should be >= 1.0.132
cat ~/.config/opencode/opencode.json # Should contain "oh-my-opencode" in plugin array
```
> **Note**: If model authentication is needed, run `opencode` and use `/connect` command, or assist the user with `opencode auth login` via tmux.
### Step 4: Configure Authentication
Once `oh-my-opencode` is added to the plugin list, use `tmux` to proceed with the following:
1. Open a shell using `tmux`.
2. Run `opencode auth login` > Select Provider `Anthropic` > Select Login method `Claude Pro/Max` > Instruct the user to authenticate via the OAuth screen.
3. Recommend the setup: [VibeProxy](https://github.com/automazeio/vibeproxy) for macOS users, or [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI) for others.
### ⚠️ Warning
**Do NOT create or modify the `oh-my-opencode.json` configuration file unless the user explicitly requests it.**
The plugin works perfectly with default settings without any configuration file. The configuration file is only needed when the user wants customization.
</details>
@@ -114,7 +128,7 @@ OpenCode is limitlessly extensible and customizable. Zero screen flicker.
You can mix and orchestrate models to your exact specifications.
It is feature-rich. It is elegant. It handles the terminal without hesitation. It is high-performance.
But here is the catch: the learning curve is steep. There is a lot to master.
But here is the catch: the learning curve is steep. There is a lot to master. And your time is expensive.
Inspired by [AmpCode](https://ampcode.com) and [Claude Code](https://code.claude.com/docs/en/overview), I have implemented their features here—often with superior execution.
Because this is OpenCode.
@@ -221,29 +235,72 @@ Don't need these? Disable them via `oh-my-opencode.json`:
}
```
### Claude Code Compatibility
Oh My OpenCode provides seamless Claude Code configuration compatibility. If you've been using Claude Code, your existing setup works out of the box.
#### Hooks Integration
Execute custom scripts via Claude Code's `settings.json` hook system. Oh My OpenCode reads and executes hooks defined in:
- `~/.claude/settings.json` (user)
- `./.claude/settings.json` (project)
- `./.claude/settings.local.json` (local, git-ignored)
Supported hook events:
- **PreToolUse**: Runs before tool execution. Can block or modify tool input.
- **PostToolUse**: Runs after tool execution. Can add warnings or context.
- **UserPromptSubmit**: Runs when user submits a prompt. Can block or inject messages.
- **Stop**: Runs when session goes idle. Can inject follow-up prompts.
Example `settings.json`:
```json
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [{ "type": "command", "command": "eslint --fix $FILE" }]
}
]
}
}
```
#### Configuration Loaders
**Command Loader**: Loads markdown-based slash commands from 4 directories:
- `~/.claude/commands/` (user)
- `./.claude/commands/` (project)
- `~/.config/opencode/command/` (opencode global)
- `./.opencode/command/` (opencode project)
**Skill Loader**: Loads directory-based skills with `SKILL.md`:
- `~/.claude/skills/` (user)
- `./.claude/skills/` (project)
**Agent Loader**: Loads custom agent definitions from markdown files:
- `~/.claude/agents/*.md` (user)
- `./.claude/agents/*.md` (project)
**MCP Loader**: Loads MCP server configurations from `.mcp.json` files:
- `~/.claude/.mcp.json` (user)
- `./.mcp.json` (project)
- `./.claude/.mcp.json` (local)
- Supports environment variable expansion (`${VAR}` syntax)
#### Data Storage
**Todo Management**: Session todos are stored in Claude Code compatible format at `~/.claude/todos/`.
**Transcript**: Session activity is logged to `~/.claude/transcripts/` in JSONL format, enabling replay and analysis.
> **Note on `claude-code-*` naming**: Features under `src/features/claude-code-*/` are migrated from Claude Code's configuration system. This naming convention clearly identifies which features originated from Claude Code.
### Other Features
- **Terminal Title**: Auto-updates terminal title with session status (idle ○, processing ◐, tool ⚡, error ✖). Supports tmux.
- **Command Loader** (`src/features/claude-code-command-loader/`): Loads markdown-based commands from multiple directories:
- User scope: `~/.claude/commands/`
- Project scope: `./.claude/commands/`
- OpenCode global: `~/.config/opencode/command/`
- OpenCode project: `./.opencode/command/`
- **Skill Loader** (`src/features/claude-code-skill-loader/`): Loads directory-based skills as executable commands:
- User scope: `~/.claude/skills/`
- Project scope: `./.claude/skills/`
- **Agent Loader** (`src/features/claude-code-agent-loader/`): Loads agent definitions from markdown files with YAML frontmatter:
- User scope: `~/.claude/agents/`
- Project scope: `./.claude/agents/`
- Format: `*.md` files with frontmatter (name, description, tools)
- **Session State** (`src/features/claude-code-session-state/`): Centralized session tracking module used by event hooks and terminal title updates.
- **MCP Loader** (`src/features/claude-code-mcp-loader/`): Loads MCP server configurations from `.mcp.json` files:
- User scope: `~/.claude/.mcp.json`
- Project scope: `./.mcp.json`
- Local scope: `./.claude/.mcp.json`
- Supports environment variable expansion (`${VAR}` syntax)
> **Note on `claude-code-*` naming**: Features under `src/features/claude-code-*/` are migrated from Claude Code's configuration system. This naming convention clearly identifies which features originated from Claude Code, such as `claude-code-command-loader`, `claude-code-skill-loader`, `claude-code-agent-loader`, and `claude-code-mcp-loader`.
- **Session State**: Centralized session tracking module used by event hooks and terminal title updates.
## Configuration
@@ -279,7 +336,7 @@ Override built-in agent settings:
Each agent supports: `model`, `temperature`, `top_p`, `prompt`, `tools`, `disable`, `description`, `mode`, `color`, `permission`.
Or disable agents via `disabled_agents`:
Or you can disable them using `disabled_agents` in `~/.config/opencode/oh-my-opencode.json` or `.opencode/oh-my-opencode.json`:
```json
{
@@ -291,7 +348,9 @@ Available agents: `oracle`, `librarian`, `explore`, `frontend-ui-ux-engineer`, `
### MCPs
Disable built-in MCPs:
By default, Context7 and Exa MCP are supported.
If you don't want these, you can disable them using `disabled_mcps` in `~/.config/opencode/oh-my-opencode.json` or `.opencode/oh-my-opencode.json`:
```json
{
@@ -299,13 +358,13 @@ Disable built-in MCPs:
}
```
See [OpenCode MCP Servers](https://opencode.ai/docs/mcp-servers) for more.
### LSP
Oh My OpenCode's LSP tools are for **refactoring only** (rename, code actions). Analysis LSP is handled by OpenCode itself.
OpenCode provides LSP tools for analysis.
Oh My OpenCode provides LSP tools for refactoring (rename, code actions).
It supports all LSP configurations and custom settings supported by OpenCode (those configured in opencode.json), and you can also configure additional settings specifically for Oh My OpenCode as shown below.
Configure LSP servers via `lsp` option:
You can configure additional LSP servers via the `lsp` option in `~/.config/opencode/oh-my-opencode.json` or `.opencode/oh-my-opencode.json`:
```json
{
@@ -345,7 +404,7 @@ If this sounds arrogant and you have a superior solution, send a PR. You are wel
As of now, I have no affiliation with any of the projects or models mentioned here. This plugin is purely based on personal experimentation and preference.
I constructed 99% of this project using OpenCode. I focused on functional verification. This documentation has been personally reviewed and comprehensively rewritten, so you can rely on it with confidence.
I constructed 99% of this project using OpenCode. I focused on functional verification, and honestly, I don't know how to write proper TypeScript. **But I personally reviewed and comprehensively rewritten this documentation, so you can rely on it with confidence.**
## Warnings
- If you are on [1.0.132](https://github.com/sst/opencode/releases/tag/v1.0.132) or lower, OpenCode has a bug that might break config.

View File

@@ -1,162 +0,0 @@
# Comment-Checker TypeScript Port 구현 계획
## 1. 아키텍처 개요
### 1.1 핵심 도전 과제
**OpenCode Hook의 제약사항:**
- `tool.execute.before`: `output.args`에서 파일 경로/내용 접근 가능
- `tool.execute.after`: `tool_input`**제공되지 않음** (Claude Code와의 핵심 차이점)
- **해결책**: Before hook에서 데이터를 캡처하여 callID로 키잉된 Map에 저장, After hook에서 조회
### 1.2 디렉토리 구조
```
src/hooks/comment-checker/
├── index.ts # Hook factory, 메인 엔트리포인트
├── types.ts # 모든 타입 정의
├── constants.ts # 언어 레지스트리, 쿼리 템플릿, 디렉티브 목록
├── detector.ts # CommentDetector - web-tree-sitter 기반 코멘트 감지
├── filters/
│ ├── index.ts # 필터 barrel export
│ ├── bdd.ts # BDD 패턴 필터
│ ├── directive.ts # 린터/타입체커 디렉티브 필터
│ ├── docstring.ts # 독스트링 필터
│ └── shebang.ts # Shebang 필터
├── output/
│ ├── index.ts # 출력 barrel export
│ ├── formatter.ts # FormatHookMessage
│ └── xml-builder.ts # BuildCommentsXML
└── utils.ts # 유틸리티 함수
```
### 1.3 데이터 흐름
```
[write/edit 도구 실행]
┌──────────────────────┐
│ tool.execute.before │
│ - 파일 경로 캡처 │
│ - pendingCalls Map │
│ 에 저장 │
└──────────┬───────────┘
[도구 실제 실행]
┌──────────────────────┐
│ tool.execute.after │
│ - pendingCalls에서 │
│ 데이터 조회 │
│ - 파일 읽기 │
│ - 코멘트 감지 │
│ - 필터 적용 │
│ - 메시지 주입 │
└──────────────────────┘
```
---
## 2. 구현 순서
### Phase 1: 기반 구조
1. `src/hooks/comment-checker/` 디렉토리 생성
2. `types.ts` - 모든 타입 정의
3. `constants.ts` - 언어 레지스트리, 디렉티브 패턴
### Phase 2: 필터 구현
4. `filters/bdd.ts` - BDD 패턴 필터
5. `filters/directive.ts` - 디렉티브 필터
6. `filters/docstring.ts` - 독스트링 필터
7. `filters/shebang.ts` - Shebang 필터
8. `filters/index.ts` - 필터 조합
### Phase 3: 코어 로직
9. `detector.ts` - web-tree-sitter 기반 코멘트 감지
10. `output/xml-builder.ts` - XML 출력
11. `output/formatter.ts` - 메시지 포매팅
### Phase 4: Hook 통합
12. `index.ts` - Hook factory 및 상태 관리
13. `src/hooks/index.ts` 업데이트 - export 추가
### Phase 5: 의존성 및 빌드
14. `package.json` 업데이트 - web-tree-sitter 추가
15. typecheck 및 build 검증
---
## 3. 핵심 구현 사항
### 3.1 언어 레지스트리 (38개 언어)
```typescript
const LANGUAGE_REGISTRY: Record<string, LanguageConfig> = {
python: { extensions: [".py"], commentQuery: "(comment) @comment", docstringQuery: "..." },
javascript: { extensions: [".js", ".jsx"], commentQuery: "(comment) @comment" },
typescript: { extensions: [".ts"], commentQuery: "(comment) @comment" },
tsx: { extensions: [".tsx"], commentQuery: "(comment) @comment" },
go: { extensions: [".go"], commentQuery: "(comment) @comment" },
rust: { extensions: [".rs"], commentQuery: "(line_comment) @comment (block_comment) @comment" },
// ... 38개 전체
}
```
### 3.2 필터 로직
**BDD 필터**: `given, when, then, arrange, act, assert`
**Directive 필터**: `noqa, pyright:, eslint-disable, @ts-ignore` 등 30+
**Docstring 필터**: `IsDocstring || starts with /**`
**Shebang 필터**: `starts with #!`
### 3.3 출력 형식 (Go 버전과 100% 동일)
```
COMMENT/DOCSTRING DETECTED - IMMEDIATE ACTION REQUIRED
Your recent changes contain comments or docstrings, which triggered this hook.
You need to take immediate action. You must follow the conditions below.
(Listed in priority order - you must always act according to this priority order)
CRITICAL WARNING: This hook message MUST NEVER be ignored...
<comments file="/path/to/file.py">
<comment line-number="10">// comment text</comment>
</comments>
```
---
## 4. 생성할 파일 목록
1. `src/hooks/comment-checker/types.ts`
2. `src/hooks/comment-checker/constants.ts`
3. `src/hooks/comment-checker/filters/bdd.ts`
4. `src/hooks/comment-checker/filters/directive.ts`
5. `src/hooks/comment-checker/filters/docstring.ts`
6. `src/hooks/comment-checker/filters/shebang.ts`
7. `src/hooks/comment-checker/filters/index.ts`
8. `src/hooks/comment-checker/output/xml-builder.ts`
9. `src/hooks/comment-checker/output/formatter.ts`
10. `src/hooks/comment-checker/output/index.ts`
11. `src/hooks/comment-checker/detector.ts`
12. `src/hooks/comment-checker/index.ts`
## 5. 수정할 파일 목록
1. `src/hooks/index.ts` - export 추가
2. `package.json` - web-tree-sitter 의존성
---
## 6. Definition of Done
- [ ] write/edit 도구 실행 시 코멘트 감지 동작
- [ ] 4개 필터 모두 정상 작동
- [ ] 최소 5개 언어 지원 (Python, JS, TS, TSX, Go)
- [ ] Go 버전과 동일한 출력 형식
- [ ] typecheck 통과
- [ ] build 성공

View File

@@ -1,12 +0,0 @@
#!/bin/bash
set -e
cd /Users/yeongyu/local-workspaces/oh-my-opencode
echo "=== Pushing to origin ==="
git push -f origin master
echo "=== Triggering workflow ==="
gh workflow run publish.yml --repo code-yeongyu/oh-my-opencode --ref master -f bump=patch -f version=$1
echo "=== Done! ==="
echo "Usage: ./local-ignore/push-and-release.sh 0.1.6"

View File

@@ -1,398 +0,0 @@
# MCP Loader Plugin - Orchestration Notepad
## Task Started
All tasks execution STARTED: Thu Dec 4 16:52:57 KST 2025
---
## Orchestration Overview
**Todo List File**: ./tool-search-tool-plan.md
**Total Tasks**: 5 (Phase 1-5)
**Target Files**:
- `~/.config/opencode/plugin/mcp-loader.ts` - Main plugin
- `~/.config/opencode/mcp-loader.json` - Global config example
- `~/.config/opencode/plugin/mcp-loader.test.ts` - Unit tests
---
## Accumulated Wisdom
(To be populated by executors)
---
## Task Progress
| Task | Description | Status |
|------|-------------|--------|
| 1 | Plugin skeleton + config loader | pending |
| 2 | MCP server registry + lifecycle | pending |
| 3 | mcp_search + mcp_status tools | pending |
| 4 | mcp_call tool | pending |
| 5 | Documentation | pending |
---
## 2025-12-04 16:58 - Task 1 Completed
### Summary
- Created `~/.config/opencode/plugin/mcp-loader.ts` - Plugin skeleton with config loader
- Created `~/.config/opencode/plugin/mcp-loader.test.ts` - 14 unit tests
### Key Implementation Details
- Config merge: project overrides global for same server names, merges different
- Env var substitution: `{env:VAR}``process.env.VAR`
- Validation: type required, local needs command, remote needs url
- Empty config returns `{ servers: {} }` (not error)
### Test Results
- 14 tests passed
- substituteEnvVars: 4 tests
- substituteHeaderEnvVars: 1 test
- loadConfig: 9 tests
### Files Created
- `~/.config/opencode/plugin/mcp-loader.ts`
- `~/.config/opencode/plugin/mcp-loader.test.ts`
---
## [2025-12-08 18:56] - Task 1: Remove unused import formatWorkspaceEdit from LSP tools
### DISCOVERED ISSUES
- None - simple import cleanup task
### IMPLEMENTATION DECISIONS
- Removed only `formatWorkspaceEdit` from import list at line 17
- Kept all other imports intact (formatCodeActions, applyWorkspaceEdit, formatApplyResult remain)
- Verified the function exists in utils.ts:212 but is truly unused in tools.ts
### PROBLEMS FOR NEXT TASKS
- None identified for remaining tasks
### VERIFICATION RESULTS
- Ran: `bun run typecheck` → exit 0, no errors
- Ran: `bun run build` → exit 0, bundled 200 modules
- Ran: `rg "formatWorkspaceEdit" src/tools/lsp/tools.ts` → no matches (confirmed removal)
### LEARNINGS
- Convention: This project uses `bun run typecheck` (tsc --noEmit) and `bun run build` for verification
- The `formatWorkspaceEdit` function still exists in utils.ts - it's exported but just not used in tools.ts
소요 시간: ~2분
---
## [2025-12-08 19:00] - Task 2: Remove unused ThinkingPart interface and fallbackRevertStrategy function
### DISCOVERED ISSUES
- None - both items were genuinely unused (no callers found)
### IMPLEMENTATION DECISIONS
- Removed `ThinkingPart` interface (lines 37-40) - defined but never referenced
- Removed `fallbackRevertStrategy` function (lines 189-244) - defined but never called
- Added comment explaining removal reason as per task requirements
- Kept `ThinkingPartType`, `prependThinkingPart`, `stripThinkingParts` - these are different items and ARE used
### PROBLEMS FOR NEXT TASKS
- None identified
### VERIFICATION RESULTS
- Ran: `bun run typecheck` → exit 0, no errors
- Ran: `bun run build` → exit 0, bundled 200 modules
- Ran: `rg "ThinkingPart" src/hooks/session-recovery/` → only related types/functions found, interface removed
- Ran: `rg "fallbackRevertStrategy" src/hooks/session-recovery/` → only comment found, function removed
- Ran: `rg "createSessionRecoveryHook" src/hooks/` → exports intact
### LEARNINGS
- `ThinkingPart` interface vs `ThinkingPartType` type vs `prependThinkingPart` function - different entities, verify before removing
- `fallbackRevertStrategy` was likely a planned feature that never got integrated into the recovery flow
소요 시간: ~2분
---
## [2025-12-08 19:04] - Task 3: Remove unused builtinMcps export from MCP module
### DISCOVERED ISSUES
- None - `builtinMcps` export was genuinely unused (no external importers)
### IMPLEMENTATION DECISIONS
- Removed `export const builtinMcps = allBuiltinMcps` from line 24
- Kept `allBuiltinMcps` const - used internally by `createBuiltinMcps` function
- Kept `createBuiltinMcps` function - actively used in src/index.ts:89
### PROBLEMS FOR NEXT TASKS
- None identified
### VERIFICATION RESULTS
- Ran: `bun run typecheck` → exit 0, no errors
- Ran: `bun run build` → exit 0, bundled 200 modules
- Ran: `rg "builtinMcps" src/mcp/index.ts` → no matches (export removed)
- Ran: `rg "createBuiltinMcps" src/mcp/index.ts` → function still exists
### LEARNINGS
- `createBuiltinMcps` function vs `builtinMcps` export - function is used, direct export is not
- Internal const `allBuiltinMcps` should be kept since it's referenced by the function
소요 시간: ~2분
---
## [2025-12-09 16:13] - Task 1: Add file-based logger to shared module
### DISCOVERED ISSUES
- None - straightforward file copy and modification task
### IMPLEMENTATION DECISIONS
- Copied logger.ts from opencode-cc-plugin source
- Changed log file path from `opencode-cc-plugin.log` to `oh-my-opencode.log`
- Added barrel export from `src/shared/index.ts`
- Kept original comment for module description
### PROBLEMS FOR NEXT TASKS
- None identified - logger is now available for use in all new loaders
### VERIFICATION RESULTS
- Ran: `bun run typecheck` → exit 0, no errors
- Log file path verified: `/tmp/oh-my-opencode.log`
- Exports verified: `log()` and `getLogFilePath()` now accessible via `src/shared`
### LEARNINGS
- Source file location: `~/local-workspaces/opencode-cc-plugin/src/shared/logger.ts`
- Logger uses `fs.appendFileSync` for synchronous file writing
- Empty catch block intentionally swallows errors to prevent logging from breaking main operations
소요 시간: ~1분
---
## [2025-12-09 16:14] - Task 3: Rename skill-loader to claude-code-skill-loader
### DISCOVERED ISSUES
- None - straightforward directory rename task
### IMPLEMENTATION DECISIONS
- Used `mv` command to rename `src/features/skill-loader/``src/features/claude-code-skill-loader/`
- Updated import path in `src/index.ts` (lines 19-22)
- Did NOT modify internal imports (`../command-loader/types`) since command-loader still exists (Task 2 runs in parallel)
### PROBLEMS FOR NEXT TASKS
- If Task 2 renames command-loader to claude-code-command-loader, the internal imports in claude-code-skill-loader will need to be updated as part of Task 2's scope
- The skill-loader's loader.ts:6 and types.ts:1 import from `../command-loader/types`
### VERIFICATION RESULTS
- Ran: `bun run typecheck` → exit 0, no errors
- Ran: `bun run build` → exit 0, succeeded
- Directory structure verified: `skill-loader/` deleted, `claude-code-skill-loader/` exists
### LEARNINGS
- This project uses `mv` for directory rename (acceptable per ANTI-PATTERNS - file creation is forbidden, not rename)
- Command: `bun run typecheck` for type check, `bun run build` for build
- skill-loader internal imports use relative paths (`../command-loader/types`) which remain valid after rename
소요 시간: ~2분
---
## [2025-12-09 16:16] - Task 2: Rename command-loader to claude-code-command-loader
### DISCOVERED ISSUES
- skill-loader (now claude-code-skill-loader) was importing `CommandDefinition` from `../command-loader/types`
- After renaming command-loader, these references also needed updating
### IMPLEMENTATION DECISIONS
- Used `mv` command: `src/features/command-loader/``src/features/claude-code-command-loader/`
- Updated import path in `src/index.ts` (lines 13-18)
- Also updated `claude-code-skill-loader/loader.ts:6` and `types.ts:1` to reference new path
### PROBLEMS FOR NEXT TASKS
- None identified - all dependent imports updated
### VERIFICATION RESULTS
- Ran: `bun run typecheck` → exit 0, no errors
- Directory structure verified: `command-loader/` deleted, `claude-code-command-loader/` exists
- All imports updated: src/index.ts, claude-code-skill-loader/loader.ts, claude-code-skill-loader/types.ts
### LEARNINGS
- skill-loader depends on command-loader's `CommandDefinition` type via relative import
- When renaming shared modules, must update ALL dependent modules' imports
- Task 2 and Task 3 have an implicit dependency through the type import
소요 시간: ~2분
---
## [2025-12-09 16:24] - Task 4: Add claude-code-agent-loader feature
### DISCOVERED ISSUES
- None - straightforward file copy task
### IMPLEMENTATION DECISIONS
- Copied 3 files from opencode-cc-plugin: `index.ts`, `loader.ts`, `types.ts`
- Import path `../../shared/frontmatter` unchanged - already compatible with oh-my-opencode structure
- No `log()` usage in source files - no logger integration needed
### PROBLEMS FOR NEXT TASKS
- None identified - agent-loader is self-contained
### VERIFICATION RESULTS
- Ran: `bun run typecheck` → exit 0, no errors
- Directory structure verified: `claude-code-agent-loader/` created with 3 files
- Functions exported: `loadUserAgents()`, `loadProjectAgents()`
### LEARNINGS
- Source location: `~/local-workspaces/opencode-cc-plugin/src/features/agent-loader/`
- Agent loader uses `parseFrontmatter` from shared module
- Agent configs loaded from `~/.claude/agents/` (user) and `.claude/agents/` (project)
- Scope is appended to description: `(user)` or `(project)`
소요 시간: ~1분
---
## [2025-12-09 16:25] - Task 5: Add claude-code-mcp-loader feature
### DISCOVERED ISSUES
- None - straightforward file copy task
### IMPLEMENTATION DECISIONS
- Copied 5 files from opencode-cc-plugin: `index.ts`, `loader.ts`, `transformer.ts`, `env-expander.ts`, `types.ts`
- Import path `../../shared/logger` unchanged - already compatible with oh-my-opencode structure
- Kept `Bun.file()` usage - oh-my-opencode targets Bun runtime
- Environment variable expansion supports `${VAR}` and `${VAR:-default}` syntax
### PROBLEMS FOR NEXT TASKS
- None identified - mcp-loader is self-contained
- Does NOT conflict with src/mcp/ (builtin MCPs are separate)
### VERIFICATION RESULTS
- Ran: `bun run typecheck` → exit 0, no errors
- Directory structure verified: `claude-code-mcp-loader/` created with 5 files
- Functions exported: `loadMcpConfigs()`, `formatLoadedServersForToast()`, `transformMcpServer()`, `expandEnvVars()`, `expandEnvVarsInObject()`
### LEARNINGS
- Source location: `~/local-workspaces/opencode-cc-plugin/src/features/mcp-loader/`
- MCP configs loaded from:
- `~/.claude/.mcp.json` (user scope)
- `.mcp.json` (project scope)
- `.claude/.mcp.json` (local scope)
- Later scope overrides earlier scope for same server name
- Supports stdio, http, and sse server types
소요 시간: ~1분
---
## [2025-12-09 16:24] - Task 6: Add claude-code-session-state feature
### DISCOVERED ISSUES
- None - straightforward file copy task
### IMPLEMENTATION DECISIONS
- Copied 4 files from opencode-cc-plugin: `types.ts`, `state.ts`, `detector.ts`, `index.ts`
- No import path changes needed - files are completely self-contained
- No external dependencies - types are defined locally
### PROBLEMS FOR NEXT TASKS
- Task 7 should import from `./features/claude-code-session-state` in src/index.ts
- Task 7 should remove local session variables and use the module's getter/setters
### VERIFICATION RESULTS
- Directory created: `src/features/claude-code-session-state/` (4 files confirmed)
- Exports available: sessionErrorState, sessionInterruptState, subagentSessions, sessionFirstMessageProcessed (Maps/Sets)
- Exports available: currentSessionID, currentSessionTitle, mainSessionID (state vars)
- Exports available: setCurrentSession(), setMainSession(), getCurrentSessionID(), getCurrentSessionTitle(), getMainSessionID() (getters/setters)
- Exports available: detectInterrupt() function
### LEARNINGS
- Session state module is completely self-contained - no external dependencies
- Uses barrel export pattern: index.ts re-exports everything from types, state, detector
- Source directory: `~/local-workspaces/opencode-cc-plugin/src/features/session-state/`
소요 시간: ~1분
---
## [2025-12-09 16:32] - Task 7: Integrate new features into src/index.ts
### DISCOVERED ISSUES
- None - integration task with well-defined API from previous tasks
### IMPLEMENTATION DECISIONS
- Added imports for new modules:
- `loadUserAgents`, `loadProjectAgents` from `./features/claude-code-agent-loader`
- `loadMcpConfigs` from `./features/claude-code-mcp-loader`
- `setCurrentSession`, `setMainSession`, `getMainSessionID`, `getCurrentSessionTitle` from `./features/claude-code-session-state`
- `log` from `./shared/logger`
- Removed local session variables (lines 77-79): `mainSessionID`, `currentSessionID`, `currentSessionTitle`
- Replaced direct session assignments with setter functions:
- `mainSessionID = x``setMainSession(x)`
- `currentSessionID = x; currentSessionTitle = y``setCurrentSession(x, y)`
- Replaced session variable reads with getter functions:
- `mainSessionID` comparisons → `getMainSessionID()`
- `currentSessionTitle` reads → `getCurrentSessionTitle()`
- Added agent loading in config hook: `loadUserAgents()`, `loadProjectAgents()`
- Added MCP loading in config hook: `await loadMcpConfigs()` (async)
- Replaced `console.error` with `log()` for config validation errors
- Renamed local variable `agents` to `builtinAgents` to distinguish from loaded agents
### PROBLEMS FOR NEXT TASKS
- Task 8 (README update) should document the new Agent Loader and MCP Loader features
- Should explain the `claude-code-*` naming convention
### VERIFICATION RESULTS
- Ran: `bun run typecheck` → exit 0, no errors
- Ran: `bun run build` → exit 0, successful build
- Session tracking verified: all event handlers use getter/setter functions
- Agent loading verified: config.agent merges builtin + user + project agents
- MCP loading verified: config.mcp merges builtin MCPs + loaded MCP servers
### LEARNINGS
- `setCurrentSession(id, title)` sets both ID and title atomically
- `loadMcpConfigs()` is async - must use `await` in config hook
- MCP result has `.servers` property that returns the server configs
- Order matters in spread: later values override earlier (projectAgents > userAgents > builtinAgents)
소요 시간: ~4분
---
## [2025-12-09 16:35] - Task 8: Update README.md documentation
### DISCOVERED ISSUES
- None - documentation update task
### IMPLEMENTATION DECISIONS
- Added Agent Loader feature documentation under "Other Features" section (line 235-238)
- User scope: `~/.claude/agents/`
- Project scope: `./.claude/agents/`
- Format: `*.md` files with YAML frontmatter
- Added MCP Loader feature documentation (line 239-243)
- User scope: `~/.claude/.mcp.json`
- Project scope: `./.mcp.json`
- Local scope: `./.claude/.mcp.json`
- Environment variable expansion (`${VAR}` syntax)
- Added `claude-code-*` naming convention explanation as a blockquote note (line 245)
- Explains features migrated from Claude Code
- Lists examples: claude-code-command-loader, skill-loader, agent-loader, mcp-loader
### PROBLEMS FOR NEXT TASKS
- None - this is the final task
### VERIFICATION RESULTS
- README.md updated with new documentation
- Style matches existing documentation (bullet points, code blocks for paths)
- No sections removed or modified (only additions)
### LEARNINGS
- README.md "Other Features" section is at line 224
- Existing features: Terminal Title, Command Loader, Skill Loader
- Documentation style: bold feature name, bullet points for scopes/details
소요 시간: ~1분
---

View File

@@ -1,6 +1,6 @@
{
"name": "oh-my-opencode",
"version": "0.1.31",
"version": "0.3.1",
"description": "OpenCode plugin - custom agents (oracle, librarian) and enhanced features",
"main": "dist/index.js",
"types": "dist/index.d.ts",

View File

@@ -0,0 +1,8 @@
import { join } from "node:path"
import { homedir } from "node:os"
const xdgData = process.env.XDG_DATA_HOME || join(homedir(), ".local", "share")
export const OPENCODE_STORAGE = join(xdgData, "opencode", "storage")
export const MESSAGE_STORAGE = join(OPENCODE_STORAGE, "message")
export const PART_STORAGE = join(OPENCODE_STORAGE, "part")

View File

@@ -0,0 +1,2 @@
export { injectHookMessage } from "./injector"
export type { MessageMeta, OriginalMessageContext, TextPart } from "./types"

View File

@@ -0,0 +1,141 @@
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs"
import { join } from "node:path"
import { MESSAGE_STORAGE, PART_STORAGE } from "./constants"
import type { MessageMeta, OriginalMessageContext, TextPart } from "./types"
interface StoredMessage {
agent?: string
model?: { providerID?: string; modelID?: string }
tools?: Record<string, boolean>
}
function findNearestMessageWithFields(messageDir: string): StoredMessage | null {
try {
const files = readdirSync(messageDir)
.filter((f) => f.endsWith(".json"))
.sort()
.reverse()
for (const file of files) {
try {
const content = readFileSync(join(messageDir, file), "utf-8")
const msg = JSON.parse(content) as StoredMessage
if (msg.agent && msg.model?.providerID && msg.model?.modelID) {
return msg
}
} catch {
continue
}
}
} catch {
return null
}
return null
}
function generateMessageId(): string {
const timestamp = Date.now().toString(16)
const random = Math.random().toString(36).substring(2, 14)
return `msg_${timestamp}${random}`
}
function generatePartId(): string {
const timestamp = Date.now().toString(16)
const random = Math.random().toString(36).substring(2, 10)
return `prt_${timestamp}${random}`
}
function getOrCreateMessageDir(sessionID: string): string {
if (!existsSync(MESSAGE_STORAGE)) {
mkdirSync(MESSAGE_STORAGE, { recursive: true })
}
const directPath = join(MESSAGE_STORAGE, sessionID)
if (existsSync(directPath)) {
return directPath
}
for (const dir of readdirSync(MESSAGE_STORAGE)) {
const sessionPath = join(MESSAGE_STORAGE, dir, sessionID)
if (existsSync(sessionPath)) {
return sessionPath
}
}
mkdirSync(directPath, { recursive: true })
return directPath
}
export function injectHookMessage(
sessionID: string,
hookContent: string,
originalMessage: OriginalMessageContext
): boolean {
const messageDir = getOrCreateMessageDir(sessionID)
const needsFallback =
!originalMessage.agent ||
!originalMessage.model?.providerID ||
!originalMessage.model?.modelID
const fallback = needsFallback ? findNearestMessageWithFields(messageDir) : null
const now = Date.now()
const messageID = generateMessageId()
const partID = generatePartId()
const resolvedAgent = originalMessage.agent ?? fallback?.agent ?? "general"
const resolvedModel =
originalMessage.model?.providerID && originalMessage.model?.modelID
? { providerID: originalMessage.model.providerID, modelID: originalMessage.model.modelID }
: fallback?.model?.providerID && fallback?.model?.modelID
? { providerID: fallback.model.providerID, modelID: fallback.model.modelID }
: undefined
const resolvedTools = originalMessage.tools ?? fallback?.tools
const messageMeta: MessageMeta = {
id: messageID,
sessionID,
role: "user",
time: {
created: now,
},
agent: resolvedAgent,
model: resolvedModel,
path:
originalMessage.path?.cwd
? {
cwd: originalMessage.path.cwd,
root: originalMessage.path.root ?? "/",
}
: undefined,
tools: resolvedTools,
}
const textPart: TextPart = {
id: partID,
type: "text",
text: hookContent,
synthetic: true,
time: {
start: now,
end: now,
},
messageID,
sessionID,
}
try {
writeFileSync(join(messageDir, `${messageID}.json`), JSON.stringify(messageMeta, null, 2))
const partDir = join(PART_STORAGE, messageID)
if (!existsSync(partDir)) {
mkdirSync(partDir, { recursive: true })
}
writeFileSync(join(partDir, `${partID}.json`), JSON.stringify(textPart, null, 2))
return true
} catch {
return false
}
}

View File

@@ -0,0 +1,45 @@
export interface MessageMeta {
id: string
sessionID: string
role: "user" | "assistant"
time: {
created: number
completed?: number
}
agent?: string
model?: {
providerID: string
modelID: string
}
path?: {
cwd: string
root: string
}
tools?: Record<string, boolean>
}
export interface OriginalMessageContext {
agent?: string
model?: {
providerID?: string
modelID?: string
}
path?: {
cwd?: string
root?: string
}
tools?: Record<string, boolean>
}
export interface TextPart {
id: string
type: "text"
text: string
synthetic: boolean
time: {
start: number
end: number
}
messageID: string
sessionID: string
}

View File

@@ -0,0 +1,105 @@
import { existsSync } from "fs"
import { homedir } from "os"
import { join } from "path"
import type { ClaudeHookEvent } from "./types"
import { log } from "../../shared/logger"
export interface DisabledHooksConfig {
Stop?: string[]
PreToolUse?: string[]
PostToolUse?: string[]
UserPromptSubmit?: string[]
}
export interface PluginExtendedConfig {
disabledHooks?: DisabledHooksConfig
}
const USER_CONFIG_PATH = join(homedir(), ".config", "opencode", "opencode-cc-plugin.json")
function getProjectConfigPath(): string {
return join(process.cwd(), ".opencode", "opencode-cc-plugin.json")
}
async function loadConfigFromPath(path: string): Promise<PluginExtendedConfig | null> {
if (!existsSync(path)) {
return null
}
try {
const content = await Bun.file(path).text()
return JSON.parse(content) as PluginExtendedConfig
} catch (error) {
log("Failed to load config", { path, error })
return null
}
}
function mergeDisabledHooks(
base: DisabledHooksConfig | undefined,
override: DisabledHooksConfig | undefined
): DisabledHooksConfig {
if (!override) return base ?? {}
if (!base) return override
return {
Stop: override.Stop ?? base.Stop,
PreToolUse: override.PreToolUse ?? base.PreToolUse,
PostToolUse: override.PostToolUse ?? base.PostToolUse,
UserPromptSubmit: override.UserPromptSubmit ?? base.UserPromptSubmit,
}
}
export async function loadPluginExtendedConfig(): Promise<PluginExtendedConfig> {
const userConfig = await loadConfigFromPath(USER_CONFIG_PATH)
const projectConfig = await loadConfigFromPath(getProjectConfigPath())
const merged: PluginExtendedConfig = {
disabledHooks: mergeDisabledHooks(
userConfig?.disabledHooks,
projectConfig?.disabledHooks
),
}
if (userConfig || projectConfig) {
log("Plugin extended config loaded", {
userConfigExists: userConfig !== null,
projectConfigExists: projectConfig !== null,
mergedDisabledHooks: merged.disabledHooks,
})
}
return merged
}
const regexCache = new Map<string, RegExp>()
function getRegex(pattern: string): RegExp {
let regex = regexCache.get(pattern)
if (!regex) {
try {
regex = new RegExp(pattern)
regexCache.set(pattern, regex)
} catch {
regex = new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
regexCache.set(pattern, regex)
}
}
return regex
}
export function isHookCommandDisabled(
eventType: ClaudeHookEvent,
command: string,
config: PluginExtendedConfig | null
): boolean {
if (!config?.disabledHooks) return false
const patterns = config.disabledHooks[eventType]
if (!patterns || patterns.length === 0) return false
return patterns.some((pattern) => {
const regex = getRegex(pattern)
return regex.test(command)
})
}

View File

@@ -0,0 +1,100 @@
import { homedir } from "os"
import { join } from "path"
import { existsSync } from "fs"
import type { ClaudeHooksConfig, HookMatcher, HookCommand } from "./types"
interface RawHookMatcher {
matcher?: string
pattern?: string
hooks: HookCommand[]
}
interface RawClaudeHooksConfig {
PreToolUse?: RawHookMatcher[]
PostToolUse?: RawHookMatcher[]
UserPromptSubmit?: RawHookMatcher[]
Stop?: RawHookMatcher[]
}
function normalizeHookMatcher(raw: RawHookMatcher): HookMatcher {
return {
matcher: raw.matcher ?? raw.pattern ?? "*",
hooks: raw.hooks,
}
}
function normalizeHooksConfig(raw: RawClaudeHooksConfig): ClaudeHooksConfig {
const result: ClaudeHooksConfig = {}
const eventTypes: (keyof RawClaudeHooksConfig)[] = [
"PreToolUse",
"PostToolUse",
"UserPromptSubmit",
"Stop",
]
for (const eventType of eventTypes) {
if (raw[eventType]) {
result[eventType] = raw[eventType].map(normalizeHookMatcher)
}
}
return result
}
export function getClaudeSettingsPaths(customPath?: string): string[] {
const home = homedir()
const paths = [
join(home, ".claude", "settings.json"),
join(process.cwd(), ".claude", "settings.json"),
join(process.cwd(), ".claude", "settings.local.json"),
]
if (customPath && existsSync(customPath)) {
paths.unshift(customPath)
}
return paths
}
function mergeHooksConfig(
base: ClaudeHooksConfig,
override: ClaudeHooksConfig
): ClaudeHooksConfig {
const result: ClaudeHooksConfig = { ...base }
const eventTypes: (keyof ClaudeHooksConfig)[] = [
"PreToolUse",
"PostToolUse",
"UserPromptSubmit",
"Stop",
]
for (const eventType of eventTypes) {
if (override[eventType]) {
result[eventType] = [...(base[eventType] || []), ...override[eventType]]
}
}
return result
}
export async function loadClaudeHooksConfig(
customSettingsPath?: string
): Promise<ClaudeHooksConfig | null> {
const paths = getClaudeSettingsPaths(customSettingsPath)
let mergedConfig: ClaudeHooksConfig = {}
for (const settingsPath of paths) {
if (existsSync(settingsPath)) {
try {
const content = await Bun.file(settingsPath).text()
const settings = JSON.parse(content) as { hooks?: RawClaudeHooksConfig }
if (settings.hooks) {
const normalizedHooks = normalizeHooksConfig(settings.hooks)
mergedConfig = mergeHooksConfig(mergedConfig, normalizedHooks)
}
} catch {
continue
}
}
}
return Object.keys(mergedConfig).length > 0 ? mergedConfig : null
}

View File

@@ -0,0 +1,336 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { loadClaudeHooksConfig } from "./config"
import { loadPluginExtendedConfig } from "./config-loader"
import {
executePreToolUseHooks,
type PreToolUseContext,
} from "./pre-tool-use"
import {
executePostToolUseHooks,
type PostToolUseContext,
type PostToolUseClient,
} from "./post-tool-use"
import {
executeUserPromptSubmitHooks,
type UserPromptSubmitContext,
type MessagePart,
} from "./user-prompt-submit"
import {
executeStopHooks,
type StopContext,
} from "./stop"
import { cacheToolInput, getToolInput } from "./tool-input-cache"
import { recordToolUse, recordToolResult, getTranscriptPath, recordUserMessage } from "./transcript"
import type { PluginConfig } from "./types"
import { log, isHookDisabled } from "../../shared"
import { injectHookMessage } from "../../features/hook-message-injector"
const sessionFirstMessageProcessed = new Set<string>()
const sessionErrorState = new Map<string, { hasError: boolean; errorMessage?: string }>()
const sessionInterruptState = new Map<string, { interrupted: boolean }>()
export function createClaudeCodeHooksHook(ctx: PluginInput, config: PluginConfig = {}) {
return {
"chat.message": async (
input: {
sessionID: string
agent?: string
model?: { providerID: string; modelID: string }
messageID?: string
},
output: {
message: Record<string, unknown>
parts: Array<{ type: string; text?: string; [key: string]: unknown }>
}
): Promise<void> => {
const interruptState = sessionInterruptState.get(input.sessionID)
if (interruptState?.interrupted) {
log("chat.message hook skipped - session interrupted", { sessionID: input.sessionID })
return
}
const claudeConfig = await loadClaudeHooksConfig()
const extendedConfig = await loadPluginExtendedConfig()
const textParts = output.parts.filter((p) => p.type === "text" && p.text)
const prompt = textParts.map((p) => p.text ?? "").join("\n")
recordUserMessage(input.sessionID, prompt)
const messageParts: MessagePart[] = textParts.map((p) => ({
type: p.type as "text",
text: p.text,
}))
const interruptStateBeforeHooks = sessionInterruptState.get(input.sessionID)
if (interruptStateBeforeHooks?.interrupted) {
log("chat.message hooks skipped - interrupted during preparation", { sessionID: input.sessionID })
return
}
let parentSessionId: string | undefined
try {
const sessionInfo = await ctx.client.session.get({
path: { id: input.sessionID },
})
parentSessionId = sessionInfo.data?.parentID
} catch {}
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,
parentSessionId,
prompt,
parts: messageParts,
cwd: ctx.directory,
}
const result = await executeUserPromptSubmitHooks(
userPromptCtx,
claudeConfig,
extendedConfig
)
if (result.block) {
throw new Error(result.reason ?? "Hook blocked the prompt")
}
const interruptStateAfterHooks = sessionInterruptState.get(input.sessionID)
if (interruptStateAfterHooks?.interrupted) {
log("chat.message injection skipped - interrupted during hooks", { sessionID: input.sessionID })
return
}
if (result.messages.length > 0) {
const hookContent = result.messages.join("\n\n")
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,
})
}
}
},
"tool.execute.before": async (
input: { tool: string; sessionID: string; callID: string },
output: { args: Record<string, unknown> }
): Promise<void> => {
const claudeConfig = await loadClaudeHooksConfig()
const extendedConfig = await loadPluginExtendedConfig()
recordToolUse(input.sessionID, input.tool, output.args as Record<string, unknown>)
cacheToolInput(input.sessionID, input.tool, input.callID, output.args as Record<string, unknown>)
if (!isHookDisabled(config, "PreToolUse")) {
const preCtx: PreToolUseContext = {
sessionId: input.sessionID,
toolName: input.tool,
toolInput: output.args as Record<string, unknown>,
cwd: ctx.directory,
toolUseId: input.callID,
}
const result = await executePreToolUseHooks(preCtx, claudeConfig, extendedConfig)
if (result.decision === "deny") {
ctx.client.tui
.showToast({
body: {
title: "PreToolUse Hook Executed",
message: `${result.toolName ?? input.tool} ${result.hookName ?? "hook"}: BLOCKED ${result.elapsedMs ?? 0}ms\n${result.inputLines ?? ""}`,
variant: "error",
duration: 4000,
},
})
.catch(() => {})
throw new Error(result.reason ?? "Hook blocked the operation")
}
if (result.modifiedInput) {
Object.assign(output.args as Record<string, unknown>, result.modifiedInput)
}
}
},
"tool.execute.after": async (
input: { tool: string; sessionID: string; callID: string },
output: { title: string; output: string; metadata: unknown }
): Promise<void> => {
const claudeConfig = await loadClaudeHooksConfig()
const extendedConfig = await loadPluginExtendedConfig()
const cachedInput = getToolInput(input.sessionID, input.tool, input.callID) || {}
recordToolResult(input.sessionID, input.tool, cachedInput, (output.metadata as Record<string, unknown>) || {})
if (!isHookDisabled(config, "PostToolUse")) {
const postClient: PostToolUseClient = {
session: {
messages: (opts) => ctx.client.session.messages(opts),
},
}
const postCtx: PostToolUseContext = {
sessionId: input.sessionID,
toolName: input.tool,
toolInput: cachedInput,
toolOutput: {
title: input.tool,
output: output.output,
metadata: output.metadata as Record<string, unknown>,
},
cwd: ctx.directory,
transcriptPath: getTranscriptPath(input.sessionID),
toolUseId: input.callID,
client: postClient,
permissionMode: "bypassPermissions",
}
const result = await executePostToolUseHooks(postCtx, claudeConfig, extendedConfig)
if (result.block) {
ctx.client.tui
.showToast({
body: {
title: "PostToolUse Hook Warning",
message: result.reason ?? "Hook returned warning",
variant: "warning",
duration: 4000,
},
})
.catch(() => {})
}
if (result.warnings && result.warnings.length > 0) {
output.output = `${output.output}\n\n${result.warnings.join("\n")}`
}
if (result.message) {
output.output = `${output.output}\n\n${result.message}`
}
if (result.hookName) {
ctx.client.tui
.showToast({
body: {
title: "PostToolUse Hook Executed",
message: `${result.toolName ?? input.tool} ${result.hookName}: ${result.elapsedMs ?? 0}ms`,
variant: "success",
duration: 2000,
},
})
.catch(() => {})
}
}
},
event: async (input: { event: { type: string; properties?: unknown } }) => {
const { event } = input
if (event.type === "session.error") {
const props = event.properties as Record<string, unknown> | undefined
const sessionID = props?.sessionID as string | undefined
if (sessionID) {
sessionErrorState.set(sessionID, {
hasError: true,
errorMessage: String(props?.error ?? "Unknown error"),
})
}
return
}
if (event.type === "session.deleted") {
const props = event.properties as Record<string, unknown> | undefined
const sessionInfo = props?.info as { id?: string } | undefined
if (sessionInfo?.id) {
sessionErrorState.delete(sessionInfo.id)
sessionInterruptState.delete(sessionInfo.id)
sessionFirstMessageProcessed.delete(sessionInfo.id)
}
return
}
if (event.type === "session.idle") {
const props = event.properties as Record<string, unknown> | undefined
const sessionID = props?.sessionID as string | undefined
if (!sessionID) return
const claudeConfig = await loadClaudeHooksConfig()
const extendedConfig = await loadPluginExtendedConfig()
const errorStateBefore = sessionErrorState.get(sessionID)
const endedWithErrorBefore = errorStateBefore?.hasError === true
const interruptStateBefore = sessionInterruptState.get(sessionID)
const interruptedBefore = interruptStateBefore?.interrupted === true
let parentSessionId: string | undefined
try {
const sessionInfo = await ctx.client.session.get({
path: { id: sessionID },
})
parentSessionId = sessionInfo.data?.parentID
} catch {}
if (!isHookDisabled(config, "Stop")) {
const stopCtx: StopContext = {
sessionId: sessionID,
parentSessionId,
cwd: ctx.directory,
}
const stopResult = await executeStopHooks(stopCtx, claudeConfig, extendedConfig)
const errorStateAfter = sessionErrorState.get(sessionID)
const endedWithErrorAfter = errorStateAfter?.hasError === true
const interruptStateAfter = sessionInterruptState.get(sessionID)
const interruptedAfter = interruptStateAfter?.interrupted === true
const shouldBypass = endedWithErrorBefore || endedWithErrorAfter || interruptedBefore || interruptedAfter
if (shouldBypass && stopResult.block) {
const interrupted = interruptedBefore || interruptedAfter
const endedWithError = endedWithErrorBefore || endedWithErrorAfter
log("Stop hook block ignored", { sessionID, block: stopResult.block, interrupted, endedWithError })
} else if (stopResult.block && stopResult.injectPrompt) {
log("Stop hook returned block with inject_prompt", { sessionID })
ctx.client.session
.prompt({
path: { id: sessionID },
body: { parts: [{ type: "text", text: stopResult.injectPrompt }] },
query: { directory: ctx.directory },
})
.catch((err: unknown) => log("Failed to inject prompt from Stop hook", err))
} else if (stopResult.block) {
log("Stop hook returned block", { sessionID, reason: stopResult.reason })
}
}
sessionErrorState.delete(sessionID)
sessionInterruptState.delete(sessionID)
}
},
}
}

View File

@@ -0,0 +1,9 @@
/**
* Plugin configuration for Claude Code hooks execution
* Contains settings for hook command execution (zsh, etc.)
*/
export const DEFAULT_CONFIG = {
forceZsh: true,
zshPath: "/bin/zsh",
}

View File

@@ -0,0 +1,199 @@
import type {
PostToolUseInput,
PostToolUseOutput,
ClaudeHooksConfig,
} from "./types"
import { findMatchingHooks, executeHookCommand, objectToSnakeCase, transformToolName, log } from "../../shared"
import { DEFAULT_CONFIG } from "./plugin-config"
import { buildTranscriptFromSession, deleteTempTranscript } from "./transcript"
import { isHookCommandDisabled, type PluginExtendedConfig } from "./config-loader"
export interface PostToolUseClient {
session: {
messages: (opts: { path: { id: string }; query?: { directory: string } }) => Promise<unknown>
}
}
export interface PostToolUseContext {
sessionId: string
toolName: string
toolInput: Record<string, unknown>
toolOutput: Record<string, unknown>
cwd: string
transcriptPath?: string // Fallback for append-based transcript
toolUseId?: string
client?: PostToolUseClient
permissionMode?: "default" | "plan" | "acceptEdits" | "bypassPermissions"
}
export interface PostToolUseResult {
block: boolean
reason?: string
message?: string
warnings?: string[]
elapsedMs?: number
hookName?: string
toolName?: string
additionalContext?: string
continue?: boolean
stopReason?: string
suppressOutput?: boolean
systemMessage?: string
}
export async function executePostToolUseHooks(
ctx: PostToolUseContext,
config: ClaudeHooksConfig | null,
extendedConfig?: PluginExtendedConfig | null
): Promise<PostToolUseResult> {
if (!config) {
return { block: false }
}
const transformedToolName = transformToolName(ctx.toolName)
const matchers = findMatchingHooks(config, "PostToolUse", transformedToolName)
if (matchers.length === 0) {
return { block: false }
}
// PORT FROM DISABLED: Build Claude Code compatible transcript (temp file)
let tempTranscriptPath: string | null = null
try {
// Try to build full transcript from API if client available
if (ctx.client) {
tempTranscriptPath = await buildTranscriptFromSession(
ctx.client,
ctx.sessionId,
ctx.cwd,
ctx.toolName,
ctx.toolInput
)
}
const stdinData: PostToolUseInput = {
session_id: ctx.sessionId,
// Use temp transcript if available, otherwise fallback to append-based
transcript_path: tempTranscriptPath ?? ctx.transcriptPath,
cwd: ctx.cwd,
permission_mode: ctx.permissionMode ?? "bypassPermissions",
hook_event_name: "PostToolUse",
tool_name: transformedToolName,
tool_input: objectToSnakeCase(ctx.toolInput),
tool_response: objectToSnakeCase(ctx.toolOutput),
tool_use_id: ctx.toolUseId,
hook_source: "opencode-plugin",
}
const messages: string[] = []
const warnings: string[] = []
let firstHookName: string | undefined
const startTime = Date.now()
for (const matcher of matchers) {
for (const hook of matcher.hooks) {
if (hook.type !== "command") continue
if (isHookCommandDisabled("PostToolUse", hook.command, extendedConfig ?? null)) {
log("PostToolUse hook command skipped (disabled by config)", { command: hook.command, toolName: ctx.toolName })
continue
}
const hookName = hook.command.split("/").pop() || hook.command
if (!firstHookName) firstHookName = hookName
const result = await executeHookCommand(
hook.command,
JSON.stringify(stdinData),
ctx.cwd,
{ forceZsh: DEFAULT_CONFIG.forceZsh, zshPath: DEFAULT_CONFIG.zshPath }
)
if (result.stdout) {
messages.push(result.stdout)
}
if (result.exitCode === 2) {
if (result.stderr) {
warnings.push(`[${hookName}]\n${result.stderr.trim()}`)
}
continue
}
if (result.exitCode === 0 && result.stdout) {
try {
const output = JSON.parse(result.stdout) as PostToolUseOutput
if (output.decision === "block") {
return {
block: true,
reason: output.reason || result.stderr,
message: messages.join("\n"),
warnings: warnings.length > 0 ? warnings : undefined,
elapsedMs: Date.now() - startTime,
hookName: firstHookName,
toolName: transformedToolName,
additionalContext: output.hookSpecificOutput?.additionalContext,
continue: output.continue,
stopReason: output.stopReason,
suppressOutput: output.suppressOutput,
systemMessage: output.systemMessage,
}
}
if (output.hookSpecificOutput?.additionalContext || output.continue !== undefined || output.systemMessage || output.suppressOutput === true || output.stopReason !== undefined) {
return {
block: false,
message: messages.join("\n"),
warnings: warnings.length > 0 ? warnings : undefined,
elapsedMs: Date.now() - startTime,
hookName: firstHookName,
toolName: transformedToolName,
additionalContext: output.hookSpecificOutput?.additionalContext,
continue: output.continue,
stopReason: output.stopReason,
suppressOutput: output.suppressOutput,
systemMessage: output.systemMessage,
}
}
} catch {
}
} else if (result.exitCode !== 0 && result.exitCode !== 2) {
try {
const output = JSON.parse(result.stdout || "{}") as PostToolUseOutput
if (output.decision === "block") {
return {
block: true,
reason: output.reason || result.stderr,
message: messages.join("\n"),
warnings: warnings.length > 0 ? warnings : undefined,
elapsedMs: Date.now() - startTime,
hookName: firstHookName,
toolName: transformedToolName,
additionalContext: output.hookSpecificOutput?.additionalContext,
continue: output.continue,
stopReason: output.stopReason,
suppressOutput: output.suppressOutput,
systemMessage: output.systemMessage,
}
}
} catch {
}
}
}
}
const elapsedMs = Date.now() - startTime
return {
block: false,
message: messages.length > 0 ? messages.join("\n") : undefined,
warnings: warnings.length > 0 ? warnings : undefined,
elapsedMs,
hookName: firstHookName,
toolName: transformedToolName,
}
} finally {
// PORT FROM DISABLED: Cleanup temp file to avoid disk accumulation
deleteTempTranscript(tempTranscriptPath)
}
}

View File

@@ -0,0 +1,172 @@
import type {
PreToolUseInput,
PreToolUseOutput,
PermissionDecision,
ClaudeHooksConfig,
} from "./types"
import { findMatchingHooks, executeHookCommand, objectToSnakeCase, transformToolName, log } from "../../shared"
import { DEFAULT_CONFIG } from "./plugin-config"
import { isHookCommandDisabled, type PluginExtendedConfig } from "./config-loader"
export interface PreToolUseContext {
sessionId: string
toolName: string
toolInput: Record<string, unknown>
cwd: string
transcriptPath?: string
toolUseId?: string
permissionMode?: "default" | "plan" | "acceptEdits" | "bypassPermissions"
}
export interface PreToolUseResult {
decision: PermissionDecision
reason?: string
modifiedInput?: Record<string, unknown>
elapsedMs?: number
hookName?: string
toolName?: string
inputLines?: string
// Common output fields (Claude Code spec)
continue?: boolean
stopReason?: string
suppressOutput?: boolean
systemMessage?: string
}
function buildInputLines(toolInput: Record<string, unknown>): string {
return Object.entries(toolInput)
.slice(0, 3)
.map(([key, val]) => {
const valStr = String(val).slice(0, 40)
return ` ${key}: ${valStr}${String(val).length > 40 ? "..." : ""}`
})
.join("\n")
}
export async function executePreToolUseHooks(
ctx: PreToolUseContext,
config: ClaudeHooksConfig | null,
extendedConfig?: PluginExtendedConfig | null
): Promise<PreToolUseResult> {
if (!config) {
return { decision: "allow" }
}
const transformedToolName = transformToolName(ctx.toolName)
const matchers = findMatchingHooks(config, "PreToolUse", transformedToolName)
if (matchers.length === 0) {
return { decision: "allow" }
}
const stdinData: PreToolUseInput = {
session_id: ctx.sessionId,
transcript_path: ctx.transcriptPath,
cwd: ctx.cwd,
permission_mode: ctx.permissionMode ?? "bypassPermissions",
hook_event_name: "PreToolUse",
tool_name: transformedToolName,
tool_input: objectToSnakeCase(ctx.toolInput),
tool_use_id: ctx.toolUseId,
hook_source: "opencode-plugin",
}
const startTime = Date.now()
let firstHookName: string | undefined
const inputLines = buildInputLines(ctx.toolInput)
for (const matcher of matchers) {
for (const hook of matcher.hooks) {
if (hook.type !== "command") continue
if (isHookCommandDisabled("PreToolUse", hook.command, extendedConfig ?? null)) {
log("PreToolUse hook command skipped (disabled by config)", { command: hook.command, toolName: ctx.toolName })
continue
}
const hookName = hook.command.split("/").pop() || hook.command
if (!firstHookName) firstHookName = hookName
const result = await executeHookCommand(
hook.command,
JSON.stringify(stdinData),
ctx.cwd,
{ forceZsh: DEFAULT_CONFIG.forceZsh, zshPath: DEFAULT_CONFIG.zshPath }
)
if (result.exitCode === 2) {
return {
decision: "deny",
reason: result.stderr || result.stdout || "Hook blocked the operation",
elapsedMs: Date.now() - startTime,
hookName: firstHookName,
toolName: transformedToolName,
inputLines,
}
}
if (result.exitCode === 1) {
return {
decision: "ask",
reason: result.stderr || result.stdout,
elapsedMs: Date.now() - startTime,
hookName: firstHookName,
toolName: transformedToolName,
inputLines,
}
}
if (result.stdout) {
try {
const output = JSON.parse(result.stdout) as PreToolUseOutput
// Handle deprecated decision/reason fields (Claude Code backward compat)
let decision: PermissionDecision | undefined
let reason: string | undefined
let modifiedInput: Record<string, unknown> | undefined
if (output.hookSpecificOutput?.permissionDecision) {
decision = output.hookSpecificOutput.permissionDecision
reason = output.hookSpecificOutput.permissionDecisionReason
modifiedInput = output.hookSpecificOutput.updatedInput
} else if (output.decision) {
// Map deprecated values: approve->allow, block->deny, ask->ask
const legacyDecision = output.decision
if (legacyDecision === "approve" || legacyDecision === "allow") {
decision = "allow"
} else if (legacyDecision === "block" || legacyDecision === "deny") {
decision = "deny"
} else if (legacyDecision === "ask") {
decision = "ask"
}
reason = output.reason
}
// Return if decision is set OR if any common fields are set (fallback to allow)
const hasCommonFields = output.continue !== undefined ||
output.stopReason !== undefined ||
output.suppressOutput !== undefined ||
output.systemMessage !== undefined
if (decision || hasCommonFields) {
return {
decision: decision ?? "allow",
reason,
modifiedInput,
elapsedMs: Date.now() - startTime,
hookName: firstHookName,
toolName: transformedToolName,
inputLines,
continue: output.continue,
stopReason: output.stopReason,
suppressOutput: output.suppressOutput,
systemMessage: output.systemMessage,
}
}
} catch {
}
}
}
}
return { decision: "allow" }
}

View File

@@ -0,0 +1,118 @@
import type {
StopInput,
StopOutput,
ClaudeHooksConfig,
} from "./types"
import { findMatchingHooks, executeHookCommand, log } from "../../shared"
import { DEFAULT_CONFIG } from "./plugin-config"
import { getTodoPath } from "./todo"
import { isHookCommandDisabled, type PluginExtendedConfig } from "./config-loader"
// Module-level state to track stop_hook_active per session
const stopHookActiveState = new Map<string, boolean>()
export function setStopHookActive(sessionId: string, active: boolean): void {
stopHookActiveState.set(sessionId, active)
}
export function getStopHookActive(sessionId: string): boolean {
return stopHookActiveState.get(sessionId) ?? false
}
export interface StopContext {
sessionId: string
parentSessionId?: string
cwd: string
transcriptPath?: string
permissionMode?: "default" | "acceptEdits" | "bypassPermissions"
stopHookActive?: boolean
}
export interface StopResult {
block: boolean
reason?: string
stopHookActive?: boolean
permissionMode?: "default" | "plan" | "acceptEdits" | "bypassPermissions"
injectPrompt?: string
}
export async function executeStopHooks(
ctx: StopContext,
config: ClaudeHooksConfig | null,
extendedConfig?: PluginExtendedConfig | null
): Promise<StopResult> {
if (ctx.parentSessionId) {
return { block: false }
}
if (!config) {
return { block: false }
}
const matchers = findMatchingHooks(config, "Stop")
if (matchers.length === 0) {
return { block: false }
}
const stdinData: StopInput = {
session_id: ctx.sessionId,
transcript_path: ctx.transcriptPath,
cwd: ctx.cwd,
permission_mode: ctx.permissionMode ?? "bypassPermissions",
hook_event_name: "Stop",
stop_hook_active: stopHookActiveState.get(ctx.sessionId) ?? false,
todo_path: getTodoPath(ctx.sessionId),
hook_source: "opencode-plugin",
}
for (const matcher of matchers) {
for (const hook of matcher.hooks) {
if (hook.type !== "command") continue
if (isHookCommandDisabled("Stop", hook.command, extendedConfig ?? null)) {
log("Stop hook command skipped (disabled by config)", { command: hook.command })
continue
}
const result = await executeHookCommand(
hook.command,
JSON.stringify(stdinData),
ctx.cwd,
{ forceZsh: DEFAULT_CONFIG.forceZsh, zshPath: DEFAULT_CONFIG.zshPath }
)
// Check exit code first - exit code 2 means block
if (result.exitCode === 2) {
const reason = result.stderr || result.stdout || "Blocked by stop hook"
return {
block: true,
reason,
injectPrompt: reason,
}
}
if (result.stdout) {
try {
const output = JSON.parse(result.stdout) as StopOutput
if (output.stop_hook_active !== undefined) {
stopHookActiveState.set(ctx.sessionId, output.stop_hook_active)
}
const isBlock = output.decision === "block"
// Determine inject_prompt: prefer explicit value, fallback to reason if blocking
const injectPrompt = output.inject_prompt ?? (isBlock && output.reason ? output.reason : undefined)
return {
block: isBlock,
reason: output.reason,
stopHookActive: output.stop_hook_active,
permissionMode: output.permission_mode,
injectPrompt,
}
} catch {
// Ignore JSON parse errors - hook may return non-JSON output
}
}
}
}
return { block: false }
}

View File

@@ -0,0 +1,76 @@
import { join } from "path"
import { mkdirSync, writeFileSync, readFileSync, existsSync, unlinkSync } from "fs"
import { homedir } from "os"
import type { TodoFile, TodoItem, ClaudeCodeTodoItem } from "./types"
const TODO_DIR = join(homedir(), ".claude", "todos")
export function getTodoPath(sessionId: string): string {
return join(TODO_DIR, `${sessionId}-agent-${sessionId}.json`)
}
function ensureTodoDir(): void {
if (!existsSync(TODO_DIR)) {
mkdirSync(TODO_DIR, { recursive: true })
}
}
export interface OpenCodeTodo {
content: string
status: string
priority: string
id: string
}
function toClaudeCodeFormat(item: OpenCodeTodo | TodoItem): ClaudeCodeTodoItem {
return {
content: item.content,
status: item.status === "cancelled" ? "completed" : item.status,
activeForm: item.content,
}
}
export function loadTodoFile(sessionId: string): TodoFile | null {
const path = getTodoPath(sessionId)
if (!existsSync(path)) return null
try {
const content = JSON.parse(readFileSync(path, "utf-8"))
if (Array.isArray(content)) {
return {
session_id: sessionId,
items: content.map((item: ClaudeCodeTodoItem, idx: number) => ({
id: String(idx),
content: item.content,
status: item.status as TodoItem["status"],
created_at: new Date().toISOString(),
})),
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
}
}
return content
} catch {
return null
}
}
export function saveTodoFile(sessionId: string, file: TodoFile): void {
ensureTodoDir()
const path = getTodoPath(sessionId)
const claudeCodeFormat: ClaudeCodeTodoItem[] = file.items.map(toClaudeCodeFormat)
writeFileSync(path, JSON.stringify(claudeCodeFormat, null, 2))
}
export function saveOpenCodeTodos(sessionId: string, todos: OpenCodeTodo[]): void {
ensureTodoDir()
const path = getTodoPath(sessionId)
const claudeCodeFormat: ClaudeCodeTodoItem[] = todos.map(toClaudeCodeFormat)
writeFileSync(path, JSON.stringify(claudeCodeFormat, null, 2))
}
export function deleteTodoFile(sessionId: string): void {
const path = getTodoPath(sessionId)
if (existsSync(path)) {
unlinkSync(path)
}
}

View File

@@ -0,0 +1,47 @@
/**
* Caches tool_input from PreToolUse for PostToolUse
*/
interface CacheEntry {
toolInput: Record<string, unknown>
timestamp: number
}
const cache = new Map<string, CacheEntry>()
const CACHE_TTL = 60000 // 1 minute
export function cacheToolInput(
sessionId: string,
toolName: string,
invocationId: string,
toolInput: Record<string, unknown>
): void {
const key = `${sessionId}:${toolName}:${invocationId}`
cache.set(key, { toolInput, timestamp: Date.now() })
}
export function getToolInput(
sessionId: string,
toolName: string,
invocationId: string
): Record<string, unknown> | null {
const key = `${sessionId}:${toolName}:${invocationId}`
const entry = cache.get(key)
if (!entry) return null
cache.delete(key)
if (Date.now() - entry.timestamp > CACHE_TTL) return null
return entry.toolInput
}
// Periodic cleanup (every minute)
setInterval(() => {
const now = Date.now()
for (const [key, entry] of cache.entries()) {
if (now - entry.timestamp > CACHE_TTL) {
cache.delete(key)
}
}
}, CACHE_TTL)

View File

@@ -0,0 +1,255 @@
/**
* Transcript Manager
* Creates and manages Claude Code compatible transcript files
*/
import { join } from "path"
import { mkdirSync, appendFileSync, existsSync, writeFileSync, unlinkSync } from "fs"
import { homedir, tmpdir } from "os"
import { randomUUID } from "crypto"
import type { TranscriptEntry } from "./types"
import { transformToolName } from "../../shared/tool-name"
const TRANSCRIPT_DIR = join(homedir(), ".claude", "transcripts")
export function getTranscriptPath(sessionId: string): string {
return join(TRANSCRIPT_DIR, `${sessionId}.jsonl`)
}
function ensureTranscriptDir(): void {
if (!existsSync(TRANSCRIPT_DIR)) {
mkdirSync(TRANSCRIPT_DIR, { recursive: true })
}
}
export function appendTranscriptEntry(
sessionId: string,
entry: TranscriptEntry
): void {
ensureTranscriptDir()
const path = getTranscriptPath(sessionId)
const line = JSON.stringify(entry) + "\n"
appendFileSync(path, line)
}
export function recordToolUse(
sessionId: string,
toolName: string,
toolInput: Record<string, unknown>
): void {
appendTranscriptEntry(sessionId, {
type: "tool_use",
timestamp: new Date().toISOString(),
tool_name: toolName,
tool_input: toolInput,
})
}
export function recordToolResult(
sessionId: string,
toolName: string,
toolInput: Record<string, unknown>,
toolOutput: Record<string, unknown>
): void {
appendTranscriptEntry(sessionId, {
type: "tool_result",
timestamp: new Date().toISOString(),
tool_name: toolName,
tool_input: toolInput,
tool_output: toolOutput,
})
}
export function recordUserMessage(
sessionId: string,
content: string
): void {
appendTranscriptEntry(sessionId, {
type: "user",
timestamp: new Date().toISOString(),
content,
})
}
export function recordAssistantMessage(
sessionId: string,
content: string
): void {
appendTranscriptEntry(sessionId, {
type: "assistant",
timestamp: new Date().toISOString(),
content,
})
}
// ============================================================================
// Claude Code Compatible Transcript Builder (PORT FROM DISABLED)
// ============================================================================
/**
* OpenCode API response type (loosely typed)
*/
interface OpenCodeMessagePart {
type: string
tool?: string
state?: {
status?: string
input?: Record<string, unknown>
}
}
interface OpenCodeMessage {
info?: {
role?: string
}
parts?: OpenCodeMessagePart[]
}
/**
* Claude Code compatible transcript entry (from disabled file)
*/
interface DisabledTranscriptEntry {
type: "assistant"
message: {
role: "assistant"
content: Array<{
type: "tool_use"
name: string
input: Record<string, unknown>
}>
}
}
/**
* Build Claude Code compatible transcript from session messages
*
* PORT FROM DISABLED: This calls client.session.messages() API to fetch
* the full session history and builds a JSONL file in Claude Code format.
*
* @param client OpenCode client instance
* @param sessionId Session ID
* @param directory Working directory
* @param currentToolName Current tool being executed (added as last entry)
* @param currentToolInput Current tool input
* @returns Temp file path (caller must call deleteTempTranscript!)
*/
export async function buildTranscriptFromSession(
client: {
session: {
messages: (opts: { path: { id: string }; query?: { directory: string } }) => Promise<unknown>
}
},
sessionId: string,
directory: string,
currentToolName: string,
currentToolInput: Record<string, unknown>
): Promise<string | null> {
try {
const response = await client.session.messages({
path: { id: sessionId },
query: { directory },
})
// Handle various response formats
const messages = (response as { "200"?: unknown[]; data?: unknown[] })["200"]
?? (response as { data?: unknown[] }).data
?? (Array.isArray(response) ? response : [])
const entries: string[] = []
if (Array.isArray(messages)) {
for (const msg of messages as OpenCodeMessage[]) {
if (msg.info?.role !== "assistant") continue
for (const part of msg.parts || []) {
if (part.type !== "tool") continue
if (part.state?.status !== "completed") continue
if (!part.state?.input) continue
const rawToolName = part.tool as string
const toolName = transformToolName(rawToolName)
const entry: DisabledTranscriptEntry = {
type: "assistant",
message: {
role: "assistant",
content: [
{
type: "tool_use",
name: toolName,
input: part.state.input,
},
],
},
}
entries.push(JSON.stringify(entry))
}
}
}
// Always add current tool call as the last entry
const currentEntry: DisabledTranscriptEntry = {
type: "assistant",
message: {
role: "assistant",
content: [
{
type: "tool_use",
name: transformToolName(currentToolName),
input: currentToolInput,
},
],
},
}
entries.push(JSON.stringify(currentEntry))
// Write to temp file
const tempPath = join(
tmpdir(),
`opencode-transcript-${sessionId}-${randomUUID()}.jsonl`
)
writeFileSync(tempPath, entries.join("\n") + "\n")
return tempPath
} catch {
// CRITICAL FIX: Even on API failure, create file with current tool entry only
// (matching original disabled behavior - never return null with incompatible format)
try {
const currentEntry: DisabledTranscriptEntry = {
type: "assistant",
message: {
role: "assistant",
content: [
{
type: "tool_use",
name: transformToolName(currentToolName),
input: currentToolInput,
},
],
},
}
const tempPath = join(
tmpdir(),
`opencode-transcript-${sessionId}-${randomUUID()}.jsonl`
)
writeFileSync(tempPath, JSON.stringify(currentEntry) + "\n")
return tempPath
} catch {
// If even this fails, return null (truly catastrophic failure)
return null
}
}
}
/**
* Delete temp transcript file (call in finally block)
*
* PORT FROM DISABLED: Cleanup mechanism to avoid disk accumulation
*/
export function deleteTempTranscript(path: string | null): void {
if (!path) return
try {
unlinkSync(path)
} catch {
// Ignore deletion errors
}
}

View File

@@ -0,0 +1,184 @@
/**
* Claude Code Hooks Type Definitions
* Maps Claude Code hook concepts to OpenCode plugin events
*/
export type ClaudeHookEvent =
| "PreToolUse"
| "PostToolUse"
| "UserPromptSubmit"
| "Stop"
export interface HookMatcher {
matcher: string
hooks: HookCommand[]
}
export interface HookCommand {
type: "command"
command: string
}
export interface ClaudeHooksConfig {
PreToolUse?: HookMatcher[]
PostToolUse?: HookMatcher[]
UserPromptSubmit?: HookMatcher[]
Stop?: HookMatcher[]
}
export interface PreToolUseInput {
session_id: string
transcript_path?: string
cwd: string
permission_mode?: PermissionMode
hook_event_name: "PreToolUse"
tool_name: string
tool_input: Record<string, unknown>
tool_use_id?: string
hook_source?: HookSource
}
export interface PostToolUseInput {
session_id: string
transcript_path?: string
cwd: string
permission_mode?: PermissionMode
hook_event_name: "PostToolUse"
tool_name: string
tool_input: Record<string, unknown>
tool_response: {
title?: string
output?: string
[key: string]: unknown
}
tool_use_id?: string
hook_source?: HookSource
}
export interface UserPromptSubmitInput {
session_id: string
cwd: string
permission_mode?: PermissionMode
hook_event_name: "UserPromptSubmit"
prompt: string
session?: {
id: string
}
hook_source?: HookSource
}
export type PermissionMode = "default" | "plan" | "acceptEdits" | "bypassPermissions"
export type HookSource = "opencode-plugin"
export interface StopInput {
session_id: string
transcript_path?: string
cwd: string
permission_mode?: PermissionMode
hook_event_name: "Stop"
stop_hook_active: boolean
todo_path?: string
hook_source?: HookSource
}
export type PermissionDecision = "allow" | "deny" | "ask"
/**
* Common JSON fields for all hook outputs (Claude Code spec)
*/
export interface HookCommonOutput {
/** If false, Claude stops entirely */
continue?: boolean
/** Message shown to user when continue=false */
stopReason?: string
/** Suppress output from transcript */
suppressOutput?: boolean
/** Warning/message displayed to user */
systemMessage?: string
}
export interface PreToolUseOutput extends HookCommonOutput {
/** Deprecated: use hookSpecificOutput.permissionDecision instead */
decision?: "allow" | "deny" | "approve" | "block" | "ask"
/** Deprecated: use hookSpecificOutput.permissionDecisionReason instead */
reason?: string
hookSpecificOutput?: {
hookEventName: "PreToolUse"
permissionDecision: PermissionDecision
permissionDecisionReason?: string
updatedInput?: Record<string, unknown>
}
}
export interface PostToolUseOutput extends HookCommonOutput {
decision?: "block"
reason?: string
hookSpecificOutput?: {
hookEventName: "PostToolUse"
/** Additional context to provide to Claude */
additionalContext?: string
}
}
export interface HookResult {
exitCode: number
stdout?: string
stderr?: string
}
export interface TranscriptEntry {
type: "tool_use" | "tool_result" | "user" | "assistant"
timestamp: string
tool_name?: string
tool_input?: Record<string, unknown>
tool_output?: Record<string, unknown>
content?: string
}
export interface TodoItem {
id: string
content: string
status: "pending" | "in_progress" | "completed" | "cancelled"
priority?: "low" | "medium" | "high"
created_at: string
updated_at?: string
}
export interface ClaudeCodeTodoItem {
content: string
status: string // "pending" | "in_progress" | "completed"
activeForm: string
}
export interface TodoFile {
session_id: string
items: TodoItem[]
created_at: string
updated_at: string
}
export interface StopOutput {
decision?: "block" | "continue"
reason?: string
stop_hook_active?: boolean
permission_mode?: PermissionMode
inject_prompt?: string
}
export type ClaudeCodeContent =
| { type: "text"; text: string }
| { type: "tool_use"; id: string; name: string; input: Record<string, unknown> }
| { type: "tool_result"; tool_use_id: string; content: string }
export interface ClaudeCodeMessage {
type: "user" | "assistant"
message: {
role: "user" | "assistant"
content: ClaudeCodeContent[]
}
}
export interface PluginConfig {
disabledHooks?: boolean | ClaudeHookEvent[]
}

View File

@@ -0,0 +1,117 @@
import type {
UserPromptSubmitInput,
PostToolUseOutput,
ClaudeHooksConfig,
} from "./types"
import { findMatchingHooks, executeHookCommand, log } from "../../shared"
import { DEFAULT_CONFIG } from "./plugin-config"
import { isHookCommandDisabled, type PluginExtendedConfig } from "./config-loader"
const USER_PROMPT_SUBMIT_TAG_OPEN = "<user-prompt-submit-hook>"
const USER_PROMPT_SUBMIT_TAG_CLOSE = "</user-prompt-submit-hook>"
export interface MessagePart {
type: "text" | "tool_use" | "tool_result"
text?: string
[key: string]: unknown
}
export interface UserPromptSubmitContext {
sessionId: string
parentSessionId?: string
prompt: string
parts: MessagePart[]
cwd: string
permissionMode?: "default" | "acceptEdits" | "bypassPermissions"
}
export interface UserPromptSubmitResult {
block: boolean
reason?: string
modifiedParts: MessagePart[]
messages: string[]
}
export async function executeUserPromptSubmitHooks(
ctx: UserPromptSubmitContext,
config: ClaudeHooksConfig | null,
extendedConfig?: PluginExtendedConfig | null
): Promise<UserPromptSubmitResult> {
const modifiedParts = ctx.parts
const messages: string[] = []
if (ctx.parentSessionId) {
return { block: false, modifiedParts, messages }
}
if (
ctx.prompt.includes(USER_PROMPT_SUBMIT_TAG_OPEN) &&
ctx.prompt.includes(USER_PROMPT_SUBMIT_TAG_CLOSE)
) {
return { block: false, modifiedParts, messages }
}
if (!config) {
return { block: false, modifiedParts, messages }
}
const matchers = findMatchingHooks(config, "UserPromptSubmit")
if (matchers.length === 0) {
return { block: false, modifiedParts, messages }
}
const stdinData: UserPromptSubmitInput = {
session_id: ctx.sessionId,
cwd: ctx.cwd,
permission_mode: ctx.permissionMode ?? "bypassPermissions",
hook_event_name: "UserPromptSubmit",
prompt: ctx.prompt,
session: { id: ctx.sessionId },
hook_source: "opencode-plugin",
}
for (const matcher of matchers) {
for (const hook of matcher.hooks) {
if (hook.type !== "command") continue
if (isHookCommandDisabled("UserPromptSubmit", hook.command, extendedConfig ?? null)) {
log("UserPromptSubmit hook command skipped (disabled by config)", { command: hook.command })
continue
}
const result = await executeHookCommand(
hook.command,
JSON.stringify(stdinData),
ctx.cwd,
{ forceZsh: DEFAULT_CONFIG.forceZsh, zshPath: DEFAULT_CONFIG.zshPath }
)
if (result.stdout) {
const output = result.stdout.trim()
if (output.startsWith(USER_PROMPT_SUBMIT_TAG_OPEN)) {
messages.push(output)
} else {
messages.push(`${USER_PROMPT_SUBMIT_TAG_OPEN}\n${output}\n${USER_PROMPT_SUBMIT_TAG_CLOSE}`)
}
}
if (result.exitCode !== 0) {
try {
const output = JSON.parse(result.stdout || "{}") as PostToolUseOutput
if (output.decision === "block") {
return {
block: true,
reason: output.reason || result.stderr,
modifiedParts,
messages,
}
}
} catch {
// Ignore JSON parse errors
}
}
}
}
return { block: false, modifiedParts, messages }
}

View File

@@ -7,16 +7,8 @@ const CONTEXT_WARNING_THRESHOLD = 0.70
const CONTEXT_REMINDER = `[SYSTEM REMINDER - 1M Context Window]
You are using Anthropic Claude with 1M context window.
Current usage has exceeded 75%.
RECOMMENDATIONS:
- Consider compacting the session if available
- Break complex tasks into smaller, focused sessions
- Be concise in your responses
- Avoid redundant file reads
You have access to 1M tokens - use them wisely. Do NOT rush or skip tasks.
Complete your work thoroughly despite the context usage warning.`
You have plenty of context remaining - do NOT rush or skip tasks.
Complete your work thoroughly and methodically.`
interface AssistantMessageInfo {
role: "assistant"

View File

@@ -8,3 +8,4 @@ export { createDirectoryAgentsInjectorHook } from "./directory-agents-injector";
export { createEmptyTaskResponseDetectorHook } from "./empty-task-response-detector";
export { createAnthropicAutoCompactHook } from "./anthropic-auto-compact";
export { createThinkModeHook } from "./think-mode";
export { createClaudeCodeHooksHook } from "./claude-code-hooks";

View File

@@ -2,6 +2,7 @@ import type { PluginInput } from "@opencode-ai/plugin"
import type { createOpencodeClient } from "@opencode-ai/sdk"
import {
findEmptyMessages,
findEmptyMessageByIndex,
findMessagesWithOrphanThinking,
findMessagesWithThinkingBlocks,
injectTextPart,
@@ -54,6 +55,12 @@ function getErrorMessage(error: unknown): string {
return (errorObj.data?.message || errorObj.error?.message || errorObj.message || "").toLowerCase()
}
function extractMessageIndex(error: unknown): number | null {
const message = getErrorMessage(error)
const match = message.match(/messages\.(\d+)/)
return match ? parseInt(match[1], 10) : null
}
function detectErrorType(error: unknown): RecoveryErrorType {
const message = getErrorMessage(error)
@@ -161,16 +168,26 @@ async function recoverEmptyContentMessage(
_client: Client,
sessionID: string,
failedAssistantMsg: MessageData,
_directory: string
_directory: string,
error: unknown
): Promise<boolean> {
const emptyMessageIDs = findEmptyMessages(sessionID)
const targetIndex = extractMessageIndex(error)
const failedID = failedAssistantMsg.info?.id
if (emptyMessageIDs.length === 0) {
const fallbackID = failedAssistantMsg.info?.id
if (!fallbackID) return false
return injectTextPart(sessionID, fallbackID, "(interrupted)")
if (targetIndex !== null) {
const targetMessageID = findEmptyMessageByIndex(sessionID, targetIndex)
if (targetMessageID) {
return injectTextPart(sessionID, targetMessageID, "(interrupted)")
}
}
if (failedID) {
if (injectTextPart(sessionID, failedID, "(interrupted)")) {
return true
}
}
const emptyMessageIDs = findEmptyMessages(sessionID)
let anySuccess = false
for (const messageID of emptyMessageIDs) {
if (injectTextPart(sessionID, messageID, "(interrupted)")) {
@@ -262,15 +279,16 @@ export function createSessionRecoveryHook(ctx: PluginInput) {
} else if (errorType === "thinking_disabled_violation") {
success = await recoverThinkingDisabledViolation(ctx.client, sessionID, failedMsg)
} else if (errorType === "empty_content_message") {
success = await recoverEmptyContentMessage(ctx.client, sessionID, failedMsg, ctx.directory)
success = await recoverEmptyContentMessage(ctx.client, sessionID, failedMsg, ctx.directory, info.error)
}
return success
} catch {
return false
} finally {
processingErrors.delete(assistantMsgID)
}
return success
} catch (err) {
console.error("[session-recovery] Recovery failed:", err)
return false
} finally {
processingErrors.delete(assistantMsgID)
}
}
return {

View File

@@ -42,7 +42,12 @@ export function readMessages(sessionID: string): StoredMessageMeta[] {
}
}
return messages.sort((a, b) => a.id.localeCompare(b.id))
return messages.sort((a, b) => {
const aTime = a.time?.created ?? 0
const bTime = b.time?.created ?? 0
if (aTime !== bTime) return aTime - bTime
return a.id.localeCompare(b.id)
})
}
export function readParts(messageID: string): StoredPart[] {
@@ -117,13 +122,9 @@ export function findEmptyMessages(sessionID: string): string[] {
const messages = readMessages(sessionID)
const emptyIds: string[] = []
for (let i = 0; i < messages.length; i++) {
const msg = messages[i]
for (const msg of messages) {
if (msg.role !== "assistant") continue
const isLastMessage = i === messages.length - 1
if (isLastMessage) continue
if (!messageHasContent(msg.id)) {
emptyIds.push(msg.id)
}
@@ -132,6 +133,18 @@ export function findEmptyMessages(sessionID: string): string[] {
return emptyIds
}
export function findEmptyMessageByIndex(sessionID: string, targetIndex: number): string | null {
const messages = readMessages(sessionID)
if (targetIndex < 0 || targetIndex >= messages.length) return null
const targetMsg = messages[targetIndex]
if (targetMsg.role !== "assistant") return null
if (messageHasContent(targetMsg.id)) return null
return targetMsg.id
}
export function findFirstEmptyMessage(sessionID: string): string | null {
const emptyIds = findEmptyMessages(sessionID)
return emptyIds.length > 0 ? emptyIds[0] : null

View File

@@ -36,6 +36,7 @@ export function createTodoContinuationEnforcer(ctx: PluginInput) {
const remindedSessions = new Set<string>()
const interruptedSessions = new Set<string>()
const errorSessions = new Set<string>()
const pendingTimers = new Map<string, ReturnType<typeof setTimeout>>()
return async ({ event }: { event: { type: string; properties?: unknown } }) => {
const props = event.properties as Record<string, unknown> | undefined
@@ -47,6 +48,13 @@ export function createTodoContinuationEnforcer(ctx: PluginInput) {
if (detectInterrupt(props?.error)) {
interruptedSessions.add(sessionID)
}
// Cancel pending continuation if error occurs
const timer = pendingTimers.get(sessionID)
if (timer) {
clearTimeout(timer)
pendingTimers.delete(sessionID)
}
}
return
}
@@ -55,68 +63,78 @@ export function createTodoContinuationEnforcer(ctx: PluginInput) {
const sessionID = props?.sessionID as string | undefined
if (!sessionID) return
// Wait for potential session.error events to be processed first
await new Promise(resolve => setTimeout(resolve, 150))
const shouldBypass = interruptedSessions.has(sessionID) || errorSessions.has(sessionID)
interruptedSessions.delete(sessionID)
errorSessions.delete(sessionID)
if (shouldBypass) {
return
// Cancel any existing timer to debounce
const existingTimer = pendingTimers.get(sessionID)
if (existingTimer) {
clearTimeout(existingTimer)
}
if (remindedSessions.has(sessionID)) {
return
}
// Schedule continuation check
const timer = setTimeout(async () => {
pendingTimers.delete(sessionID)
let todos: Todo[] = []
try {
const response = await ctx.client.session.todo({
path: { id: sessionID },
})
todos = (response.data ?? response) as Todo[]
} catch {
return
}
const shouldBypass = interruptedSessions.has(sessionID) || errorSessions.has(sessionID)
interruptedSessions.delete(sessionID)
errorSessions.delete(sessionID)
if (!todos || todos.length === 0) {
return
}
if (shouldBypass) {
return
}
const incomplete = todos.filter(
(t) => t.status !== "completed" && t.status !== "cancelled"
)
if (remindedSessions.has(sessionID)) {
return
}
if (incomplete.length === 0) {
return
}
let todos: Todo[] = []
try {
const response = await ctx.client.session.todo({
path: { id: sessionID },
})
todos = (response.data ?? response) as Todo[]
} catch {
return
}
remindedSessions.add(sessionID)
if (!todos || todos.length === 0) {
return
}
// Re-check if abort occurred during the delay
if (interruptedSessions.has(sessionID) || errorSessions.has(sessionID)) {
remindedSessions.delete(sessionID)
return
}
const incomplete = todos.filter(
(t) => t.status !== "completed" && t.status !== "cancelled"
)
try {
await ctx.client.session.prompt({
path: { id: sessionID },
body: {
parts: [
{
type: "text",
text: `${CONTINUATION_PROMPT}\n\n[Status: ${todos.length - incomplete.length}/${todos.length} completed, ${incomplete.length} remaining]`,
},
],
},
query: { directory: ctx.directory },
})
} catch {
remindedSessions.delete(sessionID)
}
if (incomplete.length === 0) {
return
}
remindedSessions.add(sessionID)
// Re-check if abort occurred during the delay/fetch
if (interruptedSessions.has(sessionID) || errorSessions.has(sessionID)) {
remindedSessions.delete(sessionID)
return
}
try {
await ctx.client.session.prompt({
path: { id: sessionID },
body: {
parts: [
{
type: "text",
text: `${CONTINUATION_PROMPT}\n\n[Status: ${todos.length - incomplete.length}/${todos.length} completed, ${incomplete.length} remaining]`,
},
],
},
query: { directory: ctx.directory },
})
} catch {
remindedSessions.delete(sessionID)
}
}, 200)
pendingTimers.set(sessionID, timer)
}
if (event.type === "message.updated") {
@@ -124,6 +142,13 @@ export function createTodoContinuationEnforcer(ctx: PluginInput) {
const sessionID = info?.sessionID as string | undefined
if (sessionID && info?.role === "user") {
remindedSessions.delete(sessionID)
// Cancel pending continuation on user interaction
const timer = pendingTimers.get(sessionID)
if (timer) {
clearTimeout(timer)
pendingTimers.delete(sessionID)
}
}
}
@@ -133,6 +158,13 @@ export function createTodoContinuationEnforcer(ctx: PluginInput) {
remindedSessions.delete(sessionInfo.id)
interruptedSessions.delete(sessionInfo.id)
errorSessions.delete(sessionInfo.id)
// Cancel pending continuation
const timer = pendingTimers.get(sessionInfo.id)
if (timer) {
clearTimeout(timer)
pendingTimers.delete(sessionInfo.id)
}
}
}
}

View File

@@ -9,6 +9,8 @@ import {
createDirectoryAgentsInjectorHook,
createEmptyTaskResponseDetectorHook,
createThinkModeHook,
createClaudeCodeHooksHook,
createAnthropicAutoCompactHook,
} from "./hooks";
import {
loadUserCommands,
@@ -68,6 +70,8 @@ function loadPluginConfig(directory: string): OhMyOpenCodeConfig {
}
const OhMyOpenCodePlugin: Plugin = async (ctx) => {
const pluginConfig = loadPluginConfig(ctx.directory);
const todoContinuationEnforcer = createTodoContinuationEnforcer(ctx);
const contextWindowMonitor = createContextWindowMonitorHook(ctx);
const sessionRecovery = createSessionRecoveryHook(ctx);
@@ -76,14 +80,18 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
const directoryAgentsInjector = createDirectoryAgentsInjectorHook(ctx);
const emptyTaskResponseDetector = createEmptyTaskResponseDetectorHook(ctx);
const thinkMode = createThinkModeHook();
const claudeCodeHooks = createClaudeCodeHooksHook(ctx, {});
const anthropicAutoCompact = createAnthropicAutoCompactHook(ctx);
updateTerminalTitle({ sessionId: "main" });
const pluginConfig = loadPluginConfig(ctx.directory);
return {
tool: builtinTools,
"chat.message": async (input, output) => {
await claudeCodeHooks["chat.message"]?.(input, output)
},
config: async (config) => {
const builtinAgents = createBuiltinAgents(
pluginConfig.disabled_agents,
@@ -93,10 +101,10 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
const projectAgents = loadProjectAgents();
config.agent = {
...config.agent,
...builtinAgents,
...userAgents,
...projectAgents,
...config.agent,
};
config.tools = {
...config.tools,
@@ -129,10 +137,12 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
},
event: async (input) => {
await claudeCodeHooks.event(input);
await todoContinuationEnforcer(input);
await contextWindowMonitor.event(input);
await directoryAgentsInjector.event(input);
await thinkMode.event(input);
await anthropicAutoCompact.event(input);
const { event } = input;
const props = event.properties as Record<string, unknown> | undefined;
@@ -229,6 +239,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
},
"tool.execute.before": async (input, output) => {
await claudeCodeHooks["tool.execute.before"](input, output);
await commentChecker["tool.execute.before"](input, output);
if (input.sessionID === getMainSessionID()) {
@@ -243,6 +254,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
},
"tool.execute.after": async (input, output) => {
await claudeCodeHooks["tool.execute.after"](input, output);
await grepOutputTruncator["tool.execute.after"](input, output);
await contextWindowMonitor["tool.execute.after"](input, output);
await commentChecker["tool.execute.after"](input, output);

View File

@@ -0,0 +1,22 @@
import type { ClaudeHookEvent, PluginConfig } from "../hooks/claude-code-hooks/types"
export function isHookDisabled(
config: PluginConfig,
hookType: ClaudeHookEvent
): boolean {
const { disabledHooks } = config
if (disabledHooks === undefined) {
return false
}
if (disabledHooks === true) {
return true
}
if (Array.isArray(disabledHooks)) {
return disabledHooks.includes(hookType)
}
return false
}

View File

@@ -3,3 +3,7 @@ export * from "./command-executor"
export * from "./file-reference-resolver"
export * from "./model-sanitizer"
export * from "./logger"
export * from "./snake-case"
export * from "./tool-name"
export * from "./pattern-matcher"
export * from "./hook-disabled"

View File

@@ -0,0 +1,29 @@
import type { ClaudeHooksConfig, HookMatcher } from "../hooks/claude-code-hooks/types"
export function matchesToolMatcher(toolName: string, matcher: string): boolean {
if (!matcher) {
return true
}
const patterns = matcher.split("|").map((p) => p.trim())
return patterns.some((p) => {
if (p.includes("*")) {
const regex = new RegExp(`^${p.replace(/\*/g, ".*")}$`, "i")
return regex.test(toolName)
}
return p.toLowerCase() === toolName.toLowerCase()
})
}
export function findMatchingHooks(
config: ClaudeHooksConfig,
eventName: keyof ClaudeHooksConfig,
toolName?: string
): HookMatcher[] {
const hookMatchers = config[eventName]
if (!hookMatchers) return []
return hookMatchers.filter((hookMatcher) => {
if (!toolName) return true
return matchesToolMatcher(toolName, hookMatcher.matcher)
})
}

51
src/shared/snake-case.ts Normal file
View File

@@ -0,0 +1,51 @@
export function camelToSnake(str: string): string {
return str.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`)
}
export function snakeToCamel(str: string): string {
return str.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase())
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
export function objectToSnakeCase(
obj: Record<string, unknown>,
deep: boolean = true
): Record<string, unknown> {
const result: Record<string, unknown> = {}
for (const [key, value] of Object.entries(obj)) {
const snakeKey = camelToSnake(key)
if (deep && isPlainObject(value)) {
result[snakeKey] = objectToSnakeCase(value, true)
} else if (deep && Array.isArray(value)) {
result[snakeKey] = value.map((item) =>
isPlainObject(item) ? objectToSnakeCase(item, true) : item
)
} else {
result[snakeKey] = value
}
}
return result
}
export function objectToCamelCase(
obj: Record<string, unknown>,
deep: boolean = true
): Record<string, unknown> {
const result: Record<string, unknown> = {}
for (const [key, value] of Object.entries(obj)) {
const camelKey = snakeToCamel(key)
if (deep && isPlainObject(value)) {
result[camelKey] = objectToCamelCase(value, true)
} else if (deep && Array.isArray(value)) {
result[camelKey] = value.map((item) =>
isPlainObject(item) ? objectToCamelCase(item, true) : item
)
} else {
result[camelKey] = value
}
}
return result
}

26
src/shared/tool-name.ts Normal file
View File

@@ -0,0 +1,26 @@
const SPECIAL_TOOL_MAPPINGS: Record<string, string> = {
webfetch: "WebFetch",
websearch: "WebSearch",
todoread: "TodoRead",
todowrite: "TodoWrite",
}
function toPascalCase(str: string): string {
return str
.split(/[-_\s]+/)
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join("")
}
export function transformToolName(toolName: string): string {
const lower = toolName.toLowerCase()
if (lower in SPECIAL_TOOL_MAPPINGS) {
return SPECIAL_TOOL_MAPPINGS[lower]
}
if (toolName.includes("-") || toolName.includes("_")) {
return toPascalCase(toolName)
}
return toolName.charAt(0).toUpperCase() + toolName.slice(1)
}

View File

@@ -1,4 +0,0 @@
## Root Level Rules
- Root rule 1
- Root rule 2

View File

@@ -1,3 +0,0 @@
export const config = {
strict: true
}

View File

@@ -1,4 +0,0 @@
## Nested Level Rules
- Nested rule 1 (더 specific)
- Nested rule 2

View File

@@ -1 +0,0 @@
export const deep = true

View File

@@ -1,3 +0,0 @@
export function greet(name: string): string {
return `Hello, ${name}!`
}

View File

@@ -1,6 +0,0 @@
id: test-rule
message: Test rule
severity: info
language: JavaScript
rule:
pattern: console

View File

@@ -1 +0,0 @@
console.log("hello")