Compare commits

..

8 Commits

Author SHA1 Message Date
github-actions[bot]
765507648c release: v2.7.1 2025-12-28 18:14:11 +00:00
YeonGyu-Kim
c10bc5fcdf fix(todo-continuation-enforcer): simplify implementation and remove 10s throttle blocking background task completion
Removes the complex state machine and 10-second throttle (MIN_INJECTION_INTERVAL_MS)
that was causing background task completion to hang. The hook now:

- Uses straightforward error cooldown logic instead of complex injection throttling
- Removes unnecessary state tracking that was delaying continuation injection
- Maintains all safety checks (recovery mode, running tasks, error state)
- Keeps countdown behavior with toast notifications

Fixes #312 - Resolves v2.7.0 issue where background task completion would freeze
the agent due to injection delays.

🤖 Generated with assistance of OhMyOpenCode (https://github.com/code-yeongyu/oh-my-opencode)
2025-12-29 03:10:43 +09:00
YeonGyu-Kim
c0b28b0715 improve sanitize 2025-12-29 02:29:46 +09:00
YeonGyu-Kim
dd60002a0d fix(sisyphus-agent): handle OpenCode installer failure with pinned version fallback
Replace retry loop with intelligent fallback strategy:
- Try default installer first (better for version discovery)
- On failure, fallback to pinned version 1.0.204
- Handle corrupted downloads with direct fallback install

This addresses the sisyphus-agent workflow failure where OpenCode's installer failed
with 'Failed to fetch version information' error.

🤖 Generated with assistance of OhMyOpenCode (https://github.com/code-yeongyu/oh-my-opencode)
2025-12-29 01:09:28 +09:00
YeonGyu-Kim
25d2946b76 Update AGENTS.md with current project state (commit 122e918, 2025-12-28)
🤖 GENERATED WITH ASSISTANCE OF [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)
2025-12-28 19:42:56 +09:00
YeonGyu-Kim
122e918503 Add LSP tool integration to init-deep template for code intelligence analysis
Enhanced init-deep.ts template with LSP-First core principle and Code Intelligence Analysis phase:
- Added LSP-First principle for semantic code understanding
- Integrated lsp_servers, lsp_document_symbols, lsp_workspace_symbols, lsp_find_references in Phase 1
- Added LSP-based scoring factors (symbol density, export count, reference centrality) in Phase 2
- Included CODE_INTELLIGENCE output format specification
- Added LSP fallback guidance for unavailable servers
- Updated scoring matrix with LSP sources and enhanced metrics

🤖 Generated with assistance of OhMyOpenCode (https://github.com/code-yeongyu/oh-my-opencode)
2025-12-28 19:23:29 +09:00
YeonGyu-Kim
aeff184e0c docs: add missing hooks, session tools, and sync sections across all READMEs
- Added 4 missing hooks to disabled_hooks config: preemptive-compaction, compaction-context-injector, thinking-block-validator, claude-code-hooks
- Added session management tools section documenting: session_list, session_read, session_search, session_info, call_omo_agent
- Added Uninstallation section to KO/JA/ZH-CN READMEs (synced with EN)
- Added JSONC Support section to KO/JA/ZH-CN READMEs (synced with EN)

🤖 Generated with assistance of OhMyOpenCode (https://github.com/code-yeongyu/oh-my-opencode)
2025-12-28 18:25:32 +09:00
github-actions[bot]
b995ea8595 @SyedTahirHussan has signed the CLA in code-yeongyu/oh-my-opencode#306 2025-12-28 09:24:13 +00:00
12 changed files with 876 additions and 318 deletions

View File

@@ -86,14 +86,19 @@ jobs:
# Install OpenCode (skip if cached)
if ! command -v opencode &>/dev/null; then
for i in 1 2 3; do
echo "Attempt $i: Installing OpenCode..."
curl -fsSL https://opencode.ai/install -o /tmp/opencode-install.sh
if file /tmp/opencode-install.sh | grep -q "shell script\|text"; then
bash /tmp/opencode-install.sh && break
echo "Installing OpenCode..."
curl -fsSL https://opencode.ai/install -o /tmp/opencode-install.sh
# Try default installer first, fallback to pinned version if it fails
if file /tmp/opencode-install.sh | grep -q "shell script\|text"; then
if ! bash /tmp/opencode-install.sh 2>&1; then
echo "Default installer failed, trying with pinned version..."
bash /tmp/opencode-install.sh --version 1.0.204
fi
echo "Download corrupted, retrying in 5s..."
done
else
echo "Download corrupted, trying direct install with pinned version..."
bash <(curl -fsSL https://opencode.ai/install) --version 1.0.204
fi
fi
opencode --version

View File

@@ -1,7 +1,7 @@
# PROJECT KNOWLEDGE BASE
**Generated:** 2025-12-28T17:15:00+09:00
**Commit:** f5b74d5
**Generated:** 2025-12-28T19:26:00+09:00
**Commit:** 122e918
**Branch:** dev
## OVERVIEW

View File

@@ -390,6 +390,39 @@ gh repo star code-yeongyu/oh-my-opencode
</details>
## アンインストール
oh-my-opencode を削除するには:
1. **OpenCode 設定からプラグインを削除**
`~/.config/opencode/opencode.json` (または `opencode.jsonc`) を編集し、`plugin` 配列から `"oh-my-opencode"` を削除します:
```bash
# jq を使用する例
jq '.plugin = [.plugin[] | select(. != "oh-my-opencode")]' \
~/.config/opencode/opencode.json > /tmp/oc.json && \
mv /tmp/oc.json ~/.config/opencode/opencode.json
```
2. **設定ファイルの削除 (オプション)**
```bash
# ユーザー設定を削除
rm -f ~/.config/opencode/oh-my-opencode.json
# プロジェクト設定を削除 (存在する場合)
rm -f .opencode/oh-my-opencode.json
```
3. **削除の確認**
```bash
opencode --version
# プラグインがロードされなくなっているはずです
```
## 機能
### Agents: あなたの新しいチームメイト
@@ -457,6 +490,19 @@ Ask @explore for the policy on this feature
- **ast_grep_search**: AST 認識コードパターン検索 (25言語対応)
- **ast_grep_replace**: AST 認識コード置換
#### セッション管理
OpenCode セッション履歴をナビゲートおよび検索するためのツール:
- **session_list**: 日付およびリミットでフィルタリングしながらすべての OpenCode セッションを一覧表示
- **session_read**: 特定のセッションからメッセージと履歴を読み取る
- **session_search**: セッションメッセージ全体を全文検索
- **session_info**: セッションに関するメタデータと統計情報を取得
これらのツールにより、エージェントは以前の会話を参照し、セッション間の継続性を維持できます。
- **call_omo_agent**: 専門的な explore/librarian エージェントを起動。非同期実行のための `run_in_background` パラメータをサポート。
#### Context Is All You Need
- **Directory AGENTS.md / README.md Injector**: ファイルを読み込む際、`AGENTS.md` と `README.md` の内容を自動的に注入します。ファイルディレクトリからプロジェクトルートまで遡り、パス上の **すべて** の `AGENTS.md` ファイルを収集します。ネストされたディレクトリごとの指示をサポートします:
```
@@ -619,7 +665,7 @@ Oh My OpenCode は以下の場所からフックを読み込んで実行しま
| プラットフォーム | ユーザー設定パス |
|------------------|------------------|
| **Windows** | `~/.config/opencode/oh-my-opencode.json` (優先) または `%APPDATA%\opencode\oh-my-opencode.json` (フォールバック) |
| **Windows** | `~/.config/opencode/oh-my-opencode.json` (推奨) または `%APPDATA%\opencode\oh-my-opencode.json` (fallback) |
| **macOS/Linux** | `~/.config/opencode/oh-my-opencode.json` |
スキーマ自動補完がサポートされています:
@@ -630,6 +676,36 @@ Oh My OpenCode は以下の場所からフックを読み込んで実行しま
}
```
### JSONC のサポート
`oh-my-opencode` 設定ファイルは JSONC (コメント付き JSON) をサポートしています:
- 行コメント: `// コメント`
- ブロックコメント: `/* コメント */`
- 末尾のカンマ: `{ "key": "value", }`
`oh-my-opencode.jsonc` と `oh-my-opencode.json` の両方が存在する場合、`.jsonc` が優先されます。
**コメント付きの例:**
```jsonc
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
// Antigravity OAuth 経由で Google Gemini を有効にする
"google_auth": false,
/* エージェントのオーバーライド - 特定のタスクに合わせてモデルをカスタマイズ */
"agents": {
"oracle": {
"model": "openai/gpt-5.2" // 戦略的な推論のための GPT
},
"explore": {
"model": "opencode/grok-code" // 探索のための高速かつ無料のモデル
},
},
}
```
### Google Auth
**推奨**: 外部の [`opencode-antigravity-auth`](https://github.com/NoeFabris/opencode-antigravity-auth) プラグインを使用してください。マルチアカウントロードバランシング、より多くのモデルAntigravity 経由の Claude を含む)、活発なメンテナンスを提供します。[インストール > Google Gemini](#42-google-gemini-antigravity-oauth) を参照。
@@ -792,7 +868,7 @@ Oh My OpenCode は以下の場所からフックを読み込んで実行しま
}
```
利用可能なフック:`todo-continuation-enforcer`, `context-window-monitor`, `session-recovery`, `session-notification`, `comment-checker`, `grep-output-truncator`, `tool-output-truncator`, `directory-agents-injector`, `directory-readme-injector`, `empty-task-response-detector`, `think-mode`, `anthropic-auto-compact`, `rules-injector`, `background-notification`, `auto-update-checker`, `startup-toast`, `keyword-detector`, `agent-usage-reminder`, `non-interactive-env`, `interactive-bash-session`, `empty-message-sanitizer`
利用可能なフック:`todo-continuation-enforcer`, `context-window-monitor`, `session-recovery`, `session-notification`, `comment-checker`, `grep-output-truncator`, `tool-output-truncator`, `directory-agents-injector`, `directory-readme-injector`, `empty-task-response-detector`, `think-mode`, `anthropic-auto-compact`, `rules-injector`, `background-notification`, `auto-update-checker`, `startup-toast`, `keyword-detector`, `agent-usage-reminder`, `non-interactive-env`, `interactive-bash-session`, `empty-message-sanitizer`, `preemptive-compaction`, `compaction-context-injector`, `thinking-block-validator`, `claude-code-hooks`
**`auto-update-checker`と`startup-toast`について**: `startup-toast` フックは `auto-update-checker` のサブ機能です。アップデートチェックは有効なまま起動トースト通知のみを無効化するには、`disabled_hooks` に `"startup-toast"` を追加してください。すべてのアップデートチェック機能(トーストを含む)を無効化するには、`"auto-update-checker"` を追加してください。

View File

@@ -387,6 +387,39 @@ gh repo star code-yeongyu/oh-my-opencode
</details>
## 언인스톨
oh-my-opencode를 제거하려면:
1. **OpenCode 설정에서 플러그인 제거**
`~/.config/opencode/opencode.json` (또는 `opencode.jsonc`)를 편집하여 `plugin` 배열에서 `"oh-my-opencode"`를 제거합니다:
```bash
# jq 사용 예시
jq '.plugin = [.plugin[] | select(. != "oh-my-opencode")]' \
~/.config/opencode/opencode.json > /tmp/oc.json && \
mv /tmp/oc.json ~/.config/opencode/opencode.json
```
2. **설정 파일 삭제 (선택 사항)**
```bash
# 사용자 설정 삭제
rm -f ~/.config/opencode/oh-my-opencode.json
# 프로젝트 설정 삭제 (존재하는 경우)
rm -f .opencode/oh-my-opencode.json
```
3. **제거 확인**
```bash
opencode --version
# 플러그인이 더 이상 로드되지 않아야 합니다
```
## 기능
### Agents: 당신의 새로운 팀원들
@@ -450,6 +483,18 @@ Syntax Highlighting, Autocomplete, Refactoring, Navigation, Analysis, 그리고
- **lsp_code_action_resolve**: 코드 액션 적용
- **ast_grep_search**: AST 인식 코드 패턴 검색 (25개 언어)
- **ast_grep_replace**: AST 인식 코드 교체
- **call_omo_agent**: 전문 explore/librarian 에이전트를 생성합니다. 비동기 실행을 위한 `run_in_background` 파라미터를 지원합니다.
#### 세션 관리 (Session Management)
OpenCode 세션 히스토리를 탐색하고 검색하기 위한 도구들입니다:
- **session_list**: 날짜 및 개수 제한 필터링을 포함한 모든 OpenCode 세션 목록 조회
- **session_read**: 특정 세션의 메시지 및 히스토리 읽기
- **session_search**: 세션 메시지 전체 텍스트 검색
- **session_info**: 세션에 대한 메타데이터 및 통계 정보 조회
이 도구들을 통해 에이전트는 이전 대화를 참조하고 세션 간의 연속성을 유지할 수 있습니다.
#### Context is all you need.
- **Directory AGENTS.md / README.md Injector**: 파일을 읽을 때 `AGENTS.md`, `README.md` 내용을 자동으로 주입합니다. 파일 디렉토리부터 프로젝트 루트까지 탐색하며, 경로 상의 **모든** `AGENTS.md` 파일을 수집합니다. 중첩된 디렉토리별 지침을 지원합니다:
@@ -602,6 +647,10 @@ Oh My OpenCode는 다음 위치의 훅을 읽고 실행합니다:
- **Empty Message Sanitizer**: 빈 채팅 메시지로 인한 API 오류를 방지합니다. 전송 전 메시지 내용을 자동으로 정리합니다.
- **Grep Output Truncator**: grep은 산더미 같은 텍스트를 반환할 수 있습니다. 남은 컨텍스트 윈도우에 따라 동적으로 출력을 축소합니다—50% 여유 공간 유지, 최대 50k 토큰.
- **Tool Output Truncator**: 같은 아이디어, 더 넓은 범위. Grep, Glob, LSP 도구, AST-grep의 출력을 축소합니다. 한 번의 장황한 검색이 전체 컨텍스트를 잡아먹는 것을 방지합니다.
- **선제적 압축 (Preemptive Compaction)**: 세션 토큰 한계에 도달하기 전에 선제적으로 세션을 압축합니다. 문제가 발생하기 전에 미리 실행됩니다.
- **압축 컨텍스트 주입기 (Compaction Context Injector)**: 세션 압축 중에 중요한 컨텍스트(AGENTS.md, 현재 디렉토리 정보 등)를 유지하여 중요한 상태를 잃지 않도록 합니다.
- **사고 블록 검증기 (Thinking Block Validator)**: 사고(thinking) 블록의 형식이 올바른지 검증하여 잘못된 형식으로 인한 API 오류를 방지합니다.
- **Claude Code 훅 (Claude Code Hooks)**: Claude Code의 settings.json에 설정된 훅을 실행합니다. PreToolUse/PostToolUse/UserPromptSubmit/Stop 이벤트를 지원하는 호환성 레이어입니다.
## 설정
@@ -613,7 +662,7 @@ Oh My OpenCode는 다음 위치의 훅을 읽고 실행합니다:
| 플랫폼 | 사용자 설정 경로 |
|--------|------------------|
| **Windows** | `~/.config/opencode/oh-my-opencode.json` (우선) 또는 `%APPDATA%\opencode\oh-my-opencode.json` (fallback) |
| **Windows** | `~/.config/opencode/oh-my-opencode.json` (권장) 또는 `%APPDATA%\opencode\oh-my-opencode.json` (fallback) |
| **macOS/Linux** | `~/.config/opencode/oh-my-opencode.json` |
Schema 자동 완성이 지원됩니다:
@@ -624,6 +673,36 @@ Schema 자동 완성이 지원됩니다:
}
```
### JSONC 지원
`oh-my-opencode` 설정 파일은 JSONC(주석이 포함된 JSON)를 지원합니다:
- 한 줄 주석: `// 주석`
- 블록 주석: `/* 주석 */`
- 후행 콤마(Trailing commas): `{ "key": "value", }`
`oh-my-opencode.jsonc`와 `oh-my-opencode.json` 파일이 모두 존재할 경우, `.jsonc` 파일이 우선순위를 갖습니다.
**주석이 포함된 예시:**
```jsonc
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
// Antigravity OAuth를 통해 Google Gemini 활성화
"google_auth": false,
/* 에이전트 오버라이드 - 특정 작업에 대한 모델 커스터마이징 */
"agents": {
"oracle": {
"model": "openai/gpt-5.2" // 전략적 추론을 위한 GPT
},
"explore": {
"model": "opencode/grok-code" // 탐색을 위한 빠르고 무료인 모델
},
},
}
```
### Google Auth
**권장**: 외부 [`opencode-antigravity-auth`](https://github.com/NoeFabris/opencode-antigravity-auth) 플러그인을 사용하세요. 멀티 계정 로드밸런싱, 더 많은 모델(Antigravity를 통한 Claude 포함), 활발한 유지보수를 제공합니다. [설치 > Google Gemini](#42-google-gemini-antigravity-oauth) 참조.
@@ -786,7 +865,7 @@ Schema 자동 완성이 지원됩니다:
}
```
사용 가능한 훅: `todo-continuation-enforcer`, `context-window-monitor`, `session-recovery`, `session-notification`, `comment-checker`, `grep-output-truncator`, `tool-output-truncator`, `directory-agents-injector`, `directory-readme-injector`, `empty-task-response-detector`, `think-mode`, `anthropic-auto-compact`, `rules-injector`, `background-notification`, `auto-update-checker`, `startup-toast`, `keyword-detector`, `agent-usage-reminder`, `non-interactive-env`, `interactive-bash-session`, `empty-message-sanitizer`
사용 가능한 훅: `todo-continuation-enforcer`, `context-window-monitor`, `session-recovery`, `session-notification`, `comment-checker`, `grep-output-truncator`, `tool-output-truncator`, `directory-agents-injector`, `directory-readme-injector`, `empty-task-response-detector`, `think-mode`, `anthropic-auto-compact`, `rules-injector`, `background-notification`, `auto-update-checker`, `startup-toast`, `keyword-detector`, `agent-usage-reminder`, `non-interactive-env`, `interactive-bash-session`, `empty-message-sanitizer`, `preemptive-compaction`, `compaction-context-injector`, `thinking-block-validator`, `claude-code-hooks`
**`auto-update-checker`와 `startup-toast`에 대한 참고사항**: `startup-toast` 훅은 `auto-update-checker`의 하위 기능입니다. 업데이트 확인은 유지하면서 시작 토스트 알림만 비활성화하려면 `disabled_hooks`에 `"startup-toast"`를 추가하세요. 모든 업데이트 확인 기능(토스트 포함)을 비활성화하려면 `"auto-update-checker"`를 추가하세요.

View File

@@ -522,6 +522,18 @@ Hand your best tools to your best colleagues. Now they can properly refactor, na
- **lsp_code_action_resolve**: Apply code action
- **ast_grep_search**: AST-aware code pattern search (25 languages)
- **ast_grep_replace**: AST-aware code replacement
- **call_omo_agent**: Spawn specialized explore/librarian agents. Supports `run_in_background` parameter for async execution.
#### Session Management
Tools to navigate and search your OpenCode session history:
- **session_list**: List all OpenCode sessions with filtering by date and limit
- **session_read**: Read messages and history from a specific session
- **session_search**: Full-text search across session messages
- **session_info**: Get metadata and statistics about a session
These tools enable agents to reference previous conversations and maintain continuity across sessions.
#### Context Is All You Need
- **Directory AGENTS.md / README.md Injector**: Auto-injects `AGENTS.md` and `README.md` when reading files. Walks from file directory to project root, collecting **all** `AGENTS.md` files along the path. Supports nested directory-specific instructions:
@@ -674,6 +686,10 @@ When agents thrive, you thrive. But I want to help you directly too.
- **Empty Message Sanitizer**: Prevents API errors from empty chat messages by automatically sanitizing message content before sending.
- **Grep Output Truncator**: Grep can return mountains of text. This dynamically truncates output based on your remaining context window—keeps 50% headroom, caps at 50k tokens.
- **Tool Output Truncator**: Same idea, broader scope. Truncates output from Grep, Glob, LSP tools, and AST-grep. Prevents one verbose search from eating your entire context.
- **Preemptive Compaction**: Compacts session proactively before hitting hard token limits. Runs before you get into trouble.
- **Compaction Context Injector**: Preserves critical context (AGENTS.md, current directory info) during session compaction so you don't lose important state.
- **Thinking Block Validator**: Validates thinking blocks to ensure proper formatting and prevent API errors from malformed thinking content.
- **Claude Code Hooks**: Executes hooks from Claude Code's settings.json - this is the compatibility layer that runs PreToolUse/PostToolUse/UserPromptSubmit/Stop hooks.
## Configuration
@@ -888,7 +904,7 @@ Disable specific built-in hooks via `disabled_hooks` in `~/.config/opencode/oh-m
}
```
Available hooks: `todo-continuation-enforcer`, `context-window-monitor`, `session-recovery`, `session-notification`, `comment-checker`, `grep-output-truncator`, `tool-output-truncator`, `directory-agents-injector`, `directory-readme-injector`, `empty-task-response-detector`, `think-mode`, `anthropic-auto-compact`, `rules-injector`, `background-notification`, `auto-update-checker`, `startup-toast`, `keyword-detector`, `agent-usage-reminder`, `non-interactive-env`, `interactive-bash-session`, `empty-message-sanitizer`
Available hooks: `todo-continuation-enforcer`, `context-window-monitor`, `session-recovery`, `session-notification`, `comment-checker`, `grep-output-truncator`, `tool-output-truncator`, `directory-agents-injector`, `directory-readme-injector`, `empty-task-response-detector`, `think-mode`, `anthropic-auto-compact`, `rules-injector`, `background-notification`, `auto-update-checker`, `startup-toast`, `keyword-detector`, `agent-usage-reminder`, `non-interactive-env`, `interactive-bash-session`, `empty-message-sanitizer`, `preemptive-compaction`, `compaction-context-injector`, `thinking-block-validator`, `claude-code-hooks`
**Note on `auto-update-checker` and `startup-toast`**: The `startup-toast` hook is a sub-feature of `auto-update-checker`. To disable only the startup toast notification while keeping update checking enabled, add `"startup-toast"` to `disabled_hooks`. To disable all update checking features (including the toast), add `"auto-update-checker"` to `disabled_hooks`.

View File

@@ -398,6 +398,39 @@ gh repo star code-yeongyu/oh-my-opencode
</details>
## 卸载
要移除 oh-my-opencode
1. **从 OpenCode 配置中移除插件**
编辑 `~/.config/opencode/opencode.json` (或 `opencode.jsonc`),从 `plugin` 数组中移除 `"oh-my-opencode"`
```bash
# 使用 jq 的示例
jq '.plugin = [.plugin[] | select(. != "oh-my-opencode")]' \
~/.config/opencode/opencode.json > /tmp/oc.json && \
mv /tmp/oc.json ~/.config/opencode/opencode.json
```
2. **删除配置文件 (可选)**
```bash
# 删除用户配置
rm -f ~/.config/opencode/oh-my-opencode.json
# 删除项目配置 (如果存在)
rm -f .opencode/oh-my-opencode.json
```
3. **确认移除**
```bash
opencode --version
# 插件不应再被加载
```
## 功能
### Agents你的神队友
@@ -461,6 +494,18 @@ OhMyOpenCode 让这些成为可能。
- **lsp_code_action_resolve**:应用代码操作
- **ast_grep_search**AST 感知代码搜索(支持 25 种语言)
- **ast_grep_replace**AST 感知代码替换
- **call_omo_agent**: 产生专门的 explore/librarian Agent。支持用于异步执行的 `run_in_background` 参数。
#### 会话管理 (Session Management)
用于导航和搜索 OpenCode 会话历史的工具:
- **session_list**: 列出所有 OpenCode 会话,支持按日期和数量限制进行过滤
- **session_read**: 读取特定会话的消息和历史记录
- **session_search**: 在会话消息中进行全文搜索
- **session_info**: 获取有关会话的元数据和统计信息
这些工具使 Agent 能够引用之前的对话并保持跨会话的连续性。
#### 上下文就是一切 (Context is all you need)
- **Directory AGENTS.md / README.md 注入器**:读文件时自动把 `AGENTS.md` 和 `README.md` 塞进去。从当前目录一路往上找,路径上**所有** `AGENTS.md` 全都带上。支持嵌套指令:
@@ -620,7 +665,12 @@ Agent 爽了,你自然也爽。但我还想直接让你爽。
配置文件(优先级从高到低):
1. `.opencode/oh-my-opencode.json`(项目级)
2. `~/.config/opencode/oh-my-opencode.json`(用户级)
2. 用户配置(按平台):
| 平台 | 用户配置路径 |
|----------|------------------|
| **Windows** | `~/.config/opencode/oh-my-opencode.json` (首选) 或 `%APPDATA%\opencode\oh-my-opencode.json` (备选) |
| **macOS/Linux** | `~/.config/opencode/oh-my-opencode.json` |
支持 Schema 自动补全:
@@ -630,6 +680,36 @@ Agent 爽了,你自然也爽。但我还想直接让你爽。
}
```
### JSONC 支持
`oh-my-opencode` 配置文件支持 JSONC带注释的 JSON
- 行注释:`// 注释`
- 块注释:`/* 注释 */`
- 尾随逗号:`{ "key": "value", }`
当 `oh-my-opencode.jsonc` 和 `oh-my-opencode.json` 文件同时存在时,`.jsonc` 优先。
**带注释的示例:**
```jsonc
{
"$schema": "https://raw.githubusercontent.com/code-yeongyu/oh-my-opencode/master/assets/oh-my-opencode.schema.json",
// 通过 Antigravity OAuth 启用 Google Gemini
"google_auth": false,
/* Agent 覆盖 - 为特定任务自定义模型 */
"agents": {
"oracle": {
"model": "openai/gpt-5.2" // 用于战略推理的 GPT
},
"explore": {
"model": "opencode/grok-code" // 快速且免费的搜索模型
},
},
}
```
### Google Auth
**强推**:用外部 [`opencode-antigravity-auth`](https://github.com/NoeFabris/opencode-antigravity-auth) 插件。多账号负载均衡、更多模型(包括 Antigravity 版 Claude、有人维护。看 [安装 > Google Gemini](#42-google-gemini-antigravity-oauth)。
@@ -792,7 +872,7 @@ Sisyphus Agent 也能自定义:
}
```
可关的 hook`todo-continuation-enforcer`、`context-window-monitor`、`session-recovery`、`session-notification`、`comment-checker`、`grep-output-truncator`、`tool-output-truncator`、`directory-agents-injector`、`directory-readme-injector`、`empty-task-response-detector`、`think-mode`、`anthropic-auto-compact`、`rules-injector`、`background-notification`、`auto-update-checker`、`startup-toast`、`keyword-detector`、`agent-usage-reminder`、`non-interactive-env`、`interactive-bash-session`、`empty-message-sanitizer`
可关的 hook`todo-continuation-enforcer`、`context-window-monitor`、`session-recovery`、`session-notification`、`comment-checker`、`grep-output-truncator`、`tool-output-truncator`、`directory-agents-injector`、`directory-readme-injector`、`empty-task-response-detector`、`think-mode`、`anthropic-auto-compact`、`rules-injector`、`background-notification`、`auto-update-checker`、`startup-toast`、`keyword-detector`、`agent-usage-reminder`、`non-interactive-env`、`interactive-bash-session`、`empty-message-sanitizer`、`preemptive-compaction`、`compaction-context-injector`、`thinking-block-validator`、`claude-code-hooks`
**关于 `auto-update-checker` 和 `startup-toast`**: `startup-toast` hook 是 `auto-update-checker` 的子功能。若想保持更新检查但只禁用启动提示通知,在 `disabled_hooks` 中添加 `"startup-toast"`。若要禁用所有更新检查功能(包括提示),添加 `"auto-update-checker"`。

View File

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

View File

@@ -63,6 +63,14 @@
"created_at": "2025-12-27T17:05:50Z",
"repoId": 1108837393,
"pullRequestNo": 288
},
{
"name": "SyedTahirHussan",
"id": 9879266,
"comment_id": 3694598917,
"created_at": "2025-12-28T09:24:03Z",
"repoId": 1108837393,
"pullRequestNo": 306
}
]
}

View File

@@ -18,6 +18,7 @@ Generate comprehensive AGENTS.md files across project hierarchy. Combines root-l
- **Predict-then-Compare**: Predict standard → find actual → document ONLY deviations
- **Hierarchy Aware**: Parent covers general, children cover specific
- **No Redundancy**: Child AGENTS.md NEVER repeats parent content
- **LSP-First**: Use LSP tools for accurate code intelligence when available (semantic > text search)
---
@@ -80,6 +81,53 @@ background_task(agent="explore", prompt="Build/CI: FIND .github/workflows, Makef
background_task(agent="explore", prompt="Test patterns: FIND pytest.ini, jest.config, test structure → REPORT unique testing conventions")
\`\`\`
### Code Intelligence Analysis (LSP tools - run in parallel)
LSP provides semantic understanding beyond text search. Use for accurate code mapping.
\`\`\`
# Step 1: Check LSP availability
lsp_servers() # Verify language server is available
# Step 2: Analyze entry point files (run in parallel)
# Find entry points first, then analyze each with lsp_document_symbols
lsp_document_symbols(filePath="src/index.ts") # Main entry
lsp_document_symbols(filePath="src/main.py") # Python entry
lsp_document_symbols(filePath="cmd/main.go") # Go entry
# Step 3: Discover key symbols across workspace (run in parallel)
lsp_workspace_symbols(filePath=".", query="class") # All classes
lsp_workspace_symbols(filePath=".", query="interface") # All interfaces
lsp_workspace_symbols(filePath=".", query="function") # Top-level functions
lsp_workspace_symbols(filePath=".", query="type") # Type definitions
# Step 4: Analyze symbol centrality (for top 5-10 key symbols)
# High reference count = central/important concept
lsp_find_references(filePath="src/index.ts", line=X, character=Y) # Main export
\`\`\`
#### LSP Analysis Output Format
\`\`\`
CODE_INTELLIGENCE = {
entry_points: [
{ file: "src/index.ts", exports: ["Plugin", "createHook"], symbol_count: 12 }
],
key_symbols: [
{ name: "Plugin", type: "class", file: "src/index.ts", refs: 45, role: "Central orchestrator" },
{ name: "createHook", type: "function", file: "src/utils.ts", refs: 23, role: "Hook factory" }
],
module_boundaries: [
{ dir: "src/hooks", exports: 21, imports_from: ["shared/"] },
{ dir: "src/tools", exports: 15, imports_from: ["shared/", "hooks/"] }
]
}
\`\`\`
<critical>
**LSP Fallback**: If LSP unavailable (no server installed), skip this section and rely on explore agents + AST-grep patterns.
</critical>
</parallel-tasks>
**Collect all results. Mark "p1-analysis" as completed.**
@@ -92,13 +140,35 @@ background_task(agent="explore", prompt="Test patterns: FIND pytest.ini, jest.co
### Scoring Matrix
| Factor | Weight | Threshold |
|--------|--------|-----------|
| File count | 3x | >20 files = high |
| Subdirectory count | 2x | >5 subdirs = high |
| Code file ratio | 2x | >70% code = high |
| Unique patterns | 1x | Has own config |
| Module boundary | 2x | Has __init__.py/index.ts |
| Factor | Weight | Threshold | Source |
|--------|--------|-----------|--------|
| File count | 3x | >20 files = high | bash |
| Subdirectory count | 2x | >5 subdirs = high | bash |
| Code file ratio | 2x | >70% code = high | bash |
| Unique patterns | 1x | Has own config | explore |
| Module boundary | 2x | Has __init__.py/index.ts | bash |
| **Symbol density** | 2x | >30 symbols = high | LSP |
| **Export count** | 2x | >10 exports = high | LSP |
| **Reference centrality** | 3x | Symbols with >20 refs | LSP |
<lsp-scoring>
**LSP-Enhanced Scoring** (if available):
\`\`\`
For each directory in candidates:
symbols = lsp_document_symbols(dir/index.ts or dir/__init__.py)
symbol_score = len(symbols) > 30 ? 6 : len(symbols) > 15 ? 3 : 0
export_score = count(exported symbols) > 10 ? 4 : 0
# Check if this module is central (many things depend on it)
for each exported symbol:
refs = lsp_find_references(symbol)
if refs > 20: centrality_score += 3
total_score += symbol_score + export_score + centrality_score
\`\`\`
</lsp-scoring>
### Decision Rules
@@ -156,6 +226,28 @@ Root AGENTS.md gets **full treatment** with Predict-then-Compare synthesis.
|------|----------|-------|
| Add feature X | \\\`src/x/\\\` | {pattern hint} |
## CODE MAP
{Generated from LSP analysis - shows key symbols and their relationships}
| Symbol | Type | Location | Refs | Role |
|--------|------|----------|------|------|
| {MainClass} | Class | \\\`src/index.ts\\\` | {N} | {Central orchestrator} |
| {createX} | Function | \\\`src/utils.ts\\\` | {N} | {Factory pattern} |
| {Config} | Interface | \\\`src/types.ts\\\` | {N} | {Configuration contract} |
### Module Dependencies
\\\`\\\`\\\`
{entry} ──imports──> {core/}
│ │
└──imports──> {utils/} <──imports── {features/}
\\\`\\\`\\\`
<code-map-note>
**Skip CODE MAP if**: LSP unavailable OR project too small (<10 files) OR no clear module boundaries.
</code-map-note>
## CONVENTIONS
{ONLY deviations from standard - skip generic advice}
@@ -296,4 +388,7 @@ Hierarchy:
- **Generic content**: Remove anything that applies to ALL projects
- **Sequential execution**: MUST use parallel agents
- **Deep nesting**: Rarely need AGENTS.md at depth 4+
- **Verbose style**: "This directory contains..." → just list it`
- **Verbose style**: "This directory contains..." → just list it
- **Ignoring LSP**: If LSP available, USE IT - semantic analysis > text grep
- **LSP without fallback**: Always have explore agent backup if LSP unavailable
- **Over-referencing**: Don't trace refs for EVERY symbol - focus on exports only`

View File

@@ -42,8 +42,13 @@ export function createEmptyMessageSanitizerHook(): MessagesTransformHook {
"experimental.chat.messages.transform": async (_input, output) => {
const { messages } = output
for (const message of messages) {
if (message.info.role === "user") continue
for (let i = 0; i < messages.length; i++) {
const message = messages[i]
const isLastMessage = i === messages.length - 1
const isAssistant = message.info.role === "assistant"
// Skip final assistant message (allowed to be empty per API spec)
if (isLastMessage && isAssistant) continue
const parts = message.parts

View File

@@ -0,0 +1,383 @@
import { describe, expect, test, beforeEach, afterEach, mock } from "bun:test"
import { createTodoContinuationEnforcer } from "./todo-continuation-enforcer"
import { setMainSession } from "../features/claude-code-session-state"
import type { BackgroundManager } from "../features/background-agent"
describe("todo-continuation-enforcer", () => {
let promptCalls: Array<{ sessionID: string; agent?: string; text: string }>
let toastCalls: Array<{ title: string; message: string }>
function createMockPluginInput() {
return {
client: {
session: {
todo: async () => ({ data: [
{ id: "1", content: "Task 1", status: "pending", priority: "high" },
{ id: "2", content: "Task 2", status: "completed", priority: "medium" },
]}),
prompt: async (opts: any) => {
promptCalls.push({
sessionID: opts.path.id,
agent: opts.body.agent,
text: opts.body.parts[0].text,
})
return {}
},
},
tui: {
showToast: async (opts: any) => {
toastCalls.push({
title: opts.body.title,
message: opts.body.message,
})
return {}
},
},
},
directory: "/tmp/test",
} as any
}
function createMockBackgroundManager(runningTasks: boolean = false): BackgroundManager {
return {
getTasksByParentSession: () => runningTasks
? [{ status: "running" }]
: [],
} as any
}
beforeEach(() => {
promptCalls = []
toastCalls = []
setMainSession(undefined)
})
afterEach(() => {
setMainSession(undefined)
})
test("should inject continuation when idle with incomplete todos", async () => {
// #given - main session with incomplete todos
const sessionID = "main-123"
setMainSession(sessionID)
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {
backgroundManager: createMockBackgroundManager(false),
})
// #when - session goes idle
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
// #then - countdown toast shown
await new Promise(r => setTimeout(r, 100))
expect(toastCalls.length).toBeGreaterThanOrEqual(1)
expect(toastCalls[0].title).toBe("Todo Continuation")
// #then - after countdown, continuation injected
await new Promise(r => setTimeout(r, 2500))
expect(promptCalls.length).toBe(1)
expect(promptCalls[0].text).toContain("TODO CONTINUATION")
})
test("should not inject when all todos are complete", async () => {
// #given - session with all todos complete
const sessionID = "main-456"
setMainSession(sessionID)
const mockInput = createMockPluginInput()
mockInput.client.session.todo = async () => ({ data: [
{ id: "1", content: "Task 1", status: "completed", priority: "high" },
]})
const hook = createTodoContinuationEnforcer(mockInput, {})
// #when - session goes idle
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
await new Promise(r => setTimeout(r, 3000))
// #then - no continuation injected
expect(promptCalls).toHaveLength(0)
})
test("should not inject when background tasks are running", async () => {
// #given - session with running background tasks
const sessionID = "main-789"
setMainSession(sessionID)
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {
backgroundManager: createMockBackgroundManager(true),
})
// #when - session goes idle
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
await new Promise(r => setTimeout(r, 3000))
// #then - no continuation injected
expect(promptCalls).toHaveLength(0)
})
test("should not inject for non-main session", async () => {
// #given - main session set, different session goes idle
setMainSession("main-session")
const otherSession = "other-session"
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
// #when - non-main session goes idle
await hook.handler({
event: { type: "session.idle", properties: { sessionID: otherSession } },
})
await new Promise(r => setTimeout(r, 3000))
// #then - no continuation injected
expect(promptCalls).toHaveLength(0)
})
test("should skip injection after recent error", async () => {
// #given - session that just had an error
const sessionID = "main-error"
setMainSession(sessionID)
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
// #when - session error occurs
await hook.handler({
event: { type: "session.error", properties: { sessionID, error: new Error("test") } },
})
// #when - session goes idle immediately after
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
await new Promise(r => setTimeout(r, 3000))
// #then - no continuation injected (error cooldown)
expect(promptCalls).toHaveLength(0)
})
test("should clear error state on user message and allow injection", async () => {
// #given - session with error, then user clears it
const sessionID = "main-error-clear"
setMainSession(sessionID)
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
// #when - error occurs
await hook.handler({
event: { type: "session.error", properties: { sessionID } },
})
// #when - user sends message (clears error immediately)
await hook.handler({
event: { type: "message.updated", properties: { info: { sessionID, role: "user" } } },
})
// #when - session goes idle
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
await new Promise(r => setTimeout(r, 2500))
// #then - continuation injected (error was cleared by user message)
expect(promptCalls.length).toBe(1)
})
test("should cancel countdown on user message", async () => {
// #given - session starting countdown
const sessionID = "main-cancel"
setMainSession(sessionID)
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
// #when - session goes idle
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
// #when - user sends message immediately (before 2s countdown)
await hook.handler({
event: {
type: "message.updated",
properties: { info: { sessionID, role: "user" } }
},
})
// #then - wait past countdown time and verify no injection
await new Promise(r => setTimeout(r, 2500))
expect(promptCalls).toHaveLength(0)
})
test("should cancel countdown on assistant activity", async () => {
// #given - session starting countdown
const sessionID = "main-assistant"
setMainSession(sessionID)
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
// #when - session goes idle
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
// #when - assistant starts responding
await new Promise(r => setTimeout(r, 500))
await hook.handler({
event: {
type: "message.part.updated",
properties: { info: { sessionID, role: "assistant" } }
},
})
await new Promise(r => setTimeout(r, 3000))
// #then - no continuation injected (cancelled)
expect(promptCalls).toHaveLength(0)
})
test("should cancel countdown on tool execution", async () => {
// #given - session starting countdown
const sessionID = "main-tool"
setMainSession(sessionID)
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
// #when - session goes idle
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
// #when - tool starts executing
await new Promise(r => setTimeout(r, 500))
await hook.handler({
event: { type: "tool.execute.before", properties: { sessionID } },
})
await new Promise(r => setTimeout(r, 3000))
// #then - no continuation injected (cancelled)
expect(promptCalls).toHaveLength(0)
})
test("should skip injection during recovery mode", async () => {
// #given - session in recovery mode
const sessionID = "main-recovery"
setMainSession(sessionID)
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
// #when - mark as recovering
hook.markRecovering(sessionID)
// #when - session goes idle
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
await new Promise(r => setTimeout(r, 3000))
// #then - no continuation injected
expect(promptCalls).toHaveLength(0)
})
test("should inject after recovery complete", async () => {
// #given - session was in recovery, now complete
const sessionID = "main-recovery-done"
setMainSession(sessionID)
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
// #when - mark as recovering then complete
hook.markRecovering(sessionID)
hook.markRecoveryComplete(sessionID)
// #when - session goes idle
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
await new Promise(r => setTimeout(r, 3000))
// #then - continuation injected
expect(promptCalls.length).toBe(1)
})
test("should cleanup on session deleted", async () => {
// #given - session starting countdown
const sessionID = "main-delete"
setMainSession(sessionID)
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
// #when - session goes idle
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
// #when - session is deleted during countdown
await new Promise(r => setTimeout(r, 500))
await hook.handler({
event: { type: "session.deleted", properties: { info: { id: sessionID } } },
})
await new Promise(r => setTimeout(r, 3000))
// #then - no continuation injected (cleaned up)
expect(promptCalls).toHaveLength(0)
})
test("should show countdown toast updates", async () => {
// #given - session with incomplete todos
const sessionID = "main-toast"
setMainSession(sessionID)
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
// #when - session goes idle
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
// #then - multiple toast updates during countdown (2s countdown = 2 toasts: "2s" and "1s")
await new Promise(r => setTimeout(r, 2500))
expect(toastCalls.length).toBeGreaterThanOrEqual(2)
expect(toastCalls[0].message).toContain("2s")
})
test("should not have 10s throttle between injections", async () => {
// #given - new hook instance (no prior state)
const sessionID = "main-no-throttle"
setMainSession(sessionID)
const hook = createTodoContinuationEnforcer(createMockPluginInput(), {})
// #when - first idle cycle completes
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
await new Promise(r => setTimeout(r, 2500))
// #then - first injection happened
expect(promptCalls.length).toBe(1)
// #when - immediately trigger second idle (no 10s wait needed)
await hook.handler({
event: { type: "session.idle", properties: { sessionID } },
})
await new Promise(r => setTimeout(r, 2500))
// #then - second injection also happened (no throttle blocking)
expect(promptCalls.length).toBe(2)
}, { timeout: 10000 })
})

View File

@@ -28,6 +28,13 @@ interface Todo {
id: string
}
interface SessionState {
lastErrorAt?: number
countdownTimer?: ReturnType<typeof setTimeout>
countdownInterval?: ReturnType<typeof setInterval>
isRecovering?: boolean
}
const CONTINUATION_PROMPT = `[SYSTEM REMINDER - TODO CONTINUATION]
Incomplete tasks remain in your todo list. Continue working on the next pending task.
@@ -38,29 +45,7 @@ Incomplete tasks remain in your todo list. Continue working on the next pending
const COUNTDOWN_SECONDS = 2
const TOAST_DURATION_MS = 900
const MIN_INJECTION_INTERVAL_MS = 10_000
// ============================================================================
// STATE MACHINE TYPES
// ============================================================================
type SessionMode =
| "idle" // Observed idle, no countdown started yet
| "countingDown" // Waiting N seconds before injecting
| "injecting" // Currently calling session.prompt
| "recovering" // Session recovery in progress (external control)
| "errorBypass" // Bypass mode after session.error/interrupt
interface SessionState {
version: number // Monotonic generation token - increment to invalidate pending callbacks
mode: SessionMode
timer?: ReturnType<typeof setTimeout> // Pending countdown timer
lastAttemptedAt?: number // Timestamp of last injection attempt (throttle all attempts)
}
// ============================================================================
// HELPER FUNCTIONS
// ============================================================================
const ERROR_COOLDOWN_MS = 3_000
function getMessageDir(sessionID: string): string | null {
if (!existsSync(MESSAGE_STORAGE)) return null
@@ -76,20 +61,24 @@ function getMessageDir(sessionID: string): string | null {
return null
}
function detectInterrupt(error: unknown): boolean {
function isAbortError(error: unknown): boolean {
if (!error) return false
if (typeof error === "object") {
const errObj = error as Record<string, unknown>
const name = errObj.name as string | undefined
const message = (errObj.message as string | undefined)?.toLowerCase() ?? ""
if (name === "MessageAbortedError" || name === "AbortError") return true
if (name === "DOMException" && message.includes("abort")) return true
if (message.includes("aborted") || message.includes("cancelled") || message.includes("interrupted")) return true
}
if (typeof error === "string") {
const lower = error.toLowerCase()
return lower.includes("abort") || lower.includes("cancel") || lower.includes("interrupt")
}
return false
}
@@ -97,91 +86,56 @@ function getIncompleteCount(todos: Todo[]): number {
return todos.filter(t => t.status !== "completed" && t.status !== "cancelled").length
}
// ============================================================================
// MAIN IMPLEMENTATION
// ============================================================================
export function createTodoContinuationEnforcer(
ctx: PluginInput,
options: TodoContinuationEnforcerOptions = {}
): TodoContinuationEnforcer {
const { backgroundManager } = options
// Single source of truth: per-session state machine
const sessions = new Map<string, SessionState>()
// ============================================================================
// STATE HELPERS
// ============================================================================
function getOrCreateState(sessionID: string): SessionState {
function getState(sessionID: string): SessionState {
let state = sessions.get(sessionID)
if (!state) {
state = { version: 0, mode: "idle" }
state = {}
sessions.set(sessionID, state)
}
return state
}
function clearTimer(state: SessionState): void {
if (state.timer) {
clearTimeout(state.timer)
state.timer = undefined
}
}
/**
* Invalidate any pending or in-flight operation by incrementing version.
* ALWAYS bumps version regardless of current mode to prevent last-mile races.
*/
function invalidate(sessionID: string, reason: string): void {
function cancelCountdown(sessionID: string): void {
const state = sessions.get(sessionID)
if (!state) return
// Skip if in recovery mode (external control)
if (state.mode === "recovering") return
state.version++
clearTimer(state)
if (state.mode !== "idle" && state.mode !== "errorBypass") {
log(`[${HOOK_NAME}] Invalidated`, { sessionID, reason, prevMode: state.mode, newVersion: state.version })
state.mode = "idle"
if (state.countdownTimer) {
clearTimeout(state.countdownTimer)
state.countdownTimer = undefined
}
if (state.countdownInterval) {
clearInterval(state.countdownInterval)
state.countdownInterval = undefined
}
}
/**
* Check if this is the main session (not a subagent session).
*/
function isMainSession(sessionID: string): boolean {
const mainSessionID = getMainSessionID()
// If no main session is set, allow all. If set, only allow main.
return !mainSessionID || sessionID === mainSessionID
function cleanup(sessionID: string): void {
cancelCountdown(sessionID)
sessions.delete(sessionID)
}
// ============================================================================
// EXTERNAL API
// ============================================================================
const markRecovering = (sessionID: string): void => {
const state = getOrCreateState(sessionID)
invalidate(sessionID, "entering recovery mode")
state.mode = "recovering"
const state = getState(sessionID)
state.isRecovering = true
cancelCountdown(sessionID)
log(`[${HOOK_NAME}] Session marked as recovering`, { sessionID })
}
const markRecoveryComplete = (sessionID: string): void => {
const state = sessions.get(sessionID)
if (state && state.mode === "recovering") {
state.mode = "idle"
if (state) {
state.isRecovering = false
log(`[${HOOK_NAME}] Session recovery complete`, { sessionID })
}
}
// ============================================================================
// TOAST HELPER
// ============================================================================
async function showCountdownToast(seconds: number, incompleteCount: number): Promise<void> {
await ctx.client.tui.showToast({
body: {
@@ -193,126 +147,65 @@ export function createTodoContinuationEnforcer(
}).catch(() => {})
}
// ============================================================================
// CORE INJECTION LOGIC
// ============================================================================
async function executeInjection(sessionID: string, capturedVersion: number): Promise<void> {
async function injectContinuation(sessionID: string, incompleteCount: number, total: number): Promise<void> {
const state = sessions.get(sessionID)
if (!state) return
// Version check: if version changed since we started, abort
if (state.version !== capturedVersion) {
log(`[${HOOK_NAME}] Injection aborted: version mismatch`, {
sessionID, capturedVersion, currentVersion: state.version
})
if (state?.isRecovering) {
log(`[${HOOK_NAME}] Skipped injection: in recovery`, { sessionID })
return
}
// Mode check: must still be in countingDown mode
if (state.mode !== "countingDown") {
log(`[${HOOK_NAME}] Injection aborted: mode changed`, {
sessionID, mode: state.mode
})
if (state?.lastErrorAt && Date.now() - state.lastErrorAt < ERROR_COOLDOWN_MS) {
log(`[${HOOK_NAME}] Skipped injection: recent error`, { sessionID })
return
}
// Throttle check: minimum interval between injection attempts
if (state.lastAttemptedAt) {
const elapsed = Date.now() - state.lastAttemptedAt
if (elapsed < MIN_INJECTION_INTERVAL_MS) {
log(`[${HOOK_NAME}] Injection throttled: too soon since last injection`, {
sessionID, elapsedMs: elapsed, minIntervalMs: MIN_INJECTION_INTERVAL_MS
})
state.mode = "idle"
return
}
const hasRunningBgTasks = backgroundManager
? backgroundManager.getTasksByParentSession(sessionID).some(t => t.status === "running")
: false
if (hasRunningBgTasks) {
log(`[${HOOK_NAME}] Skipped injection: background tasks running`, { sessionID })
return
}
state.mode = "injecting"
// Re-verify todos (CRITICAL: always re-check before injecting)
let todos: Todo[] = []
try {
const response = await ctx.client.session.todo({ path: { id: sessionID } })
todos = (response.data ?? response) as Todo[]
} catch (err) {
log(`[${HOOK_NAME}] Failed to fetch todos for injection`, { sessionID, error: String(err) })
state.mode = "idle"
log(`[${HOOK_NAME}] Failed to fetch todos`, { sessionID, error: String(err) })
return
}
// Version check again after async operation
if (state.version !== capturedVersion) {
log(`[${HOOK_NAME}] Injection aborted after todo fetch: version mismatch`, { sessionID })
state.mode = "idle"
const freshIncompleteCount = getIncompleteCount(todos)
if (freshIncompleteCount === 0) {
log(`[${HOOK_NAME}] Skipped injection: no incomplete todos`, { sessionID })
return
}
const incompleteCount = getIncompleteCount(todos)
if (incompleteCount === 0) {
log(`[${HOOK_NAME}] No incomplete todos at injection time`, { sessionID, total: todos.length })
state.mode = "idle"
return
}
// Skip entirely if background tasks are running (no false positives)
const hasRunningBgTasks = backgroundManager
? backgroundManager.getTasksByParentSession(sessionID).some((t) => t.status === "running")
: false
if (hasRunningBgTasks) {
log(`[${HOOK_NAME}] Skipped: background tasks still running`, { sessionID })
state.mode = "idle"
return
}
// Get previous message agent info
const messageDir = getMessageDir(sessionID)
const prevMessage = messageDir ? findNearestMessageWithFields(messageDir) : null
// Check write permission
const agentHasWritePermission = !prevMessage?.tools ||
const hasWritePermission = !prevMessage?.tools ||
(prevMessage.tools.write !== false && prevMessage.tools.edit !== false)
if (!agentHasWritePermission) {
log(`[${HOOK_NAME}] Skipped: agent lacks write permission`, {
sessionID, agent: prevMessage?.agent, tools: prevMessage?.tools
})
state.mode = "idle"
if (!hasWritePermission) {
log(`[${HOOK_NAME}] Skipped: agent lacks write permission`, { sessionID, agent: prevMessage?.agent })
return
}
// Plan mode agents only analyze and plan, not implement - skip todo continuation
const agentName = prevMessage?.agent?.toLowerCase() ?? ""
const isPlanModeAgent = agentName === "plan" || agentName === "planner-sisyphus"
if (isPlanModeAgent) {
log(`[${HOOK_NAME}] Skipped: plan mode agent detected`, {
sessionID, agent: prevMessage?.agent
})
state.mode = "idle"
if (agentName === "plan" || agentName === "planner-sisyphus") {
log(`[${HOOK_NAME}] Skipped: plan mode agent`, { sessionID, agent: prevMessage?.agent })
return
}
const prompt = `${CONTINUATION_PROMPT}\n\n[Status: ${todos.length - incompleteCount}/${todos.length} completed, ${incompleteCount} remaining]`
// Final version check right before API call (last-mile race mitigation)
if (state.version !== capturedVersion) {
log(`[${HOOK_NAME}] Injection aborted: version changed before API call`, { sessionID })
state.mode = "idle"
return
}
// Set lastAttemptedAt BEFORE calling API (throttle attempts, not just successes)
state.lastAttemptedAt = Date.now()
const prompt = `${CONTINUATION_PROMPT}\n\n[Status: ${todos.length - freshIncompleteCount}/${todos.length} completed, ${freshIncompleteCount} remaining]`
try {
log(`[${HOOK_NAME}] Injecting continuation prompt`, {
sessionID,
agent: prevMessage?.agent,
incompleteCount
})
log(`[${HOOK_NAME}] Injecting continuation`, { sessionID, agent: prevMessage?.agent, incompleteCount: freshIncompleteCount })
await ctx.client.session.prompt({
path: { id: sessionID },
body: {
@@ -321,235 +214,153 @@ export function createTodoContinuationEnforcer(
},
query: { directory: ctx.directory },
})
log(`[${HOOK_NAME}] Continuation prompt injected successfully`, { sessionID })
log(`[${HOOK_NAME}] Injection successful`, { sessionID })
} catch (err) {
log(`[${HOOK_NAME}] Prompt injection failed`, { sessionID, error: String(err) })
log(`[${HOOK_NAME}] Injection failed`, { sessionID, error: String(err) })
}
state.mode = "idle"
}
// ============================================================================
// COUNTDOWN STARTER
// ============================================================================
function startCountdown(sessionID: string, incompleteCount: number, total: number): void {
const state = getState(sessionID)
cancelCountdown(sessionID)
function startCountdown(sessionID: string, incompleteCount: number): void {
const state = getOrCreateState(sessionID)
// Cancel any existing countdown
invalidate(sessionID, "starting new countdown")
// Increment version for this new countdown
state.version++
state.mode = "countingDown"
const capturedVersion = state.version
log(`[${HOOK_NAME}] Starting countdown`, {
sessionID,
seconds: COUNTDOWN_SECONDS,
version: capturedVersion,
incompleteCount
})
// Show initial toast
showCountdownToast(COUNTDOWN_SECONDS, incompleteCount)
// Show countdown toasts
let secondsRemaining = COUNTDOWN_SECONDS
const toastInterval = setInterval(() => {
// Check if countdown was cancelled
if (state.version !== capturedVersion) {
clearInterval(toastInterval)
return
}
showCountdownToast(secondsRemaining, incompleteCount)
state.countdownInterval = setInterval(() => {
secondsRemaining--
if (secondsRemaining > 0) {
showCountdownToast(secondsRemaining, incompleteCount)
}
}, 1000)
// Schedule the injection
state.timer = setTimeout(() => {
clearInterval(toastInterval)
clearTimer(state)
executeInjection(sessionID, capturedVersion)
state.countdownTimer = setTimeout(() => {
cancelCountdown(sessionID)
injectContinuation(sessionID, incompleteCount, total)
}, COUNTDOWN_SECONDS * 1000)
}
// ============================================================================
// EVENT HANDLER
// ============================================================================
log(`[${HOOK_NAME}] Countdown started`, { sessionID, seconds: COUNTDOWN_SECONDS, incompleteCount })
}
const handler = async ({ event }: { event: { type: string; properties?: unknown } }): Promise<void> => {
const props = event.properties as Record<string, unknown> | undefined
// -------------------------------------------------------------------------
// SESSION.ERROR - Enter error bypass mode
// -------------------------------------------------------------------------
if (event.type === "session.error") {
const sessionID = props?.sessionID as string | undefined
if (!sessionID) return
const isInterrupt = detectInterrupt(props?.error)
const state = getOrCreateState(sessionID)
const state = getState(sessionID)
state.lastErrorAt = Date.now()
cancelCountdown(sessionID)
invalidate(sessionID, isInterrupt ? "user interrupt" : "session error")
state.mode = "errorBypass"
log(`[${HOOK_NAME}] session.error received`, { sessionID, isInterrupt, error: props?.error })
log(`[${HOOK_NAME}] session.error`, { sessionID, isAbort: isAbortError(props?.error) })
return
}
// -------------------------------------------------------------------------
// SESSION.IDLE - Main trigger for todo continuation
// -------------------------------------------------------------------------
if (event.type === "session.idle") {
const sessionID = props?.sessionID as string | undefined
if (!sessionID) return
log(`[${HOOK_NAME}] session.idle received`, { sessionID })
log(`[${HOOK_NAME}] session.idle`, { sessionID })
// Skip if not main session
if (!isMainSession(sessionID)) {
const mainSessionID = getMainSessionID()
if (mainSessionID && sessionID !== mainSessionID) {
log(`[${HOOK_NAME}] Skipped: not main session`, { sessionID })
return
}
const state = getOrCreateState(sessionID)
const state = getState(sessionID)
// Skip if in recovery mode
if (state.mode === "recovering") {
log(`[${HOOK_NAME}] Skipped: session in recovery mode`, { sessionID })
if (state.isRecovering) {
log(`[${HOOK_NAME}] Skipped: in recovery`, { sessionID })
return
}
// Skip if in error bypass mode (DO NOT clear - wait for user message)
if (state.mode === "errorBypass") {
log(`[${HOOK_NAME}] Skipped: error bypass (awaiting user message to resume)`, { sessionID })
if (state.lastErrorAt && Date.now() - state.lastErrorAt < ERROR_COOLDOWN_MS) {
log(`[${HOOK_NAME}] Skipped: recent error (cooldown)`, { sessionID })
return
}
// Skip if already counting down or injecting
if (state.mode === "countingDown" || state.mode === "injecting") {
log(`[${HOOK_NAME}] Skipped: already ${state.mode}`, { sessionID })
const hasRunningBgTasks = backgroundManager
? backgroundManager.getTasksByParentSession(sessionID).some(t => t.status === "running")
: false
if (hasRunningBgTasks) {
log(`[${HOOK_NAME}] Skipped: background tasks running`, { sessionID })
return
}
// Fetch todos
let todos: Todo[] = []
try {
const response = await ctx.client.session.todo({ path: { id: sessionID } })
todos = (response.data ?? response) as Todo[]
} catch (err) {
log(`[${HOOK_NAME}] Todo API error`, { sessionID, error: String(err) })
log(`[${HOOK_NAME}] Todo fetch failed`, { sessionID, error: String(err) })
return
}
if (!todos || todos.length === 0) {
log(`[${HOOK_NAME}] No todos found`, { sessionID })
log(`[${HOOK_NAME}] No todos`, { sessionID })
return
}
const incompleteCount = getIncompleteCount(todos)
if (incompleteCount === 0) {
log(`[${HOOK_NAME}] All todos completed`, { sessionID, total: todos.length })
log(`[${HOOK_NAME}] All todos complete`, { sessionID, total: todos.length })
return
}
// Skip if background tasks are running (avoid toast spam with no injection)
const hasRunningBgTasks = backgroundManager
? backgroundManager.getTasksByParentSession(sessionID).some((t) => t.status === "running")
: false
if (hasRunningBgTasks) {
log(`[${HOOK_NAME}] Skipped: background tasks still running`, { sessionID })
return
}
log(`[${HOOK_NAME}] Found incomplete todos`, {
sessionID,
incomplete: incompleteCount,
total: todos.length
})
startCountdown(sessionID, incompleteCount)
startCountdown(sessionID, incompleteCount, todos.length)
return
}
// -------------------------------------------------------------------------
// MESSAGE.UPDATED - Cancel countdown on activity
// -------------------------------------------------------------------------
if (event.type === "message.updated") {
const info = props?.info as Record<string, unknown> | undefined
const sessionID = info?.sessionID as string | undefined
const role = info?.role as string | undefined
const finish = info?.finish as string | undefined
if (!sessionID) return
// User message: Always cancel countdown and clear errorBypass
if (role === "user") {
const state = sessions.get(sessionID)
if (state?.mode === "errorBypass") {
state.mode = "idle"
log(`[${HOOK_NAME}] User message cleared errorBypass mode`, { sessionID })
if (state) {
state.lastErrorAt = undefined
}
invalidate(sessionID, "user message received")
return
cancelCountdown(sessionID)
log(`[${HOOK_NAME}] User message: cleared error state`, { sessionID })
}
// Assistant message WITHOUT finish: Agent is working, cancel countdown
if (role === "assistant" && !finish) {
invalidate(sessionID, "assistant is working (streaming)")
return
}
// Assistant message WITH finish: Agent finished a turn (let session.idle handle it)
if (role === "assistant" && finish) {
log(`[${HOOK_NAME}] Assistant turn finished`, { sessionID, finish })
return
if (role === "assistant") {
cancelCountdown(sessionID)
}
return
}
// -------------------------------------------------------------------------
// MESSAGE.PART.UPDATED - Cancel countdown on streaming activity
// -------------------------------------------------------------------------
if (event.type === "message.part.updated") {
const info = props?.info as Record<string, unknown> | undefined
const sessionID = info?.sessionID as string | undefined
const role = info?.role as string | undefined
if (sessionID && role === "assistant") {
invalidate(sessionID, "assistant streaming")
cancelCountdown(sessionID)
}
return
}
// -------------------------------------------------------------------------
// TOOL EVENTS - Cancel countdown when tools are executing
// -------------------------------------------------------------------------
if (event.type === "tool.execute.before" || event.type === "tool.execute.after") {
const sessionID = props?.sessionID as string | undefined
if (sessionID) {
invalidate(sessionID, `tool execution (${event.type})`)
cancelCountdown(sessionID)
}
return
}
// -------------------------------------------------------------------------
// SESSION.DELETED - Cleanup
// -------------------------------------------------------------------------
if (event.type === "session.deleted") {
const sessionInfo = props?.info as { id?: string } | undefined
if (sessionInfo?.id) {
const state = sessions.get(sessionInfo.id)
if (state) {
clearTimer(state)
}
sessions.delete(sessionInfo.id)
log(`[${HOOK_NAME}] Session deleted, state cleaned up`, { sessionID: sessionInfo.id })
cleanup(sessionInfo.id)
log(`[${HOOK_NAME}] Session deleted: cleaned up`, { sessionID: sessionInfo.id })
}
return
}