Compare commits
34 Commits
v3.0.0-bet
...
v3.0.0-bet
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f240dbb7ee | ||
|
|
571810f1e7 | ||
|
|
83d958580f | ||
|
|
f1e7b6ab1e | ||
|
|
1bbb61b1c2 | ||
|
|
2a95c91cab | ||
|
|
307d583ad6 | ||
|
|
ce5315fbd0 | ||
|
|
1c262a65fe | ||
|
|
0c127879c0 | ||
|
|
65a6a702ec | ||
|
|
60f4cd4fac | ||
|
|
5f823b0f8e | ||
|
|
e35a488cf6 | ||
|
|
adb1a9fcb9 | ||
|
|
9bfed238b9 | ||
|
|
61abd553fb | ||
|
|
6425d9d97e | ||
|
|
d57744905f | ||
|
|
c7ae2d7be6 | ||
|
|
358f7f439d | ||
|
|
4fde139dd8 | ||
|
|
b10703ec9a | ||
|
|
8b12257729 | ||
|
|
7536a12754 | ||
|
|
0fb765732a | ||
|
|
d4c8ec6690 | ||
|
|
d6416082a2 | ||
|
|
e6aaf57a21 | ||
|
|
5242f3daef | ||
|
|
3f2ded54ee | ||
|
|
aa5018583e | ||
|
|
185d4e1e54 | ||
|
|
79e9fd82c5 |
@@ -124,7 +124,7 @@ oh-my-opencode/
|
||||
|-------|---------------|---------|
|
||||
| Sisyphus | anthropic/claude-opus-4-5 | Primary orchestrator |
|
||||
| oracle | openai/gpt-5.2 | Read-only consultation. High-IQ debugging, architecture |
|
||||
| librarian | anthropic/claude-sonnet-4-5 | Multi-repo analysis, docs |
|
||||
| librarian | opencode/glm-4.7-free | Multi-repo analysis, docs |
|
||||
| explore | opencode/grok-code | Fast codebase exploration |
|
||||
| frontend-ui-ux-engineer | google/gemini-3-pro-preview | UI generation |
|
||||
| document-writer | google/gemini-3-pro-preview | Technical docs |
|
||||
|
||||
87
README.ja.md
87
README.ja.md
@@ -28,7 +28,7 @@
|
||||
|
||||
> `oh-my-opencode` をインストールして、ドーピングしたかのようにコーディングしましょう。バックグラウンドでエージェントを走らせ、oracle、librarian、frontend engineer のような専門エージェントを呼び出してください。丹精込めて作られた LSP/AST ツール、厳選された MCP、そして完全な Claude Code 互換レイヤーを、たった一行で手に入れましょう。
|
||||
|
||||
**今すぐ始めましょう。ChatGPT、Claude、Gemini のサブスクリプションで使えます。**
|
||||
**注意: librarianには高価なモデルを使用しないでください。これはあなたにとって役に立たないだけでなく、LLMプロバイダーにも負担をかけます。代わりにClaude Haiku、Gemini Flash、GLM 4.7、MiniMaxなどのモデルを使用してください。**
|
||||
|
||||
<div align="center">
|
||||
|
||||
@@ -76,6 +76,7 @@
|
||||
- [Oh My OpenCode](#oh-my-opencode)
|
||||
- [この Readme は読まなくていいです](#この-readme-は読まなくていいです)
|
||||
- [エージェントの時代ですから](#エージェントの時代ですから)
|
||||
- [🪄 魔法の言葉:`ultrawork`](#-魔法の言葉ultrawork)
|
||||
- [読みたい方のために:シジフォスに会う](#読みたい方のためにシジフォスに会う)
|
||||
- [インストールするだけで。](#インストールするだけで)
|
||||
- [インストール](#インストール)
|
||||
@@ -88,10 +89,15 @@
|
||||
- [ステップ 4: 認証情報の設定](#ステップ-4-認証情報の設定)
|
||||
- [4.1 Anthropic (Claude)](#41-anthropic-claude)
|
||||
- [4.2 Google Gemini (Antigravity OAuth)](#42-google-gemini-antigravity-oauth)
|
||||
- [4.2.1 モデル設定](#421-モデル設定)
|
||||
- [4.2.2 oh-my-opencode エージェントモデルのオーバーライド](#422-oh-my-opencode-エージェントモデルのオーバーライド)
|
||||
- [4.3 OpenAI (ChatGPT Plus/Pro)](#43-openai-chatgpt-pluspro)
|
||||
- [モデル設定](#モデル設定)
|
||||
- [⚠️ 注意](#️-注意)
|
||||
- [セットアップの確認](#セットアップの確認)
|
||||
- [ユーザーに「おめでとうございます!🎉」と伝える](#ユーザーにおめでとうございますと伝える)
|
||||
- [無料広告](#無料広告)
|
||||
- [スターをお願いする ⭐](#スターをお願いする-)
|
||||
- [複雑すぎますか?](#複雑すぎますか)
|
||||
- [アンインストール](#アンインストール)
|
||||
- [機能](#機能)
|
||||
@@ -99,7 +105,8 @@
|
||||
- [バックグラウンドエージェント: 本当のチームのように働く](#バックグラウンドエージェント-本当のチームのように働く)
|
||||
- [ツール: 同僚にはもっと良い道具を](#ツール-同僚にはもっと良い道具を)
|
||||
- [なぜあなただけ IDE を使っているのですか?](#なぜあなただけ-ide-を使っているのですか)
|
||||
- [Context is all you need.](#context-is-all-you-need)
|
||||
- [セッション管理](#セッション管理)
|
||||
- [Context Is All You Need](#context-is-all-you-need)
|
||||
- [マルチモーダルを活用し、トークンは節約する](#マルチモーダルを活用しトークンは節約する)
|
||||
- [止まらないエージェントループ](#止まらないエージェントループ)
|
||||
- [Claude Code 互換性: さらば Claude Code、ようこそ OpenCode](#claude-code-互換性-さらば-claude-codeようこそ-opencode)
|
||||
@@ -109,16 +116,20 @@
|
||||
- [互換性トグル](#互換性トグル)
|
||||
- [エージェントのためだけでなく、あなたのために](#エージェントのためだけでなくあなたのために)
|
||||
- [設定](#設定)
|
||||
- [JSONC のサポート](#jsonc-のサポート)
|
||||
- [Google Auth](#google-auth)
|
||||
- [Agents](#agents)
|
||||
- [Permission オプション](#permission-オプション)
|
||||
- [Sisyphus Agent](#sisyphus-agent)
|
||||
- [Background Tasks](#background-tasks)
|
||||
- [Hooks](#hooks)
|
||||
- [MCPs](#mcps)
|
||||
- [LSP](#lsp)
|
||||
- [Experimental](#experimental)
|
||||
- [作者のノート](#作者のノート)
|
||||
- [注意](#注意)
|
||||
- [こちらの企業の専門家にご愛用いただいています](#こちらの企業の専門家にご愛用いただいています)
|
||||
- [スポンサー](#スポンサー)
|
||||
|
||||
# Oh My OpenCode
|
||||
|
||||
@@ -322,9 +333,9 @@ opencode auth login
|
||||
{
|
||||
"google_auth": false,
|
||||
"agents": {
|
||||
"frontend-ui-ux-engineer": { "model": "google/gemini-3-pro-high" },
|
||||
"document-writer": { "model": "google/gemini-3-flash" },
|
||||
"multimodal-looker": { "model": "google/gemini-3-flash" }
|
||||
"frontend-ui-ux-engineer": { "model": "google/antigravity-gemini-3-pro-high" },
|
||||
"document-writer": { "model": "google/antigravity-gemini-3-flash" },
|
||||
"multimodal-looker": { "model": "google/antigravity-gemini-3-flash" }
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -461,7 +472,7 @@ oh-my-opencode を削除するには:
|
||||
|
||||
- **Sisyphus** (`anthropic/claude-opus-4-5`): **デフォルトエージェントです。** OpenCode のための強力な AI オーケストレーターです。専門のサブエージェントを活用して、複雑なタスクを計画、委任、実行します。バックグラウンドタスクへの委任と Todo ベースのワークフローを重視します。最大の推論能力を発揮するため、Claude Opus 4.5 と拡張思考 (32k token budget) を使用します。
|
||||
- **oracle** (`openai/gpt-5.2`): アーキテクチャ、コードレビュー、戦略立案のための専門アドバイザー。GPT-5.2 の卓越した論理的推論と深い分析能力を活用します。AmpCode からインスピレーションを得ました。
|
||||
- **librarian** (`anthropic/claude-sonnet-4-5` または `google/gemini-3-flash`): マルチリポジトリ分析、ドキュメント検索、実装例の調査を担当。Antigravity 認証が設定されている場合は Gemini 3 Flash を使用し、それ以外は Claude Sonnet 4.5 を使用して、深いコードベース理解と GitHub リサーチ、根拠に基づいた回答を提供します。AmpCode からインスピレーションを得ました。
|
||||
- **librarian** (`opencode/glm-4.7-free`): マルチリポジトリ分析、ドキュメント検索、実装例の調査を担当。GLM-4.7 Free を使用して、深いコードベース理解と GitHub リサーチ、根拠に基づいた回答を提供します。AmpCode からインスピレーションを得ました。
|
||||
- **explore** (`opencode/grok-code`、`google/gemini-3-flash`、または `anthropic/claude-haiku-4-5`): 高速なコードベース探索、ファイルパターンマッチング。Antigravity 認証が設定されている場合は Gemini 3 Flash を使用し、Claude max20 が利用可能な場合は Haiku を使用し、それ以外は Grok を使います。Claude Code からインスピレーションを得ました。
|
||||
- **frontend-ui-ux-engineer** (`google/gemini-3-pro-preview`): 開発者に転身したデザイナーという設定です。素晴らしい UI を作ります。美しく独創的な UI コードを生成することに長けた Gemini を使用します。
|
||||
- **document-writer** (`google/gemini-3-pro-preview`): テクニカルライティングの専門家という設定です。Gemini は文筆家であり、流れるような文章を書きます。
|
||||
@@ -721,10 +732,10 @@ Oh My OpenCode は以下の場所からフックを読み込んで実行しま
|
||||
1. `.opencode/oh-my-opencode.json` (プロジェクト)
|
||||
2. ユーザー設定(プラットフォーム別):
|
||||
|
||||
| プラットフォーム | ユーザー設定パス |
|
||||
|------------------|------------------|
|
||||
| **Windows** | `~/.config/opencode/oh-my-opencode.json` (推奨) または `%APPDATA%\opencode\oh-my-opencode.json` (fallback) |
|
||||
| **macOS/Linux** | `~/.config/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` |
|
||||
|
||||
スキーマ自動補完がサポートされています:
|
||||
|
||||
@@ -748,10 +759,10 @@ Oh My OpenCode は以下の場所からフックを読み込んで実行しま
|
||||
```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": {
|
||||
@@ -774,9 +785,9 @@ Oh My OpenCode は以下の場所からフックを読み込んで実行しま
|
||||
{
|
||||
"google_auth": false,
|
||||
"agents": {
|
||||
"frontend-ui-ux-engineer": { "model": "google/gemini-3-pro-high" },
|
||||
"document-writer": { "model": "google/gemini-3-flash" },
|
||||
"multimodal-looker": { "model": "google/gemini-3-flash" }
|
||||
"frontend-ui-ux-engineer": { "model": "google/antigravity-gemini-3-pro-high" },
|
||||
"document-writer": { "model": "google/antigravity-gemini-3-flash" },
|
||||
"multimodal-looker": { "model": "google/antigravity-gemini-3-flash" }
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -841,13 +852,13 @@ Oh My OpenCode は以下の場所からフックを読み込んで実行しま
|
||||
}
|
||||
```
|
||||
|
||||
| Permission | 説明 | 値 |
|
||||
|------------|------|----|
|
||||
| `edit` | ファイル編集権限 | `ask` / `allow` / `deny` |
|
||||
| `bash` | Bash コマンド実行権限 | `ask` / `allow` / `deny` またはコマンド別: `{ "git": "allow", "rm": "deny" }` |
|
||||
| `webfetch` | ウェブアクセス権限 | `ask` / `allow` / `deny` |
|
||||
| `doom_loop` | 無限ループ検知のオーバーライド許可 | `ask` / `allow` / `deny` |
|
||||
| `external_directory` | プロジェクトルート外へのファイルアクセス | `ask` / `allow` / `deny` |
|
||||
| Permission | 説明 | 値 |
|
||||
| -------------------- | ---------------------------------------- | ----------------------------------------------------------------------------- |
|
||||
| `edit` | ファイル編集権限 | `ask` / `allow` / `deny` |
|
||||
| `bash` | Bash コマンド実行権限 | `ask` / `allow` / `deny` またはコマンド別: `{ "git": "allow", "rm": "deny" }` |
|
||||
| `webfetch` | ウェブアクセス権限 | `ask` / `allow` / `deny` |
|
||||
| `doom_loop` | 無限ループ検知のオーバーライド許可 | `ask` / `allow` / `deny` |
|
||||
| `external_directory` | プロジェクトルート外へのファイルアクセス | `ask` / `allow` / `deny` |
|
||||
|
||||
または `~/.config/opencode/oh-my-opencode.json` か `.opencode/oh-my-opencode.json` の `disabled_agents` を使用して無効化できます:
|
||||
|
||||
@@ -925,12 +936,12 @@ Oh My OpenCode は以下の場所からフックを読み込んで実行しま
|
||||
}
|
||||
```
|
||||
|
||||
| オプション | デフォルト | 説明 |
|
||||
| --------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `disabled` | `false` | `true` の場合、すべての Sisyphus オーケストレーションを無効化し、元の build/plan をプライマリとして復元します。 |
|
||||
| `default_builder_enabled` | `false` | `true` の場合、OpenCode-Builder エージェントを有効化します(OpenCode build と同じ、SDK 制限により名前変更)。デフォルトでは無効です。 |
|
||||
| `planner_enabled` | `true` | `true` の場合、Prometheus (Planner) エージェントを有効化します(work-planner 方法論を含む)。デフォルトで有効です。 |
|
||||
| `replace_plan` | `true` | `true` の場合、デフォルトのプランエージェントをサブエージェントモードに降格させます。`false` に設定すると、Prometheus (Planner) とデフォルトのプランの両方を利用できます。 |
|
||||
| オプション | デフォルト | 説明 |
|
||||
| ------------------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `disabled` | `false` | `true` の場合、すべての Sisyphus オーケストレーションを無効化し、元の build/plan をプライマリとして復元します。 |
|
||||
| `default_builder_enabled` | `false` | `true` の場合、OpenCode-Builder エージェントを有効化します(OpenCode build と同じ、SDK 制限により名前変更)。デフォルトでは無効です。 |
|
||||
| `planner_enabled` | `true` | `true` の場合、Prometheus (Planner) エージェントを有効化します(work-planner 方法論を含む)。デフォルトで有効です。 |
|
||||
| `replace_plan` | `true` | `true` の場合、デフォルトのプランエージェントをサブエージェントモードに降格させます。`false` に設定すると、Prometheus (Planner) とデフォルトのプランの両方を利用できます。 |
|
||||
|
||||
### Background Tasks
|
||||
|
||||
@@ -953,10 +964,10 @@ Oh My OpenCode は以下の場所からフックを読み込んで実行しま
|
||||
}
|
||||
```
|
||||
|
||||
| オプション | デフォルト | 説明 |
|
||||
| --------------------- | ---------- | -------------------------------------------------------------------------------------------------------------- |
|
||||
| `defaultConcurrency` | - | すべてのプロバイダー/モデルに対するデフォルトの最大同時バックグラウンドタスク数 |
|
||||
| `providerConcurrency` | - | プロバイダーごとの同時実行制限。キーはプロバイダー名(例:`anthropic`、`openai`、`google`) |
|
||||
| オプション | デフォルト | 説明 |
|
||||
| --------------------- | ---------- | --------------------------------------------------------------------------------------------------------------------- |
|
||||
| `defaultConcurrency` | - | すべてのプロバイダー/モデルに対するデフォルトの最大同時バックグラウンドタスク数 |
|
||||
| `providerConcurrency` | - | プロバイダーごとの同時実行制限。キーはプロバイダー名(例:`anthropic`、`openai`、`google`) |
|
||||
| `modelConcurrency` | - | モデルごとの同時実行制限。キーは完全なモデル名(例:`anthropic/claude-opus-4-5`)。プロバイダー制限より優先されます。 |
|
||||
|
||||
**優先順位**: `modelConcurrency` > `providerConcurrency` > `defaultConcurrency`
|
||||
@@ -1035,13 +1046,13 @@ OpenCode でサポートされるすべての LSP 構成およびカスタム設
|
||||
}
|
||||
```
|
||||
|
||||
| オプション | デフォルト | 説明 |
|
||||
| --------------------------------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `preemptive_compaction_threshold` | `0.85` | プリエンプティブコンパクションをトリガーする閾値(0.5-0.95)。`preemptive-compaction` フックはデフォルトで有効です。このオプションで閾値をカスタマイズできます。 |
|
||||
| オプション | デフォルト | 説明 |
|
||||
| --------------------------------- | ---------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `preemptive_compaction_threshold` | `0.85` | プリエンプティブコンパクションをトリガーする閾値(0.5-0.95)。`preemptive-compaction` フックはデフォルトで有効です。このオプションで閾値をカスタマイズできます。 |
|
||||
| `truncate_all_tool_outputs` | `false` | ホワイトリストのツール(Grep、Glob、LSP、AST-grep)だけでなく、すべてのツール出力を切り詰めます。Tool output truncator はデフォルトで有効です - `disabled_hooks`で無効化できます。 |
|
||||
| `aggressive_truncation` | `false` | トークン制限を超えた場合、ツール出力を積極的に切り詰めて制限内に収めます。デフォルトの切り詰めより積極的です。不十分な場合は要約/復元にフォールバックします。 |
|
||||
| `auto_resume` | `false` | thinking block エラーや thinking disabled violation からの回復成功後、自動的にセッションを再開します。最後のユーザーメッセージを抽出して続行します。 |
|
||||
| `dcp_for_compaction` | `false` | コンパクション用DCP(動的コンテキスト整理)を有効化 - トークン制限超過時に最初に実行されます。コンパクション前に重複したツール呼び出しと古いツール出力を整理します。 |
|
||||
| `aggressive_truncation` | `false` | トークン制限を超えた場合、ツール出力を積極的に切り詰めて制限内に収めます。デフォルトの切り詰めより積極的です。不十分な場合は要約/復元にフォールバックします。 |
|
||||
| `auto_resume` | `false` | thinking block エラーや thinking disabled violation からの回復成功後、自動的にセッションを再開します。最後のユーザーメッセージを抽出して続行します。 |
|
||||
| `dcp_for_compaction` | `false` | コンパクション用DCP(動的コンテキスト整理)を有効化 - トークン制限超過時に最初に実行されます。コンパクション前に重複したツール呼び出しと古いツール出力を整理します。 |
|
||||
|
||||
**警告**:これらの機能は実験的であり、予期しない動作を引き起こす可能性があります。影響を理解した場合にのみ有効にしてください。
|
||||
|
||||
|
||||
59
README.md
59
README.md
@@ -29,10 +29,7 @@
|
||||
> This is coding on steroids—`oh-my-opencode` in action. Run background agents, call specialized agents like oracle, librarian, and frontend engineer. Use crafted LSP/AST tools, curated MCPs, and a full Claude Code compatibility layer.
|
||||
|
||||
|
||||
No stupid token consumption massive subagents here. No bloat tools here.
|
||||
|
||||
**Certified, Verified, Tested, Actually Useful Harness in Production, after $24,000 worth of tokens spent.**
|
||||
**START WITH YOUR ChatGPT, Claude, Gemini SUBSCRIPTIONS. WE ALL COVER THEM.**
|
||||
**Notice: Do not use expensive models for librarian. This is not only unhelpful to you, but also burdens LLM providers. Use models like Claude Haiku, Gemini Flash, GLM 4.7, or MiniMax instead.**
|
||||
|
||||
|
||||
<div align="center">
|
||||
@@ -128,6 +125,7 @@ No stupid token consumption massive subagents here. No bloat tools here.
|
||||
- [Agents](#agents)
|
||||
- [Permission Options](#permission-options)
|
||||
- [Built-in Skills](#built-in-skills)
|
||||
- [Git Master](#git-master)
|
||||
- [Sisyphus Agent](#sisyphus-agent)
|
||||
- [Background Tasks](#background-tasks)
|
||||
- [Categories](#categories)
|
||||
@@ -135,6 +133,7 @@ No stupid token consumption massive subagents here. No bloat tools here.
|
||||
- [MCPs](#mcps)
|
||||
- [LSP](#lsp)
|
||||
- [Experimental](#experimental)
|
||||
- [Environment Variables](#environment-variables)
|
||||
- [Author's Note](#authors-note)
|
||||
- [Warnings](#warnings)
|
||||
- [Loved by professionals at](#loved-by-professionals-at)
|
||||
@@ -361,9 +360,9 @@ The `opencode-antigravity-auth` plugin uses different model names than the built
|
||||
{
|
||||
"google_auth": false,
|
||||
"agents": {
|
||||
"frontend-ui-ux-engineer": { "model": "google/gemini-3-pro-high" },
|
||||
"document-writer": { "model": "google/gemini-3-flash" },
|
||||
"multimodal-looker": { "model": "google/gemini-3-flash" }
|
||||
"frontend-ui-ux-engineer": { "model": "google/antigravity-gemini-3-pro-high" },
|
||||
"document-writer": { "model": "google/antigravity-gemini-3-flash" },
|
||||
"multimodal-looker": { "model": "google/antigravity-gemini-3-flash" }
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -499,9 +498,9 @@ To remove oh-my-opencode:
|
||||
|
||||
- **Sisyphus** (`anthropic/claude-opus-4-5`): **The default agent.** A powerful AI orchestrator for OpenCode. Plans, delegates, and executes complex tasks using specialized subagents with aggressive parallel execution. Emphasizes background task delegation and todo-driven workflow. Uses Claude Opus 4.5 with extended thinking (32k budget) for maximum reasoning capability.
|
||||
- **oracle** (`openai/gpt-5.2`): Architecture, code review, strategy. Uses GPT-5.2 for its stellar logical reasoning and deep analysis. Inspired by AmpCode.
|
||||
- **librarian** (`anthropic/claude-sonnet-4-5` or `google/gemini-3-flash`): Multi-repo analysis, doc lookup, implementation examples. Uses Gemini 3 Flash when Antigravity auth is configured, otherwise Claude Sonnet 4.5 for deep codebase understanding and GitHub research with evidence-based answers. Inspired by AmpCode.
|
||||
- **librarian** (`opencode/glm-4.7-free`): Multi-repo analysis, doc lookup, implementation examples. Uses GLM-4.7 Free for deep codebase understanding and GitHub research with evidence-based answers. Inspired by AmpCode.
|
||||
- **explore** (`opencode/grok-code`, `google/gemini-3-flash`, or `anthropic/claude-haiku-4-5`): Fast codebase exploration and pattern matching. Uses Gemini 3 Flash when Antigravity auth is configured, Haiku when Claude max20 is available, otherwise Grok. Inspired by Claude Code.
|
||||
- **frontend-ui-ux-engineer** (`google/gemini-3-pro-high`): A designer turned developer. Builds gorgeous UIs. Gemini excels at creative, beautiful UI code.
|
||||
- **frontend-ui-ux-engineer** (`google/gemini-3-pro-preview`): A designer turned developer. Builds gorgeous UIs. Gemini excels at creative, beautiful UI code.
|
||||
- **document-writer** (`google/gemini-3-flash`): Technical writing expert. Gemini is a wordsmith—writes prose that flows.
|
||||
- **multimodal-looker** (`google/gemini-3-flash`): Visual content specialist. Analyzes PDFs, images, diagrams to extract information.
|
||||
|
||||
@@ -830,9 +829,9 @@ When using `opencode-antigravity-auth`, disable the built-in auth and override a
|
||||
{
|
||||
"google_auth": false,
|
||||
"agents": {
|
||||
"frontend-ui-ux-engineer": { "model": "google/gemini-3-pro-high" },
|
||||
"document-writer": { "model": "google/gemini-3-flash" },
|
||||
"multimodal-looker": { "model": "google/gemini-3-flash" }
|
||||
"frontend-ui-ux-engineer": { "model": "google/antigravity-gemini-3-pro-high" },
|
||||
"document-writer": { "model": "google/antigravity-gemini-3-flash" },
|
||||
"multimodal-looker": { "model": "google/antigravity-gemini-3-flash" }
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -945,10 +944,10 @@ Configure git-master skill behavior:
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Default | Description |
|
||||
| ------ | ------- | ----------- |
|
||||
| `commit_footer` | `true` | Adds "Ultraworked with Sisyphus" footer to commit messages. |
|
||||
| `include_co_authored_by` | `true` | Adds `Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>` trailer to commits. |
|
||||
| Option | Default | Description |
|
||||
| ------------------------ | ------- | -------------------------------------------------------------------------------- |
|
||||
| `commit_footer` | `true` | Adds "Ultraworked with Sisyphus" footer to commit messages. |
|
||||
| `include_co_authored_by` | `true` | Adds `Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>` trailer to commits. |
|
||||
|
||||
### Sisyphus Agent
|
||||
|
||||
@@ -1016,12 +1015,12 @@ You can also customize Sisyphus agents like other agents:
|
||||
}
|
||||
```
|
||||
|
||||
| Option | Default | Description |
|
||||
| --------------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `disabled` | `false` | When `true`, disables all Sisyphus orchestration and restores original build/plan as primary. |
|
||||
| `default_builder_enabled` | `false` | When `true`, enables OpenCode-Builder agent (same as OpenCode build, renamed due to SDK limitations). Disabled by default. |
|
||||
| `planner_enabled` | `true` | When `true`, enables Prometheus (Planner) agent with work-planner methodology. Enabled by default. |
|
||||
| `replace_plan` | `true` | When `true`, demotes default plan agent to subagent mode. Set to `false` to keep both Prometheus (Planner) and default plan available. |
|
||||
| Option | Default | Description |
|
||||
| ------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `disabled` | `false` | When `true`, disables all Sisyphus orchestration and restores original build/plan as primary. |
|
||||
| `default_builder_enabled` | `false` | When `true`, enables OpenCode-Builder agent (same as OpenCode build, renamed due to SDK limitations). Disabled by default. |
|
||||
| `planner_enabled` | `true` | When `true`, enables Prometheus (Planner) agent with work-planner methodology. Enabled by default. |
|
||||
| `replace_plan` | `true` | When `true`, demotes default plan agent to subagent mode. Set to `false` to keep both Prometheus (Planner) and default plan available. |
|
||||
|
||||
### Background Tasks
|
||||
|
||||
@@ -1063,10 +1062,10 @@ Categories enable domain-specific task delegation via the `sisyphus_task` tool.
|
||||
|
||||
**Default Categories:**
|
||||
|
||||
| Category | Model | Description |
|
||||
|----------|-------|-------------|
|
||||
| `visual` | `google/gemini-3-pro-preview` | Frontend, UI/UX, design-focused tasks. High creativity (temp 0.7). |
|
||||
| `business-logic` | `openai/gpt-5.2` | Backend logic, architecture, strategic reasoning. Low creativity (temp 0.1). |
|
||||
| Category | Model | Description |
|
||||
| ---------------- | ----------------------------- | ---------------------------------------------------------------------------- |
|
||||
| `visual` | `google/gemini-3-pro-preview` | Frontend, UI/UX, design-focused tasks. High creativity (temp 0.7). |
|
||||
| `business-logic` | `openai/gpt-5.2` | Backend logic, architecture, strategic reasoning. Low creativity (temp 0.1). |
|
||||
|
||||
**Usage:**
|
||||
|
||||
@@ -1092,7 +1091,7 @@ Add custom categories in `oh-my-opencode.json`:
|
||||
"prompt_append": "Focus on data analysis, ML pipelines, and statistical methods."
|
||||
},
|
||||
"visual": {
|
||||
"model": "google/gemini-3-pro-high",
|
||||
"model": "google/gemini-3-pro-preview",
|
||||
"prompt_append": "Use shadcn/ui components and Tailwind CSS."
|
||||
}
|
||||
}
|
||||
@@ -1181,6 +1180,12 @@ Opt-in experimental features that may change or be removed in future versions. U
|
||||
|
||||
**Warning**: These features are experimental and may cause unexpected behavior. Enable only if you understand the implications.
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Description |
|
||||
| --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `OPENCODE_CONFIG_DIR` | Override the OpenCode configuration directory. Useful for profile isolation with tools like [OCX](https://github.com/kdcokenny/ocx) ghost mode. |
|
||||
|
||||
|
||||
## Author's Note
|
||||
|
||||
|
||||
@@ -28,9 +28,7 @@
|
||||
> 装上 `oh-my-opencode`,编程体验直接起飞。后台跑着一堆 Agent,随时呼叫 Oracle、Librarian、Frontend Engineer 这些专家。精心打磨的 LSP/AST 工具、精选 MCP、完美的 Claude Code 兼容层——一行配置,全套带走。
|
||||
|
||||
这里没有为了显摆而疯狂烧 Token 的臃肿 Subagent。没有垃圾工具。
|
||||
|
||||
**这是烧了 24,000 美元 Token 换来的、真正经过生产环境验证、测试、靠谱的 Harness。**
|
||||
**拿着你的 ChatGPT、Claude、Gemini 订阅直接就能用。我们全包圆了。**
|
||||
**注意:请勿为 librarian 使用昂贵的模型。这不仅对你没有帮助,还会给 LLM 提供商带来负担。请使用 Claude Haiku、Gemini Flash、GLM 4.7 或 MiniMax 等模型。**
|
||||
|
||||
<div align="center">
|
||||
|
||||
@@ -78,6 +76,7 @@
|
||||
- [Oh My OpenCode](#oh-my-opencode)
|
||||
- [太长不看?(TL;DR)](#太长不看tldr)
|
||||
- [现在是 Agent 的时代](#现在是-agent-的时代)
|
||||
- [🪄 魔法口令:`ultrawork`](#-魔法口令ultrawork)
|
||||
- [如果你真的想读读看:认识西西弗斯](#如果你真的想读读看认识西西弗斯)
|
||||
- [闭眼装就行](#闭眼装就行)
|
||||
- [安装](#安装)
|
||||
@@ -90,10 +89,15 @@
|
||||
- [步骤 4:搞定认证](#步骤-4搞定认证)
|
||||
- [4.1 Anthropic (Claude)](#41-anthropic-claude)
|
||||
- [4.2 Google Gemini (Antigravity OAuth)](#42-google-gemini-antigravity-oauth)
|
||||
- [模型配置](#模型配置)
|
||||
- [oh-my-opencode Agent 模型覆盖](#oh-my-opencode-agent-模型覆盖)
|
||||
- [4.3 OpenAI (ChatGPT Plus/Pro)](#43-openai-chatgpt-pluspro)
|
||||
- [模型配置](#模型配置-1)
|
||||
- [⚠️ 注意](#️-注意)
|
||||
- [检查作业](#检查作业)
|
||||
- [跟用户说"恭喜!🎉"](#跟用户说恭喜)
|
||||
- [免费广告](#免费广告)
|
||||
- [求个 Star ⭐](#求个-star-)
|
||||
- [太麻烦了?](#太麻烦了)
|
||||
- [卸载](#卸载)
|
||||
- [功能](#功能)
|
||||
@@ -101,6 +105,7 @@
|
||||
- [后台 Agent:像真正的团队一样干活](#后台-agent像真正的团队一样干活)
|
||||
- [工具:给队友配点好的](#工具给队友配点好的)
|
||||
- [凭什么只有你能用 IDE?](#凭什么只有你能用-ide)
|
||||
- [会话管理 (Session Management)](#会话管理-session-management)
|
||||
- [上下文就是一切 (Context is all you need)](#上下文就是一切-context-is-all-you-need)
|
||||
- [多模态全开,Token 省着用](#多模态全开token-省着用)
|
||||
- [根本停不下来的 Agent Loop](#根本停不下来的-agent-loop)
|
||||
@@ -111,16 +116,20 @@
|
||||
- [兼容性开关](#兼容性开关)
|
||||
- [不只是为了 Agent,也是为了你](#不只是为了-agent也是为了你)
|
||||
- [配置](#配置)
|
||||
- [JSONC 支持](#jsonc-支持)
|
||||
- [Google Auth](#google-auth)
|
||||
- [Agents](#agents)
|
||||
- [权限选项](#权限选项)
|
||||
- [Sisyphus Agent](#sisyphus-agent)
|
||||
- [Background Tasks(后台任务)](#background-tasks后台任务)
|
||||
- [Hooks](#hooks)
|
||||
- [MCPs](#mcps)
|
||||
- [LSP](#lsp)
|
||||
- [Experimental](#experimental)
|
||||
- [作者的话](#作者的话)
|
||||
- [注意事项](#注意事项)
|
||||
- [以下企业的专业人士都在用](#以下企业的专业人士都在用)
|
||||
- [赞助者](#赞助者)
|
||||
|
||||
# Oh My OpenCode
|
||||
|
||||
@@ -327,9 +336,9 @@ opencode auth login
|
||||
{
|
||||
"google_auth": false,
|
||||
"agents": {
|
||||
"frontend-ui-ux-engineer": { "model": "google/gemini-3-pro-high" },
|
||||
"document-writer": { "model": "google/gemini-3-flash" },
|
||||
"multimodal-looker": { "model": "google/gemini-3-flash" }
|
||||
"frontend-ui-ux-engineer": { "model": "google/antigravity-gemini-3-pro-high" },
|
||||
"document-writer": { "model": "google/antigravity-gemini-3-flash" },
|
||||
"multimodal-looker": { "model": "google/antigravity-gemini-3-flash" }
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -466,7 +475,7 @@ gh repo star code-yeongyu/oh-my-opencode
|
||||
|
||||
- **Sisyphus** (`anthropic/claude-opus-4-5`):**默认 Agent。** OpenCode 专属的强力 AI 编排器。指挥专业子 Agent 搞定复杂任务。主打后台任务委派和 Todo 驱动。用 Claude Opus 4.5 加上扩展思考(32k token 预算),智商拉满。
|
||||
- **oracle** (`openai/gpt-5.2`):架构师、代码审查员、战略家。GPT-5.2 的逻辑推理和深度分析能力不是盖的。致敬 AmpCode。
|
||||
- **librarian** (`anthropic/claude-sonnet-4-5` 或 `google/gemini-3-flash`):多仓库分析、查文档、找示例。配置 Antigravity 认证时使用 Gemini 3 Flash,否则使用 Claude Sonnet 4.5 深入理解代码库,GitHub 调研,给出的答案都有据可查。致敬 AmpCode。
|
||||
- **librarian** (`opencode/glm-4.7-free`):多仓库分析、查文档、找示例。使用 GLM-4.7 Free 深入理解代码库,GitHub 调研,给出的答案都有据可查。致敬 AmpCode。
|
||||
- **explore** (`opencode/grok-code`、`google/gemini-3-flash` 或 `anthropic/claude-haiku-4-5`):极速代码库扫描、模式匹配。配置 Antigravity 认证时使用 Gemini 3 Flash,Claude max20 可用时使用 Haiku,否则用 Grok。致敬 Claude Code。
|
||||
- **frontend-ui-ux-engineer** (`google/gemini-3-pro-preview`):设计师出身的程序员。UI 做得那是真漂亮。Gemini 写这种创意美观的代码是一绝。
|
||||
- **document-writer** (`google/gemini-3-pro-preview`):技术写作专家。Gemini 文笔好,写出来的东西读着顺畅。
|
||||
@@ -722,10 +731,10 @@ Agent 爽了,你自然也爽。但我还想直接让你爽。
|
||||
1. `.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` |
|
||||
| 平台 | 用户配置路径 |
|
||||
| --------------- | -------------------------------------------------------------------------------------------------- |
|
||||
| **Windows** | `~/.config/opencode/oh-my-opencode.json` (首选) 或 `%APPDATA%\opencode\oh-my-opencode.json` (备选) |
|
||||
| **macOS/Linux** | `~/.config/opencode/oh-my-opencode.json` |
|
||||
|
||||
支持 Schema 自动补全:
|
||||
|
||||
@@ -749,10 +758,10 @@ Agent 爽了,你自然也爽。但我还想直接让你爽。
|
||||
```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": {
|
||||
@@ -775,9 +784,9 @@ Agent 爽了,你自然也爽。但我还想直接让你爽。
|
||||
{
|
||||
"google_auth": false,
|
||||
"agents": {
|
||||
"frontend-ui-ux-engineer": { "model": "google/gemini-3-pro-high" },
|
||||
"document-writer": { "model": "google/gemini-3-flash" },
|
||||
"multimodal-looker": { "model": "google/gemini-3-flash" }
|
||||
"frontend-ui-ux-engineer": { "model": "google/antigravity-gemini-3-pro-high" },
|
||||
"document-writer": { "model": "google/antigravity-gemini-3-flash" },
|
||||
"multimodal-looker": { "model": "google/antigravity-gemini-3-flash" }
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -842,13 +851,13 @@ Agent 爽了,你自然也爽。但我还想直接让你爽。
|
||||
}
|
||||
```
|
||||
|
||||
| Permission | 说明 | 值 |
|
||||
| -------------------- | ------------------------ | -------------------------------------------------------------------- |
|
||||
| `edit` | 改文件 | `ask` / `allow` / `deny` |
|
||||
| `bash` | 跑 Bash 命令 | `ask` / `allow` / `deny` 或按命令:`{ "git": "allow", "rm": "deny" }` |
|
||||
| `webfetch` | 上网 | `ask` / `allow` / `deny` |
|
||||
| `doom_loop` | 覆盖无限循环检测 | `ask` / `allow` / `deny` |
|
||||
| `external_directory` | 访问根目录外面的文件 | `ask` / `allow` / `deny` |
|
||||
| Permission | 说明 | 值 |
|
||||
| -------------------- | -------------------- | --------------------------------------------------------------------- |
|
||||
| `edit` | 改文件 | `ask` / `allow` / `deny` |
|
||||
| `bash` | 跑 Bash 命令 | `ask` / `allow` / `deny` 或按命令:`{ "git": "allow", "rm": "deny" }` |
|
||||
| `webfetch` | 上网 | `ask` / `allow` / `deny` |
|
||||
| `doom_loop` | 覆盖无限循环检测 | `ask` / `allow` / `deny` |
|
||||
| `external_directory` | 访问根目录外面的文件 | `ask` / `allow` / `deny` |
|
||||
|
||||
或者在 `~/.config/opencode/oh-my-opencode.json` 或 `.opencode/oh-my-opencode.json` 的 `disabled_agents` 里直接禁了:
|
||||
|
||||
@@ -926,12 +935,12 @@ Sisyphus Agent 也能自定义:
|
||||
}
|
||||
```
|
||||
|
||||
| 选项 | 默认值 | 说明 |
|
||||
| --------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `disabled` | `false` | 设为 `true` 就禁用所有 Sisyphus 编排,恢复原来的 build/plan。 |
|
||||
| `default_builder_enabled` | `false` | 设为 `true` 就启用 OpenCode-Builder Agent(与 OpenCode build 相同,因 SDK 限制仅改名)。默认禁用。 |
|
||||
| `planner_enabled` | `true` | 设为 `true` 就启用 Prometheus (Planner) Agent(含 work-planner 方法论)。默认启用。 |
|
||||
| `replace_plan` | `true` | 设为 `true` 就把默认计划 Agent 降级为子 Agent 模式。设为 `false` 可以同时保留 Prometheus (Planner) 和默认计划。 |
|
||||
| 选项 | 默认值 | 说明 |
|
||||
| ------------------------- | ------- | --------------------------------------------------------------------------------------------------------------- |
|
||||
| `disabled` | `false` | 设为 `true` 就禁用所有 Sisyphus 编排,恢复原来的 build/plan。 |
|
||||
| `default_builder_enabled` | `false` | 设为 `true` 就启用 OpenCode-Builder Agent(与 OpenCode build 相同,因 SDK 限制仅改名)。默认禁用。 |
|
||||
| `planner_enabled` | `true` | 设为 `true` 就启用 Prometheus (Planner) Agent(含 work-planner 方法论)。默认启用。 |
|
||||
| `replace_plan` | `true` | 设为 `true` 就把默认计划 Agent 降级为子 Agent 模式。设为 `false` 可以同时保留 Prometheus (Planner) 和默认计划。 |
|
||||
|
||||
### Background Tasks(后台任务)
|
||||
|
||||
@@ -954,11 +963,11 @@ Sisyphus Agent 也能自定义:
|
||||
}
|
||||
```
|
||||
|
||||
| 选项 | 默认值 | 说明 |
|
||||
| --------------------- | ------ | -------------------------------------------------------------------------------------------------------------- |
|
||||
| `defaultConcurrency` | - | 所有提供商/模型的默认最大并发后台任务数 |
|
||||
| `providerConcurrency` | - | 按提供商设置并发限制。键是提供商名称(例如:`anthropic`、`openai`、`google`) |
|
||||
| `modelConcurrency` | - | 按模型设置并发限制。键是完整的模型名称(例如:`anthropic/claude-opus-4-5`)。会覆盖提供商级别的限制。 |
|
||||
| 选项 | 默认值 | 说明 |
|
||||
| --------------------- | ------ | ----------------------------------------------------------------------------------------------------- |
|
||||
| `defaultConcurrency` | - | 所有提供商/模型的默认最大并发后台任务数 |
|
||||
| `providerConcurrency` | - | 按提供商设置并发限制。键是提供商名称(例如:`anthropic`、`openai`、`google`) |
|
||||
| `modelConcurrency` | - | 按模型设置并发限制。键是完整的模型名称(例如:`anthropic/claude-opus-4-5`)。会覆盖提供商级别的限制。 |
|
||||
|
||||
**优先级顺序**: `modelConcurrency` > `providerConcurrency` > `defaultConcurrency`
|
||||
|
||||
@@ -1036,13 +1045,13 @@ Oh My OpenCode 送你重构工具(重命名、代码操作)。
|
||||
}
|
||||
```
|
||||
|
||||
| 选项 | 默认值 | 说明 |
|
||||
| --------------------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `preemptive_compaction_threshold` | `0.85` | 触发预防性压缩的阈值比例(0.5-0.95)。`preemptive-compaction` 钩子默认启用;此选项用于自定义阈值。 |
|
||||
| `truncate_all_tool_outputs` | `false` | 截断所有工具输出,而不仅仅是白名单工具(Grep、Glob、LSP、AST-grep)。Tool output truncator 默认启用 - 使用 `disabled_hooks` 禁用。 |
|
||||
| `aggressive_truncation` | `false` | 超出 token 限制时,激进地截断工具输出以适应限制。比默认截断更激进。不够的话会回退到摘要/恢复。 |
|
||||
| `auto_resume` | `false` | 从 thinking block 错误或 thinking disabled violation 成功恢复后,自动恢复会话。提取最后一条用户消息继续执行。 |
|
||||
| `dcp_for_compaction` | `false` | 启用压缩用 DCP(动态上下文剪枝)- 在超出 token 限制时首先执行。在压缩前清理重复的工具调用和旧的工具输出。 |
|
||||
| 选项 | 默认值 | 说明 |
|
||||
| --------------------------------- | ------- | ---------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `preemptive_compaction_threshold` | `0.85` | 触发预防性压缩的阈值比例(0.5-0.95)。`preemptive-compaction` 钩子默认启用;此选项用于自定义阈值。 |
|
||||
| `truncate_all_tool_outputs` | `false` | 截断所有工具输出,而不仅仅是白名单工具(Grep、Glob、LSP、AST-grep)。Tool output truncator 默认启用 - 使用 `disabled_hooks` 禁用。 |
|
||||
| `aggressive_truncation` | `false` | 超出 token 限制时,激进地截断工具输出以适应限制。比默认截断更激进。不够的话会回退到摘要/恢复。 |
|
||||
| `auto_resume` | `false` | 从 thinking block 错误或 thinking disabled violation 成功恢复后,自动恢复会话。提取最后一条用户消息继续执行。 |
|
||||
| `dcp_for_compaction` | `false` | 启用压缩用 DCP(动态上下文剪枝)- 在超出 token 限制时首先执行。在压缩前清理重复的工具调用和旧的工具输出。 |
|
||||
|
||||
**警告**:这些功能是实验性的,可能会导致意外行为。只有在理解其影响的情况下才启用。
|
||||
|
||||
|
||||
@@ -1,5 +1,26 @@
|
||||
# Oh-My-OpenCode Orchestration Guide
|
||||
|
||||
## TL;DR - When to Use What
|
||||
|
||||
| Complexity | Approach | When to Use |
|
||||
|------------|----------|-------------|
|
||||
| **Simple** | Just prompt | Simple tasks, quick fixes, single-file changes |
|
||||
| **Complex + Lazy** | Just type `ulw` or `ultrawork` | Complex tasks where explaining context is tedious. Agent figures it out. |
|
||||
| **Complex + Precise** | `@plan` → `/start-work` | Precise, multi-step work requiring true orchestration. Prometheus plans, Sisyphus executes. |
|
||||
|
||||
**Decision Flow:**
|
||||
```
|
||||
Is it a quick fix or simple task?
|
||||
└─ YES → Just prompt normally
|
||||
└─ NO → Is explaining the full context tedious?
|
||||
└─ YES → Type "ulw" and let the agent figure it out
|
||||
└─ NO → Do you need precise, verifiable execution?
|
||||
└─ YES → Use @plan for Prometheus planning, then /start-work
|
||||
└─ NO → Just use "ulw"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
This document provides a comprehensive guide to the orchestration system that implements Oh-My-OpenCode's core philosophy: **"Separation of Planning and Execution"**.
|
||||
|
||||
## 1. Overview
|
||||
@@ -16,7 +37,7 @@ Oh-My-OpenCode solves this by clearly separating two roles:
|
||||
## 2. Overall Architecture
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
flowchart TD
|
||||
User[User Request] --> Prometheus
|
||||
|
||||
subgraph Planning Phase
|
||||
@@ -24,10 +45,10 @@ graph TD
|
||||
Metis --> Prometheus
|
||||
Prometheus --> Momus[Momus<br>Reviewer]
|
||||
Momus --> Prometheus
|
||||
Prometheus --> PlanFile[/.sisyphus/plans/*.md]
|
||||
Prometheus --> PlanFile["/.sisyphus/plans/{name}.md"]
|
||||
end
|
||||
|
||||
PlanFile --> StartWork[/start-work]
|
||||
PlanFile --> StartWork[//start-work/]
|
||||
StartWork --> BoulderState[boulder.json]
|
||||
|
||||
subgraph Execution Phase
|
||||
@@ -93,9 +114,9 @@ When the user enters `/start-work`, the execution phase begins.
|
||||
|
||||
## 5. Commands and Usage
|
||||
|
||||
### `/plan [request]`
|
||||
### `@plan [request]`
|
||||
Invokes Prometheus to start a planning session.
|
||||
- Example: `/plan "I want to refactor the authentication system to NextAuth"`
|
||||
- Example: `@plan "I want to refactor the authentication system to NextAuth"`
|
||||
|
||||
### `/start-work`
|
||||
Executes the generated plan.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "oh-my-opencode",
|
||||
"version": "3.0.0-beta.2",
|
||||
"version": "3.0.0-beta.4",
|
||||
"description": "The Best AI Agent Harness - Batteries-Included OpenCode Plugin with Multi-Model Orchestration, Parallel Background Agents, and Crafted LSP/AST Tools",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
|
||||
@@ -319,6 +319,94 @@
|
||||
"created_at": "2026-01-08T20:18:27Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 603
|
||||
},
|
||||
{
|
||||
"name": "SJY0917032",
|
||||
"id": 88534701,
|
||||
"comment_id": 3728199745,
|
||||
"created_at": "2026-01-09T10:01:19Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 625
|
||||
},
|
||||
{
|
||||
"name": "kdcokenny",
|
||||
"id": 99611484,
|
||||
"comment_id": 3728801075,
|
||||
"created_at": "2026-01-09T12:54:05Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 629
|
||||
},
|
||||
{
|
||||
"name": "ElwinLiu",
|
||||
"id": 87802244,
|
||||
"comment_id": 3731812585,
|
||||
"created_at": "2026-01-10T04:32:16Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 645
|
||||
},
|
||||
{
|
||||
"name": "Luodian",
|
||||
"id": 15847405,
|
||||
"comment_id": 3731833107,
|
||||
"created_at": "2026-01-10T05:01:16Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 634
|
||||
},
|
||||
{
|
||||
"name": "imarshallwidjaja",
|
||||
"id": 60992624,
|
||||
"comment_id": 3732124681,
|
||||
"created_at": "2026-01-10T07:58:43Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 648
|
||||
},
|
||||
{
|
||||
"name": "GollyJer",
|
||||
"id": 689204,
|
||||
"comment_id": 3732253764,
|
||||
"created_at": "2026-01-10T09:33:21Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 649
|
||||
},
|
||||
{
|
||||
"name": "kargnas",
|
||||
"id": 1438533,
|
||||
"comment_id": 3732344143,
|
||||
"created_at": "2026-01-10T10:25:25Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 653
|
||||
},
|
||||
{
|
||||
"name": "ashir6892",
|
||||
"id": 52703606,
|
||||
"comment_id": 3733435826,
|
||||
"created_at": "2026-01-10T19:50:07Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 675
|
||||
},
|
||||
{
|
||||
"name": "arthur404dev",
|
||||
"id": 59490008,
|
||||
"comment_id": 3733697071,
|
||||
"created_at": "2026-01-10T23:51:44Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 676
|
||||
},
|
||||
{
|
||||
"name": "KNN-07",
|
||||
"id": 55886589,
|
||||
"comment_id": 3733788592,
|
||||
"created_at": "2026-01-11T01:11:38Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 679
|
||||
},
|
||||
{
|
||||
"name": "aw338WoWmUI",
|
||||
"id": 121638634,
|
||||
"comment_id": 3734013343,
|
||||
"created_at": "2026-01-11T04:56:38Z",
|
||||
"repoId": 1108837393,
|
||||
"pullRequestNo": 681
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -33,7 +33,7 @@ agents/
|
||||
|-------|---------------|----------|---------|
|
||||
| Sisyphus | anthropic/claude-opus-4-5 | - | Primary orchestrator with extended thinking |
|
||||
| oracle | openai/gpt-5.2 | - | Read-only consultation. High-IQ debugging, architecture |
|
||||
| librarian | anthropic/claude-sonnet-4-5 | google/gemini-3-flash | Docs, OSS research, GitHub examples |
|
||||
| librarian | opencode/glm-4.7-free | - | Docs, OSS research, GitHub examples |
|
||||
| explore | opencode/grok-code | google/gemini-3-flash, anthropic/claude-haiku-4-5 | Fast contextual grep |
|
||||
| frontend-ui-ux-engineer | google/gemini-3-pro-preview | - | UI/UX code generation |
|
||||
| document-writer | google/gemini-3-pro-preview | - | Technical writing |
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { AgentConfig } from "@opencode-ai/sdk"
|
||||
import type { AgentPromptMetadata } from "./types"
|
||||
|
||||
const DEFAULT_MODEL = "anthropic/claude-sonnet-4-5"
|
||||
const DEFAULT_MODEL = "opencode/glm-4.7-free"
|
||||
|
||||
export const LIBRARIAN_PROMPT_METADATA: AgentPromptMetadata = {
|
||||
category: "exploration",
|
||||
@@ -129,15 +129,15 @@ Tool 3: grep_app_searchGitHub(query: "usage pattern", language: ["TypeScript"])
|
||||
\`\`\`
|
||||
Step 1: Clone to temp directory
|
||||
gh repo clone owner/repo \${TMPDIR:-/tmp}/repo-name -- --depth 1
|
||||
|
||||
|
||||
Step 2: Get commit SHA for permalinks
|
||||
cd \${TMPDIR:-/tmp}/repo-name && git rev-parse HEAD
|
||||
|
||||
|
||||
Step 3: Find the implementation
|
||||
- grep/ast_grep_search for function/class
|
||||
- read the specific file
|
||||
- git blame for context if needed
|
||||
|
||||
|
||||
Step 4: Construct permalink
|
||||
https://github.com/owner/repo/blob/<sha>/path/to/file#L10-L20
|
||||
\`\`\`
|
||||
@@ -272,7 +272,7 @@ Use OS-appropriate temp directory:
|
||||
| TYPE B (Implementation) | 2-3 NO |
|
||||
| TYPE C (Context) | 2-3 NO |
|
||||
| TYPE D (Comprehensive) | 3-5 | YES (Phase 0.5 first) |
|
||||
| Request Type | Minimum Parallel Calls
|
||||
| Request Type | Minimum Parallel Calls
|
||||
|
||||
**Doc Discovery is SEQUENTIAL** (websearch → version check → sitemap → investigate).
|
||||
**Main phase is PARALLEL** once you know where to look.
|
||||
@@ -308,7 +308,7 @@ grep_app_searchGitHub(query: "useQuery")
|
||||
## COMMUNICATION RULES
|
||||
|
||||
1. **NO TOOL NAMES**: Say "I'll search the codebase" not "I'll use grep_app"
|
||||
2. **NO PREAMBLE**: Answer directly, skip "I'll help you with..."
|
||||
2. **NO PREAMBLE**: Answer directly, skip "I'll help you with..."
|
||||
3. **ALWAYS CITE**: Every code claim needs a permalink
|
||||
4. **USE MARKDOWN**: Code blocks with language identifiers
|
||||
5. **BE CONCISE**: Facts > opinions, evidence > speculation
|
||||
|
||||
@@ -132,7 +132,6 @@ ${rows.join("\n")}
|
||||
}
|
||||
|
||||
export const ORCHESTRATOR_SISYPHUS_SYSTEM_PROMPT = `You are "Sisyphus" - Powerful AI Agent with orchestration capabilities from OhMyOpenCode.
|
||||
Named by [YeonGyu Kim](https://github.com/code-yeongyu).
|
||||
|
||||
**Why Sisyphus?**: Humans roll their boulder every day. So do you. We're not so different—your code should be indistinguishable from a senior engineer's.
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ const DEFAULT_MODEL = "anthropic/claude-opus-4-5"
|
||||
|
||||
const SISYPHUS_ROLE_SECTION = `<Role>
|
||||
You are "Sisyphus" - Powerful AI Agent with orchestration capabilities from OhMyOpenCode.
|
||||
Named by [YeonGyu Kim](https://github.com/code-yeongyu).
|
||||
|
||||
**Why Sisyphus?**: Humans roll their boulder every day. So do you. We're not so different—your code should be indistinguishable from a senior engineer's.
|
||||
|
||||
|
||||
@@ -296,6 +296,7 @@ export const GitMasterConfigSchema = z.object({
|
||||
/** Add "Co-authored-by: Sisyphus" trailer to commit messages (default: true) */
|
||||
include_co_authored_by: z.boolean().default(true),
|
||||
})
|
||||
|
||||
export const OhMyOpenCodeConfigSchema = z.object({
|
||||
$schema: z.string().optional(),
|
||||
disabled_mcps: z.array(AnyMcpNameSchema).optional(),
|
||||
|
||||
@@ -674,3 +674,95 @@ describe("LaunchInput.skillContent", () => {
|
||||
expect(input.skillContent).toBe("You are a playwright expert")
|
||||
})
|
||||
})
|
||||
|
||||
describe("BackgroundManager.notifyParentSession - agent context preservation", () => {
|
||||
test("should not pass agent field when parentAgent is undefined", async () => {
|
||||
// #given
|
||||
const task: BackgroundTask = {
|
||||
id: "task-no-agent",
|
||||
sessionID: "session-child",
|
||||
parentSessionID: "session-parent",
|
||||
parentMessageID: "msg-parent",
|
||||
description: "task without agent context",
|
||||
prompt: "test",
|
||||
agent: "explore",
|
||||
status: "completed",
|
||||
startedAt: new Date(),
|
||||
completedAt: new Date(),
|
||||
parentAgent: undefined,
|
||||
parentModel: { providerID: "anthropic", modelID: "claude-opus" },
|
||||
}
|
||||
|
||||
// #when
|
||||
const promptBody = buildNotificationPromptBody(task)
|
||||
|
||||
// #then
|
||||
expect("agent" in promptBody).toBe(false)
|
||||
expect(promptBody.model).toEqual({ providerID: "anthropic", modelID: "claude-opus" })
|
||||
})
|
||||
|
||||
test("should include agent field when parentAgent is defined", async () => {
|
||||
// #given
|
||||
const task: BackgroundTask = {
|
||||
id: "task-with-agent",
|
||||
sessionID: "session-child",
|
||||
parentSessionID: "session-parent",
|
||||
parentMessageID: "msg-parent",
|
||||
description: "task with agent context",
|
||||
prompt: "test",
|
||||
agent: "explore",
|
||||
status: "completed",
|
||||
startedAt: new Date(),
|
||||
completedAt: new Date(),
|
||||
parentAgent: "Sisyphus",
|
||||
parentModel: { providerID: "anthropic", modelID: "claude-opus" },
|
||||
}
|
||||
|
||||
// #when
|
||||
const promptBody = buildNotificationPromptBody(task)
|
||||
|
||||
// #then
|
||||
expect(promptBody.agent).toBe("Sisyphus")
|
||||
})
|
||||
|
||||
test("should not pass model field when parentModel is undefined", async () => {
|
||||
// #given
|
||||
const task: BackgroundTask = {
|
||||
id: "task-no-model",
|
||||
sessionID: "session-child",
|
||||
parentSessionID: "session-parent",
|
||||
parentMessageID: "msg-parent",
|
||||
description: "task without model context",
|
||||
prompt: "test",
|
||||
agent: "explore",
|
||||
status: "completed",
|
||||
startedAt: new Date(),
|
||||
completedAt: new Date(),
|
||||
parentAgent: "Sisyphus",
|
||||
parentModel: undefined,
|
||||
}
|
||||
|
||||
// #when
|
||||
const promptBody = buildNotificationPromptBody(task)
|
||||
|
||||
// #then
|
||||
expect("model" in promptBody).toBe(false)
|
||||
expect(promptBody.agent).toBe("Sisyphus")
|
||||
})
|
||||
})
|
||||
|
||||
function buildNotificationPromptBody(task: BackgroundTask): Record<string, unknown> {
|
||||
const body: Record<string, unknown> = {
|
||||
parts: [{ type: "text", text: `[BACKGROUND TASK COMPLETED] Task "${task.description}" finished.` }],
|
||||
}
|
||||
|
||||
if (task.parentAgent !== undefined) {
|
||||
body.agent = task.parentAgent
|
||||
}
|
||||
|
||||
if (task.parentModel?.providerID && task.parentModel?.modelID) {
|
||||
body.model = { providerID: task.parentModel.providerID, modelID: task.parentModel.modelID }
|
||||
}
|
||||
|
||||
return body
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { subagentSessions } from "../claude-code-session-state"
|
||||
import { getTaskToastManager } from "../task-toast-manager"
|
||||
|
||||
const TASK_TTL_MS = 30 * 60 * 1000
|
||||
const MIN_STABILITY_TIME_MS = 10 * 1000 // Must run at least 10s before stability detection kicks in
|
||||
|
||||
type OpencodeClient = PluginInput["client"]
|
||||
|
||||
@@ -43,6 +44,7 @@ interface Todo {
|
||||
export class BackgroundManager {
|
||||
private tasks: Map<string, BackgroundTask>
|
||||
private notifications: Map<string, BackgroundTask[]>
|
||||
private pendingByParent: Map<string, Set<string>> // Track pending tasks per parent for batching
|
||||
private client: OpencodeClient
|
||||
private directory: string
|
||||
private pollingInterval?: ReturnType<typeof setInterval>
|
||||
@@ -51,12 +53,20 @@ export class BackgroundManager {
|
||||
constructor(ctx: PluginInput, config?: BackgroundTaskConfig) {
|
||||
this.tasks = new Map()
|
||||
this.notifications = new Map()
|
||||
this.pendingByParent = new Map()
|
||||
this.client = ctx.client
|
||||
this.directory = ctx.directory
|
||||
this.concurrencyManager = new ConcurrencyManager(config)
|
||||
}
|
||||
|
||||
async launch(input: LaunchInput): Promise<BackgroundTask> {
|
||||
log("[background-agent] launch() called with:", {
|
||||
agent: input.agent,
|
||||
model: input.model,
|
||||
description: input.description,
|
||||
parentSessionID: input.parentSessionID,
|
||||
})
|
||||
|
||||
if (!input.agent || input.agent.trim() === "") {
|
||||
throw new Error("Agent parameter is required")
|
||||
}
|
||||
@@ -106,6 +116,11 @@ export class BackgroundManager {
|
||||
this.tasks.set(task.id, task)
|
||||
this.startPolling()
|
||||
|
||||
// Track for batched notifications
|
||||
const pending = this.pendingByParent.get(input.parentSessionID) ?? new Set()
|
||||
pending.add(task.id)
|
||||
this.pendingByParent.set(input.parentSessionID, pending)
|
||||
|
||||
log("[background-agent] Launching task:", { taskId: task.id, sessionID, agent: input.agent })
|
||||
|
||||
const toastManager = getTaskToastManager()
|
||||
@@ -119,10 +134,21 @@ export class BackgroundManager {
|
||||
})
|
||||
}
|
||||
|
||||
this.client.session.promptAsync({
|
||||
log("[background-agent] Calling prompt (fire-and-forget) for launch with:", {
|
||||
sessionID,
|
||||
agent: input.agent,
|
||||
model: input.model,
|
||||
hasSkillContent: !!input.skillContent,
|
||||
promptLength: input.prompt.length,
|
||||
})
|
||||
|
||||
// Use prompt() instead of promptAsync() to properly initialize agent loop (fire-and-forget)
|
||||
// Include model if caller provided one (e.g., from Sisyphus category configs)
|
||||
this.client.session.prompt({
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
agent: input.agent,
|
||||
...(input.model ? { model: input.model } : {}),
|
||||
system: input.skillContent,
|
||||
tools: {
|
||||
task: false,
|
||||
@@ -146,7 +172,9 @@ export class BackgroundManager {
|
||||
this.concurrencyManager.release(existingTask.concurrencyKey)
|
||||
}
|
||||
this.markForNotification(existingTask)
|
||||
this.notifyParentSession(existingTask)
|
||||
this.notifyParentSession(existingTask).catch(err => {
|
||||
log("[background-agent] Failed to notify on error:", err)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -199,6 +227,7 @@ export class BackgroundManager {
|
||||
parentSessionID: string
|
||||
description: string
|
||||
agent?: string
|
||||
parentAgent?: string
|
||||
}): BackgroundTask {
|
||||
const task: BackgroundTask = {
|
||||
id: input.taskId,
|
||||
@@ -214,12 +243,18 @@ export class BackgroundManager {
|
||||
toolCalls: 0,
|
||||
lastUpdate: new Date(),
|
||||
},
|
||||
parentAgent: input.parentAgent,
|
||||
}
|
||||
|
||||
this.tasks.set(task.id, task)
|
||||
subagentSessions.add(input.sessionID)
|
||||
this.startPolling()
|
||||
|
||||
// Track for batched notifications (external tasks need tracking too)
|
||||
const pending = this.pendingByParent.get(input.parentSessionID) ?? new Set()
|
||||
pending.add(task.id)
|
||||
this.pendingByParent.set(input.parentSessionID, pending)
|
||||
|
||||
log("[background-agent] Registered external task:", { taskId: task.id, sessionID: input.sessionID })
|
||||
|
||||
return task
|
||||
@@ -247,6 +282,11 @@ export class BackgroundManager {
|
||||
this.startPolling()
|
||||
subagentSessions.add(existingTask.sessionID)
|
||||
|
||||
// Track for batched notifications (P2 fix: resumed tasks need tracking too)
|
||||
const pending = this.pendingByParent.get(input.parentSessionID) ?? new Set()
|
||||
pending.add(existingTask.id)
|
||||
this.pendingByParent.set(input.parentSessionID, pending)
|
||||
|
||||
const toastManager = getTaskToastManager()
|
||||
if (toastManager) {
|
||||
toastManager.addTask({
|
||||
@@ -259,7 +299,15 @@ export class BackgroundManager {
|
||||
|
||||
log("[background-agent] Resuming task:", { taskId: existingTask.id, sessionID: existingTask.sessionID })
|
||||
|
||||
this.client.session.promptAsync({
|
||||
log("[background-agent] Resuming task - calling prompt (fire-and-forget) with:", {
|
||||
sessionID: existingTask.sessionID,
|
||||
agent: existingTask.agent,
|
||||
promptLength: input.prompt.length,
|
||||
})
|
||||
|
||||
// Note: Don't pass model in body - use agent's configured model instead
|
||||
// Use prompt() instead of promptAsync() to properly initialize agent loop
|
||||
this.client.session.prompt({
|
||||
path: { id: existingTask.sessionID },
|
||||
body: {
|
||||
agent: existingTask.agent,
|
||||
@@ -270,13 +318,15 @@ export class BackgroundManager {
|
||||
parts: [{ type: "text", text: input.prompt }],
|
||||
},
|
||||
}).catch((error) => {
|
||||
log("[background-agent] resume promptAsync error:", error)
|
||||
log("[background-agent] resume prompt error:", error)
|
||||
existingTask.status = "error"
|
||||
const errorMessage = error instanceof Error ? error.message : String(error)
|
||||
existingTask.error = errorMessage
|
||||
existingTask.completedAt = new Date()
|
||||
this.markForNotification(existingTask)
|
||||
this.notifyParentSession(existingTask)
|
||||
this.notifyParentSession(existingTask).catch(err => {
|
||||
log("[background-agent] Failed to notify on resume error:", err)
|
||||
})
|
||||
})
|
||||
|
||||
return existingTask
|
||||
@@ -331,7 +381,22 @@ export class BackgroundManager {
|
||||
const task = this.findBySession(sessionID)
|
||||
if (!task || task.status !== "running") return
|
||||
|
||||
this.checkSessionTodos(sessionID).then((hasIncompleteTodos) => {
|
||||
// Edge guard: Require minimum elapsed time (5 seconds) before accepting idle
|
||||
const elapsedMs = Date.now() - task.startedAt.getTime()
|
||||
const MIN_IDLE_TIME_MS = 5000
|
||||
if (elapsedMs < MIN_IDLE_TIME_MS) {
|
||||
log("[background-agent] Ignoring early session.idle, elapsed:", { elapsedMs, taskId: task.id })
|
||||
return
|
||||
}
|
||||
|
||||
// Edge guard: Verify session has actual assistant output before completing
|
||||
this.validateSessionHasOutput(sessionID).then(async (hasValidOutput) => {
|
||||
if (!hasValidOutput) {
|
||||
log("[background-agent] Session.idle but no valid output yet, waiting:", task.id)
|
||||
return
|
||||
}
|
||||
|
||||
const hasIncompleteTodos = await this.checkSessionTodos(sessionID)
|
||||
if (hasIncompleteTodos) {
|
||||
log("[background-agent] Task has incomplete todos, waiting for todo-continuation:", task.id)
|
||||
return
|
||||
@@ -340,8 +405,10 @@ export class BackgroundManager {
|
||||
task.status = "completed"
|
||||
task.completedAt = new Date()
|
||||
this.markForNotification(task)
|
||||
this.notifyParentSession(task)
|
||||
await this.notifyParentSession(task)
|
||||
log("[background-agent] Task completed via session.idle event:", task.id)
|
||||
}).catch(err => {
|
||||
log("[background-agent] Error in session.idle handler:", err)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -382,6 +449,66 @@ export class BackgroundManager {
|
||||
this.notifications.delete(sessionID)
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that a session has actual assistant/tool output before marking complete.
|
||||
* Prevents premature completion when session.idle fires before agent responds.
|
||||
*/
|
||||
private async validateSessionHasOutput(sessionID: string): Promise<boolean> {
|
||||
try {
|
||||
const response = await this.client.session.messages({
|
||||
path: { id: sessionID },
|
||||
})
|
||||
|
||||
const messages = response.data ?? []
|
||||
|
||||
// Check for at least one assistant or tool message
|
||||
const hasAssistantOrToolMessage = messages.some(
|
||||
(m: { info?: { role?: string } }) =>
|
||||
m.info?.role === "assistant" || m.info?.role === "tool"
|
||||
)
|
||||
|
||||
if (!hasAssistantOrToolMessage) {
|
||||
log("[background-agent] No assistant/tool messages found in session:", sessionID)
|
||||
return false
|
||||
}
|
||||
|
||||
// Additionally check that at least one message has content (not just empty)
|
||||
// OpenCode API uses different part types than Anthropic's API:
|
||||
// - "reasoning" with .text property (thinking/reasoning content)
|
||||
// - "tool" with .state.output property (tool call results)
|
||||
// - "text" with .text property (final text output)
|
||||
// - "step-start"/"step-finish" (metadata, no content)
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const hasContent = messages.some((m: any) => {
|
||||
if (m.info?.role !== "assistant" && m.info?.role !== "tool") return false
|
||||
const parts = m.parts ?? []
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return parts.some((p: any) =>
|
||||
// Text content (final output)
|
||||
(p.type === "text" && p.text && p.text.trim().length > 0) ||
|
||||
// Reasoning content (thinking blocks)
|
||||
(p.type === "reasoning" && p.text && p.text.trim().length > 0) ||
|
||||
// Tool calls (indicates work was done)
|
||||
p.type === "tool" ||
|
||||
// Tool results (output from executed tools) - important for tool-only tasks
|
||||
(p.type === "tool_result" && p.content &&
|
||||
(typeof p.content === "string" ? p.content.trim().length > 0 : p.content.length > 0))
|
||||
)
|
||||
})
|
||||
|
||||
if (!hasContent) {
|
||||
log("[background-agent] Messages exist but no content found in session:", sessionID)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
log("[background-agent] Error validating session output:", error)
|
||||
// On error, allow completion to proceed (don't block indefinitely)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private clearNotificationsForTask(taskId: string): void {
|
||||
for (const [sessionID, tasks] of this.notifications.entries()) {
|
||||
const filtered = tasks.filter((t) => t.id !== taskId)
|
||||
@@ -409,17 +536,33 @@ export class BackgroundManager {
|
||||
}
|
||||
}
|
||||
|
||||
cleanup(): void {
|
||||
cleanup(): void {
|
||||
this.stopPolling()
|
||||
this.tasks.clear()
|
||||
this.notifications.clear()
|
||||
this.pendingByParent.clear()
|
||||
}
|
||||
|
||||
private notifyParentSession(task: BackgroundTask): void {
|
||||
/**
|
||||
* Get all running tasks (for compaction hook)
|
||||
*/
|
||||
getRunningTasks(): BackgroundTask[] {
|
||||
return Array.from(this.tasks.values()).filter(t => t.status === "running")
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all completed tasks still in memory (for compaction hook)
|
||||
*/
|
||||
getCompletedTasks(): BackgroundTask[] {
|
||||
return Array.from(this.tasks.values()).filter(t => t.status !== "running")
|
||||
}
|
||||
|
||||
private async notifyParentSession(task: BackgroundTask): Promise<void> {
|
||||
const duration = this.formatDuration(task.startedAt, task.completedAt)
|
||||
|
||||
log("[background-agent] notifyParentSession called for task:", task.id)
|
||||
|
||||
// Show toast notification
|
||||
const toastManager = getTaskToastManager()
|
||||
if (toastManager) {
|
||||
toastManager.showCompletionToast({
|
||||
@@ -429,41 +572,83 @@ export class BackgroundManager {
|
||||
})
|
||||
}
|
||||
|
||||
const message = `[BACKGROUND TASK COMPLETED] Task "${task.description}" finished in ${duration}. Use background_output with task_id="${task.id}" to get results.`
|
||||
// Update pending tracking and check if all tasks complete
|
||||
const pendingSet = this.pendingByParent.get(task.parentSessionID)
|
||||
if (pendingSet) {
|
||||
pendingSet.delete(task.id)
|
||||
if (pendingSet.size === 0) {
|
||||
this.pendingByParent.delete(task.parentSessionID)
|
||||
}
|
||||
}
|
||||
|
||||
log("[background-agent] Sending notification to parent session:", { parentSessionID: task.parentSessionID })
|
||||
const allComplete = !pendingSet || pendingSet.size === 0
|
||||
const remainingCount = pendingSet?.size ?? 0
|
||||
|
||||
// Build notification message
|
||||
const statusText = task.status === "error" ? "FAILED" : "COMPLETED"
|
||||
const errorInfo = task.error ? `\n**Error:** ${task.error}` : ""
|
||||
|
||||
let notification: string
|
||||
if (allComplete) {
|
||||
// All tasks complete - build summary
|
||||
const completedTasks = Array.from(this.tasks.values())
|
||||
.filter(t => t.parentSessionID === task.parentSessionID && t.status !== "running")
|
||||
.map(t => `- \`${t.id}\`: ${t.description}`)
|
||||
.join("\n")
|
||||
|
||||
notification = `<system-reminder>
|
||||
[ALL BACKGROUND TASKS COMPLETE]
|
||||
|
||||
**Completed:**
|
||||
${completedTasks || `- \`${task.id}\`: ${task.description}`}
|
||||
|
||||
Use \`background_output(task_id="<id>")\` to retrieve each result.
|
||||
</system-reminder>`
|
||||
} else {
|
||||
// Individual completion - silent notification
|
||||
notification = `<system-reminder>
|
||||
[BACKGROUND TASK ${statusText}]
|
||||
**ID:** \`${task.id}\`
|
||||
**Description:** ${task.description}
|
||||
**Duration:** ${duration}${errorInfo}
|
||||
|
||||
**${remainingCount} task${remainingCount === 1 ? "" : "s"} still in progress.** You WILL be notified when ALL complete.
|
||||
Do NOT poll - continue productive work.
|
||||
|
||||
Use \`background_output(task_id="${task.id}")\` to retrieve this result when ready.
|
||||
</system-reminder>`
|
||||
}
|
||||
|
||||
// Inject notification via session.prompt with noReply
|
||||
try {
|
||||
await this.client.session.prompt({
|
||||
path: { id: task.parentSessionID },
|
||||
body: {
|
||||
noReply: !allComplete, // Silent unless all complete
|
||||
agent: task.parentAgent,
|
||||
parts: [{ type: "text", text: notification }],
|
||||
},
|
||||
})
|
||||
log("[background-agent] Sent notification to parent session:", {
|
||||
taskId: task.id,
|
||||
allComplete,
|
||||
noReply: !allComplete,
|
||||
})
|
||||
} catch (error) {
|
||||
log("[background-agent] Failed to send notification:", error)
|
||||
}
|
||||
|
||||
// Cleanup after retention period
|
||||
const taskId = task.id
|
||||
setTimeout(async () => {
|
||||
setTimeout(() => {
|
||||
if (task.concurrencyKey) {
|
||||
this.concurrencyManager.release(task.concurrencyKey)
|
||||
task.concurrencyKey = undefined
|
||||
}
|
||||
|
||||
try {
|
||||
// Use only parentModel/parentAgent - don't fallback to prevMessage
|
||||
// This prevents accidentally changing parent session's model/agent
|
||||
const modelField = task.parentModel?.providerID && task.parentModel?.modelID
|
||||
? { providerID: task.parentModel.providerID, modelID: task.parentModel.modelID }
|
||||
: undefined
|
||||
|
||||
await this.client.session.prompt({
|
||||
path: { id: task.parentSessionID },
|
||||
body: {
|
||||
agent: task.parentAgent,
|
||||
model: modelField,
|
||||
parts: [{ type: "text", text: message }],
|
||||
},
|
||||
query: { directory: this.directory },
|
||||
})
|
||||
log("[background-agent] Successfully sent prompt to parent session:", { parentSessionID: task.parentSessionID })
|
||||
} catch (error) {
|
||||
log("[background-agent] prompt failed:", String(error))
|
||||
} finally {
|
||||
this.clearNotificationsForTask(taskId)
|
||||
this.tasks.delete(taskId)
|
||||
log("[background-agent] Removed completed task from memory:", taskId)
|
||||
}
|
||||
}, 200)
|
||||
this.clearNotificationsForTask(taskId)
|
||||
this.tasks.delete(taskId)
|
||||
log("[background-agent] Removed completed task from memory:", taskId)
|
||||
}, 5 * 60 * 1000)
|
||||
}
|
||||
|
||||
private formatDuration(start: Date, end?: Date): string {
|
||||
@@ -532,15 +717,18 @@ export class BackgroundManager {
|
||||
for (const task of this.tasks.values()) {
|
||||
if (task.status !== "running") continue
|
||||
|
||||
try {
|
||||
try {
|
||||
const sessionStatus = allStatuses[task.sessionID]
|
||||
|
||||
if (!sessionStatus) {
|
||||
log("[background-agent] Session not found in status:", task.sessionID)
|
||||
continue
|
||||
}
|
||||
// Don't skip if session not in status - fall through to message-based detection
|
||||
if (sessionStatus?.type === "idle") {
|
||||
// Edge guard: Validate session has actual output before completing
|
||||
const hasValidOutput = await this.validateSessionHasOutput(task.sessionID)
|
||||
if (!hasValidOutput) {
|
||||
log("[background-agent] Polling idle but no valid output yet, waiting:", task.id)
|
||||
continue
|
||||
}
|
||||
|
||||
if (sessionStatus.type === "idle") {
|
||||
const hasIncompleteTodos = await this.checkSessionTodos(task.sessionID)
|
||||
if (hasIncompleteTodos) {
|
||||
log("[background-agent] Task has incomplete todos via polling, waiting:", task.id)
|
||||
@@ -550,7 +738,7 @@ export class BackgroundManager {
|
||||
task.status = "completed"
|
||||
task.completedAt = new Date()
|
||||
this.markForNotification(task)
|
||||
this.notifyParentSession(task)
|
||||
await this.notifyParentSession(task)
|
||||
log("[background-agent] Task completed via polling:", task.id)
|
||||
continue
|
||||
}
|
||||
@@ -591,10 +779,41 @@ export class BackgroundManager {
|
||||
task.progress.toolCalls = toolCalls
|
||||
task.progress.lastTool = lastTool
|
||||
task.progress.lastUpdate = new Date()
|
||||
if (lastMessage) {
|
||||
if (lastMessage) {
|
||||
task.progress.lastMessage = lastMessage
|
||||
task.progress.lastMessageAt = new Date()
|
||||
}
|
||||
|
||||
// Stability detection: complete when message count unchanged for 3 polls
|
||||
const currentMsgCount = messages.length
|
||||
const elapsedMs = Date.now() - task.startedAt.getTime()
|
||||
|
||||
if (elapsedMs >= MIN_STABILITY_TIME_MS) {
|
||||
if (task.lastMsgCount === currentMsgCount) {
|
||||
task.stablePolls = (task.stablePolls ?? 0) + 1
|
||||
if (task.stablePolls >= 3) {
|
||||
// Edge guard: Validate session has actual output before completing
|
||||
const hasValidOutput = await this.validateSessionHasOutput(task.sessionID)
|
||||
if (!hasValidOutput) {
|
||||
log("[background-agent] Stability reached but no valid output, waiting:", task.id)
|
||||
continue
|
||||
}
|
||||
|
||||
const hasIncompleteTodos = await this.checkSessionTodos(task.sessionID)
|
||||
if (!hasIncompleteTodos) {
|
||||
task.status = "completed"
|
||||
task.completedAt = new Date()
|
||||
this.markForNotification(task)
|
||||
await this.notifyParentSession(task)
|
||||
log("[background-agent] Task completed via stability detection:", task.id)
|
||||
continue
|
||||
}
|
||||
}
|
||||
} else {
|
||||
task.stablePolls = 0
|
||||
}
|
||||
}
|
||||
task.lastMsgCount = currentMsgCount
|
||||
}
|
||||
} catch (error) {
|
||||
log("[background-agent] Poll error for task:", { taskId: task.id, error })
|
||||
|
||||
@@ -32,6 +32,10 @@ export interface BackgroundTask {
|
||||
concurrencyKey?: string
|
||||
/** Parent session's agent name for notification */
|
||||
parentAgent?: string
|
||||
/** Last message count for stability detection */
|
||||
lastMsgCount?: number
|
||||
/** Number of consecutive polls with stable message count */
|
||||
stablePolls?: number
|
||||
}
|
||||
|
||||
export interface LaunchInput {
|
||||
|
||||
@@ -133,7 +133,7 @@ describe("createContextInjectorHook", () => {
|
||||
})
|
||||
|
||||
describe("chat.message handler", () => {
|
||||
it("is a no-op (context injection moved to messages transform)", async () => {
|
||||
it("injects pending context into output parts", async () => {
|
||||
// #given
|
||||
const hook = createContextInjectorHook(collector)
|
||||
const sessionID = "ses_hook1"
|
||||
@@ -152,8 +152,9 @@ describe("createContextInjectorHook", () => {
|
||||
await hook["chat.message"](input, output)
|
||||
|
||||
// #then
|
||||
expect(output.parts[0].text).toBe("User message")
|
||||
expect(collector.hasPending(sessionID)).toBe(true)
|
||||
expect(output.parts[0].text).toContain("Hook context")
|
||||
expect(output.parts[0].text).toContain("User message")
|
||||
expect(collector.hasPending(sessionID)).toBe(false)
|
||||
})
|
||||
|
||||
it("does nothing when no pending context", async () => {
|
||||
|
||||
@@ -52,10 +52,16 @@ interface ChatMessageOutput {
|
||||
export function createContextInjectorHook(collector: ContextCollector) {
|
||||
return {
|
||||
"chat.message": async (
|
||||
_input: ChatMessageInput,
|
||||
_output: ChatMessageOutput
|
||||
input: ChatMessageInput,
|
||||
output: ChatMessageOutput
|
||||
): Promise<void> => {
|
||||
void collector
|
||||
const result = injectPendingContext(collector, input.sessionID, output.parts)
|
||||
if (result.injected) {
|
||||
log("[context-injector] Injected pending context via chat.message", {
|
||||
sessionID: input.sessionID,
|
||||
contextLength: result.contextLength,
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
85
src/hooks/background-compaction/index.ts
Normal file
85
src/hooks/background-compaction/index.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import type { BackgroundManager } from "../../features/background-agent"
|
||||
|
||||
interface CompactingInput {
|
||||
sessionID: string
|
||||
}
|
||||
|
||||
interface CompactingOutput {
|
||||
context: string[]
|
||||
prompt?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Background agent compaction hook - preserves task state during context compaction.
|
||||
*
|
||||
* When OpenCode compacts session context to save tokens, this hook injects
|
||||
* information about running and recently completed background tasks so the
|
||||
* agent doesn't lose awareness of delegated work.
|
||||
*/
|
||||
export function createBackgroundCompactionHook(manager: BackgroundManager) {
|
||||
return {
|
||||
"experimental.session.compacting": async (
|
||||
input: CompactingInput,
|
||||
output: CompactingOutput
|
||||
): Promise<void> => {
|
||||
const { sessionID } = input
|
||||
|
||||
// Get running tasks for this session
|
||||
const running = manager.getRunningTasks()
|
||||
.filter(t => t.parentSessionID === sessionID)
|
||||
.map(t => ({
|
||||
id: t.id,
|
||||
agent: t.agent,
|
||||
description: t.description,
|
||||
startedAt: t.startedAt,
|
||||
}))
|
||||
|
||||
// Get recently completed tasks (still in memory within 5-min retention)
|
||||
const completed = manager.getCompletedTasks()
|
||||
.filter(t => t.parentSessionID === sessionID)
|
||||
.slice(-10) // Last 10 completed
|
||||
.map(t => ({
|
||||
id: t.id,
|
||||
agent: t.agent,
|
||||
description: t.description,
|
||||
status: t.status,
|
||||
}))
|
||||
|
||||
// Early exit if nothing to preserve
|
||||
if (running.length === 0 && completed.length === 0) return
|
||||
|
||||
const sections: string[] = ["<background-tasks>"]
|
||||
|
||||
// Running tasks section
|
||||
if (running.length > 0) {
|
||||
sections.push("## Running Background Tasks")
|
||||
sections.push("")
|
||||
for (const t of running) {
|
||||
const elapsed = Math.floor((Date.now() - t.startedAt.getTime()) / 1000)
|
||||
sections.push(`- **\`${t.id}\`** (${t.agent}): ${t.description} [${elapsed}s elapsed]`)
|
||||
}
|
||||
sections.push("")
|
||||
sections.push("> **Note:** You WILL be notified when tasks complete.")
|
||||
sections.push("> Do NOT poll - continue productive work.")
|
||||
sections.push("")
|
||||
}
|
||||
|
||||
// Completed tasks section
|
||||
if (completed.length > 0) {
|
||||
sections.push("## Recently Completed Tasks")
|
||||
sections.push("")
|
||||
for (const t of completed) {
|
||||
const statusEmoji = t.status === "completed" ? "✅" : t.status === "error" ? "❌" : "⏱️"
|
||||
sections.push(`- ${statusEmoji} **\`${t.id}\`**: ${t.description}`)
|
||||
}
|
||||
sections.push("")
|
||||
}
|
||||
|
||||
sections.push("## Retrieval")
|
||||
sections.push('Use `background_output(task_id="<id>")` to retrieve task results.')
|
||||
sections.push("</background-tasks>")
|
||||
|
||||
output.context.push(sections.join("\n"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,12 @@ interface EventInput {
|
||||
event: Event
|
||||
}
|
||||
|
||||
/**
|
||||
* Background notification hook - handles event routing to BackgroundManager.
|
||||
*
|
||||
* Notifications are now delivered directly via session.prompt({ noReply })
|
||||
* from the manager, so this hook only needs to handle event routing.
|
||||
*/
|
||||
export function createBackgroundNotificationHook(manager: BackgroundManager) {
|
||||
const eventHandler = async ({ event }: EventInput) => {
|
||||
manager.handleEvent(event)
|
||||
|
||||
@@ -27,7 +27,6 @@ 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 { detectKeywordsWithType, removeCodeBlocks } from "../keyword-detector"
|
||||
import type { ContextCollector } from "../../features/context-injector"
|
||||
|
||||
const sessionFirstMessageProcessed = new Set<string>()
|
||||
@@ -142,25 +141,9 @@ export function createClaudeCodeHooksHook(
|
||||
return
|
||||
}
|
||||
|
||||
const keywordMessages: string[] = []
|
||||
if (!config.keywordDetectorDisabled) {
|
||||
const detectedKeywords = detectKeywordsWithType(removeCodeBlocks(prompt), input.agent)
|
||||
keywordMessages.push(...detectedKeywords.map((k) => k.message))
|
||||
|
||||
if (keywordMessages.length > 0) {
|
||||
log("[claude-code-hooks] Detected keywords", {
|
||||
sessionID: input.sessionID,
|
||||
keywordCount: keywordMessages.length,
|
||||
types: detectedKeywords.map((k) => k.type),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const allMessages = [...keywordMessages, ...result.messages]
|
||||
|
||||
if (allMessages.length > 0) {
|
||||
const hookContent = allMessages.join("\n\n")
|
||||
log(`[claude-code-hooks] Injecting ${allMessages.length} messages (${keywordMessages.length} keyword + ${result.messages.length} hook)`, { sessionID: input.sessionID, contentLength: hookContent.length, isFirstMessage })
|
||||
if (result.messages.length > 0) {
|
||||
const hookContent = result.messages.join("\n\n")
|
||||
log(`[claude-code-hooks] Injecting ${result.messages.length} hook messages`, { sessionID: input.sessionID, contentLength: hookContent.length, isFirstMessage })
|
||||
|
||||
if (isFirstMessage) {
|
||||
const idx = output.parts.findIndex((p) => p.type === "text" && p.text)
|
||||
|
||||
@@ -14,6 +14,7 @@ export { createThinkModeHook } from "./think-mode";
|
||||
export { createClaudeCodeHooksHook } from "./claude-code-hooks";
|
||||
export { createRulesInjectorHook } from "./rules-injector";
|
||||
export { createBackgroundNotificationHook } from "./background-notification"
|
||||
export { createBackgroundCompactionHook } from "./background-compaction"
|
||||
export { createAutoUpdateCheckerHook } from "./auto-update-checker";
|
||||
|
||||
export { createAgentUsageReminderHook } from "./agent-usage-reminder";
|
||||
|
||||
@@ -1,7 +1,95 @@
|
||||
import { describe, expect, test, beforeEach, afterEach, spyOn } from "bun:test"
|
||||
import { createKeywordDetectorHook } from "./index"
|
||||
import { setMainSession } from "../../features/claude-code-session-state"
|
||||
import { ContextCollector } from "../../features/context-injector"
|
||||
import * as sharedModule from "../../shared"
|
||||
import * as sessionState from "../../features/claude-code-session-state"
|
||||
|
||||
describe("keyword-detector registers to ContextCollector", () => {
|
||||
let logCalls: Array<{ msg: string; data?: unknown }>
|
||||
let logSpy: ReturnType<typeof spyOn>
|
||||
let getMainSessionSpy: ReturnType<typeof spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
logCalls = []
|
||||
logSpy = spyOn(sharedModule, "log").mockImplementation((msg: string, data?: unknown) => {
|
||||
logCalls.push({ msg, data })
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
logSpy?.mockRestore()
|
||||
getMainSessionSpy?.mockRestore()
|
||||
})
|
||||
|
||||
function createMockPluginInput() {
|
||||
return {
|
||||
client: {
|
||||
tui: {
|
||||
showToast: async () => {},
|
||||
},
|
||||
},
|
||||
} as any
|
||||
}
|
||||
|
||||
test("should register ultrawork keyword to ContextCollector", async () => {
|
||||
// #given - a fresh ContextCollector and keyword-detector hook
|
||||
const collector = new ContextCollector()
|
||||
const hook = createKeywordDetectorHook(createMockPluginInput(), collector)
|
||||
const sessionID = "test-session-123"
|
||||
const output = {
|
||||
message: {} as Record<string, unknown>,
|
||||
parts: [{ type: "text", text: "ultrawork do something" }],
|
||||
}
|
||||
|
||||
// #when - keyword detection runs
|
||||
await hook["chat.message"]({ sessionID }, output)
|
||||
|
||||
// #then - ultrawork context should be registered in collector
|
||||
expect(collector.hasPending(sessionID)).toBe(true)
|
||||
const pending = collector.getPending(sessionID)
|
||||
expect(pending.entries.length).toBeGreaterThan(0)
|
||||
expect(pending.entries[0].source).toBe("keyword-detector")
|
||||
expect(pending.entries[0].id).toBe("keyword-ultrawork")
|
||||
})
|
||||
|
||||
test("should register search keyword to ContextCollector", async () => {
|
||||
// #given - mock getMainSessionID to return our session (isolate from global state)
|
||||
const collector = new ContextCollector()
|
||||
const sessionID = "search-test-session"
|
||||
getMainSessionSpy = spyOn(sessionState, "getMainSessionID").mockReturnValue(sessionID)
|
||||
const hook = createKeywordDetectorHook(createMockPluginInput(), collector)
|
||||
const output = {
|
||||
message: {} as Record<string, unknown>,
|
||||
parts: [{ type: "text", text: "search for the bug" }],
|
||||
}
|
||||
|
||||
// #when - keyword detection runs
|
||||
await hook["chat.message"]({ sessionID }, output)
|
||||
|
||||
// #then - search context should be registered in collector
|
||||
expect(collector.hasPending(sessionID)).toBe(true)
|
||||
const pending = collector.getPending(sessionID)
|
||||
expect(pending.entries.some((e) => e.id === "keyword-search")).toBe(true)
|
||||
})
|
||||
|
||||
test("should NOT register to collector when no keywords detected", async () => {
|
||||
// #given - no keywords in message
|
||||
const collector = new ContextCollector()
|
||||
const hook = createKeywordDetectorHook(createMockPluginInput(), collector)
|
||||
const sessionID = "test-session"
|
||||
const output = {
|
||||
message: {} as Record<string, unknown>,
|
||||
parts: [{ type: "text", text: "just a normal message" }],
|
||||
}
|
||||
|
||||
// #when - keyword detection runs
|
||||
await hook["chat.message"]({ sessionID }, output)
|
||||
|
||||
// #then - nothing should be registered
|
||||
expect(collector.hasPending(sessionID)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("keyword-detector session filtering", () => {
|
||||
let logCalls: Array<{ msg: string; data?: unknown }>
|
||||
|
||||
@@ -2,12 +2,13 @@ import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { detectKeywordsWithType, extractPromptText, removeCodeBlocks } from "./detector"
|
||||
import { log } from "../../shared"
|
||||
import { getMainSessionID } from "../../features/claude-code-session-state"
|
||||
import type { ContextCollector } from "../../features/context-injector"
|
||||
|
||||
export * from "./detector"
|
||||
export * from "./constants"
|
||||
export * from "./types"
|
||||
|
||||
export function createKeywordDetectorHook(ctx: PluginInput) {
|
||||
export function createKeywordDetectorHook(ctx: PluginInput, collector?: ContextCollector) {
|
||||
return {
|
||||
"chat.message": async (
|
||||
input: {
|
||||
@@ -28,8 +29,6 @@ export function createKeywordDetectorHook(ctx: PluginInput) {
|
||||
return
|
||||
}
|
||||
|
||||
// Only ultrawork keywords work in non-main sessions
|
||||
// Other keywords (search, analyze, etc.) only work in main sessions
|
||||
const mainSessionID = getMainSessionID()
|
||||
const isNonMainSession = mainSessionID && input.sessionID !== mainSessionID
|
||||
|
||||
@@ -64,6 +63,17 @@ export function createKeywordDetectorHook(ctx: PluginInput) {
|
||||
)
|
||||
}
|
||||
|
||||
if (collector) {
|
||||
for (const keyword of detectedKeywords) {
|
||||
collector.register(input.sessionID, {
|
||||
id: `keyword-${keyword.type}`,
|
||||
source: "keyword-detector",
|
||||
content: keyword.message,
|
||||
priority: keyword.type === "ultrawork" ? "critical" : "high",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
log(`[keyword-detector] Detected ${detectedKeywords.length} keywords`, {
|
||||
sessionID: input.sessionID,
|
||||
types: detectedKeywords.map((k) => k.type),
|
||||
|
||||
@@ -4,7 +4,7 @@ export const PROMETHEUS_AGENTS = ["Prometheus (Planner)"]
|
||||
|
||||
export const ALLOWED_EXTENSIONS = [".md"]
|
||||
|
||||
export const ALLOWED_PATH_PREFIX = ".sisyphus/"
|
||||
export const ALLOWED_PATH_PREFIX = ".sisyphus"
|
||||
|
||||
export const BLOCKED_TOOLS = ["Write", "Edit", "write", "edit"]
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ describe("prometheus-md-only", () => {
|
||||
callID: "call-1",
|
||||
}
|
||||
const output = {
|
||||
args: { filePath: "/project/.sisyphus/plans/work-plan.md" },
|
||||
args: { filePath: "/tmp/test/.sisyphus/plans/work-plan.md" },
|
||||
}
|
||||
|
||||
// #when / #then
|
||||
@@ -295,4 +295,136 @@ describe("prometheus-md-only", () => {
|
||||
).resolves.toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe("cross-platform path validation", () => {
|
||||
beforeEach(() => {
|
||||
setupMessageStorage(TEST_SESSION_ID, "Prometheus (Planner)")
|
||||
})
|
||||
|
||||
test("should allow Windows-style backslash paths under .sisyphus/", async () => {
|
||||
// #given
|
||||
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
|
||||
const input = {
|
||||
tool: "Write",
|
||||
sessionID: TEST_SESSION_ID,
|
||||
callID: "call-1",
|
||||
}
|
||||
const output = {
|
||||
args: { filePath: ".sisyphus\\plans\\work-plan.md" },
|
||||
}
|
||||
|
||||
// #when / #then
|
||||
await expect(
|
||||
hook["tool.execute.before"](input, output)
|
||||
).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
test("should allow mixed separator paths under .sisyphus/", async () => {
|
||||
// #given
|
||||
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
|
||||
const input = {
|
||||
tool: "Write",
|
||||
sessionID: TEST_SESSION_ID,
|
||||
callID: "call-1",
|
||||
}
|
||||
const output = {
|
||||
args: { filePath: ".sisyphus\\plans/work-plan.MD" },
|
||||
}
|
||||
|
||||
// #when / #then
|
||||
await expect(
|
||||
hook["tool.execute.before"](input, output)
|
||||
).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
test("should allow uppercase .MD extension", async () => {
|
||||
// #given
|
||||
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
|
||||
const input = {
|
||||
tool: "Write",
|
||||
sessionID: TEST_SESSION_ID,
|
||||
callID: "call-1",
|
||||
}
|
||||
const output = {
|
||||
args: { filePath: ".sisyphus/plans/work-plan.MD" },
|
||||
}
|
||||
|
||||
// #when / #then
|
||||
await expect(
|
||||
hook["tool.execute.before"](input, output)
|
||||
).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
test("should block paths outside workspace root even if containing .sisyphus", async () => {
|
||||
// #given
|
||||
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
|
||||
const input = {
|
||||
tool: "Write",
|
||||
sessionID: TEST_SESSION_ID,
|
||||
callID: "call-1",
|
||||
}
|
||||
const output = {
|
||||
args: { filePath: "/other/project/.sisyphus/plans/x.md" },
|
||||
}
|
||||
|
||||
// #when / #then
|
||||
await expect(
|
||||
hook["tool.execute.before"](input, output)
|
||||
).rejects.toThrow("can only write/edit .md files inside .sisyphus/")
|
||||
})
|
||||
|
||||
test("should block nested .sisyphus directories", async () => {
|
||||
// #given
|
||||
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
|
||||
const input = {
|
||||
tool: "Write",
|
||||
sessionID: TEST_SESSION_ID,
|
||||
callID: "call-1",
|
||||
}
|
||||
const output = {
|
||||
args: { filePath: "src/.sisyphus/plans/x.md" },
|
||||
}
|
||||
|
||||
// #when / #then
|
||||
await expect(
|
||||
hook["tool.execute.before"](input, output)
|
||||
).rejects.toThrow("can only write/edit .md files inside .sisyphus/")
|
||||
})
|
||||
|
||||
test("should block path traversal attempts", async () => {
|
||||
// #given
|
||||
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
|
||||
const input = {
|
||||
tool: "Write",
|
||||
sessionID: TEST_SESSION_ID,
|
||||
callID: "call-1",
|
||||
}
|
||||
const output = {
|
||||
args: { filePath: ".sisyphus/../secrets.md" },
|
||||
}
|
||||
|
||||
// #when / #then
|
||||
await expect(
|
||||
hook["tool.execute.before"](input, output)
|
||||
).rejects.toThrow("can only write/edit .md files inside .sisyphus/")
|
||||
})
|
||||
|
||||
test("should allow case-insensitive .SISYPHUS directory", async () => {
|
||||
// #given
|
||||
const hook = createPrometheusMdOnlyHook(createMockPluginInput())
|
||||
const input = {
|
||||
tool: "Write",
|
||||
sessionID: TEST_SESSION_ID,
|
||||
callID: "call-1",
|
||||
}
|
||||
const output = {
|
||||
args: { filePath: ".SISYPHUS/plans/work-plan.md" },
|
||||
}
|
||||
|
||||
// #when / #then
|
||||
await expect(
|
||||
hook["tool.execute.before"](input, output)
|
||||
).resolves.toBeUndefined()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,16 +1,48 @@
|
||||
import type { PluginInput } from "@opencode-ai/plugin"
|
||||
import { existsSync, readdirSync } from "node:fs"
|
||||
import { join } from "node:path"
|
||||
import { join, resolve, relative, isAbsolute } from "node:path"
|
||||
import { HOOK_NAME, PROMETHEUS_AGENTS, ALLOWED_EXTENSIONS, ALLOWED_PATH_PREFIX, BLOCKED_TOOLS, PLANNING_CONSULT_WARNING } from "./constants"
|
||||
import { findNearestMessageWithFields, MESSAGE_STORAGE } from "../../features/hook-message-injector"
|
||||
import { log } from "../../shared/logger"
|
||||
|
||||
export * from "./constants"
|
||||
|
||||
function isAllowedFile(filePath: string): boolean {
|
||||
const hasAllowedExtension = ALLOWED_EXTENSIONS.some(ext => filePath.endsWith(ext))
|
||||
const isInAllowedPath = filePath.includes(ALLOWED_PATH_PREFIX)
|
||||
return hasAllowedExtension && isInAllowedPath
|
||||
/**
|
||||
* Cross-platform path validator for Prometheus file writes.
|
||||
* Uses path.resolve/relative instead of string matching to handle:
|
||||
* - Windows backslashes (e.g., .sisyphus\\plans\\x.md)
|
||||
* - Mixed separators (e.g., .sisyphus\\plans/x.md)
|
||||
* - Case-insensitive directory/extension matching
|
||||
* - Workspace confinement (blocks paths outside root or via traversal)
|
||||
*/
|
||||
function isAllowedFile(filePath: string, workspaceRoot: string): boolean {
|
||||
// 1. Resolve to absolute path
|
||||
const resolved = resolve(workspaceRoot, filePath)
|
||||
|
||||
// 2. Get relative path from workspace root
|
||||
const rel = relative(workspaceRoot, resolved)
|
||||
|
||||
// 3. Reject if escapes root (starts with ".." or is absolute)
|
||||
if (rel.startsWith("..") || isAbsolute(rel)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 4. Split by both separators and check first segment matches ALLOWED_PATH_PREFIX (case-insensitive)
|
||||
// Guard: if rel is empty (filePath === workspaceRoot), segments[0] would be "" — reject
|
||||
const segments = rel.split(/[/\\]/)
|
||||
if (!segments[0] || segments[0].toLowerCase() !== ALLOWED_PATH_PREFIX.toLowerCase()) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 5. Check extension matches one of ALLOWED_EXTENSIONS (case-insensitive)
|
||||
const hasAllowedExtension = ALLOWED_EXTENSIONS.some(
|
||||
ext => resolved.toLowerCase().endsWith(ext.toLowerCase())
|
||||
)
|
||||
if (!hasAllowedExtension) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function getMessageDir(sessionID: string): string | null {
|
||||
@@ -35,7 +67,7 @@ function getAgentFromSession(sessionID: string): string | undefined {
|
||||
return findNearestMessageWithFields(messageDir)?.agent
|
||||
}
|
||||
|
||||
export function createPrometheusMdOnlyHook(_ctx: PluginInput) {
|
||||
export function createPrometheusMdOnlyHook(ctx: PluginInput) {
|
||||
return {
|
||||
"tool.execute.before": async (
|
||||
input: { tool: string; sessionID: string; callID: string },
|
||||
@@ -72,7 +104,7 @@ export function createPrometheusMdOnlyHook(_ctx: PluginInput) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!isAllowedFile(filePath)) {
|
||||
if (!isAllowedFile(filePath, ctx.directory)) {
|
||||
log(`[${HOOK_NAME}] Blocked: Prometheus can only write to .sisyphus/*.md`, {
|
||||
sessionID: input.sessionID,
|
||||
tool: toolName,
|
||||
|
||||
@@ -591,6 +591,73 @@ describe("ralph-loop", () => {
|
||||
expect(hook.getState()).toBeNull()
|
||||
})
|
||||
|
||||
test("should allow starting new loop while previous loop is active (different session)", async () => {
|
||||
// #given - active loop in session A
|
||||
const hook = createRalphLoopHook(createMockPluginInput())
|
||||
hook.startLoop("session-A", "First task", { maxIterations: 10 })
|
||||
expect(hook.getState()?.session_id).toBe("session-A")
|
||||
expect(hook.getState()?.prompt).toBe("First task")
|
||||
|
||||
// #when - start new loop in session B (without completing A)
|
||||
hook.startLoop("session-B", "Second task", { maxIterations: 20 })
|
||||
|
||||
// #then - state should be overwritten with session B's loop
|
||||
expect(hook.getState()?.session_id).toBe("session-B")
|
||||
expect(hook.getState()?.prompt).toBe("Second task")
|
||||
expect(hook.getState()?.max_iterations).toBe(20)
|
||||
expect(hook.getState()?.iteration).toBe(1)
|
||||
|
||||
// #when - session B goes idle
|
||||
await hook.event({
|
||||
event: { type: "session.idle", properties: { sessionID: "session-B" } },
|
||||
})
|
||||
|
||||
// #then - continuation should be injected for session B
|
||||
expect(promptCalls.length).toBe(1)
|
||||
expect(promptCalls[0].sessionID).toBe("session-B")
|
||||
expect(promptCalls[0].text).toContain("Second task")
|
||||
expect(promptCalls[0].text).toContain("2/20")
|
||||
|
||||
// #then - iteration incremented
|
||||
expect(hook.getState()?.iteration).toBe(2)
|
||||
})
|
||||
|
||||
test("should allow starting new loop in same session (restart)", async () => {
|
||||
// #given - active loop in session A at iteration 5
|
||||
const hook = createRalphLoopHook(createMockPluginInput())
|
||||
hook.startLoop("session-A", "First task", { maxIterations: 10 })
|
||||
|
||||
// Simulate some iterations
|
||||
await hook.event({
|
||||
event: { type: "session.idle", properties: { sessionID: "session-A" } },
|
||||
})
|
||||
await hook.event({
|
||||
event: { type: "session.idle", properties: { sessionID: "session-A" } },
|
||||
})
|
||||
expect(hook.getState()?.iteration).toBe(3)
|
||||
expect(promptCalls.length).toBe(2)
|
||||
|
||||
// #when - start NEW loop in same session (restart)
|
||||
hook.startLoop("session-A", "Restarted task", { maxIterations: 50 })
|
||||
|
||||
// #then - state should be reset to iteration 1 with new prompt
|
||||
expect(hook.getState()?.session_id).toBe("session-A")
|
||||
expect(hook.getState()?.prompt).toBe("Restarted task")
|
||||
expect(hook.getState()?.max_iterations).toBe(50)
|
||||
expect(hook.getState()?.iteration).toBe(1)
|
||||
|
||||
// #when - session goes idle
|
||||
promptCalls = [] // Reset to check new continuation
|
||||
await hook.event({
|
||||
event: { type: "session.idle", properties: { sessionID: "session-A" } },
|
||||
})
|
||||
|
||||
// #then - continuation should use new task
|
||||
expect(promptCalls.length).toBe(1)
|
||||
expect(promptCalls[0].text).toContain("Restarted task")
|
||||
expect(promptCalls[0].text).toContain("2/50")
|
||||
})
|
||||
|
||||
test("should check transcript BEFORE API to optimize performance", async () => {
|
||||
// #given - transcript has completion promise
|
||||
const transcriptPath = join(TEST_DIR, "transcript.jsonl")
|
||||
|
||||
@@ -506,6 +506,90 @@ describe("sisyphus-orchestrator hook", () => {
|
||||
// #then
|
||||
expect(output.output).toBe(originalOutput)
|
||||
})
|
||||
|
||||
describe("cross-platform path validation (Windows support)", () => {
|
||||
test("should NOT append reminder when orchestrator writes inside .sisyphus\\ (Windows backslash)", async () => {
|
||||
// #given
|
||||
const hook = createSisyphusOrchestratorHook(createMockPluginInput())
|
||||
const originalOutput = "File written successfully"
|
||||
const output = {
|
||||
title: "Write",
|
||||
output: originalOutput,
|
||||
metadata: { filePath: ".sisyphus\\plans\\work-plan.md" },
|
||||
}
|
||||
|
||||
// #when
|
||||
await hook["tool.execute.after"](
|
||||
{ tool: "Write", sessionID: ORCHESTRATOR_SESSION },
|
||||
output
|
||||
)
|
||||
|
||||
// #then
|
||||
expect(output.output).toBe(originalOutput)
|
||||
expect(output.output).not.toContain("DELEGATION REQUIRED")
|
||||
})
|
||||
|
||||
test("should NOT append reminder when orchestrator writes inside .sisyphus with mixed separators", async () => {
|
||||
// #given
|
||||
const hook = createSisyphusOrchestratorHook(createMockPluginInput())
|
||||
const originalOutput = "File written successfully"
|
||||
const output = {
|
||||
title: "Write",
|
||||
output: originalOutput,
|
||||
metadata: { filePath: ".sisyphus\\plans/work-plan.md" },
|
||||
}
|
||||
|
||||
// #when
|
||||
await hook["tool.execute.after"](
|
||||
{ tool: "Write", sessionID: ORCHESTRATOR_SESSION },
|
||||
output
|
||||
)
|
||||
|
||||
// #then
|
||||
expect(output.output).toBe(originalOutput)
|
||||
expect(output.output).not.toContain("DELEGATION REQUIRED")
|
||||
})
|
||||
|
||||
test("should NOT append reminder for absolute Windows path inside .sisyphus\\", async () => {
|
||||
// #given
|
||||
const hook = createSisyphusOrchestratorHook(createMockPluginInput())
|
||||
const originalOutput = "File written successfully"
|
||||
const output = {
|
||||
title: "Write",
|
||||
output: originalOutput,
|
||||
metadata: { filePath: "C:\\Users\\test\\project\\.sisyphus\\plans\\x.md" },
|
||||
}
|
||||
|
||||
// #when
|
||||
await hook["tool.execute.after"](
|
||||
{ tool: "Write", sessionID: ORCHESTRATOR_SESSION },
|
||||
output
|
||||
)
|
||||
|
||||
// #then
|
||||
expect(output.output).toBe(originalOutput)
|
||||
expect(output.output).not.toContain("DELEGATION REQUIRED")
|
||||
})
|
||||
|
||||
test("should append reminder for Windows path outside .sisyphus\\", async () => {
|
||||
// #given
|
||||
const hook = createSisyphusOrchestratorHook(createMockPluginInput())
|
||||
const output = {
|
||||
title: "Write",
|
||||
output: "File written successfully",
|
||||
metadata: { filePath: "C:\\Users\\test\\project\\src\\code.ts" },
|
||||
}
|
||||
|
||||
// #when
|
||||
await hook["tool.execute.after"](
|
||||
{ tool: "Write", sessionID: ORCHESTRATOR_SESSION },
|
||||
output
|
||||
)
|
||||
|
||||
// #then
|
||||
expect(output.output).toContain("DELEGATION REQUIRED")
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -14,7 +14,14 @@ import type { BackgroundManager } from "../../features/background-agent"
|
||||
|
||||
export const HOOK_NAME = "sisyphus-orchestrator"
|
||||
|
||||
const ALLOWED_PATH_PREFIX = ".sisyphus/"
|
||||
/**
|
||||
* Cross-platform check if a path is inside .sisyphus/ directory.
|
||||
* Handles both forward slashes (Unix) and backslashes (Windows).
|
||||
*/
|
||||
function isSisyphusPath(filePath: string): boolean {
|
||||
return /\.sisyphus[/\\]/.test(filePath)
|
||||
}
|
||||
|
||||
const WRITE_EDIT_TOOLS = ["Write", "Edit", "write", "edit"]
|
||||
|
||||
const DIRECT_WORK_REMINDER = `
|
||||
@@ -549,7 +556,7 @@ export function createSisyphusOrchestratorHook(
|
||||
// Check Write/Edit tools for orchestrator - inject strong warning
|
||||
if (WRITE_EDIT_TOOLS.includes(input.tool)) {
|
||||
const filePath = (output.args.filePath ?? output.args.path ?? output.args.file) as string | undefined
|
||||
if (filePath && !filePath.includes(ALLOWED_PATH_PREFIX)) {
|
||||
if (filePath && !isSisyphusPath(filePath)) {
|
||||
// Store filePath for use in tool.execute.after
|
||||
if (input.callID) {
|
||||
pendingFilePaths.set(input.callID, filePath)
|
||||
@@ -593,7 +600,7 @@ export function createSisyphusOrchestratorHook(
|
||||
if (!filePath) {
|
||||
filePath = output.metadata?.filePath as string | undefined
|
||||
}
|
||||
if (filePath && !filePath.includes(ALLOWED_PATH_PREFIX)) {
|
||||
if (filePath && !isSisyphusPath(filePath)) {
|
||||
output.output = (output.output || "") + DIRECT_WORK_REMINDER
|
||||
log(`[${HOOK_NAME}] Direct work reminder appended`, {
|
||||
sessionID: input.sessionID,
|
||||
|
||||
@@ -63,6 +63,7 @@ import {
|
||||
createSisyphusTask,
|
||||
interactive_bash,
|
||||
startTmuxCheck,
|
||||
lspManager,
|
||||
} from "./tools";
|
||||
import { BackgroundManager } from "./features/background-agent";
|
||||
import { SkillMcpManager } from "./features/skill-mcp-manager";
|
||||
@@ -164,7 +165,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
})
|
||||
: null;
|
||||
const keywordDetector = isHookEnabled("keyword-detector")
|
||||
? createKeywordDetectorHook(ctx)
|
||||
? createKeywordDetectorHook(ctx, contextCollector)
|
||||
: null;
|
||||
const contextInjector = createContextInjectorHook(contextCollector);
|
||||
const contextInjectorMessagesTransform =
|
||||
@@ -312,8 +313,8 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
},
|
||||
|
||||
"chat.message": async (input, output) => {
|
||||
await claudeCodeHooks["chat.message"]?.(input, output);
|
||||
await keywordDetector?.["chat.message"]?.(input, output);
|
||||
await claudeCodeHooks["chat.message"]?.(input, output);
|
||||
await contextInjector["chat.message"]?.(input, output);
|
||||
await autoSlashCommand?.["chat.message"]?.(input, output);
|
||||
await startWork?.["chat.message"]?.(input, output);
|
||||
@@ -427,6 +428,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
||||
}
|
||||
if (sessionInfo?.id) {
|
||||
await skillMcpManager.disconnectSession(sessionInfo.id);
|
||||
await lspManager.cleanupTempDirectoryClients();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, test, expect, beforeEach, afterEach } from "bun:test"
|
||||
import { homedir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { join, resolve } from "node:path"
|
||||
import {
|
||||
getOpenCodeConfigDir,
|
||||
getOpenCodeConfigPaths,
|
||||
@@ -20,6 +20,7 @@ describe("opencode-config-dir", () => {
|
||||
APPDATA: process.env.APPDATA,
|
||||
XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME,
|
||||
XDG_DATA_HOME: process.env.XDG_DATA_HOME,
|
||||
OPENCODE_CONFIG_DIR: process.env.OPENCODE_CONFIG_DIR,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -34,6 +35,84 @@ describe("opencode-config-dir", () => {
|
||||
}
|
||||
})
|
||||
|
||||
describe("OPENCODE_CONFIG_DIR environment variable", () => {
|
||||
test("returns OPENCODE_CONFIG_DIR when env var is set", () => {
|
||||
// #given OPENCODE_CONFIG_DIR is set to a custom path
|
||||
process.env.OPENCODE_CONFIG_DIR = "/custom/opencode/path"
|
||||
Object.defineProperty(process, "platform", { value: "linux" })
|
||||
|
||||
// #when getOpenCodeConfigDir is called with binary="opencode"
|
||||
const result = getOpenCodeConfigDir({ binary: "opencode", version: "1.0.200" })
|
||||
|
||||
// #then returns the custom path
|
||||
expect(result).toBe("/custom/opencode/path")
|
||||
})
|
||||
|
||||
test("falls back to default when env var is not set", () => {
|
||||
// #given OPENCODE_CONFIG_DIR is not set, platform is Linux
|
||||
delete process.env.OPENCODE_CONFIG_DIR
|
||||
delete process.env.XDG_CONFIG_HOME
|
||||
Object.defineProperty(process, "platform", { value: "linux" })
|
||||
|
||||
// #when getOpenCodeConfigDir is called with binary="opencode"
|
||||
const result = getOpenCodeConfigDir({ binary: "opencode", version: "1.0.200" })
|
||||
|
||||
// #then returns default ~/.config/opencode
|
||||
expect(result).toBe(join(homedir(), ".config", "opencode"))
|
||||
})
|
||||
|
||||
test("falls back to default when env var is empty string", () => {
|
||||
// #given OPENCODE_CONFIG_DIR is set to empty string
|
||||
process.env.OPENCODE_CONFIG_DIR = ""
|
||||
delete process.env.XDG_CONFIG_HOME
|
||||
Object.defineProperty(process, "platform", { value: "linux" })
|
||||
|
||||
// #when getOpenCodeConfigDir is called with binary="opencode"
|
||||
const result = getOpenCodeConfigDir({ binary: "opencode", version: "1.0.200" })
|
||||
|
||||
// #then returns default ~/.config/opencode
|
||||
expect(result).toBe(join(homedir(), ".config", "opencode"))
|
||||
})
|
||||
|
||||
test("falls back to default when env var is whitespace only", () => {
|
||||
// #given OPENCODE_CONFIG_DIR is set to whitespace only
|
||||
process.env.OPENCODE_CONFIG_DIR = " "
|
||||
delete process.env.XDG_CONFIG_HOME
|
||||
Object.defineProperty(process, "platform", { value: "linux" })
|
||||
|
||||
// #when getOpenCodeConfigDir is called with binary="opencode"
|
||||
const result = getOpenCodeConfigDir({ binary: "opencode", version: "1.0.200" })
|
||||
|
||||
// #then returns default ~/.config/opencode
|
||||
expect(result).toBe(join(homedir(), ".config", "opencode"))
|
||||
})
|
||||
|
||||
test("resolves relative path to absolute path", () => {
|
||||
// #given OPENCODE_CONFIG_DIR is set to a relative path
|
||||
process.env.OPENCODE_CONFIG_DIR = "./my-opencode-config"
|
||||
Object.defineProperty(process, "platform", { value: "linux" })
|
||||
|
||||
// #when getOpenCodeConfigDir is called with binary="opencode"
|
||||
const result = getOpenCodeConfigDir({ binary: "opencode", version: "1.0.200" })
|
||||
|
||||
// #then returns resolved absolute path
|
||||
expect(result).toBe(resolve("./my-opencode-config"))
|
||||
})
|
||||
|
||||
test("OPENCODE_CONFIG_DIR takes priority over XDG_CONFIG_HOME", () => {
|
||||
// #given both OPENCODE_CONFIG_DIR and XDG_CONFIG_HOME are set
|
||||
process.env.OPENCODE_CONFIG_DIR = "/custom/opencode/path"
|
||||
process.env.XDG_CONFIG_HOME = "/xdg/config"
|
||||
Object.defineProperty(process, "platform", { value: "linux" })
|
||||
|
||||
// #when getOpenCodeConfigDir is called with binary="opencode"
|
||||
const result = getOpenCodeConfigDir({ binary: "opencode", version: "1.0.200" })
|
||||
|
||||
// #then OPENCODE_CONFIG_DIR takes priority
|
||||
expect(result).toBe("/custom/opencode/path")
|
||||
})
|
||||
})
|
||||
|
||||
describe("isDevBuild", () => {
|
||||
test("returns false for null version", () => {
|
||||
expect(isDevBuild(null)).toBe(false)
|
||||
@@ -213,6 +292,7 @@ describe("opencode-config-dir", () => {
|
||||
// #given no config files exist
|
||||
Object.defineProperty(process, "platform", { value: "linux" })
|
||||
delete process.env.XDG_CONFIG_HOME
|
||||
delete process.env.OPENCODE_CONFIG_DIR
|
||||
|
||||
// #when detectExistingConfigDir is called
|
||||
const result = detectExistingConfigDir("opencode", "1.0.200")
|
||||
@@ -220,5 +300,19 @@ describe("opencode-config-dir", () => {
|
||||
// #then result is either null or a valid string path
|
||||
expect(result === null || typeof result === "string").toBe(true)
|
||||
})
|
||||
|
||||
test("includes OPENCODE_CONFIG_DIR in search locations when set", () => {
|
||||
// #given OPENCODE_CONFIG_DIR is set to a custom path
|
||||
process.env.OPENCODE_CONFIG_DIR = "/custom/opencode/path"
|
||||
Object.defineProperty(process, "platform", { value: "linux" })
|
||||
delete process.env.XDG_CONFIG_HOME
|
||||
|
||||
// #when detectExistingConfigDir is called
|
||||
const result = detectExistingConfigDir("opencode", "1.0.200")
|
||||
|
||||
// #then result is either null (no config file exists) or a valid string path
|
||||
// The important thing is that the function doesn't throw
|
||||
expect(result === null || typeof result === "string").toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { existsSync } from "node:fs"
|
||||
import { homedir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { join, resolve } from "node:path"
|
||||
|
||||
export type OpenCodeBinaryType = "opencode" | "opencode-desktop"
|
||||
|
||||
@@ -47,6 +47,11 @@ function getTauriConfigDir(identifier: string): string {
|
||||
}
|
||||
|
||||
function getCliConfigDir(): string {
|
||||
const envConfigDir = process.env.OPENCODE_CONFIG_DIR?.trim()
|
||||
if (envConfigDir) {
|
||||
return resolve(envConfigDir)
|
||||
}
|
||||
|
||||
if (process.platform === "win32") {
|
||||
const crossPlatformDir = join(homedir(), ".config", "opencode")
|
||||
const crossPlatformConfig = join(crossPlatformDir, "opencode.json")
|
||||
@@ -108,6 +113,11 @@ export function getOpenCodeConfigPaths(options: OpenCodeConfigDirOptions): OpenC
|
||||
export function detectExistingConfigDir(binary: OpenCodeBinaryType, version?: string | null): string | null {
|
||||
const locations: string[] = []
|
||||
|
||||
const envConfigDir = process.env.OPENCODE_CONFIG_DIR?.trim()
|
||||
if (envConfigDir) {
|
||||
locations.push(resolve(envConfigDir))
|
||||
}
|
||||
|
||||
if (binary === "opencode-desktop") {
|
||||
const identifier = isDevBuild(version) ? TAURI_APP_IDENTIFIER_DEV : TAURI_APP_IDENTIFIER
|
||||
locations.push(getTauriConfigDir(identifier))
|
||||
|
||||
@@ -74,7 +74,7 @@ export function createBackgroundTask(manager: BackgroundManager): ToolDefinition
|
||||
parentSessionID: ctx.sessionID,
|
||||
parentMessageID: ctx.messageID,
|
||||
parentModel,
|
||||
parentAgent: prevMessage?.agent,
|
||||
parentAgent: ctx.agent ?? prevMessage?.agent,
|
||||
})
|
||||
|
||||
ctx.metadata?.({
|
||||
@@ -176,8 +176,13 @@ async function formatTaskResult(task: BackgroundTask, client: OpencodeClient): P
|
||||
// Handle both SDK response structures: direct array or wrapped in .data
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const messages = ((messagesResult as any).data ?? messagesResult) as Array<{
|
||||
info?: { role?: string }
|
||||
parts?: Array<{ type?: string; text?: string }>
|
||||
info?: { role?: string; time?: string }
|
||||
parts?: Array<{
|
||||
type?: string
|
||||
text?: string
|
||||
content?: string | Array<{ type: string; text?: string }>
|
||||
name?: string
|
||||
}>
|
||||
}>
|
||||
|
||||
if (!Array.isArray(messages) || messages.length === 0) {
|
||||
@@ -193,11 +198,13 @@ Session ID: ${task.sessionID}
|
||||
(No messages found)`
|
||||
}
|
||||
|
||||
const assistantMessages = messages.filter(
|
||||
(m) => m.info?.role === "assistant"
|
||||
// Include both assistant messages AND tool messages
|
||||
// Tool results (grep, glob, bash output) come from role "tool"
|
||||
const relevantMessages = messages.filter(
|
||||
(m) => m.info?.role === "assistant" || m.info?.role === "tool"
|
||||
)
|
||||
|
||||
if (assistantMessages.length === 0) {
|
||||
if (relevantMessages.length === 0) {
|
||||
return `Task Result
|
||||
|
||||
Task ID: ${task.id}
|
||||
@@ -207,17 +214,46 @@ Session ID: ${task.sessionID}
|
||||
|
||||
---
|
||||
|
||||
(No assistant response found)`
|
||||
(No assistant or tool response found)`
|
||||
}
|
||||
|
||||
const lastMessage = assistantMessages[assistantMessages.length - 1]
|
||||
const textParts = lastMessage?.parts?.filter(
|
||||
(p) => p.type === "text"
|
||||
) ?? []
|
||||
const textContent = textParts
|
||||
.map((p) => p.text ?? "")
|
||||
// Sort by time ascending (oldest first) to process messages in order
|
||||
const sortedMessages = [...relevantMessages].sort((a, b) => {
|
||||
const timeA = String((a as { info?: { time?: string } }).info?.time ?? "")
|
||||
const timeB = String((b as { info?: { time?: string } }).info?.time ?? "")
|
||||
return timeA.localeCompare(timeB)
|
||||
})
|
||||
|
||||
// Extract content from ALL messages, not just the last one
|
||||
// Tool results may be in earlier messages while the final message is empty
|
||||
const extractedContent: string[] = []
|
||||
|
||||
for (const message of sortedMessages) {
|
||||
for (const part of message.parts ?? []) {
|
||||
// Handle both "text" and "reasoning" parts (thinking models use "reasoning")
|
||||
if ((part.type === "text" || part.type === "reasoning") && part.text) {
|
||||
extractedContent.push(part.text)
|
||||
} else if (part.type === "tool_result") {
|
||||
// Tool results contain the actual output from tool calls
|
||||
const toolResult = part as { content?: string | Array<{ type: string; text?: string }> }
|
||||
if (typeof toolResult.content === "string" && toolResult.content) {
|
||||
extractedContent.push(toolResult.content)
|
||||
} else if (Array.isArray(toolResult.content)) {
|
||||
// Handle array of content blocks
|
||||
for (const block of toolResult.content) {
|
||||
// Handle both "text" and "reasoning" parts (thinking models use "reasoning")
|
||||
if ((block.type === "text" || block.type === "reasoning") && block.text) {
|
||||
extractedContent.push(block.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const textContent = extractedContent
|
||||
.filter((text) => text.length > 0)
|
||||
.join("\n")
|
||||
.join("\n\n")
|
||||
|
||||
const duration = formatDuration(task.startedAt, task.completedAt)
|
||||
|
||||
|
||||
@@ -170,23 +170,59 @@ async function executeSync(
|
||||
const messages = messagesResult.data
|
||||
log(`[call_omo_agent] Got ${messages.length} messages`)
|
||||
|
||||
// Include both assistant messages AND tool messages
|
||||
// Tool results (grep, glob, bash output) come from role "tool"
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const lastAssistantMessage = messages
|
||||
.filter((m: any) => m.info.role === "assistant")
|
||||
.sort((a: any, b: any) => (b.info.time?.created || 0) - (a.info.time?.created || 0))[0]
|
||||
const relevantMessages = messages.filter(
|
||||
(m: any) => m.info?.role === "assistant" || m.info?.role === "tool"
|
||||
)
|
||||
|
||||
if (!lastAssistantMessage) {
|
||||
log(`[call_omo_agent] No assistant message found`)
|
||||
if (relevantMessages.length === 0) {
|
||||
log(`[call_omo_agent] No assistant or tool messages found`)
|
||||
log(`[call_omo_agent] All messages:`, JSON.stringify(messages, null, 2))
|
||||
return `Error: No assistant response found\n\n<task_metadata>\nsession_id: ${sessionID}\n</task_metadata>`
|
||||
return `Error: No assistant or tool response found\n\n<task_metadata>\nsession_id: ${sessionID}\n</task_metadata>`
|
||||
}
|
||||
|
||||
log(`[call_omo_agent] Found assistant message with ${lastAssistantMessage.parts.length} parts`)
|
||||
log(`[call_omo_agent] Found ${relevantMessages.length} relevant messages`)
|
||||
|
||||
// Sort by time ascending (oldest first) to process messages in order
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const textParts = lastAssistantMessage.parts.filter((p: any) => p.type === "text")
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const responseText = textParts.map((p: any) => p.text).join("\n")
|
||||
const sortedMessages = [...relevantMessages].sort((a: any, b: any) => {
|
||||
const timeA = a.info?.time?.created ?? 0
|
||||
const timeB = b.info?.time?.created ?? 0
|
||||
return timeA - timeB
|
||||
})
|
||||
|
||||
// Extract content from ALL messages, not just the last one
|
||||
// Tool results may be in earlier messages while the final message is empty
|
||||
const extractedContent: string[] = []
|
||||
|
||||
for (const message of sortedMessages) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
for (const part of (message as any).parts ?? []) {
|
||||
// Handle both "text" and "reasoning" parts (thinking models use "reasoning")
|
||||
if ((part.type === "text" || part.type === "reasoning") && part.text) {
|
||||
extractedContent.push(part.text)
|
||||
} else if (part.type === "tool_result") {
|
||||
// Tool results contain the actual output from tool calls
|
||||
const toolResult = part as { content?: string | Array<{ type: string; text?: string }> }
|
||||
if (typeof toolResult.content === "string" && toolResult.content) {
|
||||
extractedContent.push(toolResult.content)
|
||||
} else if (Array.isArray(toolResult.content)) {
|
||||
// Handle array of content blocks
|
||||
for (const block of toolResult.content) {
|
||||
if ((block.type === "text" || block.type === "reasoning") && block.text) {
|
||||
extractedContent.push(block.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const responseText = extractedContent
|
||||
.filter((text) => text.length > 0)
|
||||
.join("\n\n")
|
||||
|
||||
log(`[call_omo_agent] Got response, length: ${responseText.length}`)
|
||||
|
||||
|
||||
@@ -10,8 +10,11 @@ import {
|
||||
lsp_rename,
|
||||
lsp_code_actions,
|
||||
lsp_code_action_resolve,
|
||||
lspManager,
|
||||
} from "./lsp"
|
||||
|
||||
export { lspManager }
|
||||
|
||||
import {
|
||||
ast_grep_search,
|
||||
ast_grep_replace,
|
||||
|
||||
@@ -182,6 +182,26 @@ class LSPServerManager {
|
||||
this.cleanupInterval = null
|
||||
}
|
||||
}
|
||||
|
||||
async cleanupTempDirectoryClients(): Promise<void> {
|
||||
const keysToRemove: string[] = []
|
||||
for (const [key, managed] of this.clients.entries()) {
|
||||
const isTempDir = key.startsWith("/tmp/") || key.startsWith("/var/folders/")
|
||||
const isIdle = managed.refCount === 0
|
||||
if (isTempDir && isIdle) {
|
||||
keysToRemove.push(key)
|
||||
}
|
||||
}
|
||||
for (const key of keysToRemove) {
|
||||
const managed = this.clients.get(key)
|
||||
if (managed) {
|
||||
this.clients.delete(key)
|
||||
try {
|
||||
await managed.client.stop()
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const lspManager = LSPServerManager.getInstance()
|
||||
|
||||
@@ -259,6 +259,7 @@ describe("sisyphus-task", () => {
|
||||
|
||||
describe("resume with background parameter", () => {
|
||||
test("resume with background=false should wait for result and return content", async () => {
|
||||
// Note: This test needs extended timeout because the implementation has MIN_STABILITY_TIME_MS = 5000
|
||||
// #given
|
||||
const { createSisyphusTask } = require("./tools")
|
||||
|
||||
@@ -319,7 +320,7 @@ describe("sisyphus-task", () => {
|
||||
// #then - should contain actual result, not just "Background task resumed"
|
||||
expect(result).toContain("This is the resumed task result")
|
||||
expect(result).not.toContain("Background task resumed")
|
||||
})
|
||||
}, { timeout: 10000 })
|
||||
|
||||
test("resume with background=true should return immediately without waiting", async () => {
|
||||
// #given
|
||||
|
||||
@@ -221,6 +221,33 @@ Use \`background_output\` with task_id="${task.id}" to check progress.`
|
||||
return `❌ Failed to send resume prompt: ${errorMessage}\n\nSession ID: ${args.resume}`
|
||||
}
|
||||
|
||||
// Wait for message stability after prompt completes
|
||||
const POLL_INTERVAL_MS = 500
|
||||
const MIN_STABILITY_TIME_MS = 5000
|
||||
const STABILITY_POLLS_REQUIRED = 3
|
||||
const pollStart = Date.now()
|
||||
let lastMsgCount = 0
|
||||
let stablePolls = 0
|
||||
|
||||
while (Date.now() - pollStart < 60000) {
|
||||
await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS))
|
||||
|
||||
const elapsed = Date.now() - pollStart
|
||||
if (elapsed < MIN_STABILITY_TIME_MS) continue
|
||||
|
||||
const messagesCheck = await client.session.messages({ path: { id: args.resume } })
|
||||
const msgs = ((messagesCheck as { data?: unknown }).data ?? messagesCheck) as Array<unknown>
|
||||
const currentMsgCount = msgs.length
|
||||
|
||||
if (currentMsgCount > 0 && currentMsgCount === lastMsgCount) {
|
||||
stablePolls++
|
||||
if (stablePolls >= STABILITY_POLLS_REQUIRED) break
|
||||
} else {
|
||||
stablePolls = 0
|
||||
lastMsgCount = currentMsgCount
|
||||
}
|
||||
}
|
||||
|
||||
const messagesResult = await client.session.messages({
|
||||
path: { id: args.resume },
|
||||
})
|
||||
@@ -250,7 +277,8 @@ Use \`background_output\` with task_id="${task.id}" to check progress.`
|
||||
return `❌ No assistant response found.\n\nSession ID: ${args.resume}`
|
||||
}
|
||||
|
||||
const textParts = lastMessage?.parts?.filter((p) => p.type === "text") ?? []
|
||||
// Extract text from both "text" and "reasoning" parts (thinking models use "reasoning")
|
||||
const textParts = lastMessage?.parts?.filter((p) => p.type === "text" || p.type === "reasoning") ?? []
|
||||
const textContent = textParts.map((p) => p.text ?? "").filter(Boolean).join("\n")
|
||||
|
||||
const duration = formatDuration(startTime)
|
||||
@@ -390,13 +418,13 @@ System notifies on completion. Use \`background_output\` with task_id="${task.id
|
||||
metadata: { sessionId: sessionID, category: args.category, sync: true },
|
||||
})
|
||||
|
||||
// Use promptAsync to avoid changing main session's active state
|
||||
// Use fire-and-forget prompt() - awaiting causes JSON parse errors with thinking models
|
||||
// Note: Don't pass model in body - use agent's configured model instead
|
||||
let promptError: Error | undefined
|
||||
await client.session.promptAsync({
|
||||
client.session.prompt({
|
||||
path: { id: sessionID },
|
||||
body: {
|
||||
agent: agentToUse,
|
||||
model: categoryModel,
|
||||
system: systemContent,
|
||||
tools: {
|
||||
task: false,
|
||||
@@ -408,6 +436,9 @@ System notifies on completion. Use \`background_output\` with task_id="${task.id
|
||||
promptError = error instanceof Error ? error : new Error(String(error))
|
||||
})
|
||||
|
||||
// Small delay to let the prompt start
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
|
||||
if (promptError) {
|
||||
if (toastManager && taskId !== undefined) {
|
||||
toastManager.removeTask(taskId)
|
||||
@@ -419,21 +450,63 @@ System notifies on completion. Use \`background_output\` with task_id="${task.id
|
||||
return `❌ Failed to send prompt: ${errorMessage}\n\nSession ID: ${sessionID}`
|
||||
}
|
||||
|
||||
// Poll for session completion
|
||||
// Poll for session completion with stability detection
|
||||
// The session may show as "idle" before messages appear, so we also check message stability
|
||||
const POLL_INTERVAL_MS = 500
|
||||
const MAX_POLL_TIME_MS = 10 * 60 * 1000
|
||||
const MIN_STABILITY_TIME_MS = 10000 // Minimum 10s before accepting completion
|
||||
const STABILITY_POLLS_REQUIRED = 3
|
||||
const pollStart = Date.now()
|
||||
let lastMsgCount = 0
|
||||
let stablePolls = 0
|
||||
|
||||
while (Date.now() - pollStart < MAX_POLL_TIME_MS) {
|
||||
await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS))
|
||||
|
||||
// Check for async errors that may have occurred after the initial 100ms delay
|
||||
// TypeScript doesn't understand async mutation, so we cast to check
|
||||
const asyncError = promptError as Error | undefined
|
||||
if (asyncError) {
|
||||
if (toastManager && taskId !== undefined) {
|
||||
toastManager.removeTask(taskId)
|
||||
}
|
||||
const errorMessage = asyncError.message
|
||||
if (errorMessage.includes("agent.name") || errorMessage.includes("undefined")) {
|
||||
return `❌ Agent "${agentToUse}" not found. Make sure the agent is registered in your opencode.json or provided by a plugin.\n\nSession ID: ${sessionID}`
|
||||
}
|
||||
return `❌ Failed to send prompt: ${errorMessage}\n\nSession ID: ${sessionID}`
|
||||
}
|
||||
|
||||
const statusResult = await client.session.status()
|
||||
const allStatuses = (statusResult.data ?? {}) as Record<string, { type: string }>
|
||||
const sessionStatus = allStatuses[sessionID]
|
||||
|
||||
// Break if session is idle OR no longer in status (completed and removed)
|
||||
if (!sessionStatus || sessionStatus.type === "idle") {
|
||||
break
|
||||
// If session is actively running, reset stability
|
||||
if (sessionStatus && sessionStatus.type !== "idle") {
|
||||
stablePolls = 0
|
||||
lastMsgCount = 0
|
||||
continue
|
||||
}
|
||||
|
||||
// Session is idle or not in status - check message stability
|
||||
const elapsed = Date.now() - pollStart
|
||||
if (elapsed < MIN_STABILITY_TIME_MS) {
|
||||
continue // Don't accept completion too early
|
||||
}
|
||||
|
||||
// Get current message count
|
||||
const messagesCheck = await client.session.messages({ path: { id: sessionID } })
|
||||
const msgs = ((messagesCheck as { data?: unknown }).data ?? messagesCheck) as Array<unknown>
|
||||
const currentMsgCount = msgs.length
|
||||
|
||||
if (currentMsgCount > 0 && currentMsgCount === lastMsgCount) {
|
||||
stablePolls++
|
||||
if (stablePolls >= STABILITY_POLLS_REQUIRED) {
|
||||
break // Messages stable for 3 polls - task complete
|
||||
}
|
||||
} else {
|
||||
stablePolls = 0
|
||||
lastMsgCount = currentMsgCount
|
||||
}
|
||||
}
|
||||
|
||||
@@ -459,7 +532,8 @@ System notifies on completion. Use \`background_output\` with task_id="${task.id
|
||||
return `❌ No assistant response found.\n\nSession ID: ${sessionID}`
|
||||
}
|
||||
|
||||
const textParts = lastMessage?.parts?.filter((p) => p.type === "text") ?? []
|
||||
// Extract text from both "text" and "reasoning" parts (thinking models use "reasoning")
|
||||
const textParts = lastMessage?.parts?.filter((p) => p.type === "text" || p.type === "reasoning") ?? []
|
||||
const textContent = textParts.map((p) => p.text ?? "").filter(Boolean).join("\n")
|
||||
|
||||
const duration = formatDuration(startTime)
|
||||
|
||||
@@ -194,4 +194,4 @@ export function createSkillTool(options: SkillLoadOptions = {}): ToolDefinition
|
||||
})
|
||||
}
|
||||
|
||||
export const skill = createSkillTool()
|
||||
export const skill: ToolDefinition = createSkillTool()
|
||||
|
||||
@@ -249,4 +249,4 @@ export function createSlashcommandTool(options: SlashcommandToolOptions = {}): T
|
||||
}
|
||||
|
||||
// Default instance for backward compatibility (lazy loading)
|
||||
export const slashcommand = createSlashcommandTool()
|
||||
export const slashcommand: ToolDefinition = createSlashcommandTool()
|
||||
|
||||
Reference in New Issue
Block a user