diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md index 973241bc63..ce029cca43 100644 --- a/docs/next/CHANGELOG.md +++ b/docs/next/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unreleased ### Added +- Devin CLI, Cursor Agent CLI, MastraCode, Hermes Agent, and Grok CLI integrations now install and run natively on Windows. - `theme.custom.sidebar_bg` can now give the desktop sidebar its own background without changing built-in theme defaults. - Settings and `ui.status_indicators = "symbols"` can now use distinct static shapes for blocked, working, done, idle, and unknown agent states. (#2260) - The plugin marketplace now discovers valid manifests at repository roots and subdirectories, groups multiple plugins under each repository, and publishes their versions and exact default-branch commits. diff --git a/docs/next/website/src/content/docs/integrations.mdx b/docs/next/website/src/content/docs/integrations.mdx index beef6789af..2cdc0d624f 100644 --- a/docs/next/website/src/content/docs/integrations.mdx +++ b/docs/next/website/src/content/docs/integrations.mdx @@ -91,7 +91,7 @@ Use `HERDR_BIN_PATH` and the CLI wrappers for portable integrations. Code that n Some integrations report native agent session references. Herdr uses official session references to resume Claude Code, Codex, Devin CLI, Droid, Kimi Code CLI, Qoder CLI, Cursor Agent CLI, Grok CLI, GitHub Copilot CLI, Pi, OMP, Hermes Agent, OpenCode, Kilo Code CLI, MastraCode, and Antigravity CLI panes after a Herdr server restart unless `[session] resume_agents_on_restore = false` disables it. -Native session restore requires current Herdr integrations: Pi integration version `2`, OMP version `3`, Claude Code version `6`, Codex version `5`, GitHub Copilot CLI version `2`, Devin CLI version `2`, Droid version `2`, Kimi Code CLI version `3`, Qoder CLI version `2`, Cursor Agent CLI version `1`, Grok CLI version `1`, OpenCode version `5`, Kilo Code CLI version `1`, Hermes Agent version `2`, MastraCode version `1`, or Antigravity CLI version `1`. Check installed versions with `herdr integration status`. +Native session restore requires current Herdr integrations: Pi integration version `2`, OMP version `3`, Claude Code version `6`, Codex version `5`, GitHub Copilot CLI version `2`, Devin CLI version `2`, Droid version `2`, Kimi Code CLI version `3`, Qoder CLI version `2`, Cursor Agent CLI version `1`, Grok CLI version `1`, OpenCode version `5`, Kilo Code CLI version `1`, Hermes Agent version `5`, MastraCode version `1`, or Antigravity CLI version `1`. Check installed versions with `herdr integration status`. ## Pi @@ -175,7 +175,7 @@ herdr integration install devin The hook reports native session identity from Devin session, prompt, tool-use, permission, and stop events. Devin state still comes from Herdr's screen manifest and OSC detection because Devin hooks do not emit a reliable state transition after every permission cancellation or user interrupt. -Herdr uses `~/.config/devin` by default, or `$XDG_CONFIG_HOME/devin` when `XDG_CONFIG_HOME` is set. The Devin config directory must already exist. Install writes `herdr-agent-state.sh` and updates `config.json` with Herdr hook entries. The hook refreshes the session reference while Devin runs. Uninstall removes Herdr entries from `config.json` and deletes the hook script. +Herdr uses `~/.config/devin` by default, or `$XDG_CONFIG_HOME/devin` when `XDG_CONFIG_HOME` is set. The Devin config directory must already exist. Install writes `herdr-agent-state.sh` (`herdr-agent-state.ps1` on Windows) and updates `config.json` with Herdr hook entries. The hook refreshes the session reference while Devin runs. Uninstall removes Herdr entries from `config.json` and deletes the hook script. Herdr resumes stored Devin sessions with `devin --resume `. Native screen manifest detection remains the state authority whether or not the hook is installed. @@ -239,7 +239,7 @@ Install the Hermes Agent plugin: herdr integration install hermes ``` -Herdr writes `~/.hermes/plugins/herdr-agent-state/` and enables `herdr-agent-state` in `~/.hermes/config.yaml`. The Hermes config directory must already exist. Restart Hermes after installing so the plugin loads. Uninstall removes the plugin directory and removes `herdr-agent-state` from `plugins.enabled`. +Herdr writes `plugins/herdr-agent-state/` under the Hermes home directory and enables `herdr-agent-state` in its `config.yaml`. `HERMES_HOME` defaults to `~/.hermes` on Unix and `%LOCALAPPDATA%\hermes` on Windows. The Hermes config directory must already exist. Restart Hermes after installing so the plugin loads. Uninstall removes the plugin directory and removes `herdr-agent-state` from `plugins.enabled`. The plugin reports the resumable session id while Hermes runs inside a Herdr pane. Herdr uses screen manifest detection for `working`, `idle`, and `blocked`, and can use the reported session id to resume the pane with `hermes --resume `. @@ -269,7 +269,7 @@ herdr integration install cursor The hook reports session identity through Cursor's `sessionStart` hook while Cursor Agent CLI runs inside a Herdr pane. Cursor state comes from Herdr's screen manifest detection. -Herdr uses `~/.cursor` by default, or `CURSOR_CONFIG_DIR` when set. The Cursor config directory must already exist. Install writes `herdr-agent-state.sh` and adds a Herdr `sessionStart` entry to `hooks.json`. Uninstall removes the matching hook entry and deletes the hook script. +Herdr uses `~/.cursor` by default, or `CURSOR_CONFIG_DIR` when set. The Cursor config directory must already exist. Install writes `herdr-agent-state.sh` (`herdr-agent-state.ps1` on Windows) and adds a Herdr `sessionStart` entry to `hooks.json`. Uninstall removes the matching hook entry and deletes the hook script. After Cursor emits a session start event, Herdr can use the reported session id to resume the pane with `cursor-agent --resume `. The `cursor-agent` command must be on `PATH` when Herdr restores the pane; Herdr does not launch the generic `agent` command. @@ -283,7 +283,7 @@ herdr integration install mastracode The hook reports MastraCode lifecycle state and thread identity to Herdr for authoritative `idle`, `working`, and `blocked` status and native restore. MastraCode has no screen manifest fallback; state comes from the hook while MastraCode runs inside a Herdr pane. -Herdr uses `~/.mastracode`. Install writes `hooks/herdr-agent-state.sh` and adds Herdr command entries to `hooks.json`, creating the directory when missing. Uninstall removes the matching hook entries and deletes the hook script. +Herdr uses `~/.mastracode`. Install writes `hooks/herdr-agent-state.sh` (`hooks/herdr-agent-state.ps1` on Windows) and adds Herdr command entries to `hooks.json`, creating the directory when missing. Uninstall removes the matching hook entries and deletes the hook script. Herdr resumes stored MastraCode threads with `mastracode --thread `. @@ -311,7 +311,7 @@ herdr integration install grok The hook reports session identity through Grok's `SessionStart` hook while Grok CLI runs inside a Herdr pane. Grok state comes from Herdr's screen manifest detection. -Herdr uses `~/.grok` by default, or `GROK_HOME` when set. The Grok config directory must already exist. Grok merges every `hooks/*.json` file in that directory, so install writes a self-contained `hooks/herdr.json` with the Herdr `SessionStart` entry next to the `hooks/herdr-agent-state.sh` script, and never edits other hook files. Uninstall removes exactly those two Herdr-owned files. +Herdr uses `~/.grok` by default, or `GROK_HOME` when set. The Grok config directory must already exist. Grok merges every `hooks/*.json` file in that directory, so install writes a self-contained `hooks/herdr.json` with the Herdr `SessionStart` entry next to `hooks/herdr-agent-state.sh` (`hooks/herdr-agent-state.ps1` on Windows), and never edits other hook files. Uninstall removes exactly those two Herdr-owned files. After Grok emits a session start event, Herdr can use the reported session id to resume the pane with `grok --resume `. diff --git a/docs/next/website/src/content/docs/ja/integrations.mdx b/docs/next/website/src/content/docs/ja/integrations.mdx index c8c099e849..084c9b356b 100644 --- a/docs/next/website/src/content/docs/ja/integrations.mdx +++ b/docs/next/website/src/content/docs/ja/integrations.mdx @@ -91,7 +91,7 @@ Herdr の外では何もしないように、`HERDR_ENV=1` で必要な変数が 一部のインテグレーションは、エージェントのネイティブセッション参照を報告します。Herdr は公式のセッション参照を使って、`[session] resume_agents_on_restore = false` で無効化されていない限り、Herdr サーバーの再起動後に Claude Code、Codex、Devin CLI、Droid、Kimi Code CLI、Qoder CLI、Cursor Agent CLI、Grok CLI、GitHub Copilot CLI、Pi、OMP、Hermes Agent、OpenCode、Kilo Code CLI、MastraCode、Antigravity CLI のペインを resume します。 -エージェントネイティブのセッション復元には最新の Herdr インテグレーションが必要です: Pi インテグレーションはバージョン `2`、OMP は `3`、Claude Code は `6`、Codex は `5`、GitHub Copilot CLI は `2`、Devin CLI は `2`、Droid は `2`、Kimi Code CLI は `3`、Qoder CLI は `2`、Cursor Agent CLI は `1`、Grok CLI は `1`、OpenCode は `5`、Kilo Code CLI は `1`、Hermes Agent は `2`、MastraCode は `1`、Antigravity CLI は `1` です。インストール済みバージョンは `herdr integration status` で確認してください。 +エージェントネイティブのセッション復元には最新の Herdr インテグレーションが必要です: Pi インテグレーションはバージョン `2`、OMP は `3`、Claude Code は `6`、Codex は `5`、GitHub Copilot CLI は `2`、Devin CLI は `2`、Droid は `2`、Kimi Code CLI は `3`、Qoder CLI は `2`、Cursor Agent CLI は `1`、Grok CLI は `1`、OpenCode は `5`、Kilo Code CLI は `1`、Hermes Agent は `5`、MastraCode は `1`、Antigravity CLI は `1` です。インストール済みバージョンは `herdr integration status` で確認してください。 ## Pi @@ -175,7 +175,7 @@ herdr integration install devin このフックは、Devin のセッション、プロンプト、ツール使用、許可、停止の各イベントからネイティブセッション識別を報告します。Devin のフックはすべての許可キャンセルやユーザー割り込みの後に信頼できる状態遷移を発行しないため、Devin の状態は引き続き Herdr のスクリーンマニフェストと OSC 検出から得られます。 -Herdr はデフォルトで `~/.config/devin` を使い、`XDG_CONFIG_HOME` が設定されていれば `$XDG_CONFIG_HOME/devin` を使います。Devin の設定ディレクトリはあらかじめ存在している必要があります。インストールは `herdr-agent-state.sh` を書き込み、`config.json` に Herdr のフックエントリを追加します。フックは Devin の実行中にセッション参照を更新します。アンインストールは `config.json` から Herdr のエントリを削除し、フックスクリプトを削除します。 +Herdr はデフォルトで `~/.config/devin` を使い、`XDG_CONFIG_HOME` が設定されていれば `$XDG_CONFIG_HOME/devin` を使います。Devin の設定ディレクトリはあらかじめ存在している必要があります。インストールは `herdr-agent-state.sh`(Windows では `herdr-agent-state.ps1`)を書き込み、`config.json` に Herdr のフックエントリを追加します。フックは Devin の実行中にセッション参照を更新します。アンインストールは `config.json` から Herdr のエントリを削除し、フックスクリプトを削除します。 Herdr は保存された Devin セッションを `devin --resume ` で resume します。フックのインストール有無にかかわらず、スクリーンマニフェスト検出が状態の権威のままです。 @@ -239,7 +239,7 @@ Hermes Agent プラグインをインストールします: herdr integration install hermes ``` -Herdr は `~/.hermes/plugins/herdr-agent-state/` を書き込み、`~/.hermes/config.yaml` で `herdr-agent-state` を有効にします。Hermes の設定ディレクトリはあらかじめ存在している必要があります。プラグインを読み込ませるため、インストール後に Hermes を再起動してください。アンインストールはプラグインディレクトリを削除し、`plugins.enabled` から `herdr-agent-state` を削除します。 +Herdr は Hermes ホームディレクトリ内の `plugins/herdr-agent-state/` に書き込み、その `config.yaml` で `herdr-agent-state` を有効にします。`HERMES_HOME` のデフォルトは Unix では `~/.hermes`、Windows では `%LOCALAPPDATA%\hermes` です。Hermes の設定ディレクトリはあらかじめ存在している必要があります。プラグインを読み込ませるため、インストール後に Hermes を再起動してください。アンインストールはプラグインディレクトリを削除し、`plugins.enabled` から `herdr-agent-state` を削除します。 このプラグインは、Hermes が Herdr のペイン内で動いている間、resume 可能なセッション id を報告します。Herdr は `working`、`idle`、`blocked` にスクリーンマニフェスト検出を使い、報告されたセッション id で `hermes --resume ` としてペインを resume できます。 @@ -269,7 +269,7 @@ herdr integration install cursor このフックは、Cursor Agent CLI が Herdr のペイン内で動いている間、Cursor の `sessionStart` フックを通じてセッション識別を報告します。Cursor の状態は Herdr のスクリーンマニフェスト検出から得られます。 -Herdr はデフォルトで `~/.cursor` を使い、`CURSOR_CONFIG_DIR` が設定されていればそちらを使います。Cursor の設定ディレクトリはあらかじめ存在している必要があります。インストールは `herdr-agent-state.sh` を書き込み、`hooks.json` に Herdr の `sessionStart` エントリを追加します。アンインストールは一致するフックエントリを削除し、フックスクリプトを削除します。 +Herdr はデフォルトで `~/.cursor` を使い、`CURSOR_CONFIG_DIR` が設定されていればそちらを使います。Cursor の設定ディレクトリはあらかじめ存在している必要があります。インストールは `herdr-agent-state.sh`(Windows では `herdr-agent-state.ps1`)を書き込み、`hooks.json` に Herdr の `sessionStart` エントリを追加します。アンインストールは一致するフックエントリを削除し、フックスクリプトを削除します。 Cursor がセッション開始イベントを発行した後、Herdr は報告されたセッション id を使って `cursor-agent --resume ` でペインを resume できます。Herdr がペインを復元するとき、`cursor-agent` コマンドが `PATH` にある必要があります。Herdr は汎用の `agent` コマンドを起動しません。 @@ -283,7 +283,7 @@ herdr integration install mastracode このフックは、MastraCode のライフサイクル状態とスレッド識別を Herdr に報告し、権威ある `idle`、`working`、`blocked` 状態とネイティブ復元を提供します。MastraCode にはスクリーンマニフェストのフォールバックはありません。MastraCode が Herdr ペイン内で動いている間、状態はフックから得られます。 -Herdr は `~/.mastracode` を使います。インストールは `hooks/herdr-agent-state.sh` を書き込み、`hooks.json` に Herdr のコマンドエントリを追加します。ディレクトリがなければ作成します。アンインストールは一致するフックエントリを削除し、フックスクリプトを削除します。 +Herdr は `~/.mastracode` を使います。インストールは `hooks/herdr-agent-state.sh`(Windows では `hooks/herdr-agent-state.ps1`)を書き込み、`hooks.json` に Herdr のコマンドエントリを追加します。ディレクトリがなければ作成します。アンインストールは一致するフックエントリを削除し、フックスクリプトを削除します。 Herdr は保存された MastraCode スレッドを `mastracode --thread ` で resume します。 @@ -311,7 +311,7 @@ herdr integration install grok このフックは、Grok CLI が Herdr のペイン内で動いている間、Grok の `SessionStart` フックを通じてセッション識別を報告します。Grok の状態は Herdr のスクリーンマニフェスト検出から得られます。 -Herdr はデフォルトで `~/.grok` を使い、`GROK_HOME` が設定されていればそちらを使います。Grok の設定ディレクトリはあらかじめ存在している必要があります。Grok はそのディレクトリ内のすべての `hooks/*.json` ファイルを統合するため、インストールは Herdr の `SessionStart` エントリだけを含む `hooks/herdr.json` と `hooks/herdr-agent-state.sh` を書き込み、ほかのフックファイルには触れません。アンインストールは Herdr 所有のこの 2 ファイルだけを削除します。 +Herdr はデフォルトで `~/.grok` を使い、`GROK_HOME` が設定されていればそちらを使います。Grok の設定ディレクトリはあらかじめ存在している必要があります。Grok はそのディレクトリ内のすべての `hooks/*.json` ファイルを統合するため、インストールは Herdr の `SessionStart` エントリだけを含む `hooks/herdr.json` と `hooks/herdr-agent-state.sh`(Windows では `hooks/herdr-agent-state.ps1`)を書き込み、ほかのフックファイルには触れません。アンインストールは Herdr 所有のこの 2 ファイルだけを削除します。 Grok がセッション開始イベントを発行した後、Herdr は報告されたセッション id を使って `grok --resume ` でペインを resume できます。 diff --git a/docs/next/website/src/content/docs/zh-cn/integrations.mdx b/docs/next/website/src/content/docs/zh-cn/integrations.mdx index 86f14b37fc..c9a39ba8a5 100644 --- a/docs/next/website/src/content/docs/zh-cn/integrations.mdx +++ b/docs/next/website/src/content/docs/zh-cn/integrations.mdx @@ -91,7 +91,7 @@ Herdr 以两种不同方式使用集成: 一些集成会上报智能体的原生会话引用。除非被 `[session] resume_agents_on_restore = false` 禁用,Herdr 会在服务器重启后使用官方会话引用恢复 Claude Code、Codex、Devin CLI、Droid、Kimi Code CLI、Qoder CLI、Cursor Agent CLI、Grok CLI、GitHub Copilot CLI、Pi、OMP、Hermes Agent、OpenCode、Kilo Code CLI、MastraCode 和 Antigravity CLI 的窗格。 -原生会话恢复需要最新的 Herdr 集成: Pi 集成版本 `2`、OMP 版本 `3`、Claude Code 版本 `6`、Codex 版本 `5`、GitHub Copilot CLI 版本 `2`、Devin CLI 版本 `2`、Droid 版本 `2`、Kimi Code CLI 版本 `3`、Qoder CLI 版本 `2`、Cursor Agent CLI 版本 `1`、Grok CLI 版本 `1`、OpenCode 版本 `5`、Kilo Code CLI 版本 `1`、Hermes Agent 版本 `2`、MastraCode 版本 `1`、Antigravity CLI 版本 `1`。用 `herdr integration status` 查看已安装版本。 +原生会话恢复需要最新的 Herdr 集成: Pi 集成版本 `2`、OMP 版本 `3`、Claude Code 版本 `6`、Codex 版本 `5`、GitHub Copilot CLI 版本 `2`、Devin CLI 版本 `2`、Droid 版本 `2`、Kimi Code CLI 版本 `3`、Qoder CLI 版本 `2`、Cursor Agent CLI 版本 `1`、Grok CLI 版本 `1`、OpenCode 版本 `5`、Kilo Code CLI 版本 `1`、Hermes Agent 版本 `5`、MastraCode 版本 `1`、Antigravity CLI 版本 `1`。用 `herdr integration status` 查看已安装版本。 ## Pi @@ -175,7 +175,7 @@ herdr integration install devin 该钩子从 Devin 的会话、提示、工具使用、权限和停止事件中上报原生会话身份。Devin 的状态仍来自 Herdr 的屏幕清单和 OSC 检测,因为 Devin 钩子不会在每次权限取消或用户中断后都发出可靠的状态转换。 -Herdr 默认使用 `~/.config/devin`,设置了 `XDG_CONFIG_HOME` 时使用 `$XDG_CONFIG_HOME/devin`。Devin 配置目录必须已经存在。安装会写入 `herdr-agent-state.sh`,并向 `config.json` 添加 Herdr 钩子条目。钩子在 Devin 运行期间刷新会话引用。卸载会从 `config.json` 中移除 Herdr 条目并删除钩子脚本。 +Herdr 默认使用 `~/.config/devin`,设置了 `XDG_CONFIG_HOME` 时使用 `$XDG_CONFIG_HOME/devin`。Devin 配置目录必须已经存在。安装会写入 `herdr-agent-state.sh`(Windows 上为 `herdr-agent-state.ps1`),并向 `config.json` 添加 Herdr 钩子条目。钩子在 Devin 运行期间刷新会话引用。卸载会从 `config.json` 中移除 Herdr 条目并删除钩子脚本。 Herdr 用 `devin --resume ` 恢复保存的 Devin 会话。无论钩子是否安装,屏幕清单检测始终是状态权威。 @@ -239,7 +239,7 @@ Herdr 把插件写入 `~/.config/kilo/plugin/herdr-agent-state.js`。Kilo 配置 herdr integration install hermes ``` -Herdr 写入 `~/.hermes/plugins/herdr-agent-state/`,并在 `~/.hermes/config.yaml` 中启用 `herdr-agent-state`。Hermes 配置目录必须已经存在。安装后请重启 Hermes 以加载插件。卸载会删除插件目录,并从 `plugins.enabled` 中移除 `herdr-agent-state`。 +Herdr 写入 Hermes 主目录下的 `plugins/herdr-agent-state/`,并在 Hermes 主目录的 `config.yaml` 中启用 `herdr-agent-state`。`HERMES_HOME` 在 Unix 上默认为 `~/.hermes`,在 Windows 上默认为 `%LOCALAPPDATA%\hermes`。Hermes 配置目录必须已经存在。安装后请重启 Hermes 以加载插件。卸载会删除插件目录,并从 `plugins.enabled` 中移除 `herdr-agent-state`。 该插件在 Hermes 运行于 Herdr 窗格内时上报可恢复的会话 id。Herdr 使用屏幕清单检测 `working`、`idle` 和 `blocked`,并可用上报的会话 id 通过 `hermes --resume ` 恢复该窗格。 @@ -269,7 +269,7 @@ herdr integration install cursor 该钩子在 Cursor Agent CLI 运行于 Herdr 窗格内时,通过 Cursor 的 `sessionStart` 钩子上报会话身份。Cursor 的状态来自 Herdr 的屏幕清单检测。 -Herdr 默认使用 `~/.cursor`,设置了 `CURSOR_CONFIG_DIR` 时使用后者。Cursor 配置目录必须已经存在。安装会写入 `herdr-agent-state.sh`,并向 `hooks.json` 添加 Herdr 的 `sessionStart` 条目。卸载会移除匹配的钩子条目并删除钩子脚本。 +Herdr 默认使用 `~/.cursor`,设置了 `CURSOR_CONFIG_DIR` 时使用后者。Cursor 配置目录必须已经存在。安装会写入 `herdr-agent-state.sh`(Windows 上为 `herdr-agent-state.ps1`),并向 `hooks.json` 添加 Herdr 的 `sessionStart` 条目。卸载会移除匹配的钩子条目并删除钩子脚本。 在 Cursor 发出会话启动事件后,Herdr 可以用上报的会话 id 通过 `cursor-agent --resume ` 恢复该窗格。Herdr 恢复窗格时,`cursor-agent` 命令必须在 `PATH` 上;Herdr 不会启动通用的 `agent` 命令。 @@ -283,7 +283,7 @@ herdr integration install mastracode 该钩子向 Herdr 上报 MastraCode 生命周期状态和线程身份,用于权威的 `idle`、`working`、`blocked` 状态和原生恢复。MastraCode 没有屏幕清单兜底;当 MastraCode 在 Herdr 窗格内运行时,状态来自该钩子。 -Herdr 使用 `~/.mastracode`。安装会写入 `hooks/herdr-agent-state.sh`,并把 Herdr 命令条目添加到 `hooks.json`;目录不存在时会创建。卸载会删除匹配的钩子条目和钩子脚本。 +Herdr 使用 `~/.mastracode`。安装会写入 `hooks/herdr-agent-state.sh`(Windows 上为 `hooks/herdr-agent-state.ps1`),并把 Herdr 命令条目添加到 `hooks.json`;目录不存在时会创建。卸载会删除匹配的钩子条目和钩子脚本。 Herdr 用 `mastracode --thread ` 恢复保存的 MastraCode 线程。 @@ -311,7 +311,7 @@ herdr integration install grok 该钩子在 Grok CLI 运行于 Herdr 窗格内时,通过 Grok 的 `SessionStart` 钩子上报会话身份。Grok 的状态来自 Herdr 的屏幕清单检测。 -Herdr 默认使用 `~/.grok`,设置了 `GROK_HOME` 时使用后者。Grok 配置目录必须已经存在。Grok 会合并该目录中的所有 `hooks/*.json` 文件,因此安装会写入独立的 `hooks/herdr.json`,其中包含 Herdr 的 `SessionStart` 条目,并同时写入 `hooks/herdr-agent-state.sh`;它不会修改其他钩子文件。卸载只会删除这两个由 Herdr 管理的文件。 +Herdr 默认使用 `~/.grok`,设置了 `GROK_HOME` 时使用后者。Grok 配置目录必须已经存在。Grok 会合并该目录中的所有 `hooks/*.json` 文件,因此安装会写入独立的 `hooks/herdr.json`,其中包含 Herdr 的 `SessionStart` 条目,并同时写入 `hooks/herdr-agent-state.sh`(Windows 上为 `hooks/herdr-agent-state.ps1`);它不会修改其他钩子文件。卸载只会删除这两个由 Herdr 管理的文件。 Grok 发出会话启动事件后,Herdr 可以用上报的会话 id 通过 `grok --resume ` 恢复窗格。 diff --git a/scripts/test_hermes_integration_asset.py b/scripts/test_hermes_integration_asset.py index f377821440..f2cf38bbd7 100644 --- a/scripts/test_hermes_integration_asset.py +++ b/scripts/test_hermes_integration_asset.py @@ -1,6 +1,7 @@ import importlib.util import unittest from pathlib import Path +from unittest import mock ASSET = Path(__file__).parents[1] / "src/integration/assets/hermes/__init__.py" @@ -27,7 +28,9 @@ class HermesIntegrationAssetTests(unittest.TestCase): def test_reports_only_root_session_identity(self): module = load_asset() calls = [] - module._send = lambda method, params: calls.append((method, params)) + module._send_session = lambda session_id, start_source: calls.append( + (session_id, start_source) + ) context = FakeContext() module.register(context) @@ -44,47 +47,45 @@ def test_reports_only_root_session_identity(self): session_id="background", platform="tui" ) + self.assertEqual(calls, [("root-1", "startup"), ("root-2", "new")]) + + def test_send_session_uses_cli_with_the_active_pane(self): + module = load_asset() + environment = { + "HERDR_ENV": "1", + "HERDR_PANE_ID": "w1:p2", + "HERDR_BIN_PATH": "C:/bin/herdr.exe", + } + with mock.patch.dict(module.os.environ, environment, clear=True): + with mock.patch.object(module.subprocess, "run") as run: + module._send_session("session-1", "resume") + + command = run.call_args.args[0] self.assertEqual( - calls, - [ - ( - "pane.report_agent_session", - { - "agent_session_id": "root-1", - "session_start_source": "startup", - }, - ), - ( - "pane.report_agent_session", - { - "agent_session_id": "root-2", - "session_start_source": "new", - }, - ), - ], + command[:4], + ["C:/bin/herdr.exe", "pane", "report-agent-session", "w1:p2"], ) + self.assertIn("session-1", command) + self.assertIn("resume", command) + self.assertFalse(run.call_args.kwargs["check"]) + if module.os.name == "nt": + self.assertEqual( + run.call_args.kwargs["creationflags"], + module.subprocess.CREATE_NO_WINDOW, + ) def test_first_turn_recovers_resumed_session_identity(self): module = load_asset() calls = [] - module._send = lambda method, params: calls.append((method, params)) + module._send_session = lambda session_id, start_source: calls.append( + (session_id, start_source) + ) context = FakeContext() module.register(context) context.hooks["pre_llm_call"](session_id="resumed", platform="cli") - self.assertEqual( - calls, - [ - ( - "pane.report_agent_session", - { - "agent_session_id": "resumed", - "session_start_source": "resume", - }, - ) - ], - ) + self.assertEqual(calls, [("resumed", "resume")]) if __name__ == "__main__": unittest.main() diff --git a/src/agent_resume.rs b/src/agent_resume.rs index 55b7d51ffd..ffbf43474b 100644 --- a/src/agent_resume.rs +++ b/src/agent_resume.rs @@ -182,7 +182,12 @@ pub fn plan(source: &str, agent: &str, session_ref: &AgentSessionRef) -> Option< } ("herdr:cursor", "cursor", AgentSessionRefKind::Id) => { vec![ - "cursor-agent".into(), + if cfg!(windows) { + "cursor-agent.cmd" + } else { + "cursor-agent" + } + .into(), "--resume".into(), session_ref.value.clone(), ] @@ -413,7 +418,15 @@ mod tests { ) .unwrap() .argv, - vec!["cursor-agent", "--resume", "cursor-session"] + vec![ + if cfg!(windows) { + "cursor-agent.cmd" + } else { + "cursor-agent" + }, + "--resume", + "cursor-session", + ] ); assert_eq!( plan( diff --git a/src/detect/mod.rs b/src/detect/mod.rs index de590f8236..b9f9655f5a 100644 --- a/src/detect/mod.rs +++ b/src/detect/mod.rs @@ -144,7 +144,13 @@ pub fn interactive_agent_executable(agent: Agent) -> &'static str { Agent::Claude => "claude", Agent::Codex => "codex", Agent::Gemini => "gemini", - Agent::Cursor => "cursor-agent", + Agent::Cursor => { + if cfg!(windows) { + "cursor-agent.cmd" + } else { + "cursor-agent" + } + } Agent::Devin => "devin", Agent::Antigravity => "agy", Agent::Cline => "cline", @@ -545,6 +551,11 @@ fn agent_name_from_known_package_path(path: &str) -> Option { return Some(agent_label(Agent::Pi).to_string()); } } + for window in components.windows(4) { + if window == ["node_modules", "mastracode", "dist", "cli"] { + return Some(agent_label(Agent::Mastracode).to_string()); + } + } None } @@ -743,7 +754,14 @@ mod tests { (Agent::Claude, "claude"), (Agent::Codex, "codex"), (Agent::Gemini, "gemini"), - (Agent::Cursor, "cursor-agent"), + ( + Agent::Cursor, + if cfg!(windows) { + "cursor-agent.cmd" + } else { + "cursor-agent" + }, + ), (Agent::Devin, "devin"), (Agent::Antigravity, "agy"), (Agent::Cline, "cline"), @@ -970,6 +988,26 @@ mod tests { ); } + #[test] + fn identify_agent_in_job_detects_node_wrapped_mastracode_package_cli() { + let job = crate::platform::ForegroundJob { + process_group_id: 123, + processes: vec![foreground_process( + 123, + "node.exe", + &[ + "node.exe", + "C:\\Users\\herdr\\AppData\\Roaming\\npm\\node_modules\\mastracode\\dist\\cli.js", + ], + )], + }; + + assert_eq!( + identify_agent_in_job(&job), + Some((Agent::Mastracode, "mastracode".to_string())) + ); + } + #[test] fn identify_agent_in_job_ignores_non_cli_pi_package_script() { let job = crate::platform::ForegroundJob { diff --git a/src/integration/assets/cursor/herdr-agent-state.ps1 b/src/integration/assets/cursor/herdr-agent-state.ps1 new file mode 100644 index 0000000000..ebfdb39384 --- /dev/null +++ b/src/integration/assets/cursor/herdr-agent-state.ps1 @@ -0,0 +1,48 @@ +# managed by herdr; reinstalling the integration replaces this file. +# HERDR_INTEGRATION_ID=cursor +# HERDR_INTEGRATION_VERSION=1 + +param([string]$Action = "") + +function Exit-Hook { + Write-Output "{}" + exit 0 +} + +if ($Action -ne "session") { Exit-Hook } +if ($env:HERDR_ENV -ne "1") { Exit-Hook } +if ([string]::IsNullOrWhiteSpace($env:HERDR_PANE_ID)) { Exit-Hook } + +$inputText = [Console]::In.ReadToEnd() +$jsonStart = $inputText.IndexOf("{") +if ($jsonStart -gt 0) { + $inputText = $inputText.Substring($jsonStart) +} +try { + $payload = if ([string]::IsNullOrWhiteSpace($inputText)) { $null } else { $inputText | ConvertFrom-Json } +} catch { + Exit-Hook +} + +if ($null -eq $payload) { Exit-Hook } +$event = if ($payload.hook_event_name -is [string]) { $payload.hook_event_name } else { $payload.hookEventName } +if (-not [string]::IsNullOrWhiteSpace($event) -and $event -ne "sessionStart") { Exit-Hook } + +$sessionId = $null +foreach ($name in @("session_id", "sessionId", "conversation_id", "conversationId")) { + $value = $payload.$name + if ($value -is [string] -and -not [string]::IsNullOrWhiteSpace($value)) { + $sessionId = $value + break + } +} +if ([string]::IsNullOrWhiteSpace($sessionId)) { Exit-Hook } + +$seq = [DateTime]::UtcNow.Ticks +$herdr = if ([string]::IsNullOrWhiteSpace($env:HERDR_BIN_PATH)) { "herdr" } else { $env:HERDR_BIN_PATH } +try { + & $herdr pane report-agent-session $env:HERDR_PANE_ID --source herdr:cursor --agent cursor --seq $seq --agent-session-id $sessionId 2>$null | Out-Null +} catch { +} + +Exit-Hook diff --git a/src/integration/assets/devin/herdr-agent-state.ps1 b/src/integration/assets/devin/herdr-agent-state.ps1 new file mode 100644 index 0000000000..7d14ad7575 --- /dev/null +++ b/src/integration/assets/devin/herdr-agent-state.ps1 @@ -0,0 +1,51 @@ +# installed by herdr +# managed by herdr; reinstalling or updating the integration overwrites this file. +# add custom hooks beside this file instead of editing it. +# HERDR_INTEGRATION_ID=devin +# HERDR_INTEGRATION_VERSION=2 + +param([string]$Action = "") + +if ($Action -ne "session") { exit 0 } +if ($env:HERDR_ENV -ne "1") { exit 0 } +if ([string]::IsNullOrWhiteSpace($env:HERDR_PANE_ID)) { exit 0 } + +$inputText = [Console]::In.ReadToEnd() +try { + $payload = if ([string]::IsNullOrWhiteSpace($inputText)) { $null } else { $inputText | ConvertFrom-Json } +} catch { + $payload = $null +} + +$sessionId = $null +if ($null -ne $payload) { + if ($payload.session_id -is [string]) { $sessionId = $payload.session_id } + elseif ($payload.sessionId -is [string]) { $sessionId = $payload.sessionId } +} + +$event = if ($null -ne $payload -and $payload.hook_event_name -is [string]) { $payload.hook_event_name } else { "" } +$allowFallback = $event -ne "UserPromptSubmit" -and -not ($event -eq "SessionStart" -and $payload.source -eq "startup") +if ([string]::IsNullOrWhiteSpace($sessionId) -and $allowFallback) { + $projectDir = if ([string]::IsNullOrWhiteSpace($env:DEVIN_PROJECT_DIR)) { (Get-Location).Path } else { $env:DEVIN_PROJECT_DIR } + try { + $sessions = & devin list --format json 2>$null | ConvertFrom-Json + $normalizedProject = [IO.Path]::GetFullPath($projectDir).TrimEnd('\') + foreach ($session in @($sessions)) { + if ($session.id -isnot [string] -or $session.working_directory -isnot [string]) { continue } + $workingDirectory = [IO.Path]::GetFullPath($session.working_directory).TrimEnd('\') + if ($workingDirectory -ieq $normalizedProject) { + $sessionId = $session.id + break + } + } + } catch { + } +} +if ([string]::IsNullOrWhiteSpace($sessionId)) { exit 0 } + +$seq = [DateTime]::UtcNow.Ticks +$herdr = if ([string]::IsNullOrWhiteSpace($env:HERDR_BIN_PATH)) { "herdr" } else { $env:HERDR_BIN_PATH } +try { + & $herdr pane report-agent-session $env:HERDR_PANE_ID --source herdr:devin --agent devin --seq $seq --agent-session-id $sessionId 2>$null | Out-Null +} catch { +} diff --git a/src/integration/assets/grok/herdr-agent-state.ps1 b/src/integration/assets/grok/herdr-agent-state.ps1 new file mode 100644 index 0000000000..69c4f51512 --- /dev/null +++ b/src/integration/assets/grok/herdr-agent-state.ps1 @@ -0,0 +1,41 @@ +# installed by herdr +# managed by herdr; reinstalling or updating the integration overwrites this file. +# add custom hooks beside this file instead of editing it. +# HERDR_INTEGRATION_ID=grok +# HERDR_INTEGRATION_VERSION=1 + +param([string]$Action = "") + +if ($Action -ne "session") { exit 0 } +if ($env:HERDR_ENV -ne "1") { exit 0 } +if ([string]::IsNullOrWhiteSpace($env:HERDR_PANE_ID)) { exit 0 } + +$inputText = [Console]::In.ReadToEnd() +try { + $payload = if ([string]::IsNullOrWhiteSpace($inputText)) { $null } else { $inputText | ConvertFrom-Json } +} catch { + $payload = $null +} + +$event = if ($null -ne $payload -and $payload.hook_event_name -is [string]) { + $payload.hook_event_name +} elseif ($null -ne $payload -and $payload.hookEventName -is [string]) { + $payload.hookEventName +} else { + $null +} +if ($null -ne $event -and $event -notin @("session_start", "SessionStart", "sessionStart")) { exit 0 } + +$sessionId = $env:GROK_SESSION_ID +if ([string]::IsNullOrWhiteSpace($sessionId) -and $null -ne $payload) { + if ($payload.session_id -is [string]) { $sessionId = $payload.session_id } + elseif ($payload.sessionId -is [string]) { $sessionId = $payload.sessionId } +} +if ([string]::IsNullOrWhiteSpace($sessionId)) { exit 0 } + +$seq = [DateTime]::UtcNow.Ticks +$herdr = if ([string]::IsNullOrWhiteSpace($env:HERDR_BIN_PATH)) { "herdr" } else { $env:HERDR_BIN_PATH } +try { + & $herdr pane report-agent-session $env:HERDR_PANE_ID --source herdr:grok --agent grok --seq $seq --agent-session-id $sessionId 2>$null | Out-Null +} catch { +} diff --git a/src/integration/assets/hermes/__init__.py b/src/integration/assets/hermes/__init__.py index 9b1d51e2f4..e99fc29e97 100644 --- a/src/integration/assets/hermes/__init__.py +++ b/src/integration/assets/hermes/__init__.py @@ -1,14 +1,12 @@ """Hermes plugin installed by Herdr to report resumable session identity.""" # HERDR_INTEGRATION_ID=hermes -# HERDR_INTEGRATION_VERSION=4 +# HERDR_INTEGRATION_VERSION=5 from __future__ import annotations -import json import os -import random -import socket +import subprocess import time _SOURCE = "herdr:hermes" @@ -16,43 +14,37 @@ _INTERACTIVE_PLATFORMS = {"cli", "tui", "desktop", "acp"} -def _base_params() -> tuple[str, str] | None: +def _pane_id() -> str | None: if os.environ.get("HERDR_ENV") != "1": return None - pane_id = os.environ.get("HERDR_PANE_ID", "").strip() - socket_path = os.environ.get("HERDR_SOCKET_PATH", "").strip() - if not pane_id or not socket_path: - return None - return pane_id, socket_path + return os.environ.get("HERDR_PANE_ID", "").strip() or None -def _send(method: str, params: dict) -> None: - base = _base_params() - if base is None: +def _send_session(session_id: str, start_source: str) -> None: + pane_id = _pane_id() + if pane_id is None: return - pane_id, socket_path = base - params = { - "pane_id": pane_id, - "source": _SOURCE, - "agent": _AGENT, - "seq": time.time_ns(), - **params, - } - request = { - "id": f"{_SOURCE}:{int(time.time() * 1000)}:{random.randrange(1_000_000):06d}", - "method": method, - "params": params, - } + command = [ + os.environ.get("HERDR_BIN_PATH") or "herdr", + "pane", + "report-agent-session", + pane_id, + "--source", + _SOURCE, + "--agent", + _AGENT, + "--seq", + str(time.time_ns()), + "--agent-session-id", + session_id, + "--session-start-source", + start_source, + ] try: - client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - client.settimeout(0.5) - client.connect(socket_path) - client.sendall((json.dumps(request) + "\n").encode("utf-8")) - try: - client.recv(4096) - except Exception: - pass - client.close() + kwargs = {"timeout": 1, "stdout": subprocess.DEVNULL, "stderr": subprocess.DEVNULL} + if os.name == "nt": + kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW + subprocess.run(command, check=False, **kwargs) except Exception: pass @@ -63,13 +55,7 @@ def _report_session(start_source: str, **kwargs) -> None: session_id = kwargs.get("session_id") if not isinstance(session_id, str) or not session_id: return - _send( - "pane.report_agent_session", - { - "agent_session_id": session_id, - "session_start_source": start_source, - }, - ) + _send_session(session_id, start_source) def _session_started(**kwargs) -> None: diff --git a/src/integration/assets/mastracode/herdr-agent-state.ps1 b/src/integration/assets/mastracode/herdr-agent-state.ps1 new file mode 100644 index 0000000000..b0cca059e2 --- /dev/null +++ b/src/integration/assets/mastracode/herdr-agent-state.ps1 @@ -0,0 +1,35 @@ +# installed by herdr +# managed by herdr; reinstalling or updating the integration overwrites this file. +# add custom hooks beside this file instead of editing it. +# HERDR_INTEGRATION_ID=mastracode +# HERDR_INTEGRATION_VERSION=2 + +param([string]$Action = "") + +if ($Action -notin @("session", "working", "idle", "blocked")) { exit 0 } +if ($env:HERDR_ENV -ne "1") { exit 0 } +if ([string]::IsNullOrWhiteSpace($env:HERDR_PANE_ID)) { exit 0 } + +$inputText = [Console]::In.ReadToEnd() +try { + $payload = if ([string]::IsNullOrWhiteSpace($inputText)) { $null } else { $inputText | ConvertFrom-Json } +} catch { + $payload = $null +} + +$sessionId = if ($null -ne $payload -and $payload.session_id -is [string]) { $payload.session_id } else { $null } +$seq = [DateTime]::UtcNow.Ticks +$herdr = if ([string]::IsNullOrWhiteSpace($env:HERDR_BIN_PATH)) { "herdr" } else { $env:HERDR_BIN_PATH } +try { + if ($Action -eq "session") { + if ([string]::IsNullOrWhiteSpace($sessionId)) { exit 0 } + & $herdr pane report-agent-session $env:HERDR_PANE_ID --source herdr:mastracode --agent mastracode --seq $seq --session-start-source startup --agent-session-id $sessionId 2>$null | Out-Null + } else { + $args = @("pane", "report-agent", $env:HERDR_PANE_ID, "--source", "herdr:mastracode", "--agent", "mastracode", "--state", $Action, "--seq", "$seq") + if (-not [string]::IsNullOrWhiteSpace($sessionId)) { + $args += @("--agent-session-id", $sessionId) + } + & $herdr @args 2>$null | Out-Null + } +} catch { +} diff --git a/src/integration/env.rs b/src/integration/env.rs index d76ce66cb9..e86f6ee88e 100644 --- a/src/integration/env.rs +++ b/src/integration/env.rs @@ -22,9 +22,13 @@ pub(crate) const GROK_CONFIG_DIR_ENV_VAR: &str = "GROK_CONFIG_DIR"; /// The grok CLI's own config-home override (documented alongside /// `$GROK_HOME/config.toml` and `$GROK_HOME/auth.json`). pub(crate) const GROK_HOME_ENV_VAR: &str = "GROK_HOME"; +pub(crate) const HERMES_HOME_ENV_VAR: &str = "HERMES_HOME"; pub(crate) fn apply_pane_base_env(cmd: &mut CommandBuilder) { cmd.env(crate::api::SOCKET_PATH_ENV_VAR, crate::api::socket_path()); + if let Ok(executable) = std::env::current_exe() { + cmd.env("HERDR_BIN_PATH", executable); + } } pub(crate) fn pi_extension_dir() -> io::Result { @@ -122,6 +126,24 @@ pub(crate) fn kilo_dir() -> io::Result { } pub(crate) fn hermes_dir() -> io::Result { + if let Some(value) = std::env::var_os(HERMES_HOME_ENV_VAR).filter(|value| !value.is_empty()) { + return expand_tilde_path(PathBuf::from(value)); + } + + #[cfg(windows)] + { + let explicit_home = std::env::var_os("HOME").filter(|value| !value.is_empty()); + let profile = std::env::var_os("USERPROFILE").filter(|value| !value.is_empty()); + if let Some(home) = explicit_home.filter(|home| profile.as_ref() != Some(home)) { + return Ok(PathBuf::from(home).join(".hermes")); + } + if let Some(local_app_data) = + std::env::var_os("LOCALAPPDATA").filter(|value| !value.is_empty()) + { + return Ok(PathBuf::from(local_app_data).join("hermes")); + } + } + Ok(home_dir()?.join(".hermes")) } diff --git a/src/integration/mod.rs b/src/integration/mod.rs index 8be54c8406..89d4d34d89 100644 --- a/src/integration/mod.rs +++ b/src/integration/mod.rs @@ -114,8 +114,16 @@ const COPILOT_REMOVED_LIFECYCLE_HOOK_EVENTS: [&str; 9] = [ "notification", "sessionStart", ]; -const DEVIN_HOOK_INSTALL_NAME: &str = "herdr-agent-state.sh"; -const DEVIN_HOOK_ASSET: &str = include_str!("assets/devin/herdr-agent-state.sh"); +const DEVIN_HOOK_INSTALL_NAME: &str = if cfg!(windows) { + "herdr-agent-state.ps1" +} else { + "herdr-agent-state.sh" +}; +const DEVIN_HOOK_ASSET: &str = if cfg!(windows) { + include_str!("assets/devin/herdr-agent-state.ps1") +} else { + include_str!("assets/devin/herdr-agent-state.sh") +}; const DEVIN_INTEGRATION_VERSION: u32 = 2; const DEVIN_HOOK_EVENTS: [(&str, &str); 6] = [ ("SessionStart", "session"), @@ -167,7 +175,7 @@ const HERMES_PLUGIN_MANIFEST_INSTALL_NAME: &str = "plugin.yaml"; const HERMES_PLUGIN_INIT_INSTALL_NAME: &str = "__init__.py"; const HERMES_PLUGIN_MANIFEST_ASSET: &str = include_str!("assets/hermes/plugin.yaml"); const HERMES_PLUGIN_INIT_ASSET: &str = include_str!("assets/hermes/__init__.py"); -const HERMES_INTEGRATION_VERSION: u32 = 4; +const HERMES_INTEGRATION_VERSION: u32 = 5; const QODERCLI_HOOK_INSTALL_NAME: &str = if cfg!(windows) { "herdr-agent-state.ps1" } else { @@ -194,8 +202,16 @@ const QODERCLI_REMOVED_LIFECYCLE_HOOK_EVENTS: [(&str, &str); 12] = [ ("Stop", "idle"), ("SessionEnd", "release"), ]; -const CURSOR_HOOK_INSTALL_NAME: &str = "herdr-agent-state.sh"; -const CURSOR_HOOK_ASSET: &str = include_str!("assets/cursor/herdr-agent-state.sh"); +const CURSOR_HOOK_INSTALL_NAME: &str = if cfg!(windows) { + "herdr-agent-state.ps1" +} else { + "herdr-agent-state.sh" +}; +const CURSOR_HOOK_ASSET: &str = if cfg!(windows) { + include_str!("assets/cursor/herdr-agent-state.ps1") +} else { + include_str!("assets/cursor/herdr-agent-state.sh") +}; const CURSOR_INTEGRATION_VERSION: u32 = 1; #[cfg(windows)] const ANTIGRAVITY_CLI_HOOK_INSTALL_NAME: &str = "herdr-agent-state.ps1"; @@ -223,8 +239,16 @@ const ANTIGRAVITY_CLI_HOOK_TIMEOUT_SEC: u64 = 10; /// invalidate the whole file. const ANTIGRAVITY_CLI_HOOK_EVENTS: [(&str, &str); 1] = [("PreInvocation", "session")]; const INTEGRATION_VERSION_MARKER: &str = "HERDR_INTEGRATION_VERSION="; -const MASTRACODE_HOOK_INSTALL_NAME: &str = "herdr-agent-state.sh"; -const MASTRACODE_HOOK_ASSET: &str = include_str!("assets/mastracode/herdr-agent-state.sh"); +const MASTRACODE_HOOK_INSTALL_NAME: &str = if cfg!(windows) { + "herdr-agent-state.ps1" +} else { + "herdr-agent-state.sh" +}; +const MASTRACODE_HOOK_ASSET: &str = if cfg!(windows) { + include_str!("assets/mastracode/herdr-agent-state.ps1") +} else { + include_str!("assets/mastracode/herdr-agent-state.sh") +}; const MASTRACODE_INTEGRATION_VERSION: u32 = 2; const MASTRACODE_HOOK_TIMEOUT_MS: u64 = 10_000; const MASTRACODE_REMOVED_HOOK_EVENTS: [(&str, &str); 2] = @@ -242,9 +266,17 @@ const MASTRACODE_HOOK_EVENTS: [(&str, &str); 11] = [ ("AgentEnd", "idle"), ("Stop", "idle"), ]; -const GROK_HOOK_INSTALL_NAME: &str = "herdr-agent-state.sh"; +const GROK_HOOK_INSTALL_NAME: &str = if cfg!(windows) { + "herdr-agent-state.ps1" +} else { + "herdr-agent-state.sh" +}; const GROK_HOOK_CONFIG_INSTALL_NAME: &str = "herdr.json"; -const GROK_HOOK_ASSET: &str = include_str!("assets/grok/herdr-agent-state.sh"); +const GROK_HOOK_ASSET: &str = if cfg!(windows) { + include_str!("assets/grok/herdr-agent-state.ps1") +} else { + include_str!("assets/grok/herdr-agent-state.sh") +}; const GROK_INTEGRATION_VERSION: u32 = 1; pub(crate) const INSTALL_WARNING_PREFIX: &str = "warning:"; diff --git a/src/integration/registry.rs b/src/integration/registry.rs index 7041eb1b6e..569494a2f7 100644 --- a/src/integration/registry.rs +++ b/src/integration/registry.rs @@ -76,6 +76,11 @@ pub(crate) fn integration_target_supported(target: crate::api::schema::Integrati | crate::api::schema::IntegrationTarget::Kimi | crate::api::schema::IntegrationTarget::Qodercli | crate::api::schema::IntegrationTarget::AntigravityCli + | crate::api::schema::IntegrationTarget::Devin + | crate::api::schema::IntegrationTarget::Hermes + | crate::api::schema::IntegrationTarget::Cursor + | crate::api::schema::IntegrationTarget::Mastracode + | crate::api::schema::IntegrationTarget::Grok ) } @@ -196,12 +201,9 @@ pub(crate) fn codex_executable_name() -> &'static str { pub(crate) fn hermes_install_layout_available() -> bool { #[cfg(windows)] { - let Some(local_app_data) = - std::env::var_os("LOCALAPPDATA").filter(|value| !value.is_empty()) - else { + let Ok(dir) = hermes_dir() else { return false; }; - let dir = PathBuf::from(local_app_data).join("hermes"); [ dir.join("hermes.exe"), dir.join("bin").join("hermes.exe"), diff --git a/src/integration/targets.rs b/src/integration/targets.rs index b387b960a9..0cf2f93321 100644 --- a/src/integration/targets.rs +++ b/src/integration/targets.rs @@ -7,7 +7,9 @@ use serde_json::{json, Map, Value}; use super::claude_settings::{ install as install_claude_settings, uninstall as uninstall_claude_settings, }; -use super::command::{hook_command, shell_single_quote}; +use super::command::hook_command; +#[cfg(not(windows))] +use super::command::shell_single_quote; use super::config_edit::{ build_codex_config_with_hooks, build_kimi_config_with_hooks, ensure_command_hook, ensure_direct_command_hook, ensure_flat_command_hook, ensure_hermes_plugin_enabled, @@ -948,8 +950,7 @@ pub(crate) fn install_cursor() -> io::Result { "cursor hooks file", "cursor hooks file hooks", )?; - let quoted_hook_path = shell_single_quote(&hook_path.display().to_string()); - let session_command = format!("bash {quoted_hook_path} session"); + let session_command = hook_command(&hook_path, Some("session")); remove_simple_command_hook(hooks, "beforeSubmitPrompt", &session_command)?; remove_simple_command_hook(hooks, "beforeShellExecution", &session_command)?; remove_simple_command_hook(hooks, "beforeMCPExecution", &session_command)?; @@ -1029,8 +1030,7 @@ pub(crate) fn uninstall_cursor() -> io::Result { "cursor hooks file", "cursor hooks file hooks", )? { - let quoted_hook_path = shell_single_quote(&hook_path.display().to_string()); - let session_command = format!("bash {quoted_hook_path} session"); + let session_command = hook_command(&hook_path, Some("session")); updated_hooks |= remove_simple_command_hook(hooks, "sessionStart", &session_command)?; updated_hooks |= remove_simple_command_hook(hooks, "beforeSubmitPrompt", &session_command)?; @@ -1057,6 +1057,26 @@ pub(crate) fn uninstall_cursor() -> io::Result { }) } +pub(crate) fn mastracode_hook_command(hook_path: &Path, action: &str) -> String { + #[cfg(windows)] + { + use base64::Engine; + + let path = hook_path.display().to_string().replace('\'', "''"); + let script = format!("& '{path}' {action}"); + let encoded_script = script + .encode_utf16() + .flat_map(u16::to_le_bytes) + .collect::>(); + let encoded = base64::engine::general_purpose::STANDARD.encode(encoded_script); + format!("powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand {encoded}") + } + #[cfg(not(windows))] + { + hook_command(hook_path, Some(action)) + } +} + pub(crate) fn install_mastracode() -> io::Result { let mastracode_home = mastracode_dir()?; let hook_dir = mastracode_home.join("hooks"); @@ -1082,15 +1102,16 @@ pub(crate) fn install_mastracode() -> io::Result { )) })?; - let quoted_hook_path = shell_single_quote(&hook_path.display().to_string()); for (event, action) in MASTRACODE_REMOVED_HOOK_EVENTS { - remove_flat_command_hook(hooks, event, &format!("bash {quoted_hook_path} {action}"))?; + remove_flat_command_hook(hooks, event, &hook_command(&hook_path, Some(action)))?; + remove_flat_command_hook(hooks, event, &mastracode_hook_command(&hook_path, action))?; } for (event, action) in MASTRACODE_HOOK_EVENTS { + remove_flat_command_hook(hooks, event, &hook_command(&hook_path, Some(action)))?; ensure_flat_command_hook( hooks, event, - format!("bash {quoted_hook_path} {action}"), + mastracode_hook_command(&hook_path, action), MASTRACODE_HOOK_TIMEOUT_MS, )?; } @@ -1123,15 +1144,16 @@ pub(crate) fn uninstall_mastracode() -> io::Result { )) })?; - let quoted_hook_path = shell_single_quote(&hook_path.display().to_string()); for (event, action) in MASTRACODE_HOOK_EVENTS .into_iter() .chain(MASTRACODE_REMOVED_HOOK_EVENTS) { + updated_hooks |= + remove_flat_command_hook(hooks, event, &hook_command(&hook_path, Some(action)))?; updated_hooks |= remove_flat_command_hook( hooks, event, - &format!("bash {quoted_hook_path} {action}"), + &mastracode_hook_command(&hook_path, action), )?; } @@ -1252,9 +1274,22 @@ pub(crate) fn uninstall_antigravity_cli() -> io::Result String { + #[cfg(windows)] + { + hook_command(hook_path, Some("session")) + } + #[cfg(not(windows))] + { + format!( + "sh {} session", + shell_single_quote(&hook_path.display().to_string()) + ) + } +} + pub(crate) fn grok_hook_config(hook_path: &Path) -> Value { - let quoted_hook_path = shell_single_quote(&hook_path.display().to_string()); - let session_command = format!("sh {quoted_hook_path} session"); + let session_command = grok_hook_command(hook_path); json!({ "hooks": { "SessionStart": [ diff --git a/src/integration/tests.rs b/src/integration/tests.rs index 6827dae710..0081fcdb11 100644 --- a/src/integration/tests.rs +++ b/src/integration/tests.rs @@ -179,10 +179,11 @@ fn home_dir_uses_userprofile_when_home_is_missing() { fn windows_supports_portable_integrations() { use crate::api::schema::IntegrationTarget; - assert!(!integration_target_supported(IntegrationTarget::Hermes)); - assert!(!integration_target_supported(IntegrationTarget::Cursor)); - assert!(!integration_target_supported(IntegrationTarget::Devin)); - assert!(!integration_target_supported(IntegrationTarget::Mastracode)); + assert!(integration_target_supported(IntegrationTarget::Hermes)); + assert!(integration_target_supported(IntegrationTarget::Cursor)); + assert!(integration_target_supported(IntegrationTarget::Devin)); + assert!(integration_target_supported(IntegrationTarget::Mastracode)); + assert!(integration_target_supported(IntegrationTarget::Grok)); assert!(integration_target_supported(IntegrationTarget::Pi)); assert!(integration_target_supported(IntegrationTarget::Omp)); @@ -198,7 +199,7 @@ fn windows_supports_portable_integrations() { #[cfg(windows)] #[test] -fn windows_availability_excludes_unsupported_integrations() { +fn windows_availability_includes_native_integrations() { use crate::api::schema::IntegrationTarget; let _lock = integration_env_lock(); @@ -216,15 +217,17 @@ fn windows_availability_excludes_unsupported_integrations() { fs::write(bin.join("cursor-agent.cmd"), "@echo off\r\n").unwrap(); fs::write(bin.join("devin.cmd"), "@echo off\r\n").unwrap(); fs::write(bin.join("mastracode.cmd"), "@echo off\r\n").unwrap(); + fs::write(bin.join("grok.cmd"), "@echo off\r\n").unwrap(); assert!(integration_target_available(IntegrationTarget::Pi)); assert!(integration_target_available(IntegrationTarget::Omp)); assert!(integration_target_available(IntegrationTarget::Opencode)); assert!(integration_target_available(IntegrationTarget::Kilo)); - assert!(!integration_target_available(IntegrationTarget::Hermes)); - assert!(!integration_target_available(IntegrationTarget::Cursor)); - assert!(!integration_target_available(IntegrationTarget::Devin)); - assert!(!integration_target_available(IntegrationTarget::Mastracode)); + assert!(integration_target_available(IntegrationTarget::Hermes)); + assert!(integration_target_available(IntegrationTarget::Cursor)); + assert!(integration_target_available(IntegrationTarget::Devin)); + assert!(integration_target_available(IntegrationTarget::Mastracode)); + assert!(integration_target_available(IntegrationTarget::Grok)); if let Some(path) = original_path { std::env::set_var("PATH", path); @@ -234,41 +237,6 @@ fn windows_availability_excludes_unsupported_integrations() { let _ = fs::remove_dir_all(base); } -#[cfg(windows)] -#[test] -fn windows_install_rejects_unsupported_integration_before_config_lookup() { - use crate::api::schema::IntegrationTarget; - - let _lock = integration_env_lock(); - let original_home = std::env::var_os("HOME"); - let original_userprofile = std::env::var_os("USERPROFILE"); - let original_homedrive = std::env::var_os("HOMEDRIVE"); - let original_homepath = std::env::var_os("HOMEPATH"); - std::env::remove_var("HOME"); - std::env::remove_var("USERPROFILE"); - std::env::remove_var("HOMEDRIVE"); - std::env::remove_var("HOMEPATH"); - - let err = install_target(IntegrationTarget::Hermes).unwrap_err(); - assert_eq!( - err.to_string(), - "hermes integration is not supported on Windows" - ); - - if let Some(home) = original_home { - std::env::set_var("HOME", home); - } - if let Some(userprofile) = original_userprofile { - std::env::set_var("USERPROFILE", userprofile); - } - if let Some(homedrive) = original_homedrive { - std::env::set_var("HOMEDRIVE", homedrive); - } - if let Some(homepath) = original_homepath { - std::env::set_var("HOMEPATH", homepath); - } -} - #[test] #[cfg(unix)] fn command_available_requires_executable_file_on_path() { @@ -349,23 +317,37 @@ fn qodercli_availability_checks_windows_aliases() { #[test] #[cfg(windows)] -fn hermes_layout_can_exist_without_making_unsupported_target_available() { +fn hermes_layout_makes_target_available() { let _lock = integration_env_lock(); let base = unique_base(); let local_app_data = base.join("local-app-data"); let hermes_bin = local_app_data.join("hermes").join("bin"); fs::create_dir_all(&hermes_bin).unwrap(); fs::write(hermes_bin.join("hermes.exe"), "").unwrap(); + let original_hermes_home = std::env::var_os(HERMES_HOME_ENV_VAR); + let original_home = std::env::var_os("HOME"); let original_local_app_data = std::env::var_os("LOCALAPPDATA"); let original_path = std::env::var_os("PATH"); + std::env::remove_var(HERMES_HOME_ENV_VAR); + std::env::remove_var("HOME"); std::env::set_var("LOCALAPPDATA", &local_app_data); std::env::set_var("PATH", ""); assert!(hermes_install_layout_available()); - assert!(!integration_target_available( + assert!(integration_target_available( crate::api::schema::IntegrationTarget::Hermes )); + if let Some(hermes_home) = original_hermes_home { + std::env::set_var(HERMES_HOME_ENV_VAR, hermes_home); + } else { + std::env::remove_var(HERMES_HOME_ENV_VAR); + } + if let Some(home) = original_home { + std::env::set_var("HOME", home); + } else { + std::env::remove_var("HOME"); + } if let Some(local_app_data) = original_local_app_data { std::env::set_var("LOCALAPPDATA", local_app_data); } else { @@ -3134,14 +3116,10 @@ fn install_cursor_writes_hook_and_updates_hooks_json() { let hooks = hooks_file.get("hooks").and_then(Value::as_object).unwrap(); let session_start = hooks.get("sessionStart").and_then(Value::as_array).unwrap(); assert_eq!(session_start.len(), 1); - assert!(session_start[0] - .get("command") - .and_then(Value::as_str) - .is_some_and(|command| { - command.starts_with("bash ") - && command.contains("herdr-agent-state.sh") - && command.ends_with(" session") - })); + assert_eq!( + session_start[0].get("command").and_then(Value::as_str), + Some(hook_command(&installed.hook_path, Some("session")).as_str()) + ); assert!(hooks.get("beforeSubmitPrompt").is_none()); assert!(hooks.get("beforeShellExecution").is_none()); let stop = hooks.get("stop").and_then(Value::as_array).unwrap(); @@ -3308,9 +3286,10 @@ fn install_mastracode_writes_hook_and_updates_hooks_json() { let entries = hooks.get(event).and_then(Value::as_array).unwrap(); assert_eq!(entries.len(), 1, "{event} should have one Herdr hook"); let command = entries[0].get("command").and_then(Value::as_str).unwrap(); - assert!(command.starts_with("bash ")); - assert!(command.contains(MASTRACODE_HOOK_INSTALL_NAME)); - assert!(command.ends_with(action)); + assert_eq!( + command, + mastracode_hook_command(&installed.hook_path, action) + ); assert_eq!( entries[0].get("type").and_then(Value::as_str), Some("command") @@ -3369,9 +3348,14 @@ fn install_grok_writes_hook_and_config() { let session_start = config["hooks"]["SessionStart"].as_array().unwrap(); assert_eq!(session_start.len(), 1); let command = grok_session_command(&config); - assert!(command.starts_with("sh ")); - assert!(command.contains("herdr-agent-state.sh")); - assert!(command.ends_with(" session")); + #[cfg(windows)] + assert_eq!(command, hook_command(&installed.hook_path, Some("session"))); + #[cfg(not(windows))] + { + assert!(command.starts_with("sh ")); + assert!(command.contains("herdr-agent-state.sh")); + assert!(command.ends_with(" session")); + } std::env::remove_var(GROK_CONFIG_DIR_ENV_VAR); let _ = fs::remove_dir_all(base); @@ -3392,12 +3376,12 @@ fn install_mastracode_removes_v1_lifecycle_hooks() { serde_json::to_string(&json!({ "SessionStart": [{ "type": "command", - "command": format!("bash '{}' idle", hook_path.display()), + "command": hook_command(&hook_path, Some("idle")), "timeout": MASTRACODE_HOOK_TIMEOUT_MS }], "SessionEnd": [{ "type": "command", - "command": format!("bash '{}' release", hook_path.display()), + "command": hook_command(&hook_path, Some("release")), "timeout": MASTRACODE_HOOK_TIMEOUT_MS }] })) @@ -3415,10 +3399,10 @@ fn install_mastracode_removes_v1_lifecycle_hooks() { assert!(!hooks.contains_key("SessionEnd")); let session_start = hooks["SessionStart"].as_array().unwrap(); assert_eq!(session_start.len(), 1); - assert!(session_start[0]["command"] - .as_str() - .unwrap() - .ends_with("session")); + assert_eq!( + session_start[0]["command"].as_str().unwrap(), + mastracode_hook_command(&hook_path, "session") + ); if let Some(home) = original_home { std::env::set_var("HOME", home);