Compare commits

..

2 Commits

Author SHA1 Message Date
github-actions[bot]
f0193a7e45 release: v0.2.0 2025-12-09 12:48:32 +00:00
github-actions[bot]
bedbc621fa release: v0.1.32 2025-12-09 08:00:31 +00:00
29 changed files with 206 additions and 2635 deletions

View File

@@ -15,7 +15,6 @@
- [내장 AST-Grep Tools](#내장-ast-grep-tools)
- [Grep](#grep)
- [내장 MCPs](#내장-mcps)
- [Claude Code 호환성](#claude-code-호환성)
- [기타 편의 기능](#기타-편의-기능)
- [설정](#설정)
- [작성자의 노트](#작성자의-노트)
@@ -225,72 +224,17 @@ 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를 지원합니다.
- **Session State**: 이벤트 훅과 터미널 타이틀 업데이트에 사용되는 중앙집중식 세션 추적 모듈입니다.
- **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/`
## 설정

View File

@@ -15,7 +15,6 @@ 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)
@@ -222,72 +221,29 @@ 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.
- **Session State**: Centralized session tracking module used by event hooks and terminal title updates.
- **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`.
## Configuration

View File

@@ -0,0 +1,162 @@
# 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

@@ -0,0 +1,12 @@
#!/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

@@ -396,450 +396,3 @@ All tasks execution STARTED: Thu Dec 4 16:52:57 KST 2025
---
## [2025-12-09 17:24] - Task 0: Shared Utilities 포팅
### DISCOVERED ISSUES
- command-executor.ts already existed but had minor whitespace differences (indentation inconsistency)
- pattern-matcher.ts and hook-disabled.ts import from `../claude-compat/types` which doesn't exist yet in oh-my-opencode
- Types will be created in Task 1 at `src/hooks/claude-code-hooks/types.ts`
### IMPLEMENTATION DECISIONS
- Created snake-case.ts and tool-name.ts (no dependencies) - exact copy from source
- Created temporary stub types at `src/hooks/claude-code-hooks/types.ts` with minimal definitions needed for shared utilities
- Created pattern-matcher.ts with adjusted import: `../claude-compat/types``../hooks/claude-code-hooks/types`
- Created hook-disabled.ts with adjusted import to point to stub types
- Added all new utilities to `src/shared/index.ts` using barrel export pattern
- Stub types include: HookCommand, HookMatcher, ClaudeHooksConfig, ClaudeHookEvent, PluginConfig
### PROBLEMS FOR NEXT TASKS
- Task 1 will replace stub types with full implementation from opencode-cc-plugin
- Stub types in `src/hooks/claude-code-hooks/types.ts` are marked with comments indicating they're temporary
- The real PluginConfig will likely be different - current stub only supports `disabledHooks` field
### VERIFICATION RESULTS
- Ran: `bun run typecheck` → exit 0, no errors
- All 5 functions exported: executeHookCommand, objectToSnakeCase, transformToolName, findMatchingHooks, isHookDisabled
- Import paths verified: pattern-matcher.ts and hook-disabled.ts successfully import from stub types
### LEARNINGS
- Import paths must be adjusted when porting between different project structures
- opencode-cc-plugin structure: `src/claude-compat/` → oh-my-opencode structure: `src/hooks/claude-code-hooks/`
- Stub types strategy allows Task 0 to complete and typecheck to pass before Task 1 implements full types
- command-executor.ts in oh-my-opencode had indentation inconsistency (not 100% identical to source)
소요 시간: ~5분
---
## [2025-12-09 17:34] - Task 1: types.ts 포팅
### DISCOVERED ISSUES
- Stub types.ts had `PluginConfig` interface needed by hook-disabled.ts (from Task 0)
- Full types.ts from opencode-cc-plugin did NOT have `PluginConfig`
- Typecheck initially failed: Module has no exported member 'PluginConfig'
### IMPLEMENTATION DECISIONS
- Copied full types.ts (181 lines) from opencode-cc-plugin → oh-my-opencode
- Preserved ALL types: ClaudeHooksConfig, HookMatcher, PreToolUseInput/Output, PostToolUseInput/Output
- Preserved deprecated decision fields: `decision?: "allow" | "deny" | "approve" | "block" | "ask"`
- Added `PluginConfig` interface at end (oh-my-opencode specific type needed by hook-disabled.ts)
- Kept line 150 comment (`// "pending" | "in_progress" | "completed"`) - existing source comment
### PROBLEMS FOR NEXT TASKS
- PluginConfig is now available for all subsequent tasks
- Full type definitions ready for Task 2, 3, 4+ to use
### VERIFICATION RESULTS
- Ran: `bun run typecheck` → exit 0, no errors
- Verified: ClaudeHooksConfig, HookMatcher, HookCommand types exist
- Verified: PreToolUseInput/Output, PostToolUseInput/Output types exist
- Verified: deprecated decision field (approve/block) included in PreToolUseOutput
- Verified: PluginConfig export added (fixes hook-disabled.ts import)
### LEARNINGS
- opencode-cc-plugin types.ts: 181 lines, no PluginConfig
- oh-my-opencode requires PluginConfig for hook disabling functionality
- Stub-to-full replacement pattern works: stub allows Task 0 typecheck, Task 1 replaces with full implementation
- Must preserve project-specific types (PluginConfig) when porting from different codebases
소요 시간: ~2분
---
## [2025-12-09 17:39] - Task 3: tool-input-cache.ts 포팅
### DISCOVERED ISSUES
- None - straightforward file copy task
### IMPLEMENTATION DECISIONS
- Copied tool-input-cache.ts (48 lines) from opencode-cc-plugin → oh-my-opencode
- Preserved cache structure:
* Key format: `${sessionId}:${toolName}:${invocationId}`
* TTL: 60000ms (1 minute) as CACHE_TTL constant
* Periodic cleanup: setInterval every CACHE_TTL (60000ms)
- Preserved original comments from source file (lines 12, 39)
- Functions: cacheToolInput(), getToolInput()
- Cache behavior: getToolInput() deletes entry immediately after retrieval (single-use cache)
### PROBLEMS FOR NEXT TASKS
- Task 4 (pre-tool-use.ts) will call cacheToolInput() to store tool inputs
- Task 5 (post-tool-use.ts) will call getToolInput() to retrieve cached inputs for transcript building
- No import path changes needed - this file has no external dependencies
### VERIFICATION RESULTS
- File created: `src/hooks/claude-code-hooks/tool-input-cache.ts` (48 lines)
- Functions exported: cacheToolInput(), getToolInput()
- TTL verified: CACHE_TTL = 60000 (1 minute)
- Cleanup interval verified: setInterval(cleanup, CACHE_TTL)
### LEARNINGS
- Tool input cache is a temporary storage for PreToolUse → PostToolUse communication
- Single-use pattern: getToolInput() deletes entry after first retrieval (line 33)
- TTL check happens after deletion, so expired entries still return null
- setInterval runs in background for periodic cleanup of abandoned entries
- Source location: `~/local-workspaces/opencode-cc-plugin/src/claude-compat/hooks/tool-input-cache.ts`
소요 시간: ~2분
---
## [2025-12-09 17:39] - Task 2: config.ts + transcript.ts + todo.ts 포팅
### DISCOVERED ISSUES
- transcript.ts had unused imports (ClaudeCodeMessage, ClaudeCodeContent) - same as source file
- LSP warned about unused types - removed from import to clean up
### IMPLEMENTATION DECISIONS
- Copied config.ts (101 lines) - no import path changes needed (only uses `./types` and Node.js builtins)
- Copied transcript.ts (256 lines) - changed import path:
* Line 10: `../shared/tool-name``../../shared/tool-name` (opencode-cc-plugin depth 1, oh-my-opencode depth 2)
- Copied todo.ts (78 lines) - no import path changes needed (only uses `./types` and Node.js builtins)
- Removed unused imports from transcript.ts: ClaudeCodeMessage, ClaudeCodeContent (not used in function bodies)
- Preserved ALL original comments from source files - these are pre-existing comments
### PROBLEMS FOR NEXT TASKS
- Task 3 will import cacheToolInput/getToolInput for cache functionality
- Task 4 will import loadClaudeHooksConfig, buildTranscriptFromSession
- Task 5 will import transcript building functions for PostToolUse hook
### VERIFICATION RESULTS
- Ran: `bun run typecheck` → exit 0, no errors
- Files created: config.ts (101 lines), transcript.ts (256 lines), todo.ts (78 lines)
- Functions available: loadClaudeHooksConfig(), buildTranscriptFromSession(), appendTranscriptEntry(), loadTodoFile(), saveTodoFile()
- Import paths verified: transcript.ts successfully imports transformToolName from ../../shared
### LEARNINGS
- Import path depth difference: opencode-cc-plugin `src/claude-compat/` (1 level up) → oh-my-opencode `src/hooks/claude-code-hooks/` (2 levels up)
- transcript.ts unused imports were present in original source - cleaning them is optional but improves code hygiene
- config.ts uses Bun.file() for async file reading - compatible with oh-my-opencode's Bun runtime
- Bun.file().text() automatically handles encoding
소요 시간: ~3분
---
## [2025-12-09 17:48] - Task 4: pre-tool-use.ts 포팅 (+ plugin-config.ts, config-loader.ts)
### DISCOVERED ISSUES
- pre-tool-use.ts depends on DEFAULT_CONFIG and isHookCommandDisabled which weren't created yet
- Plan document listed plugin-config.ts and config-loader.ts as separate task (Section 4), but not mentioned in Task 4 instructions
- These dependency files needed to be created before pre-tool-use.ts could compile
### IMPLEMENTATION DECISIONS
- Created plugin-config.ts (9 lines) with DEFAULT_CONFIG containing forceZsh and zshPath settings
* Minimal version - only fields used by pre-tool-use.ts (not full opencode-cc-plugin config)
* forceZsh: true, zshPath: "/bin/zsh"
- Created config-loader.ts (105 lines) - full copy from opencode-cc-plugin
* Changed import: `../claude-compat/types``./types`
* Changed import: `../shared/logger``../../shared/logger`
* Functions: loadPluginExtendedConfig(), isHookCommandDisabled()
* Supports regex patterns for disabling specific hook commands
- Created pre-tool-use.ts (172 lines) - full copy with adjusted imports:
* `../types``./types`
* `../../shared``../../shared` (unchanged)
* `../../config``./plugin-config` (NEW file)
* `../../config-loader``./config-loader` (NEW file)
- Preserved ALL exit code logic:
* exitCode === 2 → decision = "deny"
* exitCode === 1 → decision = "ask"
* exitCode === 0 → parse JSON for decision
- Preserved ALL deprecated field support:
* decision: "approve" → "allow"
* decision: "block" → "deny"
- Original comments from source preserved (backward compat, spec references)
### PROBLEMS FOR NEXT TASKS
- Task 5 (post-tool-use.ts) can now import executePreToolUseHooks if needed
- plugin-config.ts and config-loader.ts are now available for all subsequent hook implementations
- isHookCommandDisabled pattern can be reused in PostToolUse, UserPromptSubmit, Stop hooks
### VERIFICATION RESULTS
- Ran: `bun run typecheck` → exit 0, no errors
- Ran: `bun run build` → exit 0, successful
- Committed: 530c4d6 "feat(hooks): add PreToolUse hook executor"
* 4 files: tool-input-cache.ts (Task 3), plugin-config.ts, config-loader.ts, pre-tool-use.ts (Task 4)
* 333 insertions total
- Functions available: executePreToolUseHooks(), isHookCommandDisabled(), loadPluginExtendedConfig()
- Exit code mapping verified: lines 96-116 check exitCode === 2/1/0
- Deprecated field mapping verified: lines 132-141 check decision === "approve"/"block"
### LEARNINGS
- Pre-tool-use.ts depends on plugin configuration that wasn't part of oh-my-opencode's original structure
- plugin-config.ts only needs subset of opencode-cc-plugin's config.ts (forceZsh, zshPath for executeHookCommand)
- config-loader.ts provides hook command filtering via regex patterns (disabledHooks config)
- executeHookCommand from shared/ accepts ExecuteHookOptions{ forceZsh, zshPath } parameter
- Task 3 + Task 4 grouped in single commit per plan requirement
- Source: `/Users/yeongyu/local-workspaces/opencode-cc-plugin/src/claude-compat/hooks/pre-tool-use.ts` (173 lines)
소요 시간: ~5분
---
## [2025-12-09 17:52] - Task 5: post-tool-use.ts 포팅
### DISCOVERED ISSUES
- None - straightforward file copy with import path adjustments
### IMPLEMENTATION DECISIONS
- Copied post-tool-use.ts (200 lines) from opencode-cc-plugin → oh-my-opencode
- Import path adjustments:
* `../types``./types`
* `../../shared``../../shared` (unchanged)
* `../../config``./plugin-config`
* `../transcript``./transcript`
* `../../config-loader``./config-loader`
- Preserved ALL transcript logic:
* buildTranscriptFromSession() call with client.session.messages() API
* Temp file creation in try block
* deleteTempTranscript() cleanup in finally block
- Preserved ALL exit code handling:
* exitCode === 2 → warning (continue)
* exitCode === 0 → parse JSON for decision: "block"
* Non-zero, non-2 → parse JSON for decision: "block"
- Preserved ALL output fields: block, reason, message, warnings, elapsedMs, additionalContext, continue, stopReason, suppressOutput, systemMessage
- Original comments from source preserved (PORT FROM DISABLED, cleanup explanation)
### PROBLEMS FOR NEXT TASKS
- Task 6 (user-prompt-submit.ts, stop.ts) can use similar pattern for hook execution
- plugin-config.ts, config-loader.ts, transcript.ts dependencies already in place
### VERIFICATION RESULTS
- Ran: `bun run typecheck` → exit 0, no errors
- Ran: `bun run build` → exit 0, successful
- File created: `src/hooks/claude-code-hooks/post-tool-use.ts` (200 lines)
- Functions available: executePostToolUseHooks()
- Transcript integration verified: buildTranscriptFromSession() imported from ./transcript
- Cleanup mechanism verified: deleteTempTranscript() in finally block (line 196)
### LEARNINGS
- PostToolUse differs from PreToolUse: no permission decision (allow/deny/ask), only block/continue
- PostToolUse provides hook results via message/warnings/additionalContext (observability, not control)
- Exit code 2 in PostToolUse = warning (not block), collected in warnings array
- Transcript temp file pattern: create in try, cleanup in finally (prevents disk accumulation)
- Source: `/Users/yeongyu/local-workspaces/opencode-cc-plugin/src/claude-compat/hooks/post-tool-use.ts` (200 lines)
소요 시간: ~5분
---
## [2025-12-09 17:58] - Task 6: user-prompt-submit.ts + stop.ts 포팅
### DISCOVERED ISSUES
- None - straightforward file copy with import path adjustments
### IMPLEMENTATION DECISIONS
- Copied user-prompt-submit.ts (118 lines) from opencode-cc-plugin → oh-my-opencode
- Copied stop.ts (119 lines) from opencode-cc-plugin → oh-my-opencode
- Import path adjustments (both files):
* `../types``./types`
* `../../shared``../../shared` (unchanged)
* `../../config``./plugin-config`
* `../../config-loader``./config-loader`
* `../todo``./todo` (stop.ts only)
- Preserved recursion prevention logic in user-prompt-submit.ts:
* Tags: `<user-prompt-submit-hook>` (open/close)
* Check if prompt already contains tags → return early
* Wrap hook stdout with tags to prevent infinite recursion
- Preserved inject_prompt support:
* user-prompt-submit: messages array collection for injection
* stop: injectPrompt field in result (from output.inject_prompt or output.reason)
- Preserved stopHookActiveState management in stop.ts:
* Module-level Map<string, boolean> for per-session state
* setStopHookActive(), getStopHookActive() exported
* State persists across hook invocations
- Preserved exit code handling:
* stop.ts: exitCode === 2 → block with reason
* user-prompt-submit.ts: exitCode !== 0 → check JSON for decision: "block"
### PROBLEMS FOR NEXT TASKS
- Task 7 (hook-message-injector) will use the message injection pattern
- Task 8 (Factory + Integration) will wire these hooks to OpenCode lifecycle events
### VERIFICATION RESULTS
- Ran: `bun run typecheck` → exit 0, no errors
- Ran: `bun run build` → exit 0, successful
- Files created:
* `src/hooks/claude-code-hooks/user-prompt-submit.ts` (115 lines)
* `src/hooks/claude-code-hooks/stop.ts` (119 lines)
- Functions available:
* executeUserPromptSubmitHooks() with UserPromptSubmitContext → UserPromptSubmitResult
* executeStopHooks() with StopContext → StopResult
* setStopHookActive(), getStopHookActive()
- Recursion prevention verified: lines 47-52 check for tag presence
- inject_prompt field verified: stop.ts line 102 sets injectPrompt from output
### LEARNINGS
- user-prompt-submit uses tag wrapping pattern to prevent infinite hook loops
- stop hook can inject prompts into session via injectPrompt result field
- stopHookActiveState Map persists across hook invocations (module-level state)
- getTodoPath() from ./todo provides todo file path for Stop hook context
- Source files:
* `/Users/yeongyu/local-workspaces/opencode-cc-plugin/src/claude-compat/hooks/user-prompt-submit.ts` (118 lines)
* `/Users/yeongyu/local-workspaces/opencode-cc-plugin/src/claude-compat/hooks/stop.ts` (119 lines)
소요 시간: ~3분
---
## [2025-12-09 17:58] - Task 7: hook-message-injector 포팅
### DISCOVERED ISSUES
- None - straightforward file copy task
### IMPLEMENTATION DECISIONS
- Created `src/features/hook-message-injector/` directory
- Copied 4 files from opencode-cc-plugin → oh-my-opencode:
* constants.ts (9 lines): XDG-based path definitions (MESSAGE_STORAGE, PART_STORAGE)
* types.ts (46 lines): MessageMeta, OriginalMessageContext, TextPart interfaces
* injector.ts (142 lines): injectHookMessage() implementation with message/part storage
* index.ts (3 lines): Barrel export
- No import path changes needed - module is self-contained
- Preserved XDG_DATA_HOME environment variable support
- Preserved message fallback logic: finds nearest message with agent/model/tools if not provided
### PROBLEMS FOR NEXT TASKS
- Task 8 (Factory + Integration) will import injectHookMessage from this module
- Hook executors (user-prompt-submit, stop) can use injectHookMessage to store hook messages
### VERIFICATION RESULTS
- Ran: `bun run typecheck` → exit 0, no errors
- Files created: src/features/hook-message-injector/ (4 files)
- Functions exported: injectHookMessage()
- Types exported: MessageMeta, OriginalMessageContext, TextPart
- Constants exported: MESSAGE_STORAGE, PART_STORAGE (XDG-based paths)
### LEARNINGS
- Message injector uses XDG_DATA_HOME for storage (~/.local/share/opencode/storage/)
- Message storage structure: sessionID → messageID.json (meta) + partID.json (content)
- Fallback logic: searches recent messages for agent/model/tools if originalMessage is incomplete
- Part-based storage allows incremental message building
- Source: `/Users/yeongyu/local-workspaces/opencode-cc-plugin/src/features/hook-message-injector/`
소요 시간: ~2분
---
## [2025-12-09 18:08] - Task 8: Factory 생성 + 통합
### DISCOVERED ISSUES
- None - final integration task with well-defined hook executors from previous tasks
### IMPLEMENTATION DECISIONS
- Created `src/hooks/claude-code-hooks/index.ts` (146 lines) with createClaudeCodeHooksHook() factory
- Factory returns hook handler object with 3 handlers:
* `tool.execute.before`: Executes executePreToolUseHooks()
- Loads config dynamically (async) on each invocation
- Maps OpenCode input → PreToolUseContext
- Caches tool input for PostToolUse
- Handles deny/ask decisions (deny throws error, ask logs warning)
* `tool.execute.after`: Executes executePostToolUseHooks()
- Retrieves cached tool input via getToolInput()
- Maps OpenCode input → PostToolUseContext with client wrapper
- Appends hook message to output if provided
- Throws error if block decision returned
* `event`: Executes executeStopHooks() for session.idle
- Filters event.type === "session.idle"
- Maps OpenCode event → StopContext
- Injects prompt via ctx.client.session.prompt() if injectPrompt returned
- Updated `src/hooks/index.ts`: Added createClaudeCodeHooksHook export
- Updated `src/index.ts`:
* Imported createClaudeCodeHooksHook
* Created claudeCodeHooks instance
* Registered handlers in tool.execute.before, tool.execute.after, event hooks
* Claude hooks run FIRST in execution order (before other hooks)
- Config loading: Async loadClaudeHooksConfig() and loadPluginExtendedConfig() called in each handler (not cached)
- Transcript path: Uses getTranscriptPath() function (not buildTranscriptPath which doesn't exist)
### PROBLEMS FOR NEXT TASKS
- None - this is the final task (Task 8)
- All Claude Code Hooks now integrated into oh-my-opencode plugin system
### VERIFICATION RESULTS
- Ran: `bun run typecheck` → exit 0, no errors
- Ran: `bun run build` → exit 0, successful build
- Files modified:
* src/hooks/claude-code-hooks/index.ts (created)
* src/hooks/index.ts (export added)
* src/index.ts (hook registration)
- Hook handler registration verified: claudeCodeHooks handlers called in all 3 hook points
- Execution order verified: Claude hooks run before existing hooks in tool.execute.*
### LEARNINGS
- OpenCode Plugin API: Factory pattern createXxxHook(ctx: PluginInput) → handlers object
- OpenCode does NOT have chat.params hook → UserPromptSubmit not implemented in factory
- Config loading must be async → call loadClaudeHooksConfig() in each handler, not once during initialization
- Tool input cache is module-level state → cacheToolInput/getToolInput work across handlers
- Stop hook only triggers on session.idle event → filter event.type
- Import path: getTranscriptPath (exists), not buildTranscriptPath (doesn't exist)
소요 시간: ~6분
---
## [2025-12-09 18:16] - hook-message-injector 사용 패턴 크로스체크
### DISCOVERED ISSUES
- **CRITICAL**: UserPromptSubmit hooks가 oh-my-opencode에 완전히 누락됨
- opencode-cc-plugin에서는 chat.message hook으로 UserPromptSubmit 처리
- oh-my-opencode에는 chat.message hook이 구현되지 않음
- user-prompt-submit.ts는 정의만 있고 실제 사용처 없음
### IMPLEMENTATION DECISIONS
- PostToolUse는 이미 올바르게 구현됨:
* opencode-cc-plugin: result.message를 tool output에 append
* oh-my-opencode: 동일한 방식 사용 (claude-code-hooks/index.ts:95-97)
* injectHookMessage() 불필요
- UserPromptSubmit 구현 추가:
* chat.message hook handler 추가 (claude-code-hooks/index.ts)
* executeUserPromptSubmitHooks() 호출
* sessionFirstMessageProcessed Set으로 첫 메시지 skip (title generation)
* result.messages가 있으면 injectHookMessage() 호출
* src/index.ts에 hook 등록
- Import 추가:
* executeUserPromptSubmitHooks, UserPromptSubmitContext, MessagePart from ./user-prompt-submit
* injectHookMessage from ../../features/hook-message-injector
### PROBLEMS FOR NEXT TASKS
- None - UserPromptSubmit 통합 완료
### VERIFICATION RESULTS
- Ran: `bunx tsc --noEmit` → exit 0, no errors
- Ran: `bun run build` → exit 0, successful build
- Files modified:
* src/hooks/claude-code-hooks/index.ts (chat.message handler 추가)
* src/index.ts (chat.message hook 등록)
- Verified OpenCode Plugin API: chat.message hook 공식 지원 확인 (@opencode-ai/plugin/dist/index.d.ts:112-123)
### LEARNINGS
- OpenCode Plugin API에 chat.message hook 존재:
* input: sessionID, agent?, model?, messageID?
* output: message, parts[]
- PostToolUse는 tool output에 직접 append (injectHookMessage 불필요)
- UserPromptSubmit는 file system injection 사용 (injectHookMessage 필수)
- opencode-cc-plugin 구조: src/plugin/chat-handler.ts → handleChatMessage()
- oh-my-opencode 구조: src/hooks/claude-code-hooks/index.ts → createClaudeCodeHooksHook()
- 첫 메시지 skip 로직: title generation을 위해 UserPromptSubmit hooks 실행 안 함
소요 시간: ~8분
---

View File

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

View File

@@ -1,8 +0,0 @@
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

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

View File

@@ -1,141 +0,0 @@
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

@@ -1,45 +0,0 @@
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

@@ -1,105 +0,0 @@
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

@@ -1,100 +0,0 @@
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

@@ -1,336 +0,0 @@
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

@@ -1,9 +0,0 @@
/**
* 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

@@ -1,199 +0,0 @@
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

@@ -1,172 +0,0 @@
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

@@ -1,118 +0,0 @@
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

@@ -1,76 +0,0 @@
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

@@ -1,47 +0,0 @@
/**
* 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

@@ -1,255 +0,0 @@
/**
* 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

@@ -1,184 +0,0 @@
/**
* 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

@@ -1,117 +0,0 @@
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

@@ -8,4 +8,3 @@ 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

@@ -9,7 +9,6 @@ import {
createDirectoryAgentsInjectorHook,
createEmptyTaskResponseDetectorHook,
createThinkModeHook,
createClaudeCodeHooksHook,
} from "./hooks";
import {
loadUserCommands,
@@ -69,8 +68,6 @@ 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);
@@ -79,17 +76,14 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
const directoryAgentsInjector = createDirectoryAgentsInjectorHook(ctx);
const emptyTaskResponseDetector = createEmptyTaskResponseDetectorHook(ctx);
const thinkMode = createThinkModeHook();
const claudeCodeHooks = createClaudeCodeHooksHook(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,
@@ -99,10 +93,10 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
const projectAgents = loadProjectAgents();
config.agent = {
...config.agent,
...builtinAgents,
...userAgents,
...projectAgents,
...config.agent,
};
config.tools = {
...config.tools,
@@ -135,7 +129,6 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
},
event: async (input) => {
await claudeCodeHooks.event(input);
await todoContinuationEnforcer(input);
await contextWindowMonitor.event(input);
await directoryAgentsInjector.event(input);
@@ -236,7 +229,6 @@ 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()) {
@@ -251,7 +243,6 @@ 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

@@ -1,22 +0,0 @@
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,7 +3,3 @@ 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

@@ -1,29 +0,0 @@
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)
})
}

View File

@@ -1,51 +0,0 @@
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
}

View File

@@ -1,26 +0,0 @@
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)
}