Compare commits
81 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d23a81926 | ||
|
|
31cb8616c2 | ||
|
|
1932257f82 | ||
|
|
5a793bb526 | ||
|
|
2ec351d0d8 | ||
|
|
441fc1a219 | ||
|
|
bd67419d1d | ||
|
|
dca98121ac | ||
|
|
3fcfedcec0 | ||
|
|
530c4d63d5 | ||
|
|
e0b43380cc | ||
|
|
a27cac96d5 | ||
|
|
fef7f4ca03 | ||
|
|
e147be7ed4 | ||
|
|
124c3b3e8f | ||
|
|
5678e0bac6 | ||
|
|
207435450c | ||
|
|
376bf363af | ||
|
|
c7a65af475 | ||
|
|
8e7447deee | ||
|
|
15a748b817 | ||
|
|
c0e0dc1f95 | ||
|
|
7059407cbc | ||
|
|
589cf60252 | ||
|
|
e5cdaa5192 | ||
|
|
340eb30147 | ||
|
|
e72b927ccb | ||
|
|
3c6ffe5d9c | ||
|
|
938a3709e1 | ||
|
|
47f218e33f | ||
|
|
e07a25baa4 | ||
|
|
08ede0a28d | ||
|
|
a711d58289 | ||
|
|
431ec14991 | ||
|
|
62cae8114d | ||
|
|
e6eafe267a | ||
|
|
e4ef832405 | ||
|
|
ef6d67645e | ||
|
|
227d93f106 | ||
|
|
edff922afb | ||
|
|
45bdcf3580 | ||
|
|
b07dd22093 | ||
|
|
c7d29fea48 | ||
|
|
55675497a5 | ||
|
|
ae2d347d81 | ||
|
|
2683de825a | ||
|
|
0b5c8250ca | ||
|
|
66fcd8570b | ||
|
|
5cd3f0cbf2 | ||
|
|
9a9512b705 | ||
|
|
6ece7476ef | ||
|
|
9ed23d4037 | ||
|
|
79b791117a | ||
|
|
4e328a937c | ||
|
|
a500f0c9ad | ||
|
|
16806da615 | ||
|
|
c5f651c0a9 | ||
|
|
ed3d7a55f4 | ||
|
|
b77dd2fcdf | ||
|
|
64b3564760 | ||
|
|
0df7e9b10b | ||
|
|
aa35f2eab6 | ||
|
|
64db980803 | ||
|
|
b86346a79d | ||
|
|
4debb57402 | ||
|
|
a763db61cf | ||
|
|
341e5a959d | ||
|
|
bac304c035 | ||
|
|
1aaeefac0e | ||
|
|
dda7b4f56d | ||
|
|
a287e59262 | ||
|
|
80fe3ae612 | ||
|
|
b045f6918e | ||
|
|
725ec9b91d | ||
|
|
1f717a76be | ||
|
|
3bcb869a5d | ||
|
|
54e13e4330 | ||
|
|
1780e2971d | ||
|
|
ded97701b8 | ||
|
|
316cdc1a62 | ||
|
|
f19cd8fc71 |
110
README.ko.md
110
README.ko.md
@@ -13,8 +13,9 @@
|
||||
- [Tools](#tools)
|
||||
- [내장 LSP Tools](#내장-lsp-tools)
|
||||
- [내장 AST-Grep Tools](#내장-ast-grep-tools)
|
||||
- [Safe Grep](#safe-grep)
|
||||
- [Grep](#grep)
|
||||
- [내장 MCPs](#내장-mcps)
|
||||
- [Claude Code 호환성](#claude-code-호환성)
|
||||
- [기타 편의 기능](#기타-편의-기능)
|
||||
- [설정](#설정)
|
||||
- [작성자의 노트](#작성자의-노트)
|
||||
@@ -136,7 +137,27 @@ OpenCode 는 아주 확장가능하고 아주 커스터마이저블합니다.
|
||||
- **Context Window Monitor**: [컨텍스트 윈도우 불안 관리](https://agentic-patterns.com/patterns/context-window-anxiety-management/) 패턴을 구현합니다.
|
||||
- 사용량이 70%를 넘으면 에이전트에게 아직 토큰이 충분하다고 상기시켜, 급하게 불완전한 작업을 하는 것을 완화합니다.
|
||||
- **Session Notification**: 에이전트가 작업을 마치면 OS 네이티브 알림을 보냅니다 (macOS, Linux, Windows).
|
||||
- **Session Recovery**: API 에러로부터 자동으로 복구하여 세션 안정성을 보장합니다. 네 가지 시나리오를 처리합니다:
|
||||
- **Tool Result Missing**: `tool_use` 블록이 있지만 `tool_result`가 없을 때 (ESC 인터럽트) → "cancelled" tool result 주입
|
||||
- **Thinking Block Order**: thinking 블록이 첫 번째여야 하는데 아닐 때 → 빈 thinking 블록 추가
|
||||
- **Thinking Disabled Violation**: thinking 이 비활성화인데 thinking 블록이 있을 때 → thinking 블록 제거
|
||||
- **Empty Content Message**: 메시지가 thinking/meta 블록만 있고 실제 내용이 없을 때 → 파일시스템을 통해 "(interrupted)" 텍스트 주입
|
||||
- **Comment Checker**: 코드 수정 후 불필요한 주석을 감지하여 보고합니다. BDD 패턴, 지시어, 독스트링 등 유효한 주석은 똑똑하게 제외하고, AI가 남긴 흔적을 제거하여 코드를 깨끗하게 유지합니다.
|
||||
- **Directory AGENTS.md Injector**: 파일을 읽을 때 `AGENTS.md` 내용을 자동으로 주입합니다. 파일 디렉토리부터 프로젝트 루트까지 탐색하며, 경로 상의 **모든** `AGENTS.md` 파일을 수집합니다. 중첩된 디렉토리별 지침을 지원합니다:
|
||||
```
|
||||
project/
|
||||
├── AGENTS.md # 프로젝트 전체 컨텍스트
|
||||
├── src/
|
||||
│ ├── AGENTS.md # src 전용 컨텍스트
|
||||
│ └── components/
|
||||
│ ├── AGENTS.md # 컴포넌트 전용 컨텍스트
|
||||
│ └── Button.tsx # 이 파일을 읽으면 위 3개 AGENTS.md 모두 주입
|
||||
```
|
||||
`Button.tsx`를 읽으면 순서대로 주입됩니다: `project/AGENTS.md` → `src/AGENTS.md` → `components/AGENTS.md`. 각 디렉토리의 컨텍스트는 세션당 한 번만 주입됩니다. Claude Code의 CLAUDE.md 기능에서 영감을 받았습니다.
|
||||
- **Think Mode**: 확장된 사고(Extended Thinking)가 필요한 상황을 자동으로 감지하고 모드를 전환합니다. 사용자가 깊은 사고를 요청하는 표현(예: "think deeply", "ultrathink")을 감지하면, 추론 능력을 극대화하도록 모델 설정을 동적으로 조정합니다.
|
||||
- **Anthropic Auto Compact**: Anthropic 모델 사용 시 컨텍스트 한계에 도달하면 대화 기록을 자동으로 압축하여 효율적으로 관리합니다.
|
||||
- **Empty Task Response Detector**: 서브 에이전트가 수행한 작업이 비어있거나 무의미한 응답을 반환하는 경우를 감지하여, 오류 없이 우아하게 처리합니다.
|
||||
- **Grep Output Truncator**: Grep 검색 결과가 너무 길어 컨텍스트를 장악해버리는 것을 방지하기 위해, 과도한 출력을 자동으로 자릅니다.
|
||||
|
||||
### Agents
|
||||
|
||||
@@ -146,12 +167,22 @@ OpenCode 는 아주 확장가능하고 아주 커스터마이저블합니다.
|
||||
- **frontend-ui-ux-engineer** (`google/gemini-3-pro-preview`): 개발자로 전향한 디자이너라는 설정을 갖고 있습니다. 멋진 UI를 만듭니다. 아름답고 창의적인 UI 코드를 생성하는 데 탁월한 Gemini를 사용합니다.
|
||||
- **document-writer** (`google/gemini-3-pro-preview`): 기술 문서 전문가라는 설정을 갖고 있습니다. Gemini 는 문학가입니다. 글을 기가막히게 씁니다.
|
||||
|
||||
각 에이전트는 메인 에이전트가 알아서 호출하지만, 명시적으로 요청할 수도 있습니다:
|
||||
|
||||
```
|
||||
@oracle 한테 이 부분 설계 고민하고서 아키텍쳐 제안을 부탁해줘
|
||||
@librarian 한테 이 부분 어떻게 구현돼있길래 자꾸 안에서 동작이 바뀌는지 알려달라고 해줘
|
||||
@explore 한테 이 기능 정책 알려달라고 해줘
|
||||
```
|
||||
|
||||
에이전트의 모델, 프롬프트, 권한은 `oh-my-opencode.json`에서 커스텀할 수 있습니다. 자세한 내용은 [설정](#설정)을 참고하세요.
|
||||
|
||||
### Tools
|
||||
|
||||
#### 내장 LSP Tools
|
||||
|
||||
당신이 에디터에서 사용하는 그 기능을 다른 에이전트들은 사용하지 못합니다. Oh My OpenCode 는 당신만의 그 도구를 LLM Agent 에게 쥐어줍니다. 리팩토링하고, 탐색하고, 분석하는 모든 작업을 OpenCode 의 설정값을 그대로 사용하여 지원합니다.
|
||||
|
||||
[OpenCode 는 LSP 를 제공하지만](https://opencode.ai/docs/lsp/), 오로지 분석용으로만 제공합니다. 탐색과 리팩토링을 위한 도구는 OpenCode 와 동일한 스펙과 설정으로 Oh My OpenCode 가 제공합니다.
|
||||
|
||||
- **lsp_hover**: 위치의 타입 정보, 문서, 시그니처 가져오기
|
||||
@@ -170,11 +201,16 @@ OpenCode 는 아주 확장가능하고 아주 커스터마이저블합니다.
|
||||
- **ast_grep_search**: AST 인식 코드 패턴 검색 (25개 언어)
|
||||
- **ast_grep_replace**: AST 인식 코드 교체
|
||||
|
||||
#### Safe Grep
|
||||
- **safe_grep**: 안전 제한이 있는 콘텐츠 검색 (5분 타임아웃, 10MB 출력 제한).
|
||||
#### Grep
|
||||
- **grep**: 안전 제한이 있는 콘텐츠 검색 (5분 타임아웃, 10MB 출력 제한). OpenCode의 내장 `grep` 도구를 대체합니다.
|
||||
- 기본 grep 도구는 시간제한이 걸려있지 않습니다. 대형 코드베이스에서 광범위한 패턴을 검색하면 CPU가 폭발하고 무한히 멈출 수 있습니다.
|
||||
- safe_grep 은 timeout 과 더 엄격한 출력 제한을 적용합니다.
|
||||
- **주의**: 기본 grep 도구는 Agent 를 햇갈리게 하지 않기 위해 비활성화됩니다. 그러나 SafeGrep 은 Grep 이 제공하는 모든 기능을 제공합니다.
|
||||
- 이 도구는 엄격한 제한을 적용하며, 내장 `grep`을 완전히 대체합니다.
|
||||
|
||||
#### Glob
|
||||
|
||||
- **glob**: 타임아웃 보호가 있는 파일 패턴 매칭 (60초). OpenCode 내장 `glob` 도구를 대체합니다.
|
||||
- 기본 `glob`은 타임아웃이 없습니다. ripgrep이 멈추면 무한정 대기합니다.
|
||||
- 이 도구는 타임아웃을 강제하고 만료 시 프로세스를 종료합니다.
|
||||
|
||||
#### 내장 MCPs
|
||||
|
||||
@@ -189,8 +225,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를 지원합니다.
|
||||
- **Session State**: 이벤트 훅과 터미널 타이틀 업데이트에 사용되는 중앙집중식 세션 추적 모듈입니다.
|
||||
|
||||
## 설정
|
||||
|
||||
|
||||
110
README.md
110
README.md
@@ -13,8 +13,9 @@ English | [한국어](README.ko.md)
|
||||
- [Tools](#tools)
|
||||
- [Built-in LSP Tools](#built-in-lsp-tools)
|
||||
- [Built-in AST-Grep Tools](#built-in-ast-grep-tools)
|
||||
- [Safe Grep](#safe-grep)
|
||||
- [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)
|
||||
@@ -132,8 +133,27 @@ I believe in the right tool for the job. For your wallet's sake, use CLIProxyAPI
|
||||
- **Todo Continuation Enforcer**: Forces the agent to complete all tasks before exiting. Eliminates the common LLM issue of "giving up halfway".
|
||||
- **Context Window Monitor**: Implements [Context Window Anxiety Management](https://agentic-patterns.com/patterns/context-window-anxiety-management/). When context usage exceeds 70%, it reminds the agent that resources are sufficient, preventing rushed or low-quality output.
|
||||
- **Session Notification**: Sends a native OS notification when the job is done (macOS, Linux, Windows).
|
||||
- **Session Recovery**: Automatically recovers from API errors by injecting missing tool results and correcting thinking block violations, ensuring session stability.
|
||||
- **Session Recovery**: Automatically recovers from API errors, ensuring session stability. Handles four scenarios:
|
||||
- **Tool Result Missing**: When `tool_use` block exists without `tool_result` (ESC interrupt) → injects "cancelled" tool results
|
||||
- **Thinking Block Order**: When thinking block must be first but isn't → prepends empty thinking block
|
||||
- **Thinking Disabled Violation**: When thinking blocks exist but thinking is disabled → strips thinking blocks
|
||||
- **Empty Content Message**: When message has only thinking/meta blocks without actual content → injects "(interrupted)" text via filesystem
|
||||
- **Comment Checker**: Detects and reports unnecessary comments after code modifications. Smartly ignores valid patterns (BDD, directives, docstrings, shebangs) to keep the codebase clean from AI-generated artifacts.
|
||||
- **Directory AGENTS.md Injector**: Automatically injects `AGENTS.md` contents when reading files. Searches upward from the file's directory to project root, collecting **all** `AGENTS.md` files along the path hierarchy. This enables nested, directory-specific instructions:
|
||||
```
|
||||
project/
|
||||
├── AGENTS.md # Project-wide context
|
||||
├── src/
|
||||
│ ├── AGENTS.md # src-specific context
|
||||
│ └── components/
|
||||
│ ├── AGENTS.md # Component-specific context
|
||||
│ └── Button.tsx # Reading this injects ALL 3 AGENTS.md files
|
||||
```
|
||||
When reading `Button.tsx`, the hook injects contexts in order: `project/AGENTS.md` → `src/AGENTS.md` → `components/AGENTS.md`. Each directory's context is injected only once per session. Inspired by Claude Code's CLAUDE.md feature.
|
||||
- **Think Mode**: Automatic extended thinking detection and mode switching. Detects when user requests deep thinking (e.g., "think deeply", "ultrathink") and dynamically adjusts model settings for enhanced reasoning.
|
||||
- **Anthropic Auto Compact**: Automatically compacts conversation history when approaching context limits for Anthropic models.
|
||||
- **Empty Task Response Detector**: Detects when subagent tasks return empty or meaningless responses and handles gracefully.
|
||||
- **Grep Output Truncator**: Prevents grep output from overwhelming the context by truncating excessively long results.
|
||||
|
||||
### Agents
|
||||
- **oracle** (`openai/gpt-5.1`): The architect. Expert in code reviews and strategy. Uses GPT-5.1 for its unmatched logic and reasoning capabilities. Inspired by AmpCode.
|
||||
@@ -142,12 +162,22 @@ I believe in the right tool for the job. For your wallet's sake, use CLIProxyAPI
|
||||
- **frontend-ui-ux-engineer** (`google/gemini-3-pro-preview`): A designer turned developer. Creates stunning UIs. Uses Gemini because its creativity and UI code generation are superior.
|
||||
- **document-writer** (`google/gemini-3-pro-preview`): A technical writing expert. Gemini is a wordsmith; it writes prose that flows naturally.
|
||||
|
||||
Each agent is automatically invoked by the main agent, but you can also explicitly request them:
|
||||
|
||||
```
|
||||
@oracle Please think through the design of this part and suggest an architecture.
|
||||
@librarian Tell me how this is implemented — why does the behavior keep changing internally?
|
||||
@explore Tell me about the policy for this feature.
|
||||
```
|
||||
|
||||
Agent models, prompts, and permissions can be customized via `oh-my-opencode.json`. See [Configuration](#configuration) for details.
|
||||
|
||||
### Tools
|
||||
|
||||
#### Built-in LSP Tools
|
||||
|
||||
The features you use in your editor—other agents cannot access them. Oh My OpenCode hands those very tools to your LLM Agent. Refactoring, navigation, and analysis are all supported using the same OpenCode configuration.
|
||||
|
||||
[OpenCode provides LSP](https://opencode.ai/docs/lsp/), but only for analysis. Oh My OpenCode equips you with navigation and refactoring tools matching the same specification.
|
||||
|
||||
- **lsp_hover**: Get type info, docs, signatures at position
|
||||
@@ -167,12 +197,17 @@ Agent models, prompts, and permissions can be customized via `oh-my-opencode.jso
|
||||
- **ast_grep_search**: AST-aware code pattern search (25 languages)
|
||||
- **ast_grep_replace**: AST-aware code replacement
|
||||
|
||||
#### Safe Grep
|
||||
#### Grep
|
||||
|
||||
- **safe_grep**: Content search with safety limits (5min timeout, 10MB output).
|
||||
- **grep**: Content search with safety limits (5min timeout, 10MB output). Overrides OpenCode's built-in `grep` tool.
|
||||
- The default `grep` lacks safeguards. On a large codebase, a broad pattern can cause CPU overload and indefinite hanging.
|
||||
- `safe_grep` enforces strict limits.
|
||||
- **Note**: Default `grep` is disabled to prevent Agent confusion. `safe_grep` delivers full `grep` functionality with safety assurance.
|
||||
- This tool enforces strict limits and completely replaces the built-in `grep`.
|
||||
|
||||
#### Glob
|
||||
|
||||
- **glob**: File pattern matching with timeout protection (60s). Overrides OpenCode's built-in `glob` tool.
|
||||
- The default `glob` lacks timeout. If ripgrep hangs, it waits indefinitely.
|
||||
- This tool enforces timeouts and kills the process on expiration.
|
||||
|
||||
#### Built-in MCPs
|
||||
|
||||
@@ -187,9 +222,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.
|
||||
- **Session State**: Centralized session tracking module used by event hooks and terminal title updates.
|
||||
|
||||
## Configuration
|
||||
|
||||
|
||||
12
bun.lock
12
bun.lock
@@ -7,12 +7,14 @@
|
||||
"dependencies": {
|
||||
"@ast-grep/cli": "^0.40.0",
|
||||
"@ast-grep/napi": "^0.40.0",
|
||||
"@code-yeongyu/comment-checker": "^0.4.1",
|
||||
"@code-yeongyu/comment-checker": "^0.4.4",
|
||||
"@opencode-ai/plugin": "^1.0.7",
|
||||
"xdg-basedir": "^5.1.0",
|
||||
"zod": "^4.1.8",
|
||||
},
|
||||
"devDependencies": {
|
||||
"bun-types": "latest",
|
||||
"oh-my-opencode": "^0.1.30",
|
||||
"typescript": "^5.7.3",
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -62,7 +64,7 @@
|
||||
|
||||
"@ast-grep/napi-win32-x64-msvc": ["@ast-grep/napi-win32-x64-msvc@0.40.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Hk2IwfPqMFGZt5SRxsoWmGLxBXxprow4LRp1eG6V8EEiJCNHxZ9ZiEaIc5bNvMDBjHVSnqZAXT22dROhrcSKQg=="],
|
||||
|
||||
"@code-yeongyu/comment-checker": ["@code-yeongyu/comment-checker@0.4.1", "", { "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "comment-checker": "bin/comment-checker" } }, "sha512-E7p1V8CsRj9hMbwENd9BfxZGWYu+lKS5tXGuNNcNtkRMhWvwM/ononysKpLB7LXdxfSYAn0j7heJydyzEmm+lg=="],
|
||||
"@code-yeongyu/comment-checker": ["@code-yeongyu/comment-checker@0.4.4", "", { "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "comment-checker": "bin/comment-checker" } }, "sha512-vsbdLMQYJJNDV/baTDnNqqg/MZwA+9nz7TE6Mybj8zjZVTCn4ZivH4hAdD5p4fLxhGZEJ5x1UDmXA6pAGA7lHA=="],
|
||||
|
||||
"@opencode-ai/plugin": ["@opencode-ai/plugin@1.0.128", "", { "dependencies": { "@opencode-ai/sdk": "1.0.128", "zod": "4.1.8" } }, "sha512-M5vjz3I6KeoBSNduWmT5iHXRtTLCqICM5ocs+WrB3uxVorslcO3HVwcLzrERh/ntpxJ/1xhnHQaeG6Mg+P744A=="],
|
||||
|
||||
@@ -98,10 +100,16 @@
|
||||
|
||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||
|
||||
"oh-my-opencode": ["oh-my-opencode@0.1.30", "", { "dependencies": { "@ast-grep/cli": "^0.40.0", "@ast-grep/napi": "^0.40.0", "@code-yeongyu/comment-checker": "^0.4.1", "@opencode-ai/plugin": "^1.0.7", "xdg-basedir": "^5.1.0", "zod": "^4.1.8" }, "peerDependencies": { "bun": ">=1.0.0" } }, "sha512-pXGGgL/7Jcz3yuGJJTI72BKern2egwfRz2LQZTBq+jl+pNCybOvGvXtFmR+WGlF8O3ZjL1wIHypBbIVuHOBzxg=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
|
||||
|
||||
"xdg-basedir": ["xdg-basedir@5.1.0", "", {}, "sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ=="],
|
||||
|
||||
"zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="],
|
||||
|
||||
"oh-my-opencode/@code-yeongyu/comment-checker": ["@code-yeongyu/comment-checker@0.4.1", "", { "os": [ "linux", "win32", "darwin", ], "cpu": [ "x64", "arm64", ], "bin": { "comment-checker": "bin/comment-checker" } }, "sha512-E7p1V8CsRj9hMbwENd9BfxZGWYu+lKS5tXGuNNcNtkRMhWvwM/ononysKpLB7LXdxfSYAn0j7heJydyzEmm+lg=="],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 성공
|
||||
@@ -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"
|
||||
784
notepad.md
784
notepad.md
@@ -59,3 +59,787 @@ All tasks execution STARTED: Thu Dec 4 16:52:57 KST 2025
|
||||
|
||||
---
|
||||
|
||||
## [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분
|
||||
|
||||
---
|
||||
|
||||
## [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분
|
||||
|
||||
---
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode",
|
||||
"version": "0.1.20",
|
||||
"version": "0.3.0",
|
||||
"description": "OpenCode plugin - custom agents (oracle, librarian) and enhanced features",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
@@ -44,12 +44,14 @@
|
||||
"dependencies": {
|
||||
"@ast-grep/cli": "^0.40.0",
|
||||
"@ast-grep/napi": "^0.40.0",
|
||||
"@code-yeongyu/comment-checker": "^0.4.1",
|
||||
"@code-yeongyu/comment-checker": "^0.5.0",
|
||||
"@opencode-ai/plugin": "^1.0.7",
|
||||
"xdg-basedir": "^5.1.0",
|
||||
"zod": "^4.1.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"bun-types": "latest",
|
||||
"oh-my-opencode": "^0.1.30",
|
||||
"typescript": "^5.7.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
2
src/features/claude-code-agent-loader/index.ts
Normal file
2
src/features/claude-code-agent-loader/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./types"
|
||||
export * from "./loader"
|
||||
93
src/features/claude-code-agent-loader/loader.ts
Normal file
93
src/features/claude-code-agent-loader/loader.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { existsSync, readdirSync, readFileSync } from "fs"
|
||||
import { homedir } from "os"
|
||||
import { join, basename } from "path"
|
||||
import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
import { parseFrontmatter } from "../../shared/frontmatter"
|
||||
import type { AgentScope, AgentFrontmatter, LoadedAgent } from "./types"
|
||||
|
||||
function parseToolsConfig(toolsStr?: string): Record<string, boolean> | undefined {
|
||||
if (!toolsStr) return undefined
|
||||
|
||||
const tools = toolsStr.split(",").map((t) => t.trim()).filter(Boolean)
|
||||
if (tools.length === 0) return undefined
|
||||
|
||||
const result: Record<string, boolean> = {}
|
||||
for (const tool of tools) {
|
||||
result[tool.toLowerCase()] = true
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
function isMarkdownFile(entry: { name: string; isFile: () => boolean }): boolean {
|
||||
return !entry.name.startsWith(".") && entry.name.endsWith(".md") && entry.isFile()
|
||||
}
|
||||
|
||||
function loadAgentsFromDir(agentsDir: string, scope: AgentScope): LoadedAgent[] {
|
||||
if (!existsSync(agentsDir)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const entries = readdirSync(agentsDir, { withFileTypes: true })
|
||||
const agents: LoadedAgent[] = []
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!isMarkdownFile(entry)) continue
|
||||
|
||||
const agentPath = join(agentsDir, entry.name)
|
||||
const agentName = basename(entry.name, ".md")
|
||||
|
||||
try {
|
||||
const content = readFileSync(agentPath, "utf-8")
|
||||
const { data, body } = parseFrontmatter<AgentFrontmatter>(content)
|
||||
|
||||
const name = data.name || agentName
|
||||
const originalDescription = data.description || ""
|
||||
|
||||
const formattedDescription = `(${scope}) ${originalDescription}`
|
||||
|
||||
const config: AgentConfig = {
|
||||
description: formattedDescription,
|
||||
mode: "subagent",
|
||||
prompt: body.trim(),
|
||||
}
|
||||
|
||||
const toolsConfig = parseToolsConfig(data.tools)
|
||||
if (toolsConfig) {
|
||||
config.tools = toolsConfig
|
||||
}
|
||||
|
||||
agents.push({
|
||||
name,
|
||||
path: agentPath,
|
||||
config,
|
||||
scope,
|
||||
})
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return agents
|
||||
}
|
||||
|
||||
export function loadUserAgents(): Record<string, AgentConfig> {
|
||||
const userAgentsDir = join(homedir(), ".claude", "agents")
|
||||
const agents = loadAgentsFromDir(userAgentsDir, "user")
|
||||
|
||||
const result: Record<string, AgentConfig> = {}
|
||||
for (const agent of agents) {
|
||||
result[agent.name] = agent.config
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function loadProjectAgents(): Record<string, AgentConfig> {
|
||||
const projectAgentsDir = join(process.cwd(), ".claude", "agents")
|
||||
const agents = loadAgentsFromDir(projectAgentsDir, "project")
|
||||
|
||||
const result: Record<string, AgentConfig> = {}
|
||||
for (const agent of agents) {
|
||||
result[agent.name] = agent.config
|
||||
}
|
||||
return result
|
||||
}
|
||||
17
src/features/claude-code-agent-loader/types.ts
Normal file
17
src/features/claude-code-agent-loader/types.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
|
||||
export type AgentScope = "user" | "project"
|
||||
|
||||
export interface AgentFrontmatter {
|
||||
name?: string
|
||||
description?: string
|
||||
model?: string
|
||||
tools?: string
|
||||
}
|
||||
|
||||
export interface LoadedAgent {
|
||||
name: string
|
||||
path: string
|
||||
config: AgentConfig
|
||||
scope: AgentScope
|
||||
}
|
||||
2
src/features/claude-code-command-loader/index.ts
Normal file
2
src/features/claude-code-command-loader/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./types"
|
||||
export * from "./loader"
|
||||
94
src/features/claude-code-command-loader/loader.ts
Normal file
94
src/features/claude-code-command-loader/loader.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { existsSync, readdirSync, readFileSync } from "fs"
|
||||
import { homedir } from "os"
|
||||
import { join, basename } from "path"
|
||||
import { parseFrontmatter } from "../../shared/frontmatter"
|
||||
import { sanitizeModelField } from "../../shared/model-sanitizer"
|
||||
import type { CommandScope, CommandDefinition, CommandFrontmatter, LoadedCommand } from "./types"
|
||||
|
||||
function isMarkdownFile(entry: { name: string; isFile: () => boolean }): boolean {
|
||||
return !entry.name.startsWith(".") && entry.name.endsWith(".md") && entry.isFile()
|
||||
}
|
||||
|
||||
function loadCommandsFromDir(commandsDir: string, scope: CommandScope): LoadedCommand[] {
|
||||
if (!existsSync(commandsDir)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const entries = readdirSync(commandsDir, { withFileTypes: true })
|
||||
const commands: LoadedCommand[] = []
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!isMarkdownFile(entry)) continue
|
||||
|
||||
const commandPath = join(commandsDir, entry.name)
|
||||
const commandName = basename(entry.name, ".md")
|
||||
|
||||
try {
|
||||
const content = readFileSync(commandPath, "utf-8")
|
||||
const { data, body } = parseFrontmatter<CommandFrontmatter>(content)
|
||||
|
||||
const wrappedTemplate = `<command-instruction>
|
||||
${body.trim()}
|
||||
</command-instruction>
|
||||
|
||||
<user-request>
|
||||
$ARGUMENTS
|
||||
</user-request>`
|
||||
|
||||
const formattedDescription = `(${scope}) ${data.description || ""}`
|
||||
|
||||
const definition: CommandDefinition = {
|
||||
name: commandName,
|
||||
description: formattedDescription,
|
||||
template: wrappedTemplate,
|
||||
agent: data.agent,
|
||||
model: sanitizeModelField(data.model),
|
||||
subtask: data.subtask,
|
||||
argumentHint: data["argument-hint"],
|
||||
}
|
||||
|
||||
commands.push({
|
||||
name: commandName,
|
||||
path: commandPath,
|
||||
definition,
|
||||
scope,
|
||||
})
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return commands
|
||||
}
|
||||
|
||||
function commandsToRecord(commands: LoadedCommand[]): Record<string, CommandDefinition> {
|
||||
const result: Record<string, CommandDefinition> = {}
|
||||
for (const cmd of commands) {
|
||||
result[cmd.name] = cmd.definition
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
export function loadUserCommands(): Record<string, CommandDefinition> {
|
||||
const userCommandsDir = join(homedir(), ".claude", "commands")
|
||||
const commands = loadCommandsFromDir(userCommandsDir, "user")
|
||||
return commandsToRecord(commands)
|
||||
}
|
||||
|
||||
export function loadProjectCommands(): Record<string, CommandDefinition> {
|
||||
const projectCommandsDir = join(process.cwd(), ".claude", "commands")
|
||||
const commands = loadCommandsFromDir(projectCommandsDir, "project")
|
||||
return commandsToRecord(commands)
|
||||
}
|
||||
|
||||
export function loadOpencodeGlobalCommands(): Record<string, CommandDefinition> {
|
||||
const opencodeCommandsDir = join(homedir(), ".config", "opencode", "command")
|
||||
const commands = loadCommandsFromDir(opencodeCommandsDir, "opencode")
|
||||
return commandsToRecord(commands)
|
||||
}
|
||||
|
||||
export function loadOpencodeProjectCommands(): Record<string, CommandDefinition> {
|
||||
const opencodeProjectDir = join(process.cwd(), ".opencode", "command")
|
||||
const commands = loadCommandsFromDir(opencodeProjectDir, "opencode-project")
|
||||
return commandsToRecord(commands)
|
||||
}
|
||||
26
src/features/claude-code-command-loader/types.ts
Normal file
26
src/features/claude-code-command-loader/types.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
export type CommandScope = "user" | "project" | "opencode" | "opencode-project"
|
||||
|
||||
export interface CommandDefinition {
|
||||
name: string
|
||||
description?: string
|
||||
template: string
|
||||
agent?: string
|
||||
model?: string
|
||||
subtask?: boolean
|
||||
argumentHint?: string
|
||||
}
|
||||
|
||||
export interface CommandFrontmatter {
|
||||
description?: string
|
||||
"argument-hint"?: string
|
||||
agent?: string
|
||||
model?: string
|
||||
subtask?: boolean
|
||||
}
|
||||
|
||||
export interface LoadedCommand {
|
||||
name: string
|
||||
path: string
|
||||
definition: CommandDefinition
|
||||
scope: CommandScope
|
||||
}
|
||||
27
src/features/claude-code-mcp-loader/env-expander.ts
Normal file
27
src/features/claude-code-mcp-loader/env-expander.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
export function expandEnvVars(value: string): string {
|
||||
return value.replace(
|
||||
/\$\{([^}:]+)(?::-([^}]*))?\}/g,
|
||||
(_, varName: string, defaultValue?: string) => {
|
||||
const envValue = process.env[varName]
|
||||
if (envValue !== undefined) return envValue
|
||||
if (defaultValue !== undefined) return defaultValue
|
||||
return ""
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
export function expandEnvVarsInObject<T>(obj: T): T {
|
||||
if (obj === null || obj === undefined) return obj
|
||||
if (typeof obj === "string") return expandEnvVars(obj) as T
|
||||
if (Array.isArray(obj)) {
|
||||
return obj.map((item) => expandEnvVarsInObject(item)) as T
|
||||
}
|
||||
if (typeof obj === "object") {
|
||||
const result: Record<string, unknown> = {}
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
result[key] = expandEnvVarsInObject(value)
|
||||
}
|
||||
return result as T
|
||||
}
|
||||
return obj
|
||||
}
|
||||
11
src/features/claude-code-mcp-loader/index.ts
Normal file
11
src/features/claude-code-mcp-loader/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* MCP Configuration Loader
|
||||
*
|
||||
* Loads Claude Code .mcp.json format configurations from multiple scopes
|
||||
* and transforms them to OpenCode SDK format
|
||||
*/
|
||||
|
||||
export * from "./types"
|
||||
export * from "./loader"
|
||||
export * from "./transformer"
|
||||
export * from "./env-expander"
|
||||
89
src/features/claude-code-mcp-loader/loader.ts
Normal file
89
src/features/claude-code-mcp-loader/loader.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { existsSync } from "fs"
|
||||
import { homedir } from "os"
|
||||
import { join } from "path"
|
||||
import type {
|
||||
ClaudeCodeMcpConfig,
|
||||
LoadedMcpServer,
|
||||
McpLoadResult,
|
||||
McpScope,
|
||||
} from "./types"
|
||||
import { transformMcpServer } from "./transformer"
|
||||
import { log } from "../../shared/logger"
|
||||
|
||||
interface McpConfigPath {
|
||||
path: string
|
||||
scope: McpScope
|
||||
}
|
||||
|
||||
function getMcpConfigPaths(): McpConfigPath[] {
|
||||
const home = homedir()
|
||||
const cwd = process.cwd()
|
||||
|
||||
return [
|
||||
{ path: join(home, ".claude", ".mcp.json"), scope: "user" },
|
||||
{ path: join(cwd, ".mcp.json"), scope: "project" },
|
||||
{ path: join(cwd, ".claude", ".mcp.json"), scope: "local" },
|
||||
]
|
||||
}
|
||||
|
||||
async function loadMcpConfigFile(
|
||||
filePath: string
|
||||
): Promise<ClaudeCodeMcpConfig | null> {
|
||||
if (!existsSync(filePath)) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await Bun.file(filePath).text()
|
||||
return JSON.parse(content) as ClaudeCodeMcpConfig
|
||||
} catch (error) {
|
||||
log(`Failed to load MCP config from ${filePath}`, error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadMcpConfigs(): Promise<McpLoadResult> {
|
||||
const servers: McpLoadResult["servers"] = {}
|
||||
const loadedServers: LoadedMcpServer[] = []
|
||||
const paths = getMcpConfigPaths()
|
||||
|
||||
for (const { path, scope } of paths) {
|
||||
const config = await loadMcpConfigFile(path)
|
||||
if (!config?.mcpServers) continue
|
||||
|
||||
for (const [name, serverConfig] of Object.entries(config.mcpServers)) {
|
||||
if (serverConfig.disabled) {
|
||||
log(`Skipping disabled MCP server "${name}"`, { path })
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const transformed = transformMcpServer(name, serverConfig)
|
||||
servers[name] = transformed
|
||||
|
||||
const existingIndex = loadedServers.findIndex((s) => s.name === name)
|
||||
if (existingIndex !== -1) {
|
||||
loadedServers.splice(existingIndex, 1)
|
||||
}
|
||||
|
||||
loadedServers.push({ name, scope, config: transformed })
|
||||
|
||||
log(`Loaded MCP server "${name}" from ${scope}`, { path })
|
||||
} catch (error) {
|
||||
log(`Failed to transform MCP server "${name}"`, error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { servers, loadedServers }
|
||||
}
|
||||
|
||||
export function formatLoadedServersForToast(
|
||||
loadedServers: LoadedMcpServer[]
|
||||
): string {
|
||||
if (loadedServers.length === 0) return ""
|
||||
|
||||
return loadedServers
|
||||
.map((server) => `${server.name} (${server.scope})`)
|
||||
.join(", ")
|
||||
}
|
||||
53
src/features/claude-code-mcp-loader/transformer.ts
Normal file
53
src/features/claude-code-mcp-loader/transformer.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import type {
|
||||
ClaudeCodeMcpServer,
|
||||
McpLocalConfig,
|
||||
McpRemoteConfig,
|
||||
McpServerConfig,
|
||||
} from "./types"
|
||||
import { expandEnvVarsInObject } from "./env-expander"
|
||||
|
||||
export function transformMcpServer(
|
||||
name: string,
|
||||
server: ClaudeCodeMcpServer
|
||||
): McpServerConfig {
|
||||
const expanded = expandEnvVarsInObject(server)
|
||||
const serverType = expanded.type ?? "stdio"
|
||||
|
||||
if (serverType === "http" || serverType === "sse") {
|
||||
if (!expanded.url) {
|
||||
throw new Error(
|
||||
`MCP server "${name}" requires url for type "${serverType}"`
|
||||
)
|
||||
}
|
||||
|
||||
const config: McpRemoteConfig = {
|
||||
type: "remote",
|
||||
url: expanded.url,
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
if (expanded.headers && Object.keys(expanded.headers).length > 0) {
|
||||
config.headers = expanded.headers
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
if (!expanded.command) {
|
||||
throw new Error(`MCP server "${name}" requires command for stdio type`)
|
||||
}
|
||||
|
||||
const commandArray = [expanded.command, ...(expanded.args ?? [])]
|
||||
|
||||
const config: McpLocalConfig = {
|
||||
type: "local",
|
||||
command: commandArray,
|
||||
enabled: true,
|
||||
}
|
||||
|
||||
if (expanded.env && Object.keys(expanded.env).length > 0) {
|
||||
config.environment = expanded.env
|
||||
}
|
||||
|
||||
return config
|
||||
}
|
||||
42
src/features/claude-code-mcp-loader/types.ts
Normal file
42
src/features/claude-code-mcp-loader/types.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
export type McpScope = "user" | "project" | "local"
|
||||
|
||||
export interface ClaudeCodeMcpServer {
|
||||
type?: "http" | "sse" | "stdio"
|
||||
url?: string
|
||||
command?: string
|
||||
args?: string[]
|
||||
env?: Record<string, string>
|
||||
headers?: Record<string, string>
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export interface ClaudeCodeMcpConfig {
|
||||
mcpServers?: Record<string, ClaudeCodeMcpServer>
|
||||
}
|
||||
|
||||
export interface McpLocalConfig {
|
||||
type: "local"
|
||||
command: string[]
|
||||
environment?: Record<string, string>
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
export interface McpRemoteConfig {
|
||||
type: "remote"
|
||||
url: string
|
||||
headers?: Record<string, string>
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
export type McpServerConfig = McpLocalConfig | McpRemoteConfig
|
||||
|
||||
export interface LoadedMcpServer {
|
||||
name: string
|
||||
scope: McpScope
|
||||
config: McpServerConfig
|
||||
}
|
||||
|
||||
export interface McpLoadResult {
|
||||
servers: Record<string, McpServerConfig>
|
||||
loadedServers: LoadedMcpServer[]
|
||||
}
|
||||
21
src/features/claude-code-session-state/detector.ts
Normal file
21
src/features/claude-code-session-state/detector.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export function detectInterrupt(error: unknown): boolean {
|
||||
if (!error) return false
|
||||
|
||||
if (typeof error === "object") {
|
||||
const errObj = error as Record<string, unknown>
|
||||
const name = errObj.name as string | undefined
|
||||
const message = errObj.message as string | undefined
|
||||
|
||||
if (name === "MessageAbortedError" || name === "AbortError") return true
|
||||
if (name === "DOMException" && message?.includes("abort")) return true
|
||||
const msgLower = message?.toLowerCase()
|
||||
if (msgLower?.includes("aborted") || msgLower?.includes("cancelled") || msgLower?.includes("interrupted")) return true
|
||||
}
|
||||
|
||||
if (typeof error === "string") {
|
||||
const lower = error.toLowerCase()
|
||||
return lower.includes("abort") || lower.includes("cancel") || lower.includes("interrupt")
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
3
src/features/claude-code-session-state/index.ts
Normal file
3
src/features/claude-code-session-state/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./types"
|
||||
export * from "./state"
|
||||
export * from "./detector"
|
||||
31
src/features/claude-code-session-state/state.ts
Normal file
31
src/features/claude-code-session-state/state.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { SessionErrorState, SessionInterruptState } from "./types"
|
||||
|
||||
export const sessionErrorState = new Map<string, SessionErrorState>()
|
||||
export const sessionInterruptState = new Map<string, SessionInterruptState>()
|
||||
export const subagentSessions = new Set<string>()
|
||||
export const sessionFirstMessageProcessed = new Set<string>()
|
||||
|
||||
export let currentSessionID: string | undefined
|
||||
export let currentSessionTitle: string | undefined
|
||||
export let mainSessionID: string | undefined
|
||||
|
||||
export function setCurrentSession(id: string | undefined, title: string | undefined) {
|
||||
currentSessionID = id
|
||||
currentSessionTitle = title
|
||||
}
|
||||
|
||||
export function setMainSession(id: string | undefined) {
|
||||
mainSessionID = id
|
||||
}
|
||||
|
||||
export function getCurrentSessionID(): string | undefined {
|
||||
return currentSessionID
|
||||
}
|
||||
|
||||
export function getCurrentSessionTitle(): string | undefined {
|
||||
return currentSessionTitle
|
||||
}
|
||||
|
||||
export function getMainSessionID(): string | undefined {
|
||||
return mainSessionID
|
||||
}
|
||||
8
src/features/claude-code-session-state/types.ts
Normal file
8
src/features/claude-code-session-state/types.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export interface SessionErrorState {
|
||||
hasError: boolean
|
||||
errorMessage?: string
|
||||
}
|
||||
|
||||
export interface SessionInterruptState {
|
||||
interrupted: boolean
|
||||
}
|
||||
2
src/features/claude-code-skill-loader/index.ts
Normal file
2
src/features/claude-code-skill-loader/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./types"
|
||||
export * from "./loader"
|
||||
85
src/features/claude-code-skill-loader/loader.ts
Normal file
85
src/features/claude-code-skill-loader/loader.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { existsSync, readdirSync, readFileSync, statSync, readlinkSync } from "fs"
|
||||
import { homedir } from "os"
|
||||
import { join, resolve } from "path"
|
||||
import { parseFrontmatter } from "../../shared/frontmatter"
|
||||
import { sanitizeModelField } from "../../shared/model-sanitizer"
|
||||
import type { CommandDefinition } from "../claude-code-command-loader/types"
|
||||
import type { SkillScope, SkillMetadata, LoadedSkillAsCommand } from "./types"
|
||||
|
||||
function loadSkillsFromDir(skillsDir: string, scope: SkillScope): LoadedSkillAsCommand[] {
|
||||
if (!existsSync(skillsDir)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const entries = readdirSync(skillsDir, { withFileTypes: true })
|
||||
const skills: LoadedSkillAsCommand[] = []
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.name.startsWith(".")) continue
|
||||
|
||||
const skillPath = join(skillsDir, entry.name)
|
||||
|
||||
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue
|
||||
|
||||
let resolvedPath = skillPath
|
||||
if (statSync(skillPath, { throwIfNoEntry: false })?.isSymbolicLink()) {
|
||||
resolvedPath = resolve(skillPath, "..", readlinkSync(skillPath))
|
||||
}
|
||||
|
||||
const skillMdPath = join(resolvedPath, "SKILL.md")
|
||||
if (!existsSync(skillMdPath)) continue
|
||||
|
||||
try {
|
||||
const content = readFileSync(skillMdPath, "utf-8")
|
||||
const { data, body } = parseFrontmatter<SkillMetadata>(content)
|
||||
|
||||
const skillName = data.name || entry.name
|
||||
const originalDescription = data.description || ""
|
||||
const formattedDescription = `(${scope} - Skill) ${originalDescription}`
|
||||
|
||||
const wrappedTemplate = `<skill-instruction>
|
||||
${body.trim()}
|
||||
</skill-instruction>
|
||||
|
||||
<user-request>
|
||||
$ARGUMENTS
|
||||
</user-request>`
|
||||
|
||||
const definition: CommandDefinition = {
|
||||
name: skillName,
|
||||
description: formattedDescription,
|
||||
template: wrappedTemplate,
|
||||
model: sanitizeModelField(data.model),
|
||||
}
|
||||
|
||||
skills.push({
|
||||
name: skillName,
|
||||
path: resolvedPath,
|
||||
definition,
|
||||
scope,
|
||||
})
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return skills
|
||||
}
|
||||
|
||||
export function loadUserSkillsAsCommands(): Record<string, CommandDefinition> {
|
||||
const userSkillsDir = join(homedir(), ".claude", "skills")
|
||||
const skills = loadSkillsFromDir(userSkillsDir, "user")
|
||||
return skills.reduce((acc, skill) => {
|
||||
acc[skill.name] = skill.definition
|
||||
return acc
|
||||
}, {} as Record<string, CommandDefinition>)
|
||||
}
|
||||
|
||||
export function loadProjectSkillsAsCommands(): Record<string, CommandDefinition> {
|
||||
const projectSkillsDir = join(process.cwd(), ".claude", "skills")
|
||||
const skills = loadSkillsFromDir(projectSkillsDir, "project")
|
||||
return skills.reduce((acc, skill) => {
|
||||
acc[skill.name] = skill.definition
|
||||
return acc
|
||||
}, {} as Record<string, CommandDefinition>)
|
||||
}
|
||||
16
src/features/claude-code-skill-loader/types.ts
Normal file
16
src/features/claude-code-skill-loader/types.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { CommandDefinition } from "../claude-code-command-loader/types"
|
||||
|
||||
export type SkillScope = "user" | "project"
|
||||
|
||||
export interface SkillMetadata {
|
||||
name: string
|
||||
description: string
|
||||
model?: string
|
||||
}
|
||||
|
||||
export interface LoadedSkillAsCommand {
|
||||
name: string
|
||||
path: string
|
||||
definition: CommandDefinition
|
||||
scope: SkillScope
|
||||
}
|
||||
8
src/features/hook-message-injector/constants.ts
Normal file
8
src/features/hook-message-injector/constants.ts
Normal 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")
|
||||
2
src/features/hook-message-injector/index.ts
Normal file
2
src/features/hook-message-injector/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { injectHookMessage } from "./injector"
|
||||
export type { MessageMeta, OriginalMessageContext, TextPart } from "./types"
|
||||
141
src/features/hook-message-injector/injector.ts
Normal file
141
src/features/hook-message-injector/injector.ts
Normal 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
|
||||
}
|
||||
}
|
||||
45
src/features/hook-message-injector/types.ts
Normal file
45
src/features/hook-message-injector/types.ts
Normal 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
|
||||
}
|
||||
74
src/hooks/anthropic-auto-compact/executor.ts
Normal file
74
src/hooks/anthropic-auto-compact/executor.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import type { AutoCompactState } from "./types"
|
||||
|
||||
type Client = {
|
||||
session: {
|
||||
messages: (opts: { path: { id: string }; query?: { directory?: string } }) => Promise<unknown>
|
||||
summarize: (opts: {
|
||||
path: { id: string }
|
||||
body: { providerID: string; modelID: string }
|
||||
query: { directory: string }
|
||||
}) => Promise<unknown>
|
||||
}
|
||||
tui: {
|
||||
submitPrompt: (opts: { query: { directory: string } }) => Promise<unknown>
|
||||
}
|
||||
}
|
||||
|
||||
export async function getLastAssistant(
|
||||
sessionID: string,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
client: any,
|
||||
directory: string
|
||||
): Promise<Record<string, unknown> | null> {
|
||||
try {
|
||||
const resp = await (client as Client).session.messages({
|
||||
path: { id: sessionID },
|
||||
query: { directory },
|
||||
})
|
||||
|
||||
const data = (resp as { data?: unknown[] }).data
|
||||
if (!Array.isArray(data)) return null
|
||||
|
||||
const reversed = [...data].reverse()
|
||||
const last = reversed.find((m) => {
|
||||
const msg = m as Record<string, unknown>
|
||||
const info = msg.info as Record<string, unknown> | undefined
|
||||
return info?.role === "assistant"
|
||||
})
|
||||
if (!last) return null
|
||||
return (last as { info?: Record<string, unknown> }).info ?? null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeCompact(
|
||||
sessionID: string,
|
||||
msg: Record<string, unknown>,
|
||||
autoCompactState: AutoCompactState,
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
client: any,
|
||||
directory: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
const providerID = msg.providerID as string | undefined
|
||||
const modelID = msg.modelID as string | undefined
|
||||
|
||||
if (providerID && modelID) {
|
||||
await (client as Client).session.summarize({
|
||||
path: { id: sessionID },
|
||||
body: { providerID, modelID },
|
||||
query: { directory },
|
||||
})
|
||||
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
await (client as Client).tui.submitPrompt({ query: { directory } })
|
||||
} catch {}
|
||||
}, 500)
|
||||
}
|
||||
|
||||
autoCompactState.pendingCompact.delete(sessionID)
|
||||
autoCompactState.errorDataBySession.delete(sessionID)
|
||||
} catch {}
|
||||
}
|
||||
123
src/hooks/anthropic-auto-compact/index.ts
Normal file
123
src/hooks/anthropic-auto-compact/index.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import type { AutoCompactState, ParsedTokenLimitError } from "./types"
|
||||
import { parseAnthropicTokenLimitError } from "./parser"
|
||||
import { executeCompact, getLastAssistant } from "./executor"
|
||||
|
||||
function createAutoCompactState(): AutoCompactState {
|
||||
return {
|
||||
pendingCompact: new Set<string>(),
|
||||
errorDataBySession: new Map<string, ParsedTokenLimitError>(),
|
||||
}
|
||||
}
|
||||
|
||||
export function createAnthropicAutoCompactHook(ctx: PluginInput) {
|
||||
const autoCompactState = createAutoCompactState()
|
||||
|
||||
const eventHandler = async ({ event }: { event: { type: string; properties?: unknown } }) => {
|
||||
const props = event.properties as Record<string, unknown> | undefined
|
||||
|
||||
if (event.type === "session.deleted") {
|
||||
const sessionInfo = props?.info as { id?: string } | undefined
|
||||
if (sessionInfo?.id) {
|
||||
autoCompactState.pendingCompact.delete(sessionInfo.id)
|
||||
autoCompactState.errorDataBySession.delete(sessionInfo.id)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === "session.error") {
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
if (!sessionID) return
|
||||
|
||||
const parsed = parseAnthropicTokenLimitError(props?.error)
|
||||
if (parsed) {
|
||||
autoCompactState.pendingCompact.add(sessionID)
|
||||
autoCompactState.errorDataBySession.set(sessionID, parsed)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === "message.updated") {
|
||||
const info = props?.info as Record<string, unknown> | undefined
|
||||
const sessionID = info?.sessionID as string | undefined
|
||||
|
||||
if (sessionID && info?.role === "assistant" && info.error) {
|
||||
const parsed = parseAnthropicTokenLimitError(info.error)
|
||||
if (parsed) {
|
||||
parsed.providerID = info.providerID as string | undefined
|
||||
parsed.modelID = info.modelID as string | undefined
|
||||
autoCompactState.pendingCompact.add(sessionID)
|
||||
autoCompactState.errorDataBySession.set(sessionID, parsed)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === "session.idle") {
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
if (!sessionID) return
|
||||
|
||||
if (!autoCompactState.pendingCompact.has(sessionID)) return
|
||||
|
||||
const errorData = autoCompactState.errorDataBySession.get(sessionID)
|
||||
if (errorData?.providerID && errorData?.modelID) {
|
||||
await ctx.client.tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: "Auto Compact",
|
||||
message: "Token limit exceeded. Summarizing session...",
|
||||
variant: "warning" as const,
|
||||
duration: 3000,
|
||||
},
|
||||
})
|
||||
.catch(() => {})
|
||||
|
||||
await executeCompact(
|
||||
sessionID,
|
||||
{ providerID: errorData.providerID, modelID: errorData.modelID },
|
||||
autoCompactState,
|
||||
ctx.client,
|
||||
ctx.directory
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const lastAssistant = await getLastAssistant(sessionID, ctx.client, ctx.directory)
|
||||
if (!lastAssistant) {
|
||||
autoCompactState.pendingCompact.delete(sessionID)
|
||||
return
|
||||
}
|
||||
|
||||
if (lastAssistant.summary === true) {
|
||||
autoCompactState.pendingCompact.delete(sessionID)
|
||||
return
|
||||
}
|
||||
|
||||
if (!lastAssistant.modelID || !lastAssistant.providerID) {
|
||||
autoCompactState.pendingCompact.delete(sessionID)
|
||||
return
|
||||
}
|
||||
|
||||
await ctx.client.tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: "Auto Compact",
|
||||
message: "Token limit exceeded. Summarizing session...",
|
||||
variant: "warning" as const,
|
||||
duration: 3000,
|
||||
},
|
||||
})
|
||||
.catch(() => {})
|
||||
|
||||
await executeCompact(sessionID, lastAssistant, autoCompactState, ctx.client, ctx.directory)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
event: eventHandler,
|
||||
}
|
||||
}
|
||||
|
||||
export type { AutoCompactState, ParsedTokenLimitError } from "./types"
|
||||
export { parseAnthropicTokenLimitError } from "./parser"
|
||||
export { executeCompact, getLastAssistant } from "./executor"
|
||||
154
src/hooks/anthropic-auto-compact/parser.ts
Normal file
154
src/hooks/anthropic-auto-compact/parser.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
import type { ParsedTokenLimitError } from "./types"
|
||||
|
||||
interface AnthropicErrorData {
|
||||
type: "error"
|
||||
error: {
|
||||
type: string
|
||||
message: string
|
||||
}
|
||||
request_id?: string
|
||||
}
|
||||
|
||||
const TOKEN_LIMIT_PATTERNS = [
|
||||
/(\d+)\s*tokens?\s*>\s*(\d+)\s*maximum/i,
|
||||
/prompt.*?(\d+).*?tokens.*?exceeds.*?(\d+)/i,
|
||||
/(\d+).*?tokens.*?limit.*?(\d+)/i,
|
||||
/context.*?length.*?(\d+).*?maximum.*?(\d+)/i,
|
||||
/max.*?context.*?(\d+).*?but.*?(\d+)/i,
|
||||
]
|
||||
|
||||
const TOKEN_LIMIT_KEYWORDS = [
|
||||
"prompt is too long",
|
||||
"is too long",
|
||||
"context_length_exceeded",
|
||||
"max_tokens",
|
||||
"token limit",
|
||||
"context length",
|
||||
"too many tokens",
|
||||
]
|
||||
|
||||
function extractTokensFromMessage(message: string): { current: number; max: number } | null {
|
||||
for (const pattern of TOKEN_LIMIT_PATTERNS) {
|
||||
const match = message.match(pattern)
|
||||
if (match) {
|
||||
const num1 = parseInt(match[1], 10)
|
||||
const num2 = parseInt(match[2], 10)
|
||||
return num1 > num2 ? { current: num1, max: num2 } : { current: num2, max: num1 }
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function isTokenLimitError(text: string): boolean {
|
||||
const lower = text.toLowerCase()
|
||||
return TOKEN_LIMIT_KEYWORDS.some((kw) => lower.includes(kw.toLowerCase()))
|
||||
}
|
||||
|
||||
export function parseAnthropicTokenLimitError(err: unknown): ParsedTokenLimitError | null {
|
||||
if (typeof err === "string") {
|
||||
if (isTokenLimitError(err)) {
|
||||
const tokens = extractTokensFromMessage(err)
|
||||
return {
|
||||
currentTokens: tokens?.current ?? 0,
|
||||
maxTokens: tokens?.max ?? 0,
|
||||
errorType: "token_limit_exceeded_string",
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
if (!err || typeof err !== "object") return null
|
||||
|
||||
const errObj = err as Record<string, unknown>
|
||||
|
||||
const dataObj = errObj.data as Record<string, unknown> | undefined
|
||||
const responseBody = dataObj?.responseBody
|
||||
const errorMessage = errObj.message as string | undefined
|
||||
const errorData = errObj.error as Record<string, unknown> | undefined
|
||||
const nestedError = errorData?.error as Record<string, unknown> | undefined
|
||||
|
||||
const textSources: string[] = []
|
||||
|
||||
if (typeof responseBody === "string") textSources.push(responseBody)
|
||||
if (typeof errorMessage === "string") textSources.push(errorMessage)
|
||||
if (typeof errorData?.message === "string") textSources.push(errorData.message as string)
|
||||
if (typeof errObj.body === "string") textSources.push(errObj.body as string)
|
||||
if (typeof errObj.details === "string") textSources.push(errObj.details as string)
|
||||
if (typeof errObj.reason === "string") textSources.push(errObj.reason as string)
|
||||
if (typeof errObj.description === "string") textSources.push(errObj.description as string)
|
||||
if (typeof nestedError?.message === "string") textSources.push(nestedError.message as string)
|
||||
if (typeof dataObj?.message === "string") textSources.push(dataObj.message as string)
|
||||
if (typeof dataObj?.error === "string") textSources.push(dataObj.error as string)
|
||||
|
||||
if (textSources.length === 0) {
|
||||
try {
|
||||
const jsonStr = JSON.stringify(errObj)
|
||||
if (isTokenLimitError(jsonStr)) {
|
||||
textSources.push(jsonStr)
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const combinedText = textSources.join(" ")
|
||||
if (!isTokenLimitError(combinedText)) return null
|
||||
|
||||
if (typeof responseBody === "string") {
|
||||
try {
|
||||
const jsonPatterns = [
|
||||
/data:\s*(\{[\s\S]*?\})\s*$/m,
|
||||
/(\{"type"\s*:\s*"error"[\s\S]*?\})/,
|
||||
/(\{[\s\S]*?"error"[\s\S]*?\})/,
|
||||
]
|
||||
|
||||
for (const pattern of jsonPatterns) {
|
||||
const dataMatch = responseBody.match(pattern)
|
||||
if (dataMatch) {
|
||||
try {
|
||||
const jsonData: AnthropicErrorData = JSON.parse(dataMatch[1])
|
||||
const message = jsonData.error?.message || ""
|
||||
const tokens = extractTokensFromMessage(message)
|
||||
|
||||
if (tokens) {
|
||||
return {
|
||||
currentTokens: tokens.current,
|
||||
maxTokens: tokens.max,
|
||||
requestId: jsonData.request_id,
|
||||
errorType: jsonData.error?.type || "token_limit_exceeded",
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
const bedrockJson = JSON.parse(responseBody)
|
||||
if (typeof bedrockJson.message === "string" && isTokenLimitError(bedrockJson.message)) {
|
||||
return {
|
||||
currentTokens: 0,
|
||||
maxTokens: 0,
|
||||
errorType: "bedrock_input_too_long",
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
for (const text of textSources) {
|
||||
const tokens = extractTokensFromMessage(text)
|
||||
if (tokens) {
|
||||
return {
|
||||
currentTokens: tokens.current,
|
||||
maxTokens: tokens.max,
|
||||
errorType: "token_limit_exceeded",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isTokenLimitError(combinedText)) {
|
||||
return {
|
||||
currentTokens: 0,
|
||||
maxTokens: 0,
|
||||
errorType: "token_limit_exceeded_unknown",
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
13
src/hooks/anthropic-auto-compact/types.ts
Normal file
13
src/hooks/anthropic-auto-compact/types.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
export interface ParsedTokenLimitError {
|
||||
currentTokens: number
|
||||
maxTokens: number
|
||||
requestId?: string
|
||||
errorType: string
|
||||
providerID?: string
|
||||
modelID?: string
|
||||
}
|
||||
|
||||
export interface AutoCompactState {
|
||||
pendingCompact: Set<string>
|
||||
errorDataBySession: Map<string, ParsedTokenLimitError>
|
||||
}
|
||||
105
src/hooks/claude-code-hooks/config-loader.ts
Normal file
105
src/hooks/claude-code-hooks/config-loader.ts
Normal 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)
|
||||
})
|
||||
}
|
||||
100
src/hooks/claude-code-hooks/config.ts
Normal file
100
src/hooks/claude-code-hooks/config.ts
Normal 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
|
||||
}
|
||||
336
src/hooks/claude-code-hooks/index.ts
Normal file
336
src/hooks/claude-code-hooks/index.ts
Normal 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)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
9
src/hooks/claude-code-hooks/plugin-config.ts
Normal file
9
src/hooks/claude-code-hooks/plugin-config.ts
Normal 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",
|
||||
}
|
||||
199
src/hooks/claude-code-hooks/post-tool-use.ts
Normal file
199
src/hooks/claude-code-hooks/post-tool-use.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
172
src/hooks/claude-code-hooks/pre-tool-use.ts
Normal file
172
src/hooks/claude-code-hooks/pre-tool-use.ts
Normal 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" }
|
||||
}
|
||||
118
src/hooks/claude-code-hooks/stop.ts
Normal file
118
src/hooks/claude-code-hooks/stop.ts
Normal 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 }
|
||||
}
|
||||
76
src/hooks/claude-code-hooks/todo.ts
Normal file
76
src/hooks/claude-code-hooks/todo.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
47
src/hooks/claude-code-hooks/tool-input-cache.ts
Normal file
47
src/hooks/claude-code-hooks/tool-input-cache.ts
Normal 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)
|
||||
255
src/hooks/claude-code-hooks/transcript.ts
Normal file
255
src/hooks/claude-code-hooks/transcript.ts
Normal 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
|
||||
}
|
||||
}
|
||||
184
src/hooks/claude-code-hooks/types.ts
Normal file
184
src/hooks/claude-code-hooks/types.ts
Normal 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[]
|
||||
}
|
||||
117
src/hooks/claude-code-hooks/user-prompt-submit.ts
Normal file
117
src/hooks/claude-code-hooks/user-prompt-submit.ts
Normal 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 }
|
||||
}
|
||||
@@ -15,36 +15,13 @@ function debugLog(...args: unknown[]) {
|
||||
}
|
||||
}
|
||||
|
||||
type Platform = "darwin" | "linux" | "win32" | "unsupported"
|
||||
|
||||
function getPlatformPackageName(): string | null {
|
||||
const platform = process.platform as Platform
|
||||
const arch = process.arch
|
||||
|
||||
const platformMap: Record<string, string> = {
|
||||
"darwin-arm64": "@code-yeongyu/comment-checker-darwin-arm64",
|
||||
"darwin-x64": "@code-yeongyu/comment-checker-darwin-x64",
|
||||
"linux-arm64": "@code-yeongyu/comment-checker-linux-arm64",
|
||||
"linux-x64": "@code-yeongyu/comment-checker-linux-x64",
|
||||
"win32-x64": "@code-yeongyu/comment-checker-windows-x64",
|
||||
}
|
||||
|
||||
return platformMap[`${platform}-${arch}`] ?? null
|
||||
}
|
||||
|
||||
function getBinaryName(): string {
|
||||
return process.platform === "win32" ? "comment-checker.exe" : "comment-checker"
|
||||
}
|
||||
|
||||
/**
|
||||
* Synchronously find comment-checker binary path.
|
||||
* Checks installed packages, homebrew, cache, and system PATH.
|
||||
* Does NOT trigger download.
|
||||
*/
|
||||
function findCommentCheckerPathSync(): string | null {
|
||||
const binaryName = getBinaryName()
|
||||
|
||||
// 1. Try to find from @code-yeongyu/comment-checker package
|
||||
try {
|
||||
const require = createRequire(import.meta.url)
|
||||
const cliPkgPath = require.resolve("@code-yeongyu/comment-checker/package.json")
|
||||
@@ -59,46 +36,12 @@ function findCommentCheckerPathSync(): string | null {
|
||||
debugLog("main package not installed")
|
||||
}
|
||||
|
||||
// 2. Try platform-specific package directly (legacy, for backwards compatibility)
|
||||
const platformPkg = getPlatformPackageName()
|
||||
if (platformPkg) {
|
||||
try {
|
||||
const require = createRequire(import.meta.url)
|
||||
const pkgPath = require.resolve(`${platformPkg}/package.json`)
|
||||
const pkgDir = dirname(pkgPath)
|
||||
const binaryPath = join(pkgDir, "bin", binaryName)
|
||||
|
||||
if (existsSync(binaryPath)) {
|
||||
debugLog("found binary in platform package:", binaryPath)
|
||||
return binaryPath
|
||||
}
|
||||
} catch {
|
||||
debugLog("platform package not installed:", platformPkg)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Try homebrew installation (macOS)
|
||||
if (process.platform === "darwin") {
|
||||
const homebrewPaths = [
|
||||
"/opt/homebrew/bin/comment-checker",
|
||||
"/usr/local/bin/comment-checker",
|
||||
]
|
||||
for (const path of homebrewPaths) {
|
||||
if (existsSync(path)) {
|
||||
debugLog("found binary via homebrew:", path)
|
||||
return path
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Try cached binary (lazy download location)
|
||||
const cachedPath = getCachedBinaryPath()
|
||||
if (cachedPath) {
|
||||
debugLog("found binary in cache:", cachedPath)
|
||||
return cachedPath
|
||||
}
|
||||
|
||||
// 5. Try system PATH (as fallback)
|
||||
debugLog("no binary found in known locations")
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -72,6 +72,10 @@ PRIORITY-BASED ACTION GUIDELINES:
|
||||
\t-> Make the code itself clearer so it can be understood without comments/docstrings.
|
||||
\t-> For verbose docstrings: refactor code to be self-documenting instead of adding lengthy explanations.
|
||||
|
||||
CODE SMELL WARNING: Using comments as visual separators (e.g., "// =========", "# ---", "// *** Section ***")
|
||||
is a code smell. If you need separators, your file is too long or poorly organized.
|
||||
Refactor into smaller modules or use proper code organization instead of comment-based section dividers.
|
||||
|
||||
MANDATORY REQUIREMENT: You must acknowledge this hook message and take one of the above actions.
|
||||
Review in the above priority order and take the corresponding action EVERY TIME this appears.
|
||||
|
||||
|
||||
9
src/hooks/directory-agents-injector/constants.ts
Normal file
9
src/hooks/directory-agents-injector/constants.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { join } from "node:path";
|
||||
import { xdgData } from "xdg-basedir";
|
||||
|
||||
export const OPENCODE_STORAGE = join(xdgData ?? "", "opencode", "storage");
|
||||
export const AGENTS_INJECTOR_STORAGE = join(
|
||||
OPENCODE_STORAGE,
|
||||
"directory-agents",
|
||||
);
|
||||
export const AGENTS_FILENAME = "AGENTS.md";
|
||||
126
src/hooks/directory-agents-injector/index.ts
Normal file
126
src/hooks/directory-agents-injector/index.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import {
|
||||
loadInjectedPaths,
|
||||
saveInjectedPaths,
|
||||
clearInjectedPaths,
|
||||
} from "./storage";
|
||||
import { AGENTS_FILENAME } from "./constants";
|
||||
|
||||
interface ToolExecuteInput {
|
||||
tool: string;
|
||||
sessionID: string;
|
||||
callID: string;
|
||||
}
|
||||
|
||||
interface ToolExecuteOutput {
|
||||
title: string;
|
||||
output: string;
|
||||
metadata: unknown;
|
||||
}
|
||||
|
||||
interface EventInput {
|
||||
event: {
|
||||
type: string;
|
||||
properties?: unknown;
|
||||
};
|
||||
}
|
||||
|
||||
export function createDirectoryAgentsInjectorHook(ctx: PluginInput) {
|
||||
const sessionCaches = new Map<string, Set<string>>();
|
||||
|
||||
function getSessionCache(sessionID: string): Set<string> {
|
||||
if (!sessionCaches.has(sessionID)) {
|
||||
sessionCaches.set(sessionID, loadInjectedPaths(sessionID));
|
||||
}
|
||||
return sessionCaches.get(sessionID)!;
|
||||
}
|
||||
|
||||
function resolveFilePath(title: string): string | null {
|
||||
if (!title) return null;
|
||||
if (title.startsWith("/")) return title;
|
||||
return resolve(ctx.directory, title);
|
||||
}
|
||||
|
||||
function findAgentsMdUp(startDir: string): string[] {
|
||||
const found: string[] = [];
|
||||
let current = startDir;
|
||||
|
||||
while (true) {
|
||||
const agentsPath = join(current, AGENTS_FILENAME);
|
||||
if (existsSync(agentsPath)) {
|
||||
found.push(agentsPath);
|
||||
}
|
||||
|
||||
if (current === ctx.directory) break;
|
||||
const parent = dirname(current);
|
||||
if (parent === current) break;
|
||||
if (!parent.startsWith(ctx.directory)) break;
|
||||
current = parent;
|
||||
}
|
||||
|
||||
return found.reverse();
|
||||
}
|
||||
|
||||
const toolExecuteAfter = async (
|
||||
input: ToolExecuteInput,
|
||||
output: ToolExecuteOutput,
|
||||
) => {
|
||||
if (input.tool.toLowerCase() !== "read") return;
|
||||
|
||||
const filePath = resolveFilePath(output.title);
|
||||
if (!filePath) return;
|
||||
|
||||
const dir = dirname(filePath);
|
||||
const cache = getSessionCache(input.sessionID);
|
||||
const agentsPaths = findAgentsMdUp(dir);
|
||||
|
||||
const toInject: { path: string; content: string }[] = [];
|
||||
|
||||
for (const agentsPath of agentsPaths) {
|
||||
const agentsDir = dirname(agentsPath);
|
||||
if (cache.has(agentsDir)) continue;
|
||||
|
||||
try {
|
||||
const content = readFileSync(agentsPath, "utf-8");
|
||||
toInject.push({ path: agentsPath, content });
|
||||
cache.add(agentsDir);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (toInject.length === 0) return;
|
||||
|
||||
for (const { path, content } of toInject) {
|
||||
output.output += `\n\n[Directory Context: ${path}]\n${content}`;
|
||||
}
|
||||
|
||||
saveInjectedPaths(input.sessionID, cache);
|
||||
};
|
||||
|
||||
const eventHandler = async ({ event }: EventInput) => {
|
||||
const props = event.properties as Record<string, unknown> | undefined;
|
||||
|
||||
if (event.type === "session.deleted") {
|
||||
const sessionInfo = props?.info as { id?: string } | undefined;
|
||||
if (sessionInfo?.id) {
|
||||
sessionCaches.delete(sessionInfo.id);
|
||||
clearInjectedPaths(sessionInfo.id);
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "session.compacted") {
|
||||
const sessionID = (props?.sessionID ??
|
||||
(props?.info as { id?: string } | undefined)?.id) as string | undefined;
|
||||
if (sessionID) {
|
||||
sessionCaches.delete(sessionID);
|
||||
clearInjectedPaths(sessionID);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
"tool.execute.after": toolExecuteAfter,
|
||||
event: eventHandler,
|
||||
};
|
||||
}
|
||||
48
src/hooks/directory-agents-injector/storage.ts
Normal file
48
src/hooks/directory-agents-injector/storage.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
writeFileSync,
|
||||
unlinkSync,
|
||||
} from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { AGENTS_INJECTOR_STORAGE } from "./constants";
|
||||
import type { InjectedPathsData } from "./types";
|
||||
|
||||
function getStoragePath(sessionID: string): string {
|
||||
return join(AGENTS_INJECTOR_STORAGE, `${sessionID}.json`);
|
||||
}
|
||||
|
||||
export function loadInjectedPaths(sessionID: string): Set<string> {
|
||||
const filePath = getStoragePath(sessionID);
|
||||
if (!existsSync(filePath)) return new Set();
|
||||
|
||||
try {
|
||||
const content = readFileSync(filePath, "utf-8");
|
||||
const data: InjectedPathsData = JSON.parse(content);
|
||||
return new Set(data.injectedPaths);
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
export function saveInjectedPaths(sessionID: string, paths: Set<string>): void {
|
||||
if (!existsSync(AGENTS_INJECTOR_STORAGE)) {
|
||||
mkdirSync(AGENTS_INJECTOR_STORAGE, { recursive: true });
|
||||
}
|
||||
|
||||
const data: InjectedPathsData = {
|
||||
sessionID,
|
||||
injectedPaths: [...paths],
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
|
||||
writeFileSync(getStoragePath(sessionID), JSON.stringify(data, null, 2));
|
||||
}
|
||||
|
||||
export function clearInjectedPaths(sessionID: string): void {
|
||||
const filePath = getStoragePath(sessionID);
|
||||
if (existsSync(filePath)) {
|
||||
unlinkSync(filePath);
|
||||
}
|
||||
}
|
||||
5
src/hooks/directory-agents-injector/types.ts
Normal file
5
src/hooks/directory-agents-injector/types.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export interface InjectedPathsData {
|
||||
sessionID: string;
|
||||
injectedPaths: string[];
|
||||
updatedAt: number;
|
||||
}
|
||||
27
src/hooks/empty-task-response-detector.ts
Normal file
27
src/hooks/empty-task-response-detector.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
|
||||
const EMPTY_RESPONSE_WARNING = `[Task Empty Response Warning]
|
||||
|
||||
Task invocation completed but returned no response. This indicates the agent either:
|
||||
- Failed to execute properly
|
||||
- Did not terminate correctly
|
||||
- Returned an empty result
|
||||
|
||||
Note: The call has already completed - you are NOT waiting for a response. Proceed accordingly.`
|
||||
|
||||
export function createEmptyTaskResponseDetectorHook(_ctx: PluginInput) {
|
||||
return {
|
||||
"tool.execute.after": async (
|
||||
input: { tool: string; sessionID: string; callID: string },
|
||||
output: { title: string; output: string; metadata: unknown }
|
||||
) => {
|
||||
if (input.tool !== "Task") return
|
||||
|
||||
const responseText = output.output?.trim() ?? ""
|
||||
|
||||
if (responseText === "") {
|
||||
output.output = EMPTY_RESPONSE_WARNING
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
|
||||
const BLOCKED_MESSAGE =
|
||||
"Error: [BLOCKED] grep has no timeout and can freeze the system. " +
|
||||
"It is permanently disabled. Use 'safe_grep' instead."
|
||||
|
||||
export function createGrepBlocker(_ctx: PluginInput) {
|
||||
return {
|
||||
"tool.execute.before": async (
|
||||
input: { tool: string; sessionID: string; callID: string },
|
||||
_output: { args: unknown }
|
||||
) => {
|
||||
if (input.tool === "grep") {
|
||||
throw new Error(BLOCKED_MESSAGE)
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
131
src/hooks/grep-output-truncator.ts
Normal file
131
src/hooks/grep-output-truncator.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
|
||||
const ANTHROPIC_ACTUAL_LIMIT = 200_000
|
||||
const CHARS_PER_TOKEN_ESTIMATE = 4
|
||||
const TARGET_MAX_TOKENS = 50_000
|
||||
|
||||
interface AssistantMessageInfo {
|
||||
role: "assistant"
|
||||
tokens: {
|
||||
input: number
|
||||
output: number
|
||||
reasoning: number
|
||||
cache: { read: number; write: number }
|
||||
}
|
||||
}
|
||||
|
||||
interface MessageWrapper {
|
||||
info: { role: string } & Partial<AssistantMessageInfo>
|
||||
}
|
||||
|
||||
function estimateTokens(text: string): number {
|
||||
return Math.ceil(text.length / CHARS_PER_TOKEN_ESTIMATE)
|
||||
}
|
||||
|
||||
function truncateToTokenLimit(output: string, maxTokens: number): { result: string; truncated: boolean } {
|
||||
const currentTokens = estimateTokens(output)
|
||||
|
||||
if (currentTokens <= maxTokens) {
|
||||
return { result: output, truncated: false }
|
||||
}
|
||||
|
||||
const lines = output.split("\n")
|
||||
|
||||
if (lines.length <= 3) {
|
||||
const maxChars = maxTokens * CHARS_PER_TOKEN_ESTIMATE
|
||||
return {
|
||||
result: output.slice(0, maxChars) + "\n\n[Output truncated due to context window limit]",
|
||||
truncated: true,
|
||||
}
|
||||
}
|
||||
|
||||
const headerLines = lines.slice(0, 3)
|
||||
const contentLines = lines.slice(3)
|
||||
|
||||
const headerText = headerLines.join("\n")
|
||||
const headerTokens = estimateTokens(headerText)
|
||||
const availableTokens = maxTokens - headerTokens - 50
|
||||
|
||||
if (availableTokens <= 0) {
|
||||
return {
|
||||
result: headerText + "\n\n[Content truncated due to context window limit]",
|
||||
truncated: true,
|
||||
}
|
||||
}
|
||||
|
||||
let resultLines: string[] = []
|
||||
let currentTokenCount = 0
|
||||
|
||||
for (const line of contentLines) {
|
||||
const lineTokens = estimateTokens(line + "\n")
|
||||
if (currentTokenCount + lineTokens > availableTokens) {
|
||||
break
|
||||
}
|
||||
resultLines.push(line)
|
||||
currentTokenCount += lineTokens
|
||||
}
|
||||
|
||||
const truncatedContent = [...headerLines, ...resultLines].join("\n")
|
||||
const removedCount = contentLines.length - resultLines.length
|
||||
|
||||
return {
|
||||
result: truncatedContent + `\n\n[${removedCount} more lines truncated due to context window limit]`,
|
||||
truncated: true,
|
||||
}
|
||||
}
|
||||
|
||||
export function createGrepOutputTruncatorHook(ctx: PluginInput) {
|
||||
const GREP_TOOLS = ["safe_grep", "Grep"]
|
||||
|
||||
const toolExecuteAfter = async (
|
||||
input: { tool: string; sessionID: string; callID: string },
|
||||
output: { title: string; output: string; metadata: unknown }
|
||||
) => {
|
||||
if (!GREP_TOOLS.includes(input.tool)) return
|
||||
|
||||
const { sessionID } = input
|
||||
|
||||
try {
|
||||
const response = await ctx.client.session.messages({
|
||||
path: { id: sessionID },
|
||||
})
|
||||
|
||||
const messages = (response.data ?? response) as MessageWrapper[]
|
||||
|
||||
const assistantMessages = messages
|
||||
.filter((m) => m.info.role === "assistant")
|
||||
.map((m) => m.info as AssistantMessageInfo)
|
||||
|
||||
if (assistantMessages.length === 0) return
|
||||
|
||||
const totalInputTokens = assistantMessages.reduce((sum, m) => {
|
||||
const inputTokens = m.tokens?.input ?? 0
|
||||
const cacheReadTokens = m.tokens?.cache?.read ?? 0
|
||||
return sum + inputTokens + cacheReadTokens
|
||||
}, 0)
|
||||
|
||||
const remainingTokens = ANTHROPIC_ACTUAL_LIMIT - totalInputTokens
|
||||
|
||||
const maxOutputTokens = Math.min(
|
||||
remainingTokens * 0.5,
|
||||
TARGET_MAX_TOKENS
|
||||
)
|
||||
|
||||
if (maxOutputTokens <= 0) {
|
||||
output.output = "[Output suppressed - context window exhausted]"
|
||||
return
|
||||
}
|
||||
|
||||
const { result, truncated } = truncateToTokenLimit(output.output, maxOutputTokens)
|
||||
if (truncated) {
|
||||
output.output = result
|
||||
}
|
||||
} catch {
|
||||
// Graceful degradation
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
"tool.execute.after": toolExecuteAfter,
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,11 @@
|
||||
export { createTodoContinuationEnforcer } from "./todo-continuation-enforcer"
|
||||
export { createContextWindowMonitorHook } from "./context-window-monitor"
|
||||
export { createSessionNotification } from "./session-notification"
|
||||
export { createSessionRecoveryHook } from "./session-recovery"
|
||||
export { createCommentCheckerHooks } from "./comment-checker"
|
||||
export { createTodoContinuationEnforcer } from "./todo-continuation-enforcer";
|
||||
export { createContextWindowMonitorHook } from "./context-window-monitor";
|
||||
export { createSessionNotification } from "./session-notification";
|
||||
export { createSessionRecoveryHook } from "./session-recovery";
|
||||
export { createCommentCheckerHooks } from "./comment-checker";
|
||||
export { createGrepOutputTruncatorHook } from "./grep-output-truncator";
|
||||
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";
|
||||
|
||||
@@ -1,461 +0,0 @@
|
||||
/**
|
||||
* Session Recovery - Message State Error Recovery
|
||||
*
|
||||
* Handles FOUR specific scenarios:
|
||||
* 1. tool_use block exists without tool_result
|
||||
* - Recovery: inject tool_result with "cancelled" content
|
||||
*
|
||||
* 2. Thinking block order violation (first block must be thinking)
|
||||
* - Recovery: prepend empty thinking block
|
||||
*
|
||||
* 3. Thinking disabled but message contains thinking blocks
|
||||
* - Recovery: strip thinking/redacted_thinking blocks
|
||||
*
|
||||
* 4. Empty content message (non-empty content required)
|
||||
* - Recovery: delete the empty message via revert
|
||||
*/
|
||||
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import type { createOpencodeClient } from "@opencode-ai/sdk"
|
||||
|
||||
type Client = ReturnType<typeof createOpencodeClient>
|
||||
|
||||
type RecoveryErrorType = "tool_result_missing" | "thinking_block_order" | "thinking_disabled_violation" | "empty_content_message" | null
|
||||
|
||||
interface MessageInfo {
|
||||
id?: string
|
||||
role?: string
|
||||
sessionID?: string
|
||||
parentID?: string
|
||||
error?: unknown
|
||||
}
|
||||
|
||||
interface ToolUsePart {
|
||||
type: "tool_use"
|
||||
id: string
|
||||
name: string
|
||||
input: Record<string, unknown>
|
||||
}
|
||||
|
||||
interface ThinkingPart {
|
||||
type: "thinking"
|
||||
thinking: string
|
||||
}
|
||||
|
||||
interface MessagePart {
|
||||
type: string
|
||||
id?: string
|
||||
text?: string
|
||||
thinking?: string
|
||||
name?: string
|
||||
input?: Record<string, unknown>
|
||||
}
|
||||
|
||||
interface MessageData {
|
||||
info?: MessageInfo
|
||||
parts?: MessagePart[]
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown): string {
|
||||
if (!error) return ""
|
||||
if (typeof error === "string") return error.toLowerCase()
|
||||
const errorObj = error as { data?: { message?: string }; message?: string }
|
||||
return (errorObj.data?.message || errorObj.message || "").toLowerCase()
|
||||
}
|
||||
|
||||
function detectErrorType(error: unknown): RecoveryErrorType {
|
||||
const message = getErrorMessage(error)
|
||||
|
||||
if (message.includes("tool_use") && message.includes("tool_result")) {
|
||||
return "tool_result_missing"
|
||||
}
|
||||
|
||||
if (
|
||||
message.includes("thinking") &&
|
||||
(message.includes("first block") || message.includes("must start with") || message.includes("preceeding"))
|
||||
) {
|
||||
return "thinking_block_order"
|
||||
}
|
||||
|
||||
if (message.includes("thinking is disabled") && message.includes("cannot contain")) {
|
||||
return "thinking_disabled_violation"
|
||||
}
|
||||
|
||||
if (message.includes("non-empty content") || message.includes("must have non-empty content")) {
|
||||
return "empty_content_message"
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function extractToolUseIds(parts: MessagePart[]): string[] {
|
||||
return parts.filter((p): p is ToolUsePart => p.type === "tool_use" && !!p.id).map((p) => p.id)
|
||||
}
|
||||
|
||||
async function recoverToolResultMissing(
|
||||
client: Client,
|
||||
sessionID: string,
|
||||
failedAssistantMsg: MessageData
|
||||
): Promise<boolean> {
|
||||
const parts = failedAssistantMsg.parts || []
|
||||
const toolUseIds = extractToolUseIds(parts)
|
||||
|
||||
if (toolUseIds.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
const toolResultParts = toolUseIds.map((id) => ({
|
||||
type: "tool_result" as const,
|
||||
tool_use_id: id,
|
||||
content: "Operation cancelled by user (ESC pressed)",
|
||||
}))
|
||||
|
||||
try {
|
||||
await client.session.prompt({
|
||||
path: { id: sessionID },
|
||||
// @ts-expect-error - SDK types may not include tool_result parts, but runtime accepts it
|
||||
body: { parts: toolResultParts },
|
||||
})
|
||||
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function recoverThinkingBlockOrder(
|
||||
client: Client,
|
||||
sessionID: string,
|
||||
failedAssistantMsg: MessageData,
|
||||
directory: string
|
||||
): Promise<boolean> {
|
||||
const messageID = failedAssistantMsg.info?.id
|
||||
if (!messageID) {
|
||||
return false
|
||||
}
|
||||
|
||||
const existingParts = failedAssistantMsg.parts || []
|
||||
const patchedParts: MessagePart[] = [{ type: "thinking", thinking: "" } as ThinkingPart, ...existingParts]
|
||||
|
||||
try {
|
||||
// @ts-expect-error - Experimental API
|
||||
await client.message?.update?.({
|
||||
path: { id: messageID },
|
||||
body: { parts: patchedParts },
|
||||
})
|
||||
|
||||
return true
|
||||
} catch {
|
||||
// message.update not available
|
||||
}
|
||||
|
||||
try {
|
||||
// @ts-expect-error - Experimental API
|
||||
await client.session.patch?.({
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
messageID,
|
||||
parts: patchedParts,
|
||||
},
|
||||
})
|
||||
|
||||
return true
|
||||
} catch {
|
||||
// session.patch not available
|
||||
}
|
||||
|
||||
return await fallbackRevertStrategy(client, sessionID, failedAssistantMsg, directory)
|
||||
}
|
||||
|
||||
async function recoverThinkingDisabledViolation(
|
||||
client: Client,
|
||||
sessionID: string,
|
||||
failedAssistantMsg: MessageData
|
||||
): Promise<boolean> {
|
||||
const messageID = failedAssistantMsg.info?.id
|
||||
if (!messageID) {
|
||||
return false
|
||||
}
|
||||
|
||||
const existingParts = failedAssistantMsg.parts || []
|
||||
const strippedParts = existingParts.filter((p) => p.type !== "thinking" && p.type !== "redacted_thinking")
|
||||
|
||||
if (strippedParts.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
// @ts-expect-error - Experimental API
|
||||
await client.message?.update?.({
|
||||
path: { id: messageID },
|
||||
body: { parts: strippedParts },
|
||||
})
|
||||
|
||||
return true
|
||||
} catch {
|
||||
// message.update not available
|
||||
}
|
||||
|
||||
try {
|
||||
// @ts-expect-error - Experimental API
|
||||
await client.session.patch?.({
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
messageID,
|
||||
parts: strippedParts,
|
||||
},
|
||||
})
|
||||
|
||||
return true
|
||||
} catch {
|
||||
// session.patch not available
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
const THINKING_TYPES = new Set(["thinking", "redacted_thinking", "reasoning"])
|
||||
|
||||
function hasNonEmptyOutput(msg: MessageData): boolean {
|
||||
const parts = msg.parts
|
||||
if (!parts || parts.length === 0) return false
|
||||
|
||||
return parts.some((p) => {
|
||||
if (THINKING_TYPES.has(p.type)) return false
|
||||
if (p.type === "step-start" || p.type === "step-finish") return false
|
||||
if (p.type === "text" && p.text && p.text.trim()) return true
|
||||
if (p.type === "tool_use" && p.id) return true
|
||||
if (p.type === "tool_result") return true
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
function findEmptyContentMessage(msgs: MessageData[]): MessageData | null {
|
||||
for (let i = 0; i < msgs.length; i++) {
|
||||
const msg = msgs[i]
|
||||
const isLastMessage = i === msgs.length - 1
|
||||
const isAssistant = msg.info?.role === "assistant"
|
||||
|
||||
if (isLastMessage && isAssistant) continue
|
||||
|
||||
if (!hasNonEmptyOutput(msg)) {
|
||||
return msg
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async function recoverEmptyContentMessage(
|
||||
client: Client,
|
||||
sessionID: string,
|
||||
failedAssistantMsg: MessageData,
|
||||
directory: string
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const messagesResp = await client.session.messages({
|
||||
path: { id: sessionID },
|
||||
query: { directory },
|
||||
})
|
||||
const msgs = (messagesResp as { data?: MessageData[] }).data
|
||||
|
||||
if (!msgs || msgs.length === 0) return false
|
||||
|
||||
const emptyMsg = findEmptyContentMessage(msgs) || failedAssistantMsg
|
||||
const messageID = emptyMsg.info?.id
|
||||
if (!messageID) return false
|
||||
|
||||
const existingParts = emptyMsg.parts || []
|
||||
const hasOnlyThinkingOrMeta = existingParts.length > 0 && existingParts.every(
|
||||
(p) => THINKING_TYPES.has(p.type) || p.type === "step-start" || p.type === "step-finish"
|
||||
)
|
||||
|
||||
if (hasOnlyThinkingOrMeta) {
|
||||
const strippedParts: MessagePart[] = [{ type: "text", text: "(interrupted)" }]
|
||||
|
||||
try {
|
||||
// @ts-expect-error - Experimental API
|
||||
await client.message?.update?.({
|
||||
path: { id: messageID },
|
||||
body: { parts: strippedParts },
|
||||
})
|
||||
return true
|
||||
} catch {
|
||||
// message.update not available
|
||||
}
|
||||
|
||||
try {
|
||||
// @ts-expect-error - Experimental API
|
||||
await client.session.patch?.({
|
||||
path: { id: sessionID },
|
||||
body: { messageID, parts: strippedParts },
|
||||
})
|
||||
return true
|
||||
} catch {
|
||||
// session.patch not available
|
||||
}
|
||||
}
|
||||
|
||||
const revertTargetID = emptyMsg.info?.parentID || messageID
|
||||
await client.session.revert({
|
||||
path: { id: sessionID },
|
||||
body: { messageID: revertTargetID },
|
||||
query: { directory },
|
||||
})
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function fallbackRevertStrategy(
|
||||
client: Client,
|
||||
sessionID: string,
|
||||
failedAssistantMsg: MessageData,
|
||||
directory: string
|
||||
): Promise<boolean> {
|
||||
const parentMsgID = failedAssistantMsg.info?.parentID
|
||||
|
||||
const messagesResp = await client.session.messages({
|
||||
path: { id: sessionID },
|
||||
query: { directory },
|
||||
})
|
||||
const msgs = (messagesResp as { data?: MessageData[] }).data
|
||||
if (!msgs || msgs.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
let targetUserMsg: MessageData | null = null
|
||||
if (parentMsgID) {
|
||||
targetUserMsg = msgs.find((m) => m.info?.id === parentMsgID) ?? null
|
||||
}
|
||||
if (!targetUserMsg) {
|
||||
for (let i = msgs.length - 1; i >= 0; i--) {
|
||||
if (msgs[i].info?.role === "user") {
|
||||
targetUserMsg = msgs[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!targetUserMsg?.parts?.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
await client.session.revert({
|
||||
path: { id: sessionID },
|
||||
body: { messageID: targetUserMsg.info?.id ?? "" },
|
||||
query: { directory },
|
||||
})
|
||||
|
||||
const textParts = targetUserMsg.parts
|
||||
.filter((p) => p.type === "text" && p.text)
|
||||
.map((p) => ({ type: "text" as const, text: p.text ?? "" }))
|
||||
|
||||
if (textParts.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
await client.session.prompt({
|
||||
path: { id: sessionID },
|
||||
body: { parts: textParts },
|
||||
query: { directory },
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export function createSessionRecoveryHook(ctx: PluginInput) {
|
||||
const processingErrors = new Set<string>()
|
||||
let onAbortCallback: ((sessionID: string) => void) | null = null
|
||||
|
||||
const setOnAbortCallback = (callback: (sessionID: string) => void): void => {
|
||||
onAbortCallback = callback
|
||||
}
|
||||
|
||||
const isRecoverableError = (error: unknown): boolean => {
|
||||
return detectErrorType(error) !== null
|
||||
}
|
||||
|
||||
const handleSessionRecovery = async (info: MessageInfo): Promise<boolean> => {
|
||||
if (!info || info.role !== "assistant" || !info.error) return false
|
||||
|
||||
const errorType = detectErrorType(info.error)
|
||||
if (!errorType) return false
|
||||
|
||||
const sessionID = info.sessionID
|
||||
const assistantMsgID = info.id
|
||||
|
||||
if (!sessionID || !assistantMsgID) return false
|
||||
if (processingErrors.has(assistantMsgID)) return false
|
||||
processingErrors.add(assistantMsgID)
|
||||
|
||||
try {
|
||||
await ctx.client.session.abort({ path: { id: sessionID } }).catch(() => {})
|
||||
|
||||
if (onAbortCallback) {
|
||||
onAbortCallback(sessionID)
|
||||
}
|
||||
|
||||
const messagesResp = await ctx.client.session.messages({
|
||||
path: { id: sessionID },
|
||||
query: { directory: ctx.directory },
|
||||
})
|
||||
const msgs = (messagesResp as { data?: MessageData[] }).data
|
||||
|
||||
const failedMsg = msgs?.find((m) => m.info?.id === assistantMsgID)
|
||||
if (!failedMsg) {
|
||||
return false
|
||||
}
|
||||
|
||||
const toastTitles: Record<RecoveryErrorType & string, string> = {
|
||||
tool_result_missing: "Tool Crash Recovery",
|
||||
thinking_block_order: "Thinking Block Recovery",
|
||||
thinking_disabled_violation: "Thinking Strip Recovery",
|
||||
empty_content_message: "Empty Message Recovery",
|
||||
}
|
||||
const toastMessages: Record<RecoveryErrorType & string, string> = {
|
||||
tool_result_missing: "Injecting cancelled tool results...",
|
||||
thinking_block_order: "Fixing message structure...",
|
||||
thinking_disabled_violation: "Stripping thinking blocks...",
|
||||
empty_content_message: "Deleting empty message...",
|
||||
}
|
||||
const toastTitle = toastTitles[errorType]
|
||||
const toastMessage = toastMessages[errorType]
|
||||
|
||||
await ctx.client.tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: toastTitle,
|
||||
message: toastMessage,
|
||||
variant: "warning",
|
||||
duration: 3000,
|
||||
},
|
||||
})
|
||||
.catch(() => {})
|
||||
|
||||
let success = false
|
||||
|
||||
if (errorType === "tool_result_missing") {
|
||||
success = await recoverToolResultMissing(ctx.client, sessionID, failedMsg)
|
||||
} else if (errorType === "thinking_block_order") {
|
||||
success = await recoverThinkingBlockOrder(ctx.client, sessionID, failedMsg, ctx.directory)
|
||||
} 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)
|
||||
}
|
||||
|
||||
return success
|
||||
} catch {
|
||||
return false
|
||||
} finally {
|
||||
processingErrors.delete(assistantMsgID)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
handleSessionRecovery,
|
||||
isRecoverableError,
|
||||
setOnAbortCallback,
|
||||
}
|
||||
}
|
||||
10
src/hooks/session-recovery/constants.ts
Normal file
10
src/hooks/session-recovery/constants.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { join } from "node:path"
|
||||
import { xdgData } from "xdg-basedir"
|
||||
|
||||
export const OPENCODE_STORAGE = join(xdgData ?? "", "opencode", "storage")
|
||||
export const MESSAGE_STORAGE = join(OPENCODE_STORAGE, "message")
|
||||
export const PART_STORAGE = join(OPENCODE_STORAGE, "part")
|
||||
|
||||
export const THINKING_TYPES = new Set(["thinking", "redacted_thinking", "reasoning"])
|
||||
export const META_TYPES = new Set(["step-start", "step-finish"])
|
||||
export const CONTENT_TYPES = new Set(["text", "tool", "tool_use", "tool_result"])
|
||||
281
src/hooks/session-recovery/index.ts
Normal file
281
src/hooks/session-recovery/index.ts
Normal file
@@ -0,0 +1,281 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import type { createOpencodeClient } from "@opencode-ai/sdk"
|
||||
import {
|
||||
findEmptyMessages,
|
||||
findMessagesWithOrphanThinking,
|
||||
findMessagesWithThinkingBlocks,
|
||||
injectTextPart,
|
||||
prependThinkingPart,
|
||||
stripThinkingParts,
|
||||
} from "./storage"
|
||||
import type { MessageData } from "./types"
|
||||
|
||||
type Client = ReturnType<typeof createOpencodeClient>
|
||||
|
||||
type RecoveryErrorType =
|
||||
| "tool_result_missing"
|
||||
| "thinking_block_order"
|
||||
| "thinking_disabled_violation"
|
||||
| "empty_content_message"
|
||||
| null
|
||||
|
||||
interface MessageInfo {
|
||||
id?: string
|
||||
role?: string
|
||||
sessionID?: string
|
||||
parentID?: string
|
||||
error?: unknown
|
||||
}
|
||||
|
||||
interface ToolUsePart {
|
||||
type: "tool_use"
|
||||
id: string
|
||||
name: string
|
||||
input: Record<string, unknown>
|
||||
}
|
||||
|
||||
interface MessagePart {
|
||||
type: string
|
||||
id?: string
|
||||
text?: string
|
||||
thinking?: string
|
||||
name?: string
|
||||
input?: Record<string, unknown>
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown): string {
|
||||
if (!error) return ""
|
||||
if (typeof error === "string") return error.toLowerCase()
|
||||
const errorObj = error as {
|
||||
data?: { message?: string }
|
||||
message?: string
|
||||
error?: { message?: string }
|
||||
}
|
||||
return (errorObj.data?.message || errorObj.error?.message || errorObj.message || "").toLowerCase()
|
||||
}
|
||||
|
||||
function detectErrorType(error: unknown): RecoveryErrorType {
|
||||
const message = getErrorMessage(error)
|
||||
|
||||
if (message.includes("tool_use") && message.includes("tool_result")) {
|
||||
return "tool_result_missing"
|
||||
}
|
||||
|
||||
if (
|
||||
message.includes("thinking") &&
|
||||
(message.includes("first block") || message.includes("must start with") || message.includes("preceeding"))
|
||||
) {
|
||||
return "thinking_block_order"
|
||||
}
|
||||
|
||||
if (message.includes("thinking is disabled") && message.includes("cannot contain")) {
|
||||
return "thinking_disabled_violation"
|
||||
}
|
||||
|
||||
if (message.includes("non-empty content") || message.includes("must have non-empty content")) {
|
||||
return "empty_content_message"
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function extractToolUseIds(parts: MessagePart[]): string[] {
|
||||
return parts.filter((p): p is ToolUsePart => p.type === "tool_use" && !!p.id).map((p) => p.id)
|
||||
}
|
||||
|
||||
async function recoverToolResultMissing(
|
||||
client: Client,
|
||||
sessionID: string,
|
||||
failedAssistantMsg: MessageData
|
||||
): Promise<boolean> {
|
||||
const parts = failedAssistantMsg.parts || []
|
||||
const toolUseIds = extractToolUseIds(parts)
|
||||
|
||||
if (toolUseIds.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
const toolResultParts = toolUseIds.map((id) => ({
|
||||
type: "tool_result" as const,
|
||||
tool_use_id: id,
|
||||
content: "Operation cancelled by user (ESC pressed)",
|
||||
}))
|
||||
|
||||
try {
|
||||
await client.session.prompt({
|
||||
path: { id: sessionID },
|
||||
// @ts-expect-error - SDK types may not include tool_result parts
|
||||
body: { parts: toolResultParts },
|
||||
})
|
||||
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function recoverThinkingBlockOrder(
|
||||
_client: Client,
|
||||
sessionID: string,
|
||||
_failedAssistantMsg: MessageData,
|
||||
_directory: string
|
||||
): Promise<boolean> {
|
||||
const orphanMessages = findMessagesWithOrphanThinking(sessionID)
|
||||
|
||||
if (orphanMessages.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
let anySuccess = false
|
||||
for (const messageID of orphanMessages) {
|
||||
if (prependThinkingPart(sessionID, messageID)) {
|
||||
anySuccess = true
|
||||
}
|
||||
}
|
||||
|
||||
return anySuccess
|
||||
}
|
||||
|
||||
async function recoverThinkingDisabledViolation(
|
||||
_client: Client,
|
||||
sessionID: string,
|
||||
_failedAssistantMsg: MessageData
|
||||
): Promise<boolean> {
|
||||
const messagesWithThinking = findMessagesWithThinkingBlocks(sessionID)
|
||||
|
||||
if (messagesWithThinking.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
let anySuccess = false
|
||||
for (const messageID of messagesWithThinking) {
|
||||
if (stripThinkingParts(messageID)) {
|
||||
anySuccess = true
|
||||
}
|
||||
}
|
||||
|
||||
return anySuccess
|
||||
}
|
||||
|
||||
async function recoverEmptyContentMessage(
|
||||
_client: Client,
|
||||
sessionID: string,
|
||||
failedAssistantMsg: MessageData,
|
||||
_directory: string
|
||||
): Promise<boolean> {
|
||||
const emptyMessageIDs = findEmptyMessages(sessionID)
|
||||
|
||||
if (emptyMessageIDs.length === 0) {
|
||||
const fallbackID = failedAssistantMsg.info?.id
|
||||
if (!fallbackID) return false
|
||||
return injectTextPart(sessionID, fallbackID, "(interrupted)")
|
||||
}
|
||||
|
||||
let anySuccess = false
|
||||
for (const messageID of emptyMessageIDs) {
|
||||
if (injectTextPart(sessionID, messageID, "(interrupted)")) {
|
||||
anySuccess = true
|
||||
}
|
||||
}
|
||||
|
||||
return anySuccess
|
||||
}
|
||||
|
||||
// NOTE: fallbackRevertStrategy was removed (2025-12-08)
|
||||
// Reason: Function was defined but never called - no error recovery paths used it.
|
||||
// All error types have dedicated recovery functions (recoverToolResultMissing,
|
||||
// recoverThinkingBlockOrder, recoverThinkingDisabledViolation, recoverEmptyContentMessage).
|
||||
|
||||
export function createSessionRecoveryHook(ctx: PluginInput) {
|
||||
const processingErrors = new Set<string>()
|
||||
let onAbortCallback: ((sessionID: string) => void) | null = null
|
||||
|
||||
const setOnAbortCallback = (callback: (sessionID: string) => void): void => {
|
||||
onAbortCallback = callback
|
||||
}
|
||||
|
||||
const isRecoverableError = (error: unknown): boolean => {
|
||||
return detectErrorType(error) !== null
|
||||
}
|
||||
|
||||
const handleSessionRecovery = async (info: MessageInfo): Promise<boolean> => {
|
||||
if (!info || info.role !== "assistant" || !info.error) return false
|
||||
|
||||
const errorType = detectErrorType(info.error)
|
||||
if (!errorType) return false
|
||||
|
||||
const sessionID = info.sessionID
|
||||
const assistantMsgID = info.id
|
||||
|
||||
if (!sessionID || !assistantMsgID) return false
|
||||
if (processingErrors.has(assistantMsgID)) return false
|
||||
processingErrors.add(assistantMsgID)
|
||||
|
||||
try {
|
||||
await ctx.client.session.abort({ path: { id: sessionID } }).catch(() => {})
|
||||
|
||||
if (onAbortCallback) {
|
||||
onAbortCallback(sessionID)
|
||||
}
|
||||
|
||||
const messagesResp = await ctx.client.session.messages({
|
||||
path: { id: sessionID },
|
||||
query: { directory: ctx.directory },
|
||||
})
|
||||
const msgs = (messagesResp as { data?: MessageData[] }).data
|
||||
|
||||
const failedMsg = msgs?.find((m) => m.info?.id === assistantMsgID)
|
||||
if (!failedMsg) {
|
||||
return false
|
||||
}
|
||||
|
||||
const toastTitles: Record<RecoveryErrorType & string, string> = {
|
||||
tool_result_missing: "Tool Crash Recovery",
|
||||
thinking_block_order: "Thinking Block Recovery",
|
||||
thinking_disabled_violation: "Thinking Strip Recovery",
|
||||
empty_content_message: "Empty Message Recovery",
|
||||
}
|
||||
const toastMessages: Record<RecoveryErrorType & string, string> = {
|
||||
tool_result_missing: "Injecting cancelled tool results...",
|
||||
thinking_block_order: "Fixing message structure...",
|
||||
thinking_disabled_violation: "Stripping thinking blocks...",
|
||||
empty_content_message: "Fixing empty message...",
|
||||
}
|
||||
|
||||
await ctx.client.tui
|
||||
.showToast({
|
||||
body: {
|
||||
title: toastTitles[errorType],
|
||||
message: toastMessages[errorType],
|
||||
variant: "warning",
|
||||
duration: 3000,
|
||||
},
|
||||
})
|
||||
.catch(() => {})
|
||||
|
||||
let success = false
|
||||
|
||||
if (errorType === "tool_result_missing") {
|
||||
success = await recoverToolResultMissing(ctx.client, sessionID, failedMsg)
|
||||
} else if (errorType === "thinking_block_order") {
|
||||
success = await recoverThinkingBlockOrder(ctx.client, sessionID, failedMsg, ctx.directory)
|
||||
} 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)
|
||||
}
|
||||
|
||||
return success
|
||||
} catch {
|
||||
return false
|
||||
} finally {
|
||||
processingErrors.delete(assistantMsgID)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
handleSessionRecovery,
|
||||
isRecoverableError,
|
||||
setOnAbortCallback,
|
||||
}
|
||||
}
|
||||
235
src/hooks/session-recovery/storage.ts
Normal file
235
src/hooks/session-recovery/storage.ts
Normal file
@@ -0,0 +1,235 @@
|
||||
import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import { MESSAGE_STORAGE, PART_STORAGE, THINKING_TYPES, META_TYPES } from "./constants"
|
||||
import type { StoredMessageMeta, StoredPart, StoredTextPart } from "./types"
|
||||
|
||||
export function generatePartId(): string {
|
||||
const timestamp = Date.now().toString(16)
|
||||
const random = Math.random().toString(36).substring(2, 10)
|
||||
return `prt_${timestamp}${random}`
|
||||
}
|
||||
|
||||
export function getMessageDir(sessionID: string): string {
|
||||
if (!existsSync(MESSAGE_STORAGE)) return ""
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
export function readMessages(sessionID: string): StoredMessageMeta[] {
|
||||
const messageDir = getMessageDir(sessionID)
|
||||
if (!messageDir || !existsSync(messageDir)) return []
|
||||
|
||||
const messages: StoredMessageMeta[] = []
|
||||
for (const file of readdirSync(messageDir)) {
|
||||
if (!file.endsWith(".json")) continue
|
||||
try {
|
||||
const content = readFileSync(join(messageDir, file), "utf-8")
|
||||
messages.push(JSON.parse(content))
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return messages.sort((a, b) => a.id.localeCompare(b.id))
|
||||
}
|
||||
|
||||
export function readParts(messageID: string): StoredPart[] {
|
||||
const partDir = join(PART_STORAGE, messageID)
|
||||
if (!existsSync(partDir)) return []
|
||||
|
||||
const parts: StoredPart[] = []
|
||||
for (const file of readdirSync(partDir)) {
|
||||
if (!file.endsWith(".json")) continue
|
||||
try {
|
||||
const content = readFileSync(join(partDir, file), "utf-8")
|
||||
parts.push(JSON.parse(content))
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return parts
|
||||
}
|
||||
|
||||
export function hasContent(part: StoredPart): boolean {
|
||||
if (THINKING_TYPES.has(part.type)) return false
|
||||
if (META_TYPES.has(part.type)) return false
|
||||
|
||||
if (part.type === "text") {
|
||||
const textPart = part as StoredTextPart
|
||||
return !!(textPart.text?.trim())
|
||||
}
|
||||
|
||||
if (part.type === "tool" || part.type === "tool_use") {
|
||||
return true
|
||||
}
|
||||
|
||||
if (part.type === "tool_result") {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export function messageHasContent(messageID: string): boolean {
|
||||
const parts = readParts(messageID)
|
||||
return parts.some(hasContent)
|
||||
}
|
||||
|
||||
export function injectTextPart(sessionID: string, messageID: string, text: string): boolean {
|
||||
const partDir = join(PART_STORAGE, messageID)
|
||||
|
||||
if (!existsSync(partDir)) {
|
||||
mkdirSync(partDir, { recursive: true })
|
||||
}
|
||||
|
||||
const partId = generatePartId()
|
||||
const part: StoredTextPart = {
|
||||
id: partId,
|
||||
sessionID,
|
||||
messageID,
|
||||
type: "text",
|
||||
text,
|
||||
synthetic: true,
|
||||
}
|
||||
|
||||
try {
|
||||
writeFileSync(join(partDir, `${partId}.json`), JSON.stringify(part, null, 2))
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
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]
|
||||
if (msg.role !== "assistant") continue
|
||||
|
||||
const isLastMessage = i === messages.length - 1
|
||||
if (isLastMessage) continue
|
||||
|
||||
if (!messageHasContent(msg.id)) {
|
||||
emptyIds.push(msg.id)
|
||||
}
|
||||
}
|
||||
|
||||
return emptyIds
|
||||
}
|
||||
|
||||
export function findFirstEmptyMessage(sessionID: string): string | null {
|
||||
const emptyIds = findEmptyMessages(sessionID)
|
||||
return emptyIds.length > 0 ? emptyIds[0] : null
|
||||
}
|
||||
|
||||
export function findMessagesWithThinkingBlocks(sessionID: string): string[] {
|
||||
const messages = readMessages(sessionID)
|
||||
const result: string[] = []
|
||||
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const msg = messages[i]
|
||||
if (msg.role !== "assistant") continue
|
||||
|
||||
const isLastMessage = i === messages.length - 1
|
||||
if (isLastMessage) continue
|
||||
|
||||
const parts = readParts(msg.id)
|
||||
const hasThinking = parts.some((p) => THINKING_TYPES.has(p.type))
|
||||
if (hasThinking) {
|
||||
result.push(msg.id)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export function findMessagesWithOrphanThinking(sessionID: string): string[] {
|
||||
const messages = readMessages(sessionID)
|
||||
const result: string[] = []
|
||||
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const msg = messages[i]
|
||||
if (msg.role !== "assistant") continue
|
||||
|
||||
const isLastMessage = i === messages.length - 1
|
||||
if (isLastMessage) continue
|
||||
|
||||
const parts = readParts(msg.id)
|
||||
if (parts.length === 0) continue
|
||||
|
||||
const sortedParts = [...parts].sort((a, b) => a.id.localeCompare(b.id))
|
||||
const firstPart = sortedParts[0]
|
||||
|
||||
const hasThinking = parts.some((p) => THINKING_TYPES.has(p.type))
|
||||
const firstIsThinking = THINKING_TYPES.has(firstPart.type)
|
||||
|
||||
if (hasThinking && !firstIsThinking) {
|
||||
result.push(msg.id)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
export function prependThinkingPart(sessionID: string, messageID: string): boolean {
|
||||
const partDir = join(PART_STORAGE, messageID)
|
||||
|
||||
if (!existsSync(partDir)) {
|
||||
mkdirSync(partDir, { recursive: true })
|
||||
}
|
||||
|
||||
const partId = `prt_0000000000_thinking`
|
||||
const part = {
|
||||
id: partId,
|
||||
sessionID,
|
||||
messageID,
|
||||
type: "thinking",
|
||||
thinking: "",
|
||||
synthetic: true,
|
||||
}
|
||||
|
||||
try {
|
||||
writeFileSync(join(partDir, `${partId}.json`), JSON.stringify(part, null, 2))
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function stripThinkingParts(messageID: string): boolean {
|
||||
const partDir = join(PART_STORAGE, messageID)
|
||||
if (!existsSync(partDir)) return false
|
||||
|
||||
let anyRemoved = false
|
||||
for (const file of readdirSync(partDir)) {
|
||||
if (!file.endsWith(".json")) continue
|
||||
try {
|
||||
const filePath = join(partDir, file)
|
||||
const content = readFileSync(filePath, "utf-8")
|
||||
const part = JSON.parse(content) as StoredPart
|
||||
if (THINKING_TYPES.has(part.type)) {
|
||||
unlinkSync(filePath)
|
||||
anyRemoved = true
|
||||
}
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return anyRemoved
|
||||
}
|
||||
82
src/hooks/session-recovery/types.ts
Normal file
82
src/hooks/session-recovery/types.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
export type ThinkingPartType = "thinking" | "redacted_thinking" | "reasoning"
|
||||
export type MetaPartType = "step-start" | "step-finish"
|
||||
export type ContentPartType = "text" | "tool" | "tool_use" | "tool_result"
|
||||
|
||||
export interface StoredMessageMeta {
|
||||
id: string
|
||||
sessionID: string
|
||||
role: "user" | "assistant"
|
||||
parentID?: string
|
||||
time?: {
|
||||
created: number
|
||||
completed?: number
|
||||
}
|
||||
error?: unknown
|
||||
}
|
||||
|
||||
export interface StoredTextPart {
|
||||
id: string
|
||||
sessionID: string
|
||||
messageID: string
|
||||
type: "text"
|
||||
text: string
|
||||
synthetic?: boolean
|
||||
ignored?: boolean
|
||||
}
|
||||
|
||||
export interface StoredToolPart {
|
||||
id: string
|
||||
sessionID: string
|
||||
messageID: string
|
||||
type: "tool"
|
||||
callID: string
|
||||
tool: string
|
||||
state: {
|
||||
status: "pending" | "running" | "completed" | "error"
|
||||
input: Record<string, unknown>
|
||||
output?: string
|
||||
error?: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface StoredReasoningPart {
|
||||
id: string
|
||||
sessionID: string
|
||||
messageID: string
|
||||
type: "reasoning"
|
||||
text: string
|
||||
}
|
||||
|
||||
export interface StoredStepPart {
|
||||
id: string
|
||||
sessionID: string
|
||||
messageID: string
|
||||
type: "step-start" | "step-finish"
|
||||
}
|
||||
|
||||
export type StoredPart = StoredTextPart | StoredToolPart | StoredReasoningPart | StoredStepPart | {
|
||||
id: string
|
||||
sessionID: string
|
||||
messageID: string
|
||||
type: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface MessageData {
|
||||
info?: {
|
||||
id?: string
|
||||
role?: string
|
||||
sessionID?: string
|
||||
parentID?: string
|
||||
error?: unknown
|
||||
}
|
||||
parts?: Array<{
|
||||
type: string
|
||||
id?: string
|
||||
text?: string
|
||||
thinking?: string
|
||||
name?: string
|
||||
input?: Record<string, unknown>
|
||||
callID?: string
|
||||
}>
|
||||
}
|
||||
57
src/hooks/think-mode/detector.ts
Normal file
57
src/hooks/think-mode/detector.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
const ENGLISH_PATTERNS = [/\bultrathink\b/i, /\bthink\b/i]
|
||||
|
||||
const MULTILINGUAL_KEYWORDS = [
|
||||
"생각", "고민", "검토", "제대로",
|
||||
"思考", "考虑", "考慮",
|
||||
"思考", "考え", "熟考",
|
||||
"सोच", "विचार",
|
||||
"تفكير", "تأمل",
|
||||
"চিন্তা", "ভাবনা",
|
||||
"думать", "думай", "размышлять", "размышляй",
|
||||
"pensar", "pense", "refletir", "reflita",
|
||||
"pensar", "piensa", "reflexionar", "reflexiona",
|
||||
"penser", "pense", "réfléchir", "réfléchis",
|
||||
"denken", "denk", "nachdenken",
|
||||
"suy nghĩ", "cân nhắc",
|
||||
"düşün", "düşünmek",
|
||||
"pensare", "pensa", "riflettere", "rifletti",
|
||||
"คิด", "พิจารณา",
|
||||
"myśl", "myśleć", "zastanów",
|
||||
"denken", "denk", "nadenken",
|
||||
"berpikir", "pikir", "pertimbangkan",
|
||||
"думати", "думай", "роздумувати",
|
||||
"σκέψου", "σκέφτομαι",
|
||||
"myslet", "mysli", "přemýšlet",
|
||||
"gândește", "gândi", "reflectă",
|
||||
"tänka", "tänk", "fundera",
|
||||
"gondolkodj", "gondolkodni",
|
||||
"ajattele", "ajatella", "pohdi",
|
||||
"tænk", "tænke", "overvej",
|
||||
"tenk", "tenke", "gruble",
|
||||
"חשוב", "לחשוב", "להרהר",
|
||||
"fikir", "berfikir",
|
||||
]
|
||||
|
||||
const MULTILINGUAL_PATTERNS = MULTILINGUAL_KEYWORDS.map((kw) => new RegExp(kw, "i"))
|
||||
const THINK_PATTERNS = [...ENGLISH_PATTERNS, ...MULTILINGUAL_PATTERNS]
|
||||
|
||||
const CODE_BLOCK_PATTERN = /```[\s\S]*?```/g
|
||||
const INLINE_CODE_PATTERN = /`[^`]+`/g
|
||||
|
||||
function removeCodeBlocks(text: string): string {
|
||||
return text.replace(CODE_BLOCK_PATTERN, "").replace(INLINE_CODE_PATTERN, "")
|
||||
}
|
||||
|
||||
export function detectThinkKeyword(text: string): boolean {
|
||||
const textWithoutCode = removeCodeBlocks(text)
|
||||
return THINK_PATTERNS.some((pattern) => pattern.test(textWithoutCode))
|
||||
}
|
||||
|
||||
export function extractPromptText(
|
||||
parts: Array<{ type: string; text?: string }>
|
||||
): string {
|
||||
return parts
|
||||
.filter((p) => p.type === "text")
|
||||
.map((p) => p.text || "")
|
||||
.join("")
|
||||
}
|
||||
73
src/hooks/think-mode/index.ts
Normal file
73
src/hooks/think-mode/index.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { detectThinkKeyword, extractPromptText } from "./detector"
|
||||
import { getHighVariant, isAlreadyHighVariant } from "./switcher"
|
||||
import type { ThinkModeState, ThinkModeInput } from "./types"
|
||||
|
||||
export * from "./detector"
|
||||
export * from "./switcher"
|
||||
export * from "./types"
|
||||
|
||||
const thinkModeState = new Map<string, ThinkModeState>()
|
||||
|
||||
export function clearThinkModeState(sessionID: string): void {
|
||||
thinkModeState.delete(sessionID)
|
||||
}
|
||||
|
||||
export function createThinkModeHook() {
|
||||
return {
|
||||
"chat.params": async (
|
||||
output: ThinkModeInput,
|
||||
sessionID: string
|
||||
): Promise<void> => {
|
||||
const promptText = extractPromptText(output.parts)
|
||||
|
||||
const state: ThinkModeState = {
|
||||
requested: false,
|
||||
modelSwitched: false,
|
||||
}
|
||||
|
||||
if (!detectThinkKeyword(promptText)) {
|
||||
thinkModeState.set(sessionID, state)
|
||||
return
|
||||
}
|
||||
|
||||
state.requested = true
|
||||
|
||||
const currentModel = output.message.model
|
||||
if (!currentModel) {
|
||||
thinkModeState.set(sessionID, state)
|
||||
return
|
||||
}
|
||||
|
||||
state.providerID = currentModel.providerID
|
||||
state.modelID = currentModel.modelID
|
||||
|
||||
if (isAlreadyHighVariant(currentModel.modelID)) {
|
||||
thinkModeState.set(sessionID, state)
|
||||
return
|
||||
}
|
||||
|
||||
const highVariant = getHighVariant(currentModel.modelID)
|
||||
|
||||
if (!highVariant) {
|
||||
thinkModeState.set(sessionID, state)
|
||||
return
|
||||
}
|
||||
|
||||
output.message.model = {
|
||||
providerID: currentModel.providerID,
|
||||
modelID: highVariant,
|
||||
}
|
||||
state.modelSwitched = true
|
||||
thinkModeState.set(sessionID, state)
|
||||
},
|
||||
|
||||
event: async ({ event }: { event: { type: string; properties?: unknown } }) => {
|
||||
if (event.type === "session.deleted") {
|
||||
const props = event.properties as { info?: { id?: string } } | undefined
|
||||
if (props?.info?.id) {
|
||||
thinkModeState.delete(props.info.id)
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
91
src/hooks/think-mode/switcher.ts
Normal file
91
src/hooks/think-mode/switcher.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
const HIGH_VARIANT_MAP: Record<string, string> = {
|
||||
"claude-sonnet-4-5": "claude-sonnet-4-5-high",
|
||||
"claude-opus-4-5": "claude-opus-4-5-high",
|
||||
"gpt-5.1": "gpt-5.1-high",
|
||||
"gpt-5.1-medium": "gpt-5.1-high",
|
||||
"gpt-5.1-codex": "gpt-5.1-codex-high",
|
||||
"gemini-3-pro": "gemini-3-pro-high",
|
||||
"gemini-3-pro-low": "gemini-3-pro-high",
|
||||
}
|
||||
|
||||
const ALREADY_HIGH: Set<string> = new Set([
|
||||
"claude-sonnet-4-5-high",
|
||||
"claude-opus-4-5-high",
|
||||
"gpt-5.1-high",
|
||||
"gpt-5.1-codex-high",
|
||||
"gemini-3-pro-high",
|
||||
])
|
||||
|
||||
export const THINKING_CONFIGS: Record<string, Record<string, unknown>> = {
|
||||
anthropic: {
|
||||
thinking: {
|
||||
type: "enabled",
|
||||
budgetTokens: 64000,
|
||||
},
|
||||
},
|
||||
"amazon-bedrock": {
|
||||
reasoningConfig: {
|
||||
type: "enabled",
|
||||
budgetTokens: 32000,
|
||||
},
|
||||
},
|
||||
google: {
|
||||
providerOptions: {
|
||||
google: {
|
||||
thinkingConfig: {
|
||||
thinkingLevel: "HIGH",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"google-vertex": {
|
||||
providerOptions: {
|
||||
"google-vertex": {
|
||||
thinkingConfig: {
|
||||
thinkingLevel: "HIGH",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const THINKING_CAPABLE_MODELS: Record<string, string[]> = {
|
||||
anthropic: ["claude-sonnet-4", "claude-opus-4", "claude-3"],
|
||||
"amazon-bedrock": ["claude", "anthropic"],
|
||||
google: ["gemini-2", "gemini-3"],
|
||||
"google-vertex": ["gemini-2", "gemini-3"],
|
||||
}
|
||||
|
||||
export function getHighVariant(modelID: string): string | null {
|
||||
if (ALREADY_HIGH.has(modelID)) {
|
||||
return null
|
||||
}
|
||||
return HIGH_VARIANT_MAP[modelID] ?? null
|
||||
}
|
||||
|
||||
export function isAlreadyHighVariant(modelID: string): boolean {
|
||||
return ALREADY_HIGH.has(modelID) || modelID.endsWith("-high")
|
||||
}
|
||||
|
||||
export function getThinkingConfig(
|
||||
providerID: string,
|
||||
modelID: string
|
||||
): Record<string, unknown> | null {
|
||||
if (isAlreadyHighVariant(modelID)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const config = THINKING_CONFIGS[providerID]
|
||||
const capablePatterns = THINKING_CAPABLE_MODELS[providerID]
|
||||
|
||||
if (!config || !capablePatterns) {
|
||||
return null
|
||||
}
|
||||
|
||||
const modelLower = modelID.toLowerCase()
|
||||
const isCapable = capablePatterns.some((pattern) =>
|
||||
modelLower.includes(pattern.toLowerCase())
|
||||
)
|
||||
|
||||
return isCapable ? config : null
|
||||
}
|
||||
20
src/hooks/think-mode/types.ts
Normal file
20
src/hooks/think-mode/types.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export interface ThinkModeState {
|
||||
requested: boolean
|
||||
modelSwitched: boolean
|
||||
providerID?: string
|
||||
modelID?: string
|
||||
}
|
||||
|
||||
export interface ModelRef {
|
||||
providerID: string
|
||||
modelID: string
|
||||
}
|
||||
|
||||
export interface MessageWithModel {
|
||||
model?: ModelRef
|
||||
}
|
||||
|
||||
export interface ThinkModeInput {
|
||||
parts: Array<{ type: string; text?: string }>
|
||||
message: MessageWithModel
|
||||
}
|
||||
@@ -7,18 +7,13 @@ interface Todo {
|
||||
id: string
|
||||
}
|
||||
|
||||
const CONTINUATION_PROMPT = `[SYSTEM REMINDER - TODO ENFORCEMENT]
|
||||
const CONTINUATION_PROMPT = `[SYSTEM REMINDER - TODO CONTINUATION]
|
||||
|
||||
Your todo list is NOT complete. There are still incomplete tasks remaining.
|
||||
Incomplete tasks remain in your todo list. Continue working on the next pending task.
|
||||
|
||||
CRITICAL INSTRUCTION:
|
||||
- You MUST NOT stop working until ALL todos are marked as completed
|
||||
- Continue working on the next pending task immediately
|
||||
- Work honestly and diligently to finish every task
|
||||
- Do NOT ask for permission to continue - just proceed with the work
|
||||
- Mark each task as completed as soon as you finish it
|
||||
|
||||
Resume your work NOW.`
|
||||
- Proceed without asking for permission
|
||||
- Mark each task complete when finished
|
||||
- Do not stop until all tasks are done`
|
||||
|
||||
function detectInterrupt(error: unknown): boolean {
|
||||
if (!error) return false
|
||||
@@ -113,7 +108,7 @@ export function createTodoContinuationEnforcer(ctx: PluginInput) {
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: `${CONTINUATION_PROMPT}\n\n[Status: ${incomplete.length}/${todos.length} tasks remaining]`,
|
||||
text: `${CONTINUATION_PROMPT}\n\n[Status: ${todos.length - incomplete.length}/${todos.length} completed, ${incomplete.length} remaining]`,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
252
src/index.ts
252
src/index.ts
@@ -1,133 +1,195 @@
|
||||
import type { Plugin } from "@opencode-ai/plugin"
|
||||
import { createBuiltinAgents } from "./agents"
|
||||
import { createTodoContinuationEnforcer, createContextWindowMonitorHook, createSessionRecoveryHook, createCommentCheckerHooks } from "./hooks"
|
||||
import { updateTerminalTitle } from "./features/terminal"
|
||||
import { builtinTools } from "./tools"
|
||||
import { createBuiltinMcps } from "./mcp"
|
||||
import { OhMyOpenCodeConfigSchema, type OhMyOpenCodeConfig } from "./config"
|
||||
import * as fs from "fs"
|
||||
import * as path from "path"
|
||||
import type { Plugin } from "@opencode-ai/plugin";
|
||||
import { createBuiltinAgents } from "./agents";
|
||||
import {
|
||||
createTodoContinuationEnforcer,
|
||||
createContextWindowMonitorHook,
|
||||
createSessionRecoveryHook,
|
||||
createCommentCheckerHooks,
|
||||
createGrepOutputTruncatorHook,
|
||||
createDirectoryAgentsInjectorHook,
|
||||
createEmptyTaskResponseDetectorHook,
|
||||
createThinkModeHook,
|
||||
createClaudeCodeHooksHook,
|
||||
} from "./hooks";
|
||||
import {
|
||||
loadUserCommands,
|
||||
loadProjectCommands,
|
||||
loadOpencodeGlobalCommands,
|
||||
loadOpencodeProjectCommands,
|
||||
} from "./features/claude-code-command-loader";
|
||||
import {
|
||||
loadUserSkillsAsCommands,
|
||||
loadProjectSkillsAsCommands,
|
||||
} from "./features/claude-code-skill-loader";
|
||||
import {
|
||||
loadUserAgents,
|
||||
loadProjectAgents,
|
||||
} from "./features/claude-code-agent-loader";
|
||||
import { loadMcpConfigs } from "./features/claude-code-mcp-loader";
|
||||
import {
|
||||
setCurrentSession,
|
||||
setMainSession,
|
||||
getMainSessionID,
|
||||
getCurrentSessionTitle,
|
||||
} from "./features/claude-code-session-state";
|
||||
import { updateTerminalTitle } from "./features/terminal";
|
||||
import { builtinTools } from "./tools";
|
||||
import { createBuiltinMcps } from "./mcp";
|
||||
import { OhMyOpenCodeConfigSchema, type OhMyOpenCodeConfig } from "./config";
|
||||
import { log } from "./shared/logger";
|
||||
import * as fs from "fs";
|
||||
import * as path from "path";
|
||||
|
||||
function loadPluginConfig(directory: string): OhMyOpenCodeConfig {
|
||||
const configPaths = [
|
||||
path.join(directory, "oh-my-opencode.json"),
|
||||
path.join(directory, ".oh-my-opencode.json"),
|
||||
]
|
||||
];
|
||||
|
||||
for (const configPath of configPaths) {
|
||||
try {
|
||||
if (fs.existsSync(configPath)) {
|
||||
const content = fs.readFileSync(configPath, "utf-8")
|
||||
const rawConfig = JSON.parse(content)
|
||||
const result = OhMyOpenCodeConfigSchema.safeParse(rawConfig)
|
||||
const content = fs.readFileSync(configPath, "utf-8");
|
||||
const rawConfig = JSON.parse(content);
|
||||
const result = OhMyOpenCodeConfigSchema.safeParse(rawConfig);
|
||||
|
||||
if (!result.success) {
|
||||
console.error(`[oh-my-opencode] Config validation error in ${configPath}:`)
|
||||
for (const issue of result.error.issues) {
|
||||
console.error(` - ${issue.path.join(".")}: ${issue.message}`)
|
||||
}
|
||||
return {}
|
||||
log(`Config validation error in ${configPath}:`, result.error.issues);
|
||||
return {};
|
||||
}
|
||||
|
||||
return result.data
|
||||
return result.data;
|
||||
}
|
||||
} catch {
|
||||
// Ignore parse errors, use defaults
|
||||
}
|
||||
}
|
||||
|
||||
return {}
|
||||
return {};
|
||||
}
|
||||
|
||||
const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
const todoContinuationEnforcer = createTodoContinuationEnforcer(ctx)
|
||||
const contextWindowMonitor = createContextWindowMonitorHook(ctx)
|
||||
const sessionRecovery = createSessionRecoveryHook(ctx)
|
||||
const commentChecker = createCommentCheckerHooks()
|
||||
const pluginConfig = loadPluginConfig(ctx.directory);
|
||||
|
||||
updateTerminalTitle({ sessionId: "main" })
|
||||
const todoContinuationEnforcer = createTodoContinuationEnforcer(ctx);
|
||||
const contextWindowMonitor = createContextWindowMonitorHook(ctx);
|
||||
const sessionRecovery = createSessionRecoveryHook(ctx);
|
||||
const commentChecker = createCommentCheckerHooks();
|
||||
const grepOutputTruncator = createGrepOutputTruncatorHook(ctx);
|
||||
const directoryAgentsInjector = createDirectoryAgentsInjectorHook(ctx);
|
||||
const emptyTaskResponseDetector = createEmptyTaskResponseDetectorHook(ctx);
|
||||
const thinkMode = createThinkModeHook();
|
||||
const claudeCodeHooks = createClaudeCodeHooksHook(ctx, {});
|
||||
|
||||
const pluginConfig = loadPluginConfig(ctx.directory)
|
||||
|
||||
let mainSessionID: string | undefined
|
||||
let currentSessionID: string | undefined
|
||||
let currentSessionTitle: string | undefined
|
||||
updateTerminalTitle({ sessionId: "main" });
|
||||
|
||||
return {
|
||||
tool: builtinTools,
|
||||
|
||||
"chat.message": async (input, output) => {
|
||||
await claudeCodeHooks["chat.message"]?.(input, output)
|
||||
},
|
||||
|
||||
config: async (config) => {
|
||||
const agents = createBuiltinAgents(
|
||||
const builtinAgents = createBuiltinAgents(
|
||||
pluginConfig.disabled_agents,
|
||||
pluginConfig.agents
|
||||
)
|
||||
pluginConfig.agents,
|
||||
);
|
||||
const userAgents = loadUserAgents();
|
||||
const projectAgents = loadProjectAgents();
|
||||
|
||||
config.agent = {
|
||||
...builtinAgents,
|
||||
...userAgents,
|
||||
...projectAgents,
|
||||
...config.agent,
|
||||
...agents,
|
||||
}
|
||||
};
|
||||
config.tools = {
|
||||
...config.tools,
|
||||
grep: false,
|
||||
}
|
||||
};
|
||||
|
||||
const mcpResult = await loadMcpConfigs();
|
||||
config.mcp = {
|
||||
...config.mcp,
|
||||
...createBuiltinMcps(pluginConfig.disabled_mcps),
|
||||
}
|
||||
...mcpResult.servers,
|
||||
};
|
||||
|
||||
const userCommands = loadUserCommands();
|
||||
const opencodeGlobalCommands = loadOpencodeGlobalCommands();
|
||||
const systemCommands = config.command ?? {};
|
||||
const projectCommands = loadProjectCommands();
|
||||
const opencodeProjectCommands = loadOpencodeProjectCommands();
|
||||
const userSkills = loadUserSkillsAsCommands();
|
||||
const projectSkills = loadProjectSkillsAsCommands();
|
||||
|
||||
config.command = {
|
||||
...userCommands,
|
||||
...userSkills,
|
||||
...opencodeGlobalCommands,
|
||||
...systemCommands,
|
||||
...projectCommands,
|
||||
...projectSkills,
|
||||
...opencodeProjectCommands,
|
||||
};
|
||||
},
|
||||
|
||||
event: async (input) => {
|
||||
await todoContinuationEnforcer(input)
|
||||
await contextWindowMonitor.event(input)
|
||||
await claudeCodeHooks.event(input);
|
||||
await todoContinuationEnforcer(input);
|
||||
await contextWindowMonitor.event(input);
|
||||
await directoryAgentsInjector.event(input);
|
||||
await thinkMode.event(input);
|
||||
|
||||
const { event } = input
|
||||
const props = event.properties as Record<string, unknown> | undefined
|
||||
const { event } = input;
|
||||
const props = event.properties as Record<string, unknown> | undefined;
|
||||
|
||||
if (event.type === "session.created") {
|
||||
const sessionInfo = props?.info as { id?: string; title?: string; parentID?: string } | undefined
|
||||
const sessionInfo = props?.info as
|
||||
| { id?: string; title?: string; parentID?: string }
|
||||
| undefined;
|
||||
if (!sessionInfo?.parentID) {
|
||||
mainSessionID = sessionInfo?.id
|
||||
currentSessionID = sessionInfo?.id
|
||||
currentSessionTitle = sessionInfo?.title
|
||||
setMainSession(sessionInfo?.id);
|
||||
setCurrentSession(sessionInfo?.id, sessionInfo?.title);
|
||||
updateTerminalTitle({
|
||||
sessionId: currentSessionID || "main",
|
||||
sessionId: sessionInfo?.id || "main",
|
||||
status: "idle",
|
||||
directory: ctx.directory,
|
||||
sessionTitle: currentSessionTitle,
|
||||
})
|
||||
sessionTitle: sessionInfo?.title,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "session.updated") {
|
||||
const sessionInfo = props?.info as { id?: string; title?: string; parentID?: string } | undefined
|
||||
const sessionInfo = props?.info as
|
||||
| { id?: string; title?: string; parentID?: string }
|
||||
| undefined;
|
||||
if (!sessionInfo?.parentID) {
|
||||
currentSessionID = sessionInfo?.id
|
||||
currentSessionTitle = sessionInfo?.title
|
||||
setCurrentSession(sessionInfo?.id, sessionInfo?.title);
|
||||
updateTerminalTitle({
|
||||
sessionId: currentSessionID || "main",
|
||||
sessionId: sessionInfo?.id || "main",
|
||||
status: "processing",
|
||||
directory: ctx.directory,
|
||||
sessionTitle: currentSessionTitle,
|
||||
})
|
||||
sessionTitle: sessionInfo?.title,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "session.deleted") {
|
||||
const sessionInfo = props?.info as { id?: string } | undefined
|
||||
if (sessionInfo?.id === mainSessionID) {
|
||||
mainSessionID = undefined
|
||||
currentSessionID = undefined
|
||||
currentSessionTitle = undefined
|
||||
const sessionInfo = props?.info as { id?: string } | undefined;
|
||||
if (sessionInfo?.id === getMainSessionID()) {
|
||||
setMainSession(undefined);
|
||||
setCurrentSession(undefined, undefined);
|
||||
updateTerminalTitle({
|
||||
sessionId: "main",
|
||||
status: "idle",
|
||||
})
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "session.error") {
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
const error = props?.error
|
||||
const sessionID = props?.sessionID as string | undefined;
|
||||
const error = props?.error;
|
||||
|
||||
if (sessionRecovery.isRecoverableError(error)) {
|
||||
const messageInfo = {
|
||||
@@ -135,72 +197,80 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
role: "assistant" as const,
|
||||
sessionID,
|
||||
error,
|
||||
}
|
||||
const recovered = await sessionRecovery.handleSessionRecovery(messageInfo)
|
||||
};
|
||||
const recovered =
|
||||
await sessionRecovery.handleSessionRecovery(messageInfo);
|
||||
|
||||
if (recovered && sessionID && sessionID === mainSessionID) {
|
||||
await ctx.client.session.prompt({
|
||||
path: { id: sessionID },
|
||||
body: { parts: [{ type: "text", text: "continue" }] },
|
||||
query: { directory: ctx.directory },
|
||||
}).catch(() => {})
|
||||
if (recovered && sessionID && sessionID === getMainSessionID()) {
|
||||
await ctx.client.session
|
||||
.prompt({
|
||||
path: { id: sessionID },
|
||||
body: { parts: [{ type: "text", text: "continue" }] },
|
||||
query: { directory: ctx.directory },
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
if (sessionID && sessionID === mainSessionID) {
|
||||
if (sessionID && sessionID === getMainSessionID()) {
|
||||
updateTerminalTitle({
|
||||
sessionId: sessionID,
|
||||
status: "error",
|
||||
directory: ctx.directory,
|
||||
sessionTitle: currentSessionTitle,
|
||||
})
|
||||
sessionTitle: getCurrentSessionTitle(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (event.type === "session.idle") {
|
||||
const sessionID = props?.sessionID as string | undefined
|
||||
if (sessionID && sessionID === mainSessionID) {
|
||||
const sessionID = props?.sessionID as string | undefined;
|
||||
if (sessionID && sessionID === getMainSessionID()) {
|
||||
updateTerminalTitle({
|
||||
sessionId: sessionID,
|
||||
status: "idle",
|
||||
directory: ctx.directory,
|
||||
sessionTitle: currentSessionTitle,
|
||||
})
|
||||
sessionTitle: getCurrentSessionTitle(),
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
"tool.execute.before": async (input, output) => {
|
||||
await commentChecker["tool.execute.before"](input, output)
|
||||
await claudeCodeHooks["tool.execute.before"](input, output);
|
||||
await commentChecker["tool.execute.before"](input, output);
|
||||
|
||||
if (input.sessionID === mainSessionID) {
|
||||
if (input.sessionID === getMainSessionID()) {
|
||||
updateTerminalTitle({
|
||||
sessionId: input.sessionID,
|
||||
status: "tool",
|
||||
currentTool: input.tool,
|
||||
directory: ctx.directory,
|
||||
sessionTitle: currentSessionTitle,
|
||||
})
|
||||
sessionTitle: getCurrentSessionTitle(),
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
"tool.execute.after": async (input, output) => {
|
||||
await contextWindowMonitor["tool.execute.after"](input, output)
|
||||
await commentChecker["tool.execute.after"](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);
|
||||
await directoryAgentsInjector["tool.execute.after"](input, output);
|
||||
await emptyTaskResponseDetector["tool.execute.after"](input, output);
|
||||
|
||||
if (input.sessionID === mainSessionID) {
|
||||
if (input.sessionID === getMainSessionID()) {
|
||||
updateTerminalTitle({
|
||||
sessionId: input.sessionID,
|
||||
status: "idle",
|
||||
directory: ctx.directory,
|
||||
sessionTitle: currentSessionTitle,
|
||||
})
|
||||
sessionTitle: getCurrentSessionTitle(),
|
||||
});
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
export default OhMyOpenCodePlugin
|
||||
export default OhMyOpenCodePlugin;
|
||||
|
||||
export type {
|
||||
OhMyOpenCodeConfig,
|
||||
@@ -208,4 +278,4 @@ export type {
|
||||
AgentOverrideConfig,
|
||||
AgentOverrides,
|
||||
McpName,
|
||||
} from "./config"
|
||||
} from "./config";
|
||||
|
||||
@@ -20,5 +20,3 @@ export function createBuiltinMcps(disabledMcps: McpName[] = []) {
|
||||
|
||||
return mcps
|
||||
}
|
||||
|
||||
export const builtinMcps = allBuiltinMcps
|
||||
|
||||
203
src/shared/command-executor.ts
Normal file
203
src/shared/command-executor.ts
Normal file
@@ -0,0 +1,203 @@
|
||||
import { spawn } from "child_process"
|
||||
import { exec } from "child_process"
|
||||
import { promisify } from "util"
|
||||
import { existsSync } from "fs"
|
||||
|
||||
const DEFAULT_ZSH_PATHS = ["/bin/zsh", "/usr/bin/zsh", "/usr/local/bin/zsh"]
|
||||
|
||||
function findZshPath(customZshPath?: string): string | null {
|
||||
if (customZshPath && existsSync(customZshPath)) {
|
||||
return customZshPath
|
||||
}
|
||||
for (const path of DEFAULT_ZSH_PATHS) {
|
||||
if (existsSync(path)) {
|
||||
return path
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const execAsync = promisify(exec)
|
||||
|
||||
export interface CommandResult {
|
||||
exitCode: number
|
||||
stdout?: string
|
||||
stderr?: string
|
||||
}
|
||||
|
||||
export interface ExecuteHookOptions {
|
||||
forceZsh?: boolean
|
||||
zshPath?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a hook command with stdin input
|
||||
*/
|
||||
export async function executeHookCommand(
|
||||
command: string,
|
||||
stdin: string,
|
||||
cwd: string,
|
||||
options?: ExecuteHookOptions
|
||||
): Promise<CommandResult> {
|
||||
const home = process.env.HOME ?? ""
|
||||
|
||||
let expandedCommand = command
|
||||
.replace(/^~(?=\/|$)/g, home)
|
||||
.replace(/\s~(?=\/)/g, ` ${home}`)
|
||||
.replace(/\$CLAUDE_PROJECT_DIR/g, cwd)
|
||||
.replace(/\$\{CLAUDE_PROJECT_DIR\}/g, cwd)
|
||||
|
||||
let finalCommand = expandedCommand
|
||||
|
||||
if (options?.forceZsh) {
|
||||
const zshPath = options.zshPath || findZshPath()
|
||||
if (zshPath) {
|
||||
const escapedCommand = expandedCommand.replace(/'/g, "'\\''")
|
||||
finalCommand = `${zshPath} -lc '${escapedCommand}'`
|
||||
}
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const proc = spawn(finalCommand, {
|
||||
cwd,
|
||||
shell: true,
|
||||
env: { ...process.env, HOME: home, CLAUDE_PROJECT_DIR: cwd },
|
||||
})
|
||||
|
||||
let stdout = ""
|
||||
let stderr = ""
|
||||
|
||||
proc.stdout?.on("data", (data) => {
|
||||
stdout += data.toString()
|
||||
})
|
||||
|
||||
proc.stderr?.on("data", (data) => {
|
||||
stderr += data.toString()
|
||||
})
|
||||
|
||||
proc.stdin?.write(stdin)
|
||||
proc.stdin?.end()
|
||||
|
||||
proc.on("close", (code) => {
|
||||
resolve({
|
||||
exitCode: code ?? 0,
|
||||
stdout: stdout.trim(),
|
||||
stderr: stderr.trim(),
|
||||
})
|
||||
})
|
||||
|
||||
proc.on("error", (err) => {
|
||||
resolve({
|
||||
exitCode: 1,
|
||||
stderr: err.message,
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a simple command and return output
|
||||
*/
|
||||
export async function executeCommand(command: string): Promise<string> {
|
||||
try {
|
||||
const { stdout, stderr } = await execAsync(command)
|
||||
|
||||
const out = stdout?.toString().trim() ?? ""
|
||||
const err = stderr?.toString().trim() ?? ""
|
||||
|
||||
if (err) {
|
||||
if (out) {
|
||||
return `${out}\n[stderr: ${err}]`
|
||||
}
|
||||
return `[stderr: ${err}]`
|
||||
}
|
||||
|
||||
return out
|
||||
} catch (error: unknown) {
|
||||
const e = error as { stdout?: Buffer; stderr?: Buffer; message?: string }
|
||||
const stdout = e?.stdout?.toString().trim() ?? ""
|
||||
const stderr = e?.stderr?.toString().trim() ?? ""
|
||||
const errMsg = stderr || e?.message || String(error)
|
||||
|
||||
if (stdout) {
|
||||
return `${stdout}\n[stderr: ${errMsg}]`
|
||||
}
|
||||
return `[stderr: ${errMsg}]`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find and execute embedded commands in text (!`command`)
|
||||
*/
|
||||
interface CommandMatch {
|
||||
fullMatch: string
|
||||
command: string
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
const COMMAND_PATTERN = /!`([^`]+)`/g
|
||||
|
||||
function findCommands(text: string): CommandMatch[] {
|
||||
const matches: CommandMatch[] = []
|
||||
let match: RegExpExecArray | null
|
||||
|
||||
COMMAND_PATTERN.lastIndex = 0
|
||||
|
||||
while ((match = COMMAND_PATTERN.exec(text)) !== null) {
|
||||
matches.push({
|
||||
fullMatch: match[0],
|
||||
command: match[1],
|
||||
start: match.index,
|
||||
end: match.index + match[0].length,
|
||||
})
|
||||
}
|
||||
|
||||
return matches
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve embedded commands in text recursively
|
||||
*/
|
||||
export async function resolveCommandsInText(
|
||||
text: string,
|
||||
depth: number = 0,
|
||||
maxDepth: number = 3
|
||||
): Promise<string> {
|
||||
if (depth >= maxDepth) {
|
||||
return text
|
||||
}
|
||||
|
||||
const matches = findCommands(text)
|
||||
if (matches.length === 0) {
|
||||
return text
|
||||
}
|
||||
|
||||
const tasks = matches.map((m) => executeCommand(m.command))
|
||||
const results = await Promise.allSettled(tasks)
|
||||
|
||||
const replacements = new Map<string, string>()
|
||||
|
||||
matches.forEach((match, idx) => {
|
||||
const result = results[idx]
|
||||
if (result.status === "rejected") {
|
||||
replacements.set(
|
||||
match.fullMatch,
|
||||
`[error: ${result.reason instanceof Error ? result.reason.message : String(result.reason)}]`
|
||||
)
|
||||
} else {
|
||||
replacements.set(match.fullMatch, result.value)
|
||||
}
|
||||
})
|
||||
|
||||
let resolved = text
|
||||
for (const [pattern, replacement] of replacements.entries()) {
|
||||
resolved = resolved.split(pattern).join(replacement)
|
||||
}
|
||||
|
||||
if (findCommands(resolved).length > 0) {
|
||||
return resolveCommandsInText(resolved, depth + 1, maxDepth)
|
||||
}
|
||||
|
||||
return resolved
|
||||
}
|
||||
85
src/shared/file-reference-resolver.ts
Normal file
85
src/shared/file-reference-resolver.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { existsSync, readFileSync, statSync } from "fs"
|
||||
import { join, isAbsolute } from "path"
|
||||
|
||||
interface FileMatch {
|
||||
fullMatch: string
|
||||
filePath: string
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
const FILE_REFERENCE_PATTERN = /@([^\s@]+)/g
|
||||
|
||||
function findFileReferences(text: string): FileMatch[] {
|
||||
const matches: FileMatch[] = []
|
||||
let match: RegExpExecArray | null
|
||||
|
||||
FILE_REFERENCE_PATTERN.lastIndex = 0
|
||||
|
||||
while ((match = FILE_REFERENCE_PATTERN.exec(text)) !== null) {
|
||||
matches.push({
|
||||
fullMatch: match[0],
|
||||
filePath: match[1],
|
||||
start: match.index,
|
||||
end: match.index + match[0].length,
|
||||
})
|
||||
}
|
||||
|
||||
return matches
|
||||
}
|
||||
|
||||
function resolveFilePath(filePath: string, cwd: string): string {
|
||||
if (isAbsolute(filePath)) {
|
||||
return filePath
|
||||
}
|
||||
return join(cwd, filePath)
|
||||
}
|
||||
|
||||
function readFileContent(resolvedPath: string): string {
|
||||
if (!existsSync(resolvedPath)) {
|
||||
return `[file not found: ${resolvedPath}]`
|
||||
}
|
||||
|
||||
const stat = statSync(resolvedPath)
|
||||
if (stat.isDirectory()) {
|
||||
return `[cannot read directory: ${resolvedPath}]`
|
||||
}
|
||||
|
||||
const content = readFileSync(resolvedPath, "utf-8")
|
||||
return content
|
||||
}
|
||||
|
||||
export async function resolveFileReferencesInText(
|
||||
text: string,
|
||||
cwd: string = process.cwd(),
|
||||
depth: number = 0,
|
||||
maxDepth: number = 3
|
||||
): Promise<string> {
|
||||
if (depth >= maxDepth) {
|
||||
return text
|
||||
}
|
||||
|
||||
const matches = findFileReferences(text)
|
||||
if (matches.length === 0) {
|
||||
return text
|
||||
}
|
||||
|
||||
const replacements = new Map<string, string>()
|
||||
|
||||
for (const match of matches) {
|
||||
const resolvedPath = resolveFilePath(match.filePath, cwd)
|
||||
const content = readFileContent(resolvedPath)
|
||||
replacements.set(match.fullMatch, content)
|
||||
}
|
||||
|
||||
let resolved = text
|
||||
for (const [pattern, replacement] of replacements.entries()) {
|
||||
resolved = resolved.split(pattern).join(replacement)
|
||||
}
|
||||
|
||||
if (findFileReferences(resolved).length > 0 && depth + 1 < maxDepth) {
|
||||
return resolveFileReferencesInText(resolved, cwd, depth + 1, maxDepth)
|
||||
}
|
||||
|
||||
return resolved
|
||||
}
|
||||
34
src/shared/frontmatter.ts
Normal file
34
src/shared/frontmatter.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
export interface FrontmatterResult<T = Record<string, string>> {
|
||||
data: T
|
||||
body: string
|
||||
}
|
||||
|
||||
export function parseFrontmatter<T = Record<string, string>>(
|
||||
content: string
|
||||
): FrontmatterResult<T> {
|
||||
const frontmatterRegex = /^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/
|
||||
const match = content.match(frontmatterRegex)
|
||||
|
||||
if (!match) {
|
||||
return { data: {} as T, body: content }
|
||||
}
|
||||
|
||||
const yamlContent = match[1]
|
||||
const body = match[2]
|
||||
|
||||
const data: Record<string, string | boolean> = {}
|
||||
for (const line of yamlContent.split("\n")) {
|
||||
const colonIndex = line.indexOf(":")
|
||||
if (colonIndex !== -1) {
|
||||
const key = line.slice(0, colonIndex).trim()
|
||||
let value: string | boolean = line.slice(colonIndex + 1).trim()
|
||||
|
||||
if (value === "true") value = true
|
||||
else if (value === "false") value = false
|
||||
|
||||
data[key] = value
|
||||
}
|
||||
}
|
||||
|
||||
return { data: data as T, body }
|
||||
}
|
||||
22
src/shared/hook-disabled.ts
Normal file
22
src/shared/hook-disabled.ts
Normal 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
|
||||
}
|
||||
9
src/shared/index.ts
Normal file
9
src/shared/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export * from "./frontmatter"
|
||||
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"
|
||||
20
src/shared/logger.ts
Normal file
20
src/shared/logger.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
// Shared logging utility for the plugin
|
||||
|
||||
import * as fs from "fs"
|
||||
import * as os from "os"
|
||||
import * as path from "path"
|
||||
|
||||
const logFile = path.join(os.tmpdir(), "oh-my-opencode.log")
|
||||
|
||||
export function log(message: string, data?: unknown): void {
|
||||
try {
|
||||
const timestamp = new Date().toISOString()
|
||||
const logEntry = `[${timestamp}] ${message} ${data ? JSON.stringify(data) : ""}\n`
|
||||
fs.appendFileSync(logFile, logEntry)
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
export function getLogFilePath(): string {
|
||||
return logFile
|
||||
}
|
||||
13
src/shared/model-sanitizer.ts
Normal file
13
src/shared/model-sanitizer.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Sanitizes model field from frontmatter.
|
||||
* Always returns undefined to let SDK use default model.
|
||||
*
|
||||
* Claude Code and OpenCode use different model ID formats,
|
||||
* so we ignore the model field and let OpenCode use its configured default.
|
||||
*
|
||||
* @param _model - Raw model value from frontmatter (ignored)
|
||||
* @returns Always undefined to inherit default model
|
||||
*/
|
||||
export function sanitizeModelField(_model: unknown): undefined {
|
||||
return undefined
|
||||
}
|
||||
29
src/shared/pattern-matcher.ts
Normal file
29
src/shared/pattern-matcher.ts
Normal 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
51
src/shared/snake-case.ts
Normal 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
26
src/shared/tool-name.ts
Normal 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)
|
||||
}
|
||||
@@ -1,8 +1,15 @@
|
||||
import { spawn } from "bun"
|
||||
import { existsSync } from "fs"
|
||||
import { getSgCliPath, setSgCliPath, findSgCliPathSync } from "./constants"
|
||||
import {
|
||||
getSgCliPath,
|
||||
setSgCliPath,
|
||||
findSgCliPathSync,
|
||||
DEFAULT_TIMEOUT_MS,
|
||||
DEFAULT_MAX_OUTPUT_BYTES,
|
||||
DEFAULT_MAX_MATCHES,
|
||||
} from "./constants"
|
||||
import { ensureAstGrepBinary } from "./downloader"
|
||||
import type { CliMatch, CliLanguage } from "./types"
|
||||
import type { CliMatch, CliLanguage, SgResult } from "./types"
|
||||
|
||||
export interface RunOptions {
|
||||
pattern: string
|
||||
@@ -54,26 +61,7 @@ export function startBackgroundInit(): void {
|
||||
}
|
||||
}
|
||||
|
||||
interface SpawnResult {
|
||||
stdout: string
|
||||
stderr: string
|
||||
exitCode: number
|
||||
}
|
||||
|
||||
async function spawnSg(cliPath: string, args: string[]): Promise<SpawnResult> {
|
||||
const proc = spawn([cliPath, ...args], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
|
||||
const stdout = await new Response(proc.stdout).text()
|
||||
const stderr = await new Response(proc.stderr).text()
|
||||
const exitCode = await proc.exited
|
||||
|
||||
return { stdout, stderr, exitCode }
|
||||
}
|
||||
|
||||
export async function runSg(options: RunOptions): Promise<CliMatch[]> {
|
||||
export async function runSg(options: RunOptions): Promise<SgResult> {
|
||||
const args = ["run", "-p", options.pattern, "--lang", options.lang, "--json=compact"]
|
||||
|
||||
if (options.rewrite) {
|
||||
@@ -105,55 +93,129 @@ export async function runSg(options: RunOptions): Promise<CliMatch[]> {
|
||||
}
|
||||
}
|
||||
|
||||
let result: SpawnResult
|
||||
const timeout = DEFAULT_TIMEOUT_MS
|
||||
|
||||
const proc = spawn([cliPath, ...args], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
})
|
||||
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
const id = setTimeout(() => {
|
||||
proc.kill()
|
||||
reject(new Error(`Search timeout after ${timeout}ms`))
|
||||
}, timeout)
|
||||
proc.exited.then(() => clearTimeout(id))
|
||||
})
|
||||
|
||||
let stdout: string
|
||||
let stderr: string
|
||||
let exitCode: number
|
||||
|
||||
try {
|
||||
result = await spawnSg(cliPath, args)
|
||||
stdout = await Promise.race([new Response(proc.stdout).text(), timeoutPromise])
|
||||
stderr = await new Response(proc.stderr).text()
|
||||
exitCode = await proc.exited
|
||||
} catch (e) {
|
||||
const error = e as NodeJS.ErrnoException
|
||||
const error = e as Error
|
||||
if (error.message?.includes("timeout")) {
|
||||
return {
|
||||
matches: [],
|
||||
totalMatches: 0,
|
||||
truncated: true,
|
||||
truncatedReason: "timeout",
|
||||
error: error.message,
|
||||
}
|
||||
}
|
||||
|
||||
const nodeError = e as NodeJS.ErrnoException
|
||||
if (
|
||||
error.code === "ENOENT" ||
|
||||
error.message?.includes("ENOENT") ||
|
||||
error.message?.includes("not found")
|
||||
nodeError.code === "ENOENT" ||
|
||||
nodeError.message?.includes("ENOENT") ||
|
||||
nodeError.message?.includes("not found")
|
||||
) {
|
||||
const downloadedPath = await ensureAstGrepBinary()
|
||||
if (downloadedPath) {
|
||||
resolvedCliPath = downloadedPath
|
||||
setSgCliPath(downloadedPath)
|
||||
result = await spawnSg(downloadedPath, args)
|
||||
return runSg(options)
|
||||
} else {
|
||||
throw new Error(
|
||||
`ast-grep CLI binary not found.\n\n` +
|
||||
return {
|
||||
matches: [],
|
||||
totalMatches: 0,
|
||||
truncated: false,
|
||||
error:
|
||||
`ast-grep CLI binary not found.\n\n` +
|
||||
`Auto-download failed. Manual install options:\n` +
|
||||
` bun add -D @ast-grep/cli\n` +
|
||||
` cargo install ast-grep --locked\n` +
|
||||
` brew install ast-grep`
|
||||
)
|
||||
` brew install ast-grep`,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw new Error(`Failed to spawn ast-grep: ${error.message}`)
|
||||
}
|
||||
|
||||
return {
|
||||
matches: [],
|
||||
totalMatches: 0,
|
||||
truncated: false,
|
||||
error: `Failed to spawn ast-grep: ${error.message}`,
|
||||
}
|
||||
}
|
||||
|
||||
const { stdout, stderr, exitCode } = result
|
||||
|
||||
if (exitCode !== 0 && stdout.trim() === "") {
|
||||
if (stderr.includes("No files found")) {
|
||||
return []
|
||||
return { matches: [], totalMatches: 0, truncated: false }
|
||||
}
|
||||
if (stderr.trim()) {
|
||||
throw new Error(stderr.trim())
|
||||
return { matches: [], totalMatches: 0, truncated: false, error: stderr.trim() }
|
||||
}
|
||||
return []
|
||||
return { matches: [], totalMatches: 0, truncated: false }
|
||||
}
|
||||
|
||||
if (!stdout.trim()) {
|
||||
return []
|
||||
return { matches: [], totalMatches: 0, truncated: false }
|
||||
}
|
||||
|
||||
const outputTruncated = stdout.length >= DEFAULT_MAX_OUTPUT_BYTES
|
||||
const outputToProcess = outputTruncated ? stdout.substring(0, DEFAULT_MAX_OUTPUT_BYTES) : stdout
|
||||
|
||||
let matches: CliMatch[] = []
|
||||
try {
|
||||
return JSON.parse(stdout) as CliMatch[]
|
||||
matches = JSON.parse(outputToProcess) as CliMatch[]
|
||||
} catch {
|
||||
return []
|
||||
if (outputTruncated) {
|
||||
try {
|
||||
const lastValidIndex = outputToProcess.lastIndexOf("}")
|
||||
if (lastValidIndex > 0) {
|
||||
const bracketIndex = outputToProcess.lastIndexOf("},", lastValidIndex)
|
||||
if (bracketIndex > 0) {
|
||||
const truncatedJson = outputToProcess.substring(0, bracketIndex + 1) + "]"
|
||||
matches = JSON.parse(truncatedJson) as CliMatch[]
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
matches: [],
|
||||
totalMatches: 0,
|
||||
truncated: true,
|
||||
truncatedReason: "max_output_bytes",
|
||||
error: "Output too large and could not be parsed",
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return { matches: [], totalMatches: 0, truncated: false }
|
||||
}
|
||||
}
|
||||
|
||||
const totalMatches = matches.length
|
||||
const matchesTruncated = totalMatches > DEFAULT_MAX_MATCHES
|
||||
const finalMatches = matchesTruncated ? matches.slice(0, DEFAULT_MAX_MATCHES) : matches
|
||||
|
||||
return {
|
||||
matches: finalMatches,
|
||||
totalMatches,
|
||||
truncated: outputTruncated || matchesTruncated,
|
||||
truncatedReason: outputTruncated ? "max_output_bytes" : matchesTruncated ? "max_matches" : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import { createRequire } from "module"
|
||||
import { dirname, join } from "path"
|
||||
import { existsSync } from "fs"
|
||||
import { existsSync, statSync } from "fs"
|
||||
import { getCachedBinaryPath } from "./downloader"
|
||||
|
||||
type Platform = "darwin" | "linux" | "win32" | "unsupported"
|
||||
|
||||
function isValidBinary(filePath: string): boolean {
|
||||
try {
|
||||
return statSync(filePath).size > 10000
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function getPlatformPackageName(): string | null {
|
||||
const platform = process.platform as Platform
|
||||
const arch = process.arch
|
||||
@@ -25,13 +33,18 @@ function getPlatformPackageName(): string | null {
|
||||
export function findSgCliPathSync(): string | null {
|
||||
const binaryName = process.platform === "win32" ? "sg.exe" : "sg"
|
||||
|
||||
const cachedPath = getCachedBinaryPath()
|
||||
if (cachedPath && isValidBinary(cachedPath)) {
|
||||
return cachedPath
|
||||
}
|
||||
|
||||
try {
|
||||
const require = createRequire(import.meta.url)
|
||||
const cliPkgPath = require.resolve("@ast-grep/cli/package.json")
|
||||
const cliDir = dirname(cliPkgPath)
|
||||
const sgPath = join(cliDir, binaryName)
|
||||
|
||||
if (existsSync(sgPath)) {
|
||||
if (existsSync(sgPath) && isValidBinary(sgPath)) {
|
||||
return sgPath
|
||||
}
|
||||
} catch {
|
||||
@@ -47,7 +60,7 @@ export function findSgCliPathSync(): string | null {
|
||||
const astGrepName = process.platform === "win32" ? "ast-grep.exe" : "ast-grep"
|
||||
const binaryPath = join(pkgDir, astGrepName)
|
||||
|
||||
if (existsSync(binaryPath)) {
|
||||
if (existsSync(binaryPath) && isValidBinary(binaryPath)) {
|
||||
return binaryPath
|
||||
}
|
||||
} catch {
|
||||
@@ -58,17 +71,12 @@ export function findSgCliPathSync(): string | null {
|
||||
if (process.platform === "darwin") {
|
||||
const homebrewPaths = ["/opt/homebrew/bin/sg", "/usr/local/bin/sg"]
|
||||
for (const path of homebrewPaths) {
|
||||
if (existsSync(path)) {
|
||||
if (existsSync(path) && isValidBinary(path)) {
|
||||
return path
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const cachedPath = getCachedBinaryPath()
|
||||
if (cachedPath) {
|
||||
return cachedPath
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -127,6 +135,10 @@ export const CLI_LANGUAGES = [
|
||||
export const NAPI_LANGUAGES = ["html", "javascript", "tsx", "css", "typescript"] as const
|
||||
|
||||
// Language to file extensions mapping
|
||||
export const DEFAULT_TIMEOUT_MS = 300_000
|
||||
export const DEFAULT_MAX_OUTPUT_BYTES = 1 * 1024 * 1024
|
||||
export const DEFAULT_MAX_MATCHES = 500
|
||||
|
||||
export const LANG_EXTENSIONS: Record<string, string[]> = {
|
||||
bash: [".bash", ".sh", ".zsh", ".bats"],
|
||||
c: [".c", ".h"],
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { tool } from "@opencode-ai/plugin/tool"
|
||||
import { CLI_LANGUAGES, NAPI_LANGUAGES, LANG_EXTENSIONS } from "./constants"
|
||||
import { CLI_LANGUAGES } from "./constants"
|
||||
import { runSg } from "./cli"
|
||||
import { analyzeCode, transformCode, getRootInfo } from "./napi"
|
||||
import { formatSearchResult, formatReplaceResult, formatAnalyzeResult, formatTransformResult } from "./utils"
|
||||
import type { CliLanguage, NapiLanguage } from "./types"
|
||||
import { formatSearchResult, formatReplaceResult } from "./utils"
|
||||
import type { CliLanguage } from "./types"
|
||||
|
||||
function showOutputToUser(context: unknown, output: string): void {
|
||||
const ctx = context as { metadata?: (input: { metadata: { output: string } }) => void }
|
||||
@@ -15,10 +14,12 @@ function getEmptyResultHint(pattern: string, lang: CliLanguage): string | null {
|
||||
|
||||
if (lang === "python") {
|
||||
if (src.startsWith("class ") && src.endsWith(":")) {
|
||||
return `💡 Hint: Python class patterns need body. Try "class $NAME" or include body with $$$BODY`
|
||||
const withoutColon = src.slice(0, -1)
|
||||
return `💡 Hint: Remove trailing colon. Try: "${withoutColon}"`
|
||||
}
|
||||
if ((src.startsWith("def ") || src.startsWith("async def ")) && src.endsWith(":")) {
|
||||
return `💡 Hint: Python function patterns need body. Try "def $FUNC($$$):\\n $$$BODY"`
|
||||
const withoutColon = src.slice(0, -1)
|
||||
return `💡 Hint: Remove trailing colon. Try: "${withoutColon}"`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,7 +48,7 @@ export const ast_grep_search = tool({
|
||||
},
|
||||
execute: async (args, context) => {
|
||||
try {
|
||||
const matches = await runSg({
|
||||
const result = await runSg({
|
||||
pattern: args.pattern,
|
||||
lang: args.lang as CliLanguage,
|
||||
paths: args.paths,
|
||||
@@ -55,9 +56,9 @@ export const ast_grep_search = tool({
|
||||
context: args.context,
|
||||
})
|
||||
|
||||
let output = formatSearchResult(matches)
|
||||
let output = formatSearchResult(result)
|
||||
|
||||
if (matches.length === 0) {
|
||||
if (result.matches.length === 0 && !result.error) {
|
||||
const hint = getEmptyResultHint(args.pattern, args.lang as CliLanguage)
|
||||
if (hint) {
|
||||
output += `\n\n${hint}`
|
||||
@@ -89,7 +90,7 @@ export const ast_grep_replace = tool({
|
||||
},
|
||||
execute: async (args, context) => {
|
||||
try {
|
||||
const matches = await runSg({
|
||||
const result = await runSg({
|
||||
pattern: args.pattern,
|
||||
rewrite: args.rewrite,
|
||||
lang: args.lang as CliLanguage,
|
||||
@@ -97,7 +98,7 @@ export const ast_grep_replace = tool({
|
||||
globs: args.globs,
|
||||
updateAll: args.dryRun === false,
|
||||
})
|
||||
const output = formatReplaceResult(matches, args.dryRun !== false)
|
||||
const output = formatReplaceResult(result, args.dryRun !== false)
|
||||
showOutputToUser(context, output)
|
||||
return output
|
||||
} catch (e) {
|
||||
@@ -108,83 +109,4 @@ export const ast_grep_replace = tool({
|
||||
},
|
||||
})
|
||||
|
||||
export const ast_grep_languages = tool({
|
||||
description:
|
||||
"List all supported languages for ast-grep tools with their file extensions. " +
|
||||
"Use this to determine valid language options.",
|
||||
args: {},
|
||||
execute: async (_args, context) => {
|
||||
const lines: string[] = [`Supported Languages (${CLI_LANGUAGES.length}):`]
|
||||
for (const lang of CLI_LANGUAGES) {
|
||||
const exts = LANG_EXTENSIONS[lang]?.join(", ") || ""
|
||||
lines.push(` ${lang}: ${exts}`)
|
||||
}
|
||||
lines.push("")
|
||||
lines.push(`NAPI (in-memory) languages: ${NAPI_LANGUAGES.join(", ")}`)
|
||||
const output = lines.join("\n")
|
||||
showOutputToUser(context, output)
|
||||
return output
|
||||
},
|
||||
})
|
||||
|
||||
export const ast_grep_analyze = tool({
|
||||
description:
|
||||
"Parse code and extract AST structure with pattern matching (in-memory). " +
|
||||
"Extracts meta-variable bindings. Only for: html, javascript, tsx, css, typescript. " +
|
||||
"Use for detailed code analysis without file I/O.",
|
||||
args: {
|
||||
code: tool.schema.string().describe("Source code to analyze"),
|
||||
lang: tool.schema.enum(NAPI_LANGUAGES).describe("Language (html, javascript, tsx, css, typescript)"),
|
||||
pattern: tool.schema.string().optional().describe("Pattern to find (omit for root structure)"),
|
||||
extractMetaVars: tool.schema.boolean().optional().describe("Extract meta-variable bindings (default: true)"),
|
||||
},
|
||||
execute: async (args, context) => {
|
||||
try {
|
||||
if (!args.pattern) {
|
||||
const info = getRootInfo(args.code, args.lang as NapiLanguage)
|
||||
const output = `Root kind: ${info.kind}\nChildren: ${info.childCount}`
|
||||
showOutputToUser(context, output)
|
||||
return output
|
||||
}
|
||||
|
||||
const results = analyzeCode(args.code, args.lang as NapiLanguage, args.pattern, args.extractMetaVars !== false)
|
||||
const output = formatAnalyzeResult(results, args.extractMetaVars !== false)
|
||||
showOutputToUser(context, output)
|
||||
return output
|
||||
} catch (e) {
|
||||
const output = `Error: ${e instanceof Error ? e.message : String(e)}`
|
||||
showOutputToUser(context, output)
|
||||
return output
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
export const ast_grep_transform = tool({
|
||||
description:
|
||||
"Transform code in-memory using AST-aware rewriting. " +
|
||||
"Only for: html, javascript, tsx, css, typescript. " +
|
||||
"Returns transformed code without writing to filesystem.",
|
||||
args: {
|
||||
code: tool.schema.string().describe("Source code to transform"),
|
||||
lang: tool.schema.enum(NAPI_LANGUAGES).describe("Language"),
|
||||
pattern: tool.schema.string().describe("Pattern to match"),
|
||||
rewrite: tool.schema.string().describe("Replacement (can use $VAR from pattern)"),
|
||||
},
|
||||
execute: async (args, context) => {
|
||||
try {
|
||||
const { transformed, editCount } = transformCode(
|
||||
args.code,
|
||||
args.lang as NapiLanguage,
|
||||
args.pattern,
|
||||
args.rewrite
|
||||
)
|
||||
const output = formatTransformResult(args.code, transformed, editCount)
|
||||
showOutputToUser(context, output)
|
||||
return output
|
||||
} catch (e) {
|
||||
const output = `Error: ${e instanceof Error ? e.message : String(e)}`
|
||||
showOutputToUser(context, output)
|
||||
return output
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -51,3 +51,11 @@ export interface TransformResult {
|
||||
transformed: string
|
||||
editCount: number
|
||||
}
|
||||
|
||||
export interface SgResult {
|
||||
matches: CliMatch[]
|
||||
totalMatches: number
|
||||
truncated: boolean
|
||||
truncatedReason?: "max_matches" | "max_output_bytes" | "timeout"
|
||||
error?: string
|
||||
}
|
||||
|
||||
@@ -1,13 +1,28 @@
|
||||
import type { CliMatch, AnalyzeResult } from "./types"
|
||||
import type { CliMatch, AnalyzeResult, SgResult } from "./types"
|
||||
|
||||
export function formatSearchResult(matches: CliMatch[]): string {
|
||||
if (matches.length === 0) {
|
||||
export function formatSearchResult(result: SgResult): string {
|
||||
if (result.error) {
|
||||
return `Error: ${result.error}`
|
||||
}
|
||||
|
||||
if (result.matches.length === 0) {
|
||||
return "No matches found"
|
||||
}
|
||||
|
||||
const lines: string[] = [`Found ${matches.length} match(es):\n`]
|
||||
const lines: string[] = []
|
||||
|
||||
for (const match of matches) {
|
||||
if (result.truncated) {
|
||||
const reason = result.truncatedReason === "max_matches"
|
||||
? `showing first ${result.matches.length} of ${result.totalMatches}`
|
||||
: result.truncatedReason === "max_output_bytes"
|
||||
? "output exceeded 1MB limit"
|
||||
: "search timed out"
|
||||
lines.push(`⚠️ Results truncated (${reason})\n`)
|
||||
}
|
||||
|
||||
lines.push(`Found ${result.matches.length} match(es)${result.truncated ? ` (truncated from ${result.totalMatches})` : ""}:\n`)
|
||||
|
||||
for (const match of result.matches) {
|
||||
const loc = `${match.file}:${match.range.start.line + 1}:${match.range.start.column + 1}`
|
||||
lines.push(`${loc}`)
|
||||
lines.push(` ${match.lines.trim()}`)
|
||||
@@ -17,15 +32,30 @@ export function formatSearchResult(matches: CliMatch[]): string {
|
||||
return lines.join("\n")
|
||||
}
|
||||
|
||||
export function formatReplaceResult(matches: CliMatch[], isDryRun: boolean): string {
|
||||
if (matches.length === 0) {
|
||||
export function formatReplaceResult(result: SgResult, isDryRun: boolean): string {
|
||||
if (result.error) {
|
||||
return `Error: ${result.error}`
|
||||
}
|
||||
|
||||
if (result.matches.length === 0) {
|
||||
return "No matches found to replace"
|
||||
}
|
||||
|
||||
const prefix = isDryRun ? "[DRY RUN] " : ""
|
||||
const lines: string[] = [`${prefix}${matches.length} replacement(s):\n`]
|
||||
const lines: string[] = []
|
||||
|
||||
for (const match of matches) {
|
||||
if (result.truncated) {
|
||||
const reason = result.truncatedReason === "max_matches"
|
||||
? `showing first ${result.matches.length} of ${result.totalMatches}`
|
||||
: result.truncatedReason === "max_output_bytes"
|
||||
? "output exceeded 1MB limit"
|
||||
: "search timed out"
|
||||
lines.push(`⚠️ Results truncated (${reason})\n`)
|
||||
}
|
||||
|
||||
lines.push(`${prefix}${result.matches.length} replacement(s):\n`)
|
||||
|
||||
for (const match of result.matches) {
|
||||
const loc = `${match.file}:${match.range.start.line + 1}:${match.range.start.column + 1}`
|
||||
lines.push(`${loc}`)
|
||||
lines.push(` ${match.text}`)
|
||||
|
||||
129
src/tools/glob/cli.ts
Normal file
129
src/tools/glob/cli.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { spawn } from "bun"
|
||||
import {
|
||||
resolveGrepCli,
|
||||
DEFAULT_TIMEOUT_MS,
|
||||
DEFAULT_LIMIT,
|
||||
DEFAULT_MAX_DEPTH,
|
||||
DEFAULT_MAX_OUTPUT_BYTES,
|
||||
RG_FILES_FLAGS,
|
||||
} from "./constants"
|
||||
import type { GlobOptions, GlobResult, FileMatch } from "./types"
|
||||
import { stat } from "node:fs/promises"
|
||||
|
||||
function buildRgArgs(options: GlobOptions): string[] {
|
||||
const args: string[] = [
|
||||
...RG_FILES_FLAGS,
|
||||
`--max-depth=${Math.min(options.maxDepth ?? DEFAULT_MAX_DEPTH, DEFAULT_MAX_DEPTH)}`,
|
||||
]
|
||||
|
||||
if (options.hidden) args.push("--hidden")
|
||||
if (options.noIgnore) args.push("--no-ignore")
|
||||
|
||||
args.push(`--glob=${options.pattern}`)
|
||||
|
||||
return args
|
||||
}
|
||||
|
||||
function buildFindArgs(options: GlobOptions): string[] {
|
||||
const args: string[] = ["."]
|
||||
|
||||
const maxDepth = Math.min(options.maxDepth ?? DEFAULT_MAX_DEPTH, DEFAULT_MAX_DEPTH)
|
||||
args.push("-maxdepth", String(maxDepth))
|
||||
|
||||
args.push("-type", "f")
|
||||
args.push("-name", options.pattern)
|
||||
|
||||
if (!options.hidden) {
|
||||
args.push("-not", "-path", "*/.*")
|
||||
}
|
||||
|
||||
return args
|
||||
}
|
||||
|
||||
async function getFileMtime(filePath: string): Promise<number> {
|
||||
try {
|
||||
const stats = await stat(filePath)
|
||||
return stats.mtime.getTime()
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
export async function runRgFiles(options: GlobOptions): Promise<GlobResult> {
|
||||
const cli = resolveGrepCli()
|
||||
const timeout = Math.min(options.timeout ?? DEFAULT_TIMEOUT_MS, DEFAULT_TIMEOUT_MS)
|
||||
const limit = Math.min(options.limit ?? DEFAULT_LIMIT, DEFAULT_LIMIT)
|
||||
|
||||
const isRg = cli.backend === "rg"
|
||||
const args = isRg ? buildRgArgs(options) : buildFindArgs(options)
|
||||
|
||||
const paths = options.paths?.length ? options.paths : ["."]
|
||||
if (isRg) {
|
||||
args.push(...paths)
|
||||
}
|
||||
|
||||
const cwd = paths[0] || "."
|
||||
|
||||
const proc = spawn([cli.path, ...args], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
cwd: isRg ? undefined : cwd,
|
||||
})
|
||||
|
||||
const timeoutPromise = new Promise<never>((_, reject) => {
|
||||
const id = setTimeout(() => {
|
||||
proc.kill()
|
||||
reject(new Error(`Glob search timeout after ${timeout}ms`))
|
||||
}, timeout)
|
||||
proc.exited.then(() => clearTimeout(id))
|
||||
})
|
||||
|
||||
try {
|
||||
const stdout = await Promise.race([new Response(proc.stdout).text(), timeoutPromise])
|
||||
const stderr = await new Response(proc.stderr).text()
|
||||
const exitCode = await proc.exited
|
||||
|
||||
if (exitCode > 1 && stderr.trim()) {
|
||||
return {
|
||||
files: [],
|
||||
totalFiles: 0,
|
||||
truncated: false,
|
||||
error: stderr.trim(),
|
||||
}
|
||||
}
|
||||
|
||||
const truncatedOutput = stdout.length >= DEFAULT_MAX_OUTPUT_BYTES
|
||||
const outputToProcess = truncatedOutput ? stdout.substring(0, DEFAULT_MAX_OUTPUT_BYTES) : stdout
|
||||
|
||||
const lines = outputToProcess.trim().split("\n").filter(Boolean)
|
||||
|
||||
const files: FileMatch[] = []
|
||||
let truncated = false
|
||||
|
||||
for (const line of lines) {
|
||||
if (files.length >= limit) {
|
||||
truncated = true
|
||||
break
|
||||
}
|
||||
|
||||
const filePath = isRg ? line : `${cwd}/${line}`
|
||||
const mtime = await getFileMtime(filePath)
|
||||
files.push({ path: filePath, mtime })
|
||||
}
|
||||
|
||||
files.sort((a, b) => b.mtime - a.mtime)
|
||||
|
||||
return {
|
||||
files,
|
||||
totalFiles: files.length,
|
||||
truncated: truncated || truncatedOutput,
|
||||
}
|
||||
} catch (e) {
|
||||
return {
|
||||
files: [],
|
||||
totalFiles: 0,
|
||||
truncated: false,
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
12
src/tools/glob/constants.ts
Normal file
12
src/tools/glob/constants.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export { resolveGrepCli, type GrepBackend } from "../grep/constants"
|
||||
|
||||
export const DEFAULT_TIMEOUT_MS = 60_000
|
||||
export const DEFAULT_LIMIT = 100
|
||||
export const DEFAULT_MAX_DEPTH = 20
|
||||
export const DEFAULT_MAX_OUTPUT_BYTES = 10 * 1024 * 1024
|
||||
|
||||
export const RG_FILES_FLAGS = [
|
||||
"--files",
|
||||
"--color=never",
|
||||
"--glob=!.git/*",
|
||||
] as const
|
||||
3
src/tools/glob/index.ts
Normal file
3
src/tools/glob/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { glob } from "./tools"
|
||||
|
||||
export { glob }
|
||||
36
src/tools/glob/tools.ts
Normal file
36
src/tools/glob/tools.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { tool } from "@opencode-ai/plugin/tool"
|
||||
import { runRgFiles } from "./cli"
|
||||
import { formatGlobResult } from "./utils"
|
||||
|
||||
export const glob = tool({
|
||||
description:
|
||||
"Fast file pattern matching tool with safety limits (60s timeout, 100 file limit). " +
|
||||
"Supports glob patterns like \"**/*.js\" or \"src/**/*.ts\". " +
|
||||
"Returns matching file paths sorted by modification time. " +
|
||||
"Use this tool when you need to find files by name patterns.",
|
||||
args: {
|
||||
pattern: tool.schema.string().describe("The glob pattern to match files against"),
|
||||
path: tool.schema
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"The directory to search in. If not specified, the current working directory will be used. " +
|
||||
"IMPORTANT: Omit this field to use the default directory. DO NOT enter \"undefined\" or \"null\" - " +
|
||||
"simply omit it for the default behavior. Must be a valid directory path if provided."
|
||||
),
|
||||
},
|
||||
execute: async (args) => {
|
||||
try {
|
||||
const paths = args.path ? [args.path] : undefined
|
||||
|
||||
const result = await runRgFiles({
|
||||
pattern: args.pattern,
|
||||
paths,
|
||||
})
|
||||
|
||||
return formatGlobResult(result)
|
||||
} catch (e) {
|
||||
return `Error: ${e instanceof Error ? e.message : String(e)}`
|
||||
}
|
||||
},
|
||||
})
|
||||
21
src/tools/glob/types.ts
Normal file
21
src/tools/glob/types.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
export interface FileMatch {
|
||||
path: string
|
||||
mtime: number
|
||||
}
|
||||
|
||||
export interface GlobResult {
|
||||
files: FileMatch[]
|
||||
totalFiles: number
|
||||
truncated: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface GlobOptions {
|
||||
pattern: string
|
||||
paths?: string[]
|
||||
hidden?: boolean
|
||||
noIgnore?: boolean
|
||||
maxDepth?: number
|
||||
timeout?: number
|
||||
limit?: number
|
||||
}
|
||||
26
src/tools/glob/utils.ts
Normal file
26
src/tools/glob/utils.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { GlobResult } from "./types"
|
||||
|
||||
export function formatGlobResult(result: GlobResult): string {
|
||||
if (result.error) {
|
||||
return `Error: ${result.error}`
|
||||
}
|
||||
|
||||
if (result.files.length === 0) {
|
||||
return "No files found"
|
||||
}
|
||||
|
||||
const lines: string[] = []
|
||||
lines.push(`Found ${result.totalFiles} file(s)`)
|
||||
lines.push("")
|
||||
|
||||
for (const file of result.files) {
|
||||
lines.push(file.path)
|
||||
}
|
||||
|
||||
if (result.truncated) {
|
||||
lines.push("")
|
||||
lines.push("(Results are truncated. Consider using a more specific path or pattern.)")
|
||||
}
|
||||
|
||||
return lines.join("\n")
|
||||
}
|
||||
3
src/tools/grep/index.ts
Normal file
3
src/tools/grep/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { grep } from "./tools"
|
||||
|
||||
export { grep }
|
||||
@@ -2,7 +2,7 @@ import { tool } from "@opencode-ai/plugin/tool"
|
||||
import { runRg } from "./cli"
|
||||
import { formatGrepResult } from "./utils"
|
||||
|
||||
export const safe_grep = tool({
|
||||
export const grep = tool({
|
||||
description:
|
||||
"Fast content search tool with safety limits (60s timeout, 10MB output). " +
|
||||
"Searches file contents using regular expressions. " +
|
||||
@@ -17,7 +17,10 @@ import {
|
||||
ast_grep_replace,
|
||||
} from "./ast-grep"
|
||||
|
||||
import { safe_grep } from "./safe-grep"
|
||||
import { grep } from "./grep"
|
||||
import { glob } from "./glob"
|
||||
import { slashcommand } from "./slashcommand"
|
||||
import { skill } from "./skill"
|
||||
|
||||
export const builtinTools = {
|
||||
lsp_hover,
|
||||
@@ -33,5 +36,8 @@ export const builtinTools = {
|
||||
lsp_code_action_resolve,
|
||||
ast_grep_search,
|
||||
ast_grep_replace,
|
||||
safe_grep,
|
||||
grep,
|
||||
glob,
|
||||
slashcommand,
|
||||
skill,
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { readFileSync } from "fs"
|
||||
import { extname, resolve } from "path"
|
||||
import type { ResolvedServer } from "./config"
|
||||
import { getLanguageId } from "./config"
|
||||
import type { Diagnostic } from "./types"
|
||||
|
||||
interface ManagedClient {
|
||||
client: LSPClient
|
||||
@@ -155,6 +156,7 @@ export class LSPClient {
|
||||
private openedFiles = new Set<string>()
|
||||
private stderrBuffer: string[] = []
|
||||
private processExited = false
|
||||
private diagnosticsStore = new Map<string, Diagnostic[]>()
|
||||
|
||||
constructor(
|
||||
private root: string,
|
||||
@@ -290,7 +292,11 @@ export class LSPClient {
|
||||
try {
|
||||
const msg = JSON.parse(content)
|
||||
|
||||
if ("id" in msg && "method" in msg) {
|
||||
if ("method" in msg && !("id" in msg)) {
|
||||
if (msg.method === "textDocument/publishDiagnostics" && msg.params?.uri) {
|
||||
this.diagnosticsStore.set(msg.params.uri, msg.params.diagnostics ?? [])
|
||||
}
|
||||
} else if ("id" in msg && "method" in msg) {
|
||||
this.handleServerRequest(msg.id, msg.method, msg.params)
|
||||
} else if ("id" in msg && this.pending.has(msg.id)) {
|
||||
const handler = this.pending.get(msg.id)!
|
||||
@@ -347,9 +353,14 @@ export class LSPClient {
|
||||
this.proc.stdin.write(`Content-Length: ${Buffer.byteLength(msg)}\r\n\r\n${msg}`)
|
||||
}
|
||||
|
||||
private handleServerRequest(id: number | string, method: string, _params?: unknown): void {
|
||||
private handleServerRequest(id: number | string, method: string, params?: unknown): void {
|
||||
if (method === "workspace/configuration") {
|
||||
this.respond(id, [{}])
|
||||
const items = (params as { items?: Array<{ section?: string }> })?.items ?? []
|
||||
const result = items.map((item) => {
|
||||
if (item.section === "json") return { validate: { enable: true } }
|
||||
return {}
|
||||
})
|
||||
this.respond(id, result)
|
||||
} else if (method === "client/registerCapability") {
|
||||
this.respond(id, null)
|
||||
} else if (method === "window/workDoneProgress/create") {
|
||||
@@ -412,7 +423,9 @@ export class LSPClient {
|
||||
...this.server.initialization,
|
||||
})
|
||||
this.notify("initialized")
|
||||
this.notify("workspace/didChangeConfiguration", { settings: {} })
|
||||
this.notify("workspace/didChangeConfiguration", {
|
||||
settings: { json: { validate: { enable: true } } },
|
||||
})
|
||||
await new Promise((r) => setTimeout(r, 300))
|
||||
}
|
||||
|
||||
@@ -477,13 +490,23 @@ export class LSPClient {
|
||||
return this.send("workspace/symbol", { query })
|
||||
}
|
||||
|
||||
async diagnostics(filePath: string): Promise<unknown> {
|
||||
async diagnostics(filePath: string): Promise<{ items: Diagnostic[] }> {
|
||||
const absPath = resolve(filePath)
|
||||
const uri = `file://${absPath}`
|
||||
await this.openFile(absPath)
|
||||
await new Promise((r) => setTimeout(r, 500))
|
||||
return this.send("textDocument/diagnostic", {
|
||||
textDocument: { uri: `file://${absPath}` },
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await this.send("textDocument/diagnostic", {
|
||||
textDocument: { uri },
|
||||
})
|
||||
if (result && typeof result === "object" && "items" in result) {
|
||||
return result as { items: Diagnostic[] }
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
|
||||
return { items: this.diagnosticsStore.get(uri) ?? [] }
|
||||
}
|
||||
|
||||
async prepareRename(filePath: string, line: number, character: number): Promise<unknown> {
|
||||
@@ -545,5 +568,6 @@ export class LSPClient {
|
||||
this.proc?.kill()
|
||||
this.proc = null
|
||||
this.processExited = true
|
||||
this.diagnosticsStore.clear()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,10 @@ export const SEVERITY_MAP: Record<number, string> = {
|
||||
4: "hint",
|
||||
}
|
||||
|
||||
export const DEFAULT_MAX_REFERENCES = 200
|
||||
export const DEFAULT_MAX_SYMBOLS = 200
|
||||
export const DEFAULT_MAX_DIAGNOSTICS = 200
|
||||
|
||||
export const BUILTIN_SERVERS: Record<string, Omit<LSPServerConfig, "id">> = {
|
||||
typescript: {
|
||||
command: ["typescript-language-server", "--stdio"],
|
||||
@@ -171,4 +175,18 @@ export const EXT_TO_LANG: Record<string, string> = {
|
||||
".astro": "astro",
|
||||
".yaml": "yaml",
|
||||
".yml": "yaml",
|
||||
".json": "json",
|
||||
".jsonc": "jsonc",
|
||||
".html": "html",
|
||||
".htm": "html",
|
||||
".css": "css",
|
||||
".scss": "scss",
|
||||
".less": "less",
|
||||
".sh": "shellscript",
|
||||
".bash": "shellscript",
|
||||
".zsh": "shellscript",
|
||||
".fish": "fish",
|
||||
".md": "markdown",
|
||||
".tf": "terraform",
|
||||
".tfvars": "terraform",
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { tool } from "@opencode-ai/plugin/tool"
|
||||
import { getAllServers } from "./config"
|
||||
import {
|
||||
DEFAULT_MAX_REFERENCES,
|
||||
DEFAULT_MAX_SYMBOLS,
|
||||
DEFAULT_MAX_DIAGNOSTICS,
|
||||
} from "./constants"
|
||||
import {
|
||||
withLspClient,
|
||||
formatHoverResult,
|
||||
@@ -9,7 +14,6 @@ import {
|
||||
formatDiagnostic,
|
||||
filterDiagnosticsBySeverity,
|
||||
formatPrepareRenameResult,
|
||||
formatWorkspaceEdit,
|
||||
formatCodeActions,
|
||||
applyWorkspaceEdit,
|
||||
formatApplyResult,
|
||||
@@ -112,7 +116,14 @@ export const lsp_find_references = tool({
|
||||
return output
|
||||
}
|
||||
|
||||
const output = result.map(formatLocation).join("\n")
|
||||
const total = result.length
|
||||
const truncated = total > DEFAULT_MAX_REFERENCES
|
||||
const limited = truncated ? result.slice(0, DEFAULT_MAX_REFERENCES) : result
|
||||
const lines = limited.map(formatLocation)
|
||||
if (truncated) {
|
||||
lines.unshift(`Found ${total} references (showing first ${DEFAULT_MAX_REFERENCES}):`)
|
||||
}
|
||||
const output = lines.join("\n")
|
||||
return output
|
||||
} catch (e) {
|
||||
const output = `Error: ${e instanceof Error ? e.message : String(e)}`
|
||||
@@ -138,13 +149,21 @@ export const lsp_document_symbols = tool({
|
||||
return output
|
||||
}
|
||||
|
||||
let output: string
|
||||
if ("range" in result[0]) {
|
||||
output = (result as DocumentSymbol[]).map((s) => formatDocumentSymbol(s)).join("\n")
|
||||
} else {
|
||||
output = (result as SymbolInfo[]).map(formatSymbolInfo).join("\n")
|
||||
const total = result.length
|
||||
const truncated = total > DEFAULT_MAX_SYMBOLS
|
||||
const limited = truncated ? result.slice(0, DEFAULT_MAX_SYMBOLS) : result
|
||||
|
||||
const lines: string[] = []
|
||||
if (truncated) {
|
||||
lines.push(`Found ${total} symbols (showing first ${DEFAULT_MAX_SYMBOLS}):`)
|
||||
}
|
||||
return output
|
||||
|
||||
if ("range" in limited[0]) {
|
||||
lines.push(...(limited as DocumentSymbol[]).map((s) => formatDocumentSymbol(s)))
|
||||
} else {
|
||||
lines.push(...(limited as SymbolInfo[]).map(formatSymbolInfo))
|
||||
}
|
||||
return lines.join("\n")
|
||||
} catch (e) {
|
||||
const output = `Error: ${e instanceof Error ? e.message : String(e)}`
|
||||
return output
|
||||
@@ -171,8 +190,15 @@ export const lsp_workspace_symbols = tool({
|
||||
return output
|
||||
}
|
||||
|
||||
const limited = args.limit ? result.slice(0, args.limit) : result
|
||||
const output = limited.map(formatSymbolInfo).join("\n")
|
||||
const total = result.length
|
||||
const limit = Math.min(args.limit ?? DEFAULT_MAX_SYMBOLS, DEFAULT_MAX_SYMBOLS)
|
||||
const truncated = total > limit
|
||||
const limited = result.slice(0, limit)
|
||||
const lines = limited.map(formatSymbolInfo)
|
||||
if (truncated) {
|
||||
lines.unshift(`Found ${total} symbols (showing first ${limit}):`)
|
||||
}
|
||||
const output = lines.join("\n")
|
||||
return output
|
||||
} catch (e) {
|
||||
const output = `Error: ${e instanceof Error ? e.message : String(e)}`
|
||||
@@ -213,7 +239,14 @@ export const lsp_diagnostics = tool({
|
||||
return output
|
||||
}
|
||||
|
||||
const output = diagnostics.map(formatDiagnostic).join("\n")
|
||||
const total = diagnostics.length
|
||||
const truncated = total > DEFAULT_MAX_DIAGNOSTICS
|
||||
const limited = truncated ? diagnostics.slice(0, DEFAULT_MAX_DIAGNOSTICS) : diagnostics
|
||||
const lines = limited.map(formatDiagnostic)
|
||||
if (truncated) {
|
||||
lines.unshift(`Found ${total} diagnostics (showing first ${DEFAULT_MAX_DIAGNOSTICS}):`)
|
||||
}
|
||||
const output = lines.join("\n")
|
||||
return output
|
||||
} catch (e) {
|
||||
const output = `Error: ${e instanceof Error ? e.message : String(e)}`
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
Diagnostic,
|
||||
PrepareRenameResult,
|
||||
PrepareRenameDefaultBehavior,
|
||||
Range,
|
||||
WorkspaceEdit,
|
||||
TextEdit,
|
||||
CodeAction,
|
||||
@@ -165,21 +166,35 @@ export function filterDiagnosticsBySeverity(
|
||||
}
|
||||
|
||||
export function formatPrepareRenameResult(
|
||||
result: PrepareRenameResult | PrepareRenameDefaultBehavior | null
|
||||
result: PrepareRenameResult | PrepareRenameDefaultBehavior | Range | null
|
||||
): string {
|
||||
if (!result) return "Cannot rename at this position"
|
||||
|
||||
// Case 1: { defaultBehavior: boolean }
|
||||
if ("defaultBehavior" in result) {
|
||||
return result.defaultBehavior ? "Rename supported (using default behavior)" : "Cannot rename at this position"
|
||||
}
|
||||
|
||||
const startLine = result.range.start.line + 1
|
||||
const startChar = result.range.start.character
|
||||
const endLine = result.range.end.line + 1
|
||||
const endChar = result.range.end.character
|
||||
const placeholder = result.placeholder ? ` (current: "${result.placeholder}")` : ""
|
||||
// Case 2: { range: Range, placeholder?: string }
|
||||
if ("range" in result && result.range) {
|
||||
const startLine = result.range.start.line + 1
|
||||
const startChar = result.range.start.character
|
||||
const endLine = result.range.end.line + 1
|
||||
const endChar = result.range.end.character
|
||||
const placeholder = result.placeholder ? ` (current: "${result.placeholder}")` : ""
|
||||
return `Rename available at ${startLine}:${startChar}-${endLine}:${endChar}${placeholder}`
|
||||
}
|
||||
|
||||
return `Rename available at ${startLine}:${startChar}-${endLine}:${endChar}${placeholder}`
|
||||
// Case 3: Range directly (has start/end but no range property)
|
||||
if ("start" in result && "end" in result) {
|
||||
const startLine = result.start.line + 1
|
||||
const startChar = result.start.character
|
||||
const endLine = result.end.line + 1
|
||||
const endChar = result.end.character
|
||||
return `Rename available at ${startLine}:${startChar}-${endLine}:${endChar}`
|
||||
}
|
||||
|
||||
return "Cannot rename at this position"
|
||||
}
|
||||
|
||||
export function formatTextEdit(edit: TextEdit): string {
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
import { safe_grep } from "./tools"
|
||||
|
||||
export { safe_grep }
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user