diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-09 15:59:34 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-09 15:59:34 +0300 |
| commit | df7880c54b8b5d337421cb4b1dd8daea27008801 (patch) | |
| tree | 4d0cc6290ad71c413bac71cfaab4ef41ea017ecb | |
| parent | eeacb45b4b6ab4446c5bea1331f743fee5e5b9f8 (diff) | |
feat: update agent lifecycle integrations
24 files changed, 922 insertions, 737 deletions
diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md index f01d8880..8829c802 100644 --- a/docs/next/CHANGELOG.md +++ b/docs/next/CHANGELOG.md @@ -14,11 +14,15 @@ ### Added - Added `herdr integration install droid` for Factory Droid hooks that report session ids through Herdr's socket API. When native agent session restore is enabled, Herdr can resume Droid panes with `droid --resume <id>`. +- Added `herdr integration install kilo` for Kilo Code CLI plugins that report lifecycle state and session ids through Herdr's socket API. When native agent session restore is enabled, Herdr can resume Kilo panes with `kilo --session <id>`. - Added directional pane swap with `prefix+shift+h/j/k/l`, a pane context-menu swap action, pane layout/neighbor/edge/focus/resize socket APIs, matching CLI commands, and optional `pane split --ratio` support. - Added `herdr pane zoom` and the `pane.zoom` socket API to toggle, set, or clear tab-local pane zoom from scripts and integrations. - Added toast ergonomics controls for delayed agent notifications, in-app toast placement, copied-to-clipboard feedback, and the `notification.show` socket API with `herdr notification show` and optional `none`, `done`, or `request` sounds. (#486) - Added native Windows beta documentation, `install.ps1`, preview Windows release assets, update-channel wiring, and platform capability tracking for ConPTY panes, semantic client input, Windows agent discovery, known partial cwd behavior, and unsupported Unix-only features such as live handoff, direct terminal attach, and `herdr --remote` from the Windows binary. +### Changed +- OpenCode installed with the current Herdr plugin now reports lifecycle state directly instead of relying on PTY/screen state detection. Droid, Kimi Code CLI, and Qoder CLI now report native session identity while leaving lifecycle state to PTY/screen detection. + ## [0.6.8] - 2026-06-04 This is a hotfix release for v0.6.7, prioritizing a server-crash fix for panes that print complex Unicode or emoji output. @@ -41,11 +45,11 @@ This is a hotfix release for v0.6.7, prioritizing a server-crash fix for panes t - Added a remote SSH bridge keepalive fallback. `herdr --remote` now generates a temporary SSH config that includes the user's SSH config first, then adds `ServerAliveInterval` and `ServerAliveCountMax` only when the user has not already configured keepalives. Set `[remote].manage_ssh_config = false` to disable this. (#354, #355, thanks @SunskyXH) - Added `ui.right_click_passthrough_modifier` so a configured modifier such as `ctrl` can forward right-click hold and drag gestures to mouse-reporting pane apps while normal right-click still opens Herdr's pane menu. (#148) - Added Kilo Code CLI automatic detection for idle, working, and blocked terminal states. (#270) -- Added `herdr integration install copilot` for GitHub Copilot CLI hooks that report prompt, tool, post-approval progress, permission, `ask_user`, `exit_plan_mode`, idle, session-exit state, and session ids through Herdr's socket API. When native agent session restore is enabled, Herdr can resume Copilot panes with `copilot --resume=<id>`. (#232, #386, thanks @LaneBirmingham) +- Added `herdr integration install copilot` for GitHub Copilot CLI hooks that report native session ids through Herdr's socket API. Copilot state still comes from Herdr's PTY/screen detection because Copilot hooks do not provide complete lifecycle coverage. When native agent session restore is enabled, Herdr can resume Copilot panes with `copilot --resume=<id>`. (#232, #386, thanks @LaneBirmingham) ### Changed - Native agent session restore is now enabled by default for supported panes with current official integrations. Set `[session] resume_agents_on_restore = false` to disable it. -- Claude Code, Codex, and OpenCode integrations now report session identity only. Native state for those agents comes from Herdr's screen detection, while Pi, OMP, GitHub Copilot CLI, Hermes Agent, Qoder CLI, and custom socket integrations can still report state. +- Claude Code, Codex, GitHub Copilot CLI, Droid, Kimi Code CLI, and Qoder CLI integrations now report session identity only. Native state for those agents comes from Herdr's screen detection, while Pi, OMP, OpenCode, Kilo Code CLI, Hermes Agent, and custom socket integrations can still report state. ### Fixed - Large long-running sessions no longer hit the frame-streaming crash fixed by the vendored libghostty-vt update. (#276) diff --git a/docs/next/README.md b/docs/next/README.md index af910ba3..cf0d6c0c 100644 --- a/docs/next/README.md +++ b/docs/next/README.md @@ -153,7 +153,7 @@ states: - 🔵 **done** — work finished, you have not looked at it yet - 🟢 **idle** — done and seen -detection works by reading foreground process and terminal output. zero config, no hooks required. official claude code, codex, cursor agent cli, droid, and opencode integrations provide session restore identity; pi, omp, github copilot cli, kimi code cli, hermes, qodercli, and custom socket integrations can report their own state. +detection works by reading foreground process and terminal output. zero config, no hooks required. official claude code, codex, github copilot cli, droid, kimi code cli, qodercli, and cursor agent cli integrations provide session restore identity; pi, omp, opencode, kilo code cli, hermes, and custom socket integrations can report their own state. ## lives in your terminal @@ -200,7 +200,7 @@ for agents outside the built-in list, herdr still works as a terminal multiplexe ### direct integrations -official integrations have two roles. claude code, codex, cursor agent cli, droid, and opencode report session identity for native restore, while their state still comes from screen detection. pi, github copilot cli, and hermes report both semantic state and session identity. omp, kimi code cli, and qodercli report semantic state without native session restore. install with: +official integrations have two roles. claude code, codex, github copilot cli, droid, kimi code cli, qodercli, and cursor agent cli report session identity for native restore, while their state still comes from screen detection. pi, opencode, kilo code cli, and hermes report both semantic state and session identity. omp reports semantic state without native session restore. install with: ```bash herdr integration install pi @@ -211,6 +211,7 @@ herdr integration install copilot herdr integration install droid herdr integration install kimi herdr integration install opencode +herdr integration install kilo herdr integration install hermes herdr integration install qodercli herdr integration install cursor @@ -260,7 +261,7 @@ In-app settings cover theme, sound, and toast preferences. Herdr writes logs und - [install](https://herdr.dev/docs/install/) — install, update, Homebrew, mise, and Nix - [session state](https://herdr.dev/docs/session-state/) — detach, restart restore, agent restore, and live handoff - [configuration](https://herdr.dev/docs/configuration/) — keybindings, themes, notifications, environment variables -- [integrations](https://herdr.dev/docs/integrations/) — pi, omp, claude code, codex, cursor agent cli, github copilot cli, droid, kimi code cli, opencode, hermes, qodercli integrations +- [integrations](https://herdr.dev/docs/integrations/) — pi, omp, claude code, codex, cursor agent cli, github copilot cli, droid, kimi code cli, opencode, kilo code cli, hermes, qodercli integrations - [`SKILL.md`](./SKILL.md) — reusable agent skill - [socket api](https://herdr.dev/docs/socket-api/) — socket protocol and cli reference diff --git a/docs/next/website/src/content/docs/agents.mdx b/docs/next/website/src/content/docs/agents.mdx index ca0e29b6..e7abe5c5 100644 --- a/docs/next/website/src/content/docs/agents.mdx +++ b/docs/next/website/src/content/docs/agents.mdx @@ -13,18 +13,18 @@ Automatic detection works out of the box for common coding agents. The important | --- | --- | --- | | Pi | lifecycle hooks when installed; otherwise PTY/screen | state and session | | OMP | lifecycle hooks when installed | state | -| GitHub Copilot CLI | lifecycle hooks when installed; otherwise PTY/screen | state and session | -| Kimi Code CLI | lifecycle hooks when installed; otherwise PTY/screen | state | +| GitHub Copilot CLI | PTY/screen | session | +| Kimi Code CLI | PTY/screen | session | | Hermes Agent | lifecycle hooks when installed; otherwise PTY/screen | state and session | -| Qoder CLI | lifecycle hooks when installed; otherwise PTY/screen | state | +| Qoder CLI | PTY/screen | session | +| Droid | PTY/screen | session | +| OpenCode | lifecycle plugin when installed; otherwise PTY/screen | state and session | +| Kilo Code CLI | lifecycle plugin when installed; otherwise PTY/screen | state and session | | Claude Code | PTY/screen | session | | Codex | PTY/screen | session | | Cursor Agent CLI | PTY/screen | session | -| Droid | PTY/screen | session | -| OpenCode | PTY/screen | session | | Amp | PTY/screen | none | | Grok CLI | PTY/screen | none | -| Kilo Code CLI | PTY/screen | none | | Antigravity CLI | PTY/screen | none | | Kiro CLI | PTY/screen | none | @@ -40,7 +40,7 @@ For agents without complete lifecycle hooks, Herdr uses PTY-first detection. Ter Herdr briefly ignores detection after a new agent process appears, and after input, mouse events, or pane resize. Those events can make a terminal UI redraw even when the agent did not resume work. Once the quiet window passes, normal detection starts again from fresh PTY activity. -Claude Code, Codex, Cursor Agent CLI, Droid, and OpenCode integrations are intentionally not lifecycle authorities. They provide native session identity for restore, but their hooks do not cover the whole lifecycle. They can miss permission approval results, escape interrupts, or other transitions. For those agents, Herdr still uses PTY/screen state detection. +Claude Code, Codex, GitHub Copilot CLI, Droid, Kimi Code CLI, Qoder CLI, and Cursor Agent CLI integrations are intentionally not lifecycle authorities. They provide native session identity for restore, but their hooks do not cover the whole lifecycle. They can miss permission approval results, escape interrupts, or other transitions. For those agents, Herdr still uses PTY/screen state detection. ## Blocked state @@ -72,6 +72,7 @@ herdr integration install cursor herdr integration install droid herdr integration install kimi herdr integration install opencode +herdr integration install kilo herdr integration install hermes herdr integration install qodercli ``` diff --git a/docs/next/website/src/content/docs/cli-reference.mdx b/docs/next/website/src/content/docs/cli-reference.mdx index aaa550dd..bd98c1d5 100644 --- a/docs/next/website/src/content/docs/cli-reference.mdx +++ b/docs/next/website/src/content/docs/cli-reference.mdx @@ -230,6 +230,7 @@ herdr integration install copilot herdr integration install droid herdr integration install kimi herdr integration install opencode +herdr integration install kilo herdr integration install hermes herdr integration install qodercli herdr integration install cursor @@ -241,6 +242,7 @@ herdr integration uninstall copilot herdr integration uninstall droid herdr integration uninstall kimi herdr integration uninstall opencode +herdr integration uninstall kilo herdr integration uninstall hermes herdr integration uninstall qodercli herdr integration uninstall cursor diff --git a/docs/next/website/src/content/docs/configuration.mdx b/docs/next/website/src/content/docs/configuration.mdx index ebe28aa2..6d2d927d 100644 --- a/docs/next/website/src/content/docs/configuration.mdx +++ b/docs/next/website/src/content/docs/configuration.mdx @@ -418,7 +418,7 @@ Herdr can restart supported agent panes in their native conversation sessions af resume_agents_on_restore = true ``` -This is enabled by default. Herdr only resumes panes that reported a native session reference through an official Herdr integration. Supported resume targets are Claude Code, Codex, GitHub Copilot CLI, Droid, Pi, Hermes Agent, and OpenCode. Unsupported, missing, invalid, duplicated, or stale session references restore as a normal shell in the saved pane directory. +This is enabled by default. Herdr only resumes panes that reported a native session reference through an official Herdr integration. Supported resume targets are Claude Code, Codex, Cursor Agent CLI, GitHub Copilot CLI, Droid, Kimi Code CLI, Qoder CLI, Pi, Hermes Agent, OpenCode, and Kilo Code CLI. Unsupported, missing, invalid, duplicated, or stale session references restore as a normal shell in the saved pane directory. Session references are stored in the local Herdr session snapshot. They are not shown in normal pane, agent, status, or event output. diff --git a/docs/next/website/src/content/docs/integrations.mdx b/docs/next/website/src/content/docs/integrations.mdx index b5ed6c77..47ab11ed 100644 --- a/docs/next/website/src/content/docs/integrations.mdx +++ b/docs/next/website/src/content/docs/integrations.mdx @@ -1,11 +1,11 @@ --- title: Integrations -description: Install Herdr integrations for Pi, OMP, Claude Code, Codex, GitHub Copilot CLI, Droid, Kimi Code CLI, OpenCode, Hermes Agent, Qoder CLI, and Cursor Agent CLI. +description: Install Herdr integrations for Pi, OMP, Claude Code, Codex, GitHub Copilot CLI, Droid, Kimi Code CLI, OpenCode, Kilo Code CLI, Hermes Agent, Qoder CLI, and Cursor Agent CLI. --- Herdr detects supported agents automatically. Official integrations can add native session identity for restore, lifecycle state reports, or both. -Use integrations when you want native agent session restore, direct lifecycle reports from Pi/OMP/Copilot/Kimi/Hermes/Qoder-style hooks or plugins, or both. See [Agents](/docs/agents/) for the full status authority model. +Use integrations when you want native agent session restore, direct lifecycle reports from Pi/OMP/Copilot/OpenCode/Kilo/Hermes-style hooks or plugins, or both. See [Agents](/docs/agents/) for the full status authority model. ## Install integrations @@ -20,6 +20,7 @@ herdr integration install copilot herdr integration install droid herdr integration install kimi herdr integration install opencode +herdr integration install kilo herdr integration install hermes herdr integration install qodercli herdr integration install cursor @@ -36,6 +37,7 @@ herdr integration uninstall copilot herdr integration uninstall droid herdr integration uninstall kimi herdr integration uninstall opencode +herdr integration uninstall kilo herdr integration uninstall hermes herdr integration uninstall qodercli herdr integration uninstall cursor @@ -47,14 +49,14 @@ Herdr uses integrations in two different ways: | Integration type | Agents | Effect | | --- | --- | --- | -| Lifecycle authority | Pi, OMP, GitHub Copilot CLI, Kimi Code CLI, Hermes Agent, Qoder CLI | When installed and actively reporting for the pane, hook or plugin events author `idle`, `working`, and `blocked`. Herdr does not also use PTY/screen fallback for that same lifecycle authority. | -| Session identity | Claude Code, Codex, Cursor Agent CLI, Droid, OpenCode | The integration reports native session references for restore. State still comes from Herdr's PTY/screen detection. | +| Lifecycle authority | Pi, OMP, OpenCode, Kilo Code CLI, Hermes Agent | When installed and actively reporting for the pane, hook or plugin events author `idle`, `working`, and `blocked`. Herdr does not also use PTY/screen fallback for that same lifecycle authority. | +| Session identity | Claude Code, Codex, GitHub Copilot CLI, Droid, Kimi Code CLI, Qoder CLI, Cursor Agent CLI | The integration reports native session references for restore. State still comes from Herdr's PTY/screen detection. | Custom socket integrations can also report state when they define state that is not visible in the native terminal UI. -Some integrations report native agent session references. Herdr uses official session references to resume Claude Code, Codex, Cursor Agent CLI, GitHub Copilot CLI, Droid, Pi, Hermes Agent, and OpenCode panes after a Herdr server restart unless `[session] resume_agents_on_restore = false` disables it. +Some integrations report native agent session references. Herdr uses official session references to resume Claude Code, Codex, Droid, Kimi Code CLI, Qoder CLI, Cursor Agent CLI, GitHub Copilot CLI, Pi, Hermes Agent, OpenCode, and Kilo Code 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`, Claude Code version `5`, Codex version `5`, Cursor Agent CLI version `1`, GitHub Copilot CLI version `1`, Droid version `1`, OpenCode version `4`, or Hermes Agent version `2`. OMP integration version `2`, Kimi Code CLI integration version `1`, and Qoder CLI integration version `1` report agent state only. Check installed versions with `herdr integration status`. +Native session restore requires current Herdr integrations: Pi integration version `2`, Claude Code version `5`, Codex version `5`, GitHub Copilot CLI version `2`, Droid version `2`, Kimi Code CLI version `2`, Qoder CLI version `2`, Cursor Agent CLI version `1`, OpenCode version `5`, Kilo Code CLI version `1`, or Hermes Agent version `2`. OMP integration version `2` reports agent state only. Check installed versions with `herdr integration status`. ## Pi @@ -122,9 +124,9 @@ Install the GitHub Copilot CLI hook: herdr integration install copilot ``` -Copilot state is reported through the same local socket API used by other integrations. +The Copilot hook reports session identity through the same local socket API used by other integrations. Copilot state comes from Herdr's PTY/screen detection. -Herdr uses `~/.copilot` by default, or `COPILOT_HOME` when set. The Copilot config directory must already exist. Install writes `hooks/herdr-agent-state.sh` and updates `settings.json` with Herdr hook entries. The hook maps prompt submission, tool use, post-tool progress after approvals, `ask_user` prompts, `exit_plan_mode` plan review prompts, permission notifications, agent idle notifications, turn stops, and session exits into Herdr state. Uninstall removes Herdr entries from `settings.json` and deletes the hook script. +Herdr uses `~/.copilot` by default, or `COPILOT_HOME` when set. The Copilot config directory must already exist. Install writes `hooks/herdr-agent-state.sh` and updates `settings.json` with a `SessionStart` hook entry. Uninstall removes Herdr entries from `settings.json` and deletes the hook script. After Copilot emits a session-bearing event, Herdr can use the reported session id to resume the pane with `copilot --resume=<id>`. @@ -136,11 +138,11 @@ Install the Kimi Code CLI hook: herdr integration install kimi ``` -The hook reports semantic state to Herdr when Kimi Code emits lifecycle, tool, approval, and session events. It requires Kimi Code CLI `0.8.0` or newer. +The hook reports Kimi session identity to Herdr for native restore. Lifecycle state still comes from Herdr's PTY/screen detection because Kimi hooks do not cover every lifecycle transition. It requires Kimi Code CLI `0.8.0` or newer. Herdr uses `~/.kimi-code` by default, or `KIMI_CODE_HOME` when set. The Kimi Code config directory must already exist. Install writes `hooks/herdr-agent-state.sh` and appends Herdr-managed `[[hooks]]` entries to `config.toml`. Uninstall removes the Herdr-managed config block and deletes the hook script. -Native PTY/screen detection remains available when the hook is not installed. +Herdr resumes stored Kimi sessions with `kimi --session <id>`. ## Droid @@ -150,7 +152,7 @@ Install the Droid hook: herdr integration install droid ``` -The Droid hook reports session identity through the same local socket API used by other integrations. Droid state comes from Herdr's PTY/screen detection. +The Droid hook reports session identity through the same local socket API used by other integrations. Lifecycle state still comes from Herdr's PTY/screen detection because Droid hooks do not cover every lifecycle transition. Herdr uses `~/.factory` for Droid hooks. The Factory config directory must already exist. Install writes `hooks/herdr-agent-state.sh`, updates `settings.json` with a Herdr `SessionStart` hook entry, and removes older Herdr Droid hook entries from `hooks.json` if present. Uninstall removes Herdr entries from both config files and deletes the hook script. @@ -166,7 +168,19 @@ herdr integration install opencode Herdr writes the plugin to `~/.config/opencode/plugins/herdr-agent-state.js`. The OpenCode config directory must already exist. Uninstall removes only that plugin file. -The plugin reports session identity while OpenCode runs inside a Herdr pane. After OpenCode emits a session-bearing event, Herdr can use the reported session id to resume the pane with `opencode --session <id>`. OpenCode state comes from Herdr's PTY/screen detection. +The plugin reports lifecycle state and session identity while OpenCode runs inside a Herdr pane. After OpenCode emits a session-bearing event, Herdr can use the reported session id to resume the pane with `opencode --session <id>`. Native PTY/screen detection remains available when the plugin is not installed. + +## Kilo Code CLI + +Install the Kilo Code CLI plugin: + +```bash +herdr integration install kilo +``` + +Herdr writes the plugin to `~/.config/kilo/plugin/herdr-agent-state.js`. The Kilo config directory must already exist. Uninstall removes only that plugin file. + +The plugin reports lifecycle state and session identity while Kilo runs inside a Herdr pane. After Kilo emits a session-bearing event, Herdr can use the reported session id to resume the pane with `kilo --session <id>`. Native PTY/screen detection remains available when the plugin is not installed. ## Hermes Agent @@ -188,10 +202,12 @@ Install the Qoder CLI hook: herdr integration install qodercli ``` -The hook reports semantic state to Herdr when Qoder CLI emits lifecycle events. +The hook reports Qoder CLI session identity to Herdr for native restore. Lifecycle state still comes from Herdr's PTY/screen detection because Qoder hooks do not cover every lifecycle transition. Herdr uses `~/.qoder` by default, or `QODER_CONFIG_DIR` when set. The Qoder config directory must already exist. Install writes `hooks/herdr-agent-state.sh` and updates `settings.json` with Herdr hook entries. Uninstall removes the matching hook entries and deletes the hook script. +Herdr resumes stored Qoder CLI sessions with `qodercli --resume <id>`. + Native PTY/screen detection remains available when the hook is not installed. ## Cursor Agent CLI diff --git a/docs/next/website/src/content/docs/session-state.mdx b/docs/next/website/src/content/docs/session-state.mdx index ceaf5bb0..e88c59bf 100644 --- a/docs/next/website/src/content/docs/session-state.mdx +++ b/docs/next/website/src/content/docs/session-state.mdx @@ -59,13 +59,15 @@ Native session restore requires these Herdr integration versions or newer: | Claude Code | `5` | `claude --resume <id>` | | Codex | `5` | `codex resume <id>` | | Cursor Agent CLI | `1` | `cursor-agent --resume <id>` | -| GitHub Copilot CLI | `1` | `copilot --resume=<id>` | -| Droid | `1` | `droid --resume <id>` | -| OpenCode | `4` | `opencode --session <id>` | +| GitHub Copilot CLI | `2` | `copilot --resume=<id>` | +| Droid | `2` | `droid --resume <id>` | +| Kimi Code CLI | `2` | `kimi --session <id>` | +| Qoder CLI | `2` | `qodercli --resume <id>` | +| OpenCode | `5` | `opencode --session <id>` | +| Kilo Code CLI | `1` | `kilo --session <id>` | | Hermes Agent | `2` | `hermes --resume <id>` | OMP integration version `2` reports agent state, but does not report native session references for restore. -Qoder CLI integration version `1` reports agent state, but does not report native session references for restore. Run `herdr integration status` to check installed integration versions. Reinstall outdated integrations with `herdr integration install <agent>`. diff --git a/src/agent_detection_policy.rs b/src/agent_detection_policy.rs index a0d7ba4c..7a2677a2 100644 --- a/src/agent_detection_policy.rs +++ b/src/agent_detection_policy.rs @@ -43,7 +43,7 @@ fn screen_blocked_or_idle_fallback(detection: AgentDetection) -> AgentDetection pub(crate) fn full_lifecycle_detected_agent(agent: Agent) -> bool { matches!( agent, - Agent::Pi | Agent::GithubCopilot | Agent::Kimi | Agent::Hermes | Agent::Qodercli + Agent::Pi | Agent::Hermes | Agent::OpenCode | Agent::Kilo ) } @@ -52,10 +52,9 @@ pub(crate) fn full_lifecycle_hook_authority(source: &str, agent_label: &str) -> (source, agent_label), ("herdr:pi", "pi") | ("herdr:omp", "omp") - | ("herdr:kimi", "kimi") - | ("herdr:copilot", "copilot") | ("herdr:hermes", "hermes") - | ("herdr:qodercli", "qodercli") + | ("herdr:opencode", "opencode") + | ("herdr:kilo", "kilo") ) } @@ -122,23 +121,29 @@ mod tests { fn classifies_full_lifecycle_hook_sources() { assert!(full_lifecycle_hook_authority("herdr:pi", "pi")); assert!(full_lifecycle_hook_authority("herdr:omp", "omp")); - assert!(full_lifecycle_hook_authority("herdr:kimi", "kimi")); - assert!(full_lifecycle_hook_authority("herdr:copilot", "copilot")); assert!(full_lifecycle_hook_authority("herdr:hermes", "hermes")); - assert!(full_lifecycle_hook_authority("herdr:qodercli", "qodercli")); + assert!(full_lifecycle_hook_authority("herdr:opencode", "opencode")); + assert!(full_lifecycle_hook_authority("herdr:kilo", "kilo")); + assert!(!full_lifecycle_hook_authority("herdr:copilot", "copilot")); assert!(!full_lifecycle_hook_authority("herdr:codex", "codex")); assert!(!full_lifecycle_hook_authority("herdr:claude", "claude")); - assert!(!full_lifecycle_hook_authority("herdr:opencode", "opencode")); + assert!(!full_lifecycle_hook_authority("herdr:cursor", "cursor")); + assert!(!full_lifecycle_hook_authority("herdr:kimi", "kimi")); + assert!(!full_lifecycle_hook_authority("herdr:droid", "droid")); + assert!(!full_lifecycle_hook_authority("herdr:qodercli", "qodercli")); assert!(!full_lifecycle_hook_authority("custom", "pi")); } #[test] fn classifies_full_lifecycle_detected_agents_without_omp_variant() { assert!(full_lifecycle_detected_agent(Agent::Pi)); - assert!(full_lifecycle_detected_agent(Agent::Kimi)); - assert!(full_lifecycle_detected_agent(Agent::GithubCopilot)); assert!(full_lifecycle_detected_agent(Agent::Hermes)); - assert!(full_lifecycle_detected_agent(Agent::Qodercli)); + assert!(full_lifecycle_detected_agent(Agent::OpenCode)); + assert!(full_lifecycle_detected_agent(Agent::Kilo)); + assert!(!full_lifecycle_detected_agent(Agent::GithubCopilot)); + assert!(!full_lifecycle_detected_agent(Agent::Kimi)); + assert!(!full_lifecycle_detected_agent(Agent::Droid)); + assert!(!full_lifecycle_detected_agent(Agent::Qodercli)); assert!(!full_lifecycle_detected_agent(Agent::Codex)); assert!(!full_lifecycle_detected_agent(Agent::Claude)); } diff --git a/src/agent_resume.rs b/src/agent_resume.rs index d166aed2..b6a8c818 100644 --- a/src/agent_resume.rs +++ b/src/agent_resume.rs @@ -74,9 +74,11 @@ pub fn is_reserved_native_state_source(source: &str, agent: &str) -> bool { (source, agent), ("herdr:claude", "claude") | ("herdr:codex", "codex") - | ("herdr:cursor", "cursor") + | ("herdr:copilot", "copilot") | ("herdr:droid", "droid") - | ("herdr:opencode", "opencode") + | ("herdr:kimi", "kimi") + | ("herdr:qodercli", "qodercli") + | ("herdr:cursor", "cursor") ) } @@ -123,6 +125,9 @@ pub fn plan(source: &str, agent: &str, session_ref: &AgentSessionRef) -> Option< ("herdr:droid", "droid", AgentSessionRefKind::Id) => { vec!["droid".into(), "--resume".into(), session_ref.value.clone()] } + ("herdr:kimi", "kimi", AgentSessionRefKind::Id) => { + vec!["kimi".into(), "--session".into(), session_ref.value.clone()] + } ("herdr:pi", "pi", AgentSessionRefKind::Path | AgentSessionRefKind::Id) => { vec!["pi".into(), "--session".into(), session_ref.value.clone()] } @@ -140,6 +145,16 @@ pub fn plan(source: &str, agent: &str, session_ref: &AgentSessionRef) -> Option< session_ref.value.clone(), ] } + ("herdr:qodercli", "qodercli", AgentSessionRefKind::Id) => { + vec![ + "qodercli".into(), + "--resume".into(), + session_ref.value.clone(), + ] + } + ("herdr:kilo", "kilo", AgentSessionRefKind::Id) => { + vec!["kilo".into(), "--session".into(), session_ref.value.clone()] + } ("herdr:cursor", "cursor", AgentSessionRefKind::Id) => { vec![ "cursor-agent".into(), @@ -171,9 +186,12 @@ fn is_official_agent_source(source: &str, agent: &str) -> bool { | ("herdr:codex", "codex") | ("herdr:copilot", "copilot") | ("herdr:droid", "droid") + | ("herdr:kimi", "kimi") | ("herdr:pi", "pi") | ("herdr:hermes", "hermes") | ("herdr:opencode", "opencode") + | ("herdr:qodercli", "qodercli") + | ("herdr:kilo", "kilo") | ("herdr:cursor", "cursor") ) } @@ -246,6 +264,16 @@ mod tests { ); assert_eq!( plan( + "herdr:kimi", + "kimi", + &AgentSessionRef::id("kimi-session").unwrap() + ) + .unwrap() + .argv, + vec!["kimi", "--session", "kimi-session"] + ); + assert_eq!( + plan( "herdr:pi", "pi", &AgentSessionRef::path(&pi_session).unwrap() @@ -276,6 +304,26 @@ mod tests { ); assert_eq!( plan( + "herdr:qodercli", + "qodercli", + &AgentSessionRef::id("qoder-session").unwrap() + ) + .unwrap() + .argv, + vec!["qodercli", "--resume", "qoder-session"] + ); + assert_eq!( + plan( + "herdr:kilo", + "kilo", + &AgentSessionRef::id("kilo-session").unwrap() + ) + .unwrap() + .argv, + vec!["kilo", "--session", "kilo-session"] + ); + assert_eq!( + plan( "herdr:cursor", "cursor", &AgentSessionRef::id("cursor-session").unwrap() @@ -349,6 +397,22 @@ mod tests { Some("/tmp/droid-session".into()) ) .is_none()); + + let session_ref = + session_ref_from_report("herdr:kimi", "kimi", Some("kimi-id".into()), None).unwrap(); + assert_eq!(session_ref.kind, AgentSessionRefKind::Id); + assert_eq!(session_ref.value, "kimi-id"); + + let session_ref = + session_ref_from_report("herdr:kilo", "kilo", Some("kilo-id".into()), None).unwrap(); + assert_eq!(session_ref.kind, AgentSessionRefKind::Id); + assert_eq!(session_ref.value, "kilo-id"); + + let session_ref = + session_ref_from_report("herdr:qodercli", "qodercli", Some("qoder-id".into()), None) + .unwrap(); + assert_eq!(session_ref.kind, AgentSessionRefKind::Id); + assert_eq!(session_ref.value, "qoder-id"); } #[test] @@ -370,6 +434,7 @@ mod tests { fn planner_rejects_path_refs_for_id_only_agents() { let hermes_session = absolute_test_path("hermes-session"); let opencode_session = absolute_test_path("opencode-session"); + let kilo_session = absolute_test_path("kilo-session"); let copilot_session = absolute_test_path("copilot-session"); assert!(plan( "herdr:hermes", @@ -384,6 +449,12 @@ mod tests { ) .is_none()); assert!(plan( + "herdr:kilo", + "kilo", + &AgentSessionRef::path(&kilo_session).unwrap() + ) + .is_none()); + assert!(plan( "herdr:copilot", "copilot", &AgentSessionRef::path(&copilot_session).unwrap() @@ -404,6 +475,13 @@ mod tests { ) .is_some()); assert!(session_ref_from_snapshot( + "herdr:kilo", + "kilo", + AgentSessionRefKind::Id, + "kilo-session" + ) + .is_some()); + assert!(session_ref_from_snapshot( "herdr:copilot", "copilot", AgentSessionRefKind::Id, diff --git a/src/api/schema.rs b/src/api/schema.rs index 28be9c55..33040248 100644 --- a/src/api/schema.rs +++ b/src/api/schema.rs @@ -447,6 +447,7 @@ pub enum IntegrationTarget { Droid, Kimi, Opencode, + Kilo, Hermes, Qodercli, Cursor, diff --git a/src/cli/integration.rs b/src/cli/integration.rs index a793e4de..e46e04b9 100644 --- a/src/cli/integration.rs +++ b/src/cli/integration.rs @@ -103,13 +103,13 @@ fn parse_integration_target( ) -> std::io::Result<Option<IntegrationTarget>> { let Some(target) = args.first().map(|arg| arg.as_str()) else { eprintln!( - "usage: herdr integration {action} <pi|omp|claude|codex|copilot|droid|kimi|opencode|hermes|qodercli|cursor>" + "usage: herdr integration {action} <pi|omp|claude|codex|copilot|droid|kimi|opencode|kilo|hermes|qodercli|cursor>" ); return Ok(None); }; if args.len() != 1 { eprintln!( - "usage: herdr integration {action} <pi|omp|claude|codex|copilot|droid|kimi|opencode|hermes|qodercli|cursor>" + "usage: herdr integration {action} <pi|omp|claude|codex|copilot|droid|kimi|opencode|kilo|hermes|qodercli|cursor>" ); return Ok(None); } @@ -123,13 +123,14 @@ fn parse_integration_target( "droid" => IntegrationTarget::Droid, "kimi" => IntegrationTarget::Kimi, "opencode" => IntegrationTarget::Opencode, + "kilo" => IntegrationTarget::Kilo, "hermes" => IntegrationTarget::Hermes, "qodercli" => IntegrationTarget::Qodercli, "cursor" => IntegrationTarget::Cursor, _ => { eprintln!("unknown integration target: {target}"); eprintln!( - "currently supported: pi, omp, claude, codex, copilot, droid, kimi, opencode, hermes, qodercli, cursor" + "currently supported: pi, omp, claude, codex, copilot, droid, kimi, opencode, kilo, hermes, qodercli, cursor" ); return Ok(None); } @@ -148,6 +149,7 @@ fn print_integration_help() { eprintln!(" herdr integration install droid"); eprintln!(" herdr integration install kimi"); eprintln!(" herdr integration install opencode"); + eprintln!(" herdr integration install kilo"); eprintln!(" herdr integration install hermes"); eprintln!(" herdr integration install qodercli"); eprintln!(" herdr integration install cursor"); @@ -159,6 +161,7 @@ fn print_integration_help() { eprintln!(" herdr integration uninstall droid"); eprintln!(" herdr integration uninstall kimi"); eprintln!(" herdr integration uninstall opencode"); + eprintln!(" herdr integration uninstall kilo"); eprintln!(" herdr integration uninstall hermes"); eprintln!(" herdr integration uninstall qodercli"); eprintln!(" herdr integration uninstall cursor"); diff --git a/src/integration/assets/copilot/herdr-agent-state.ps1 b/src/integration/assets/copilot/herdr-agent-state.ps1 index 61cec0c9..8951eacb 100644 --- a/src/integration/assets/copilot/herdr-agent-state.ps1 +++ b/src/integration/assets/copilot/herdr-agent-state.ps1 @@ -2,10 +2,11 @@ # managed by herdr; reinstalling or updating the integration overwrites this file. # add custom hooks beside this file instead of editing it. # HERDR_INTEGRATION_ID=copilot -# HERDR_INTEGRATION_VERSION=1 +# HERDR_INTEGRATION_VERSION=2 if ($env:HERDR_ENV -ne "1") { exit 0 } if ([string]::IsNullOrWhiteSpace($env:HERDR_PANE_ID)) { exit 0 } +if ([string]::IsNullOrWhiteSpace($env:HERDR_SOCKET_PATH)) { exit 0 } $inputText = [Console]::In.ReadToEnd() try { @@ -31,74 +32,21 @@ function Normalize-Event { return $Event.Replace("_", "").Replace("-", "").ToLowerInvariant() } -function Infer-Event { - $explicit = First-Text @("hook_event_name", "hookEventName") - if ($explicit) { return $explicit } - if (First-Text @("notification_type", "notificationType")) { return "notification" } - if ($payload.PSObject.Properties.Name -contains "toolResult" -or $payload.PSObject.Properties.Name -contains "tool_result") { return "postToolUse" } - if (($payload.PSObject.Properties.Name -contains "error") -and (First-Text @("tool_name", "toolName"))) { return "postToolUseFailure" } - if (First-Text @("tool_name", "toolName")) { return "preToolUse" } - if (First-Text @("stop_reason", "stopReason")) { return "agentStop" } - if (First-Text @("reason")) { return "sessionEnd" } - if ($payload.PSObject.Properties.Name -contains "prompt") { return "userPromptSubmitted" } - if ( - $payload.PSObject.Properties.Name -contains "initial_prompt" -or - $payload.PSObject.Properties.Name -contains "initialPrompt" -or - $payload.PSObject.Properties.Name -contains "source" -or - (First-Text @("session_id", "sessionId")) - ) { return "sessionStart" } - return "" +$eventName = First-Text @("hook_event_name", "hookEventName") +if ($eventName) { + if ((Normalize-Event $eventName) -ne "sessionstart") { exit 0 } +} elseif ( + ($payload.PSObject.Properties.Name -contains "prompt") -or + (First-Text @("tool_name", "toolName", "notification_type", "notificationType", "stop_reason", "stopReason", "reason")) +) { + exit 0 } -function Has-Initial-Prompt { - $value = $payload.initial_prompt - if ($null -eq $value) { $value = $payload.initialPrompt } - return ($value -is [string] -and -not [string]::IsNullOrWhiteSpace($value)) +$sessionId = $payload.session_id +if ([string]::IsNullOrWhiteSpace($sessionId)) { + $sessionId = $payload.sessionId } +if ([string]::IsNullOrWhiteSpace($sessionId)) { exit 0 } -function Report-State { - param([string]$State, [string]$SessionId) - $seq = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() - $args = @("pane", "report-agent", $env:HERDR_PANE_ID, "--source", "herdr:copilot", "--agent", "copilot", "--state", $State, "--seq", "$seq") - if (-not [string]::IsNullOrWhiteSpace($SessionId)) { - $args += @("--agent-session-id", $SessionId) - } - & herdr @args 2>$null | Out-Null -} - -function Release-Agent { - $seq = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() - & herdr pane release-agent $env:HERDR_PANE_ID --source herdr:copilot --agent copilot --seq $seq 2>$null | Out-Null -} - -try { - $eventKey = Normalize-Event (Infer-Event) - $sessionId = First-Text @("session_id", "sessionId") - $toolName = First-Text @("tool_name", "toolName") - $notificationType = First-Text @("notification_type", "notificationType") - $stopReason = First-Text @("stop_reason", "stopReason") - $reason = First-Text @("reason") - - if ($eventKey -eq "sessionstart") { - Report-State ($(if (Has-Initial-Prompt) { "working" } else { "idle" })) $sessionId - } elseif ($eventKey -in @("userpromptsubmit", "userpromptsubmitted")) { - Report-State "working" $sessionId - } elseif ($eventKey -eq "pretooluse") { - Report-State ($(if ($toolName -in @("ask_user", "exit_plan_mode")) { "blocked" } else { "working" })) $sessionId - } elseif ($eventKey -in @("posttooluse", "posttoolusefailure")) { - if ($toolName -ne "report_intent") { Report-State "working" $sessionId } - } elseif ($eventKey -eq "notification") { - if ($notificationType -in @("permission_prompt", "elicitation_dialog")) { - Report-State "blocked" $sessionId - } elseif ($notificationType -eq "agent_idle") { - Report-State "idle" $sessionId - } - } elseif ($eventKey -in @("stop", "agentstop", "sessionstop")) { - if ([string]::IsNullOrWhiteSpace($stopReason) -or $stopReason -eq "end_turn") { - Report-State "idle" $sessionId - } - } elseif ($eventKey -eq "sessionend") { - if ($reason -in @("user_exit", "abort")) { Release-Agent } - } -} catch { -} +$seq = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() +& herdr pane report-agent-session $env:HERDR_PANE_ID --source herdr:copilot --agent copilot --agent-session-id $sessionId --seq $seq 2>$null | Out-Null diff --git a/src/integration/assets/copilot/herdr-agent-state.sh b/src/integration/assets/copilot/herdr-agent-state.sh index 766b562a..1ae0362b 100644 --- a/src/integration/assets/copilot/herdr-agent-state.sh +++ b/src/integration/assets/copilot/herdr-agent-state.sh @@ -2,18 +2,8 @@ # installed by herdr # managed by herdr; reinstalling or updating the integration overwrites this file. # add custom hooks beside this file instead of editing it. -# High-level state machine: -# - SessionStart reports working when Copilot includes an initial prompt, else idle. -# - UserPromptSubmit reports working for the active turn. -# - PreToolUse reports blocked for ask_user and exit_plan_mode, else working. -# - notification permission_prompt / elicitation_dialog reports blocked. -# - PostToolUse / PostToolUseFailure clears handled blockers back to working. -# - agentStop / Stop with end_turn reports idle. -# - SessionEnd only releases ownership for real exits such as user_exit / abort. -# Official Copilot hooks reference: -# https://docs.github.com/en/copilot/reference/hooks-reference # HERDR_INTEGRATION_ID=copilot -# HERDR_INTEGRATION_VERSION=1 +# HERDR_INTEGRATION_VERSION=2 set -eu @@ -34,7 +24,6 @@ import socket import time source = "herdr:copilot" -agent = "copilot" pane_id = os.environ.get("HERDR_PANE_ID") socket_path = os.environ.get("HERDR_SOCKET_PATH") hook_input_file = os.environ.get("HERDR_HOOK_INPUT_FILE") @@ -64,120 +53,41 @@ def first_text(*keys): def normalize_event(event): return event.replace("_", "").replace("-", "").lower() -def infer_event(): - explicit = first_text("hook_event_name", "hookEventName") - if explicit: - return explicit - if first_text("notification_type", "notificationType"): - return "notification" - if "toolResult" in hook_input or "tool_result" in hook_input: - return "postToolUse" - if "error" in hook_input and first_text("tool_name", "toolName"): - return "postToolUseFailure" - if first_text("tool_name", "toolName"): - return "preToolUse" - if first_text("stop_reason", "stopReason"): - return "agentStop" - if first_text("reason"): - return "sessionEnd" - if "prompt" in hook_input: - return "userPromptSubmitted" - if ( - "initial_prompt" in hook_input - or "initialPrompt" in hook_input - or "source" in hook_input - or first_text("session_id", "sessionId") - ): - return "sessionStart" - return "" - -def has_initial_prompt(): - value = hook_input.get("initial_prompt", hook_input.get("initialPrompt")) - return isinstance(value, str) and bool(value.strip()) +event = first_text("hook_event_name", "hookEventName") +if event: + if normalize_event(event) != "sessionstart": + raise SystemExit(0) +elif "prompt" in hook_input or first_text("tool_name", "toolName", "notification_type", "notificationType", "stop_reason", "stopReason", "reason"): + raise SystemExit(0) -def is_user_blocking_tool(name): - return name in ("ask_user", "exit_plan_mode") +session_id = hook_input.get("session_id") +if not isinstance(session_id, str) or not session_id: + session_id = hook_input.get("sessionId") +if not isinstance(session_id, str) or not session_id: + raise SystemExit(0) -def is_ignored_post_tool(name): - # report_intent can arrive after ask_user has already blocked the turn but - # before the user answers. Suppress it so it cannot clear that blocker. - return name in ("report_intent",) +request = { + "id": f"{source}:{int(time.time() * 1000)}:{random.randrange(1_000_000):06d}", + "method": "pane.report_agent_session", + "params": { + "pane_id": pane_id, + "source": source, + "agent": "copilot", + "agent_session_id": session_id, + "seq": time.time_ns(), + }, +} -def send(method, params): - request = { - "id": f"{source}:{int(time.time() * 1000)}:{random.randrange(1_000_000):06d}", - "method": method, - "params": { - "pane_id": pane_id, - "source": source, - "agent": agent, - "seq": time.time_ns(), - **params, - }, - } +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 = 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() + client.recv(4096) except Exception: pass - -def report(state, session_id): - params = {"state": state} - if session_id: - params["agent_session_id"] = session_id - send("pane.report_agent", params) - -def release(): - send("pane.release_agent", {}) - -try: - event = infer_event() - event_key = normalize_event(event) - session_id = first_text("session_id", "sessionId") - tool_name = first_text("tool_name", "toolName") - notification_type = first_text("notification_type", "notificationType") - stop_reason = first_text("stop_reason", "stopReason") - reason = first_text("reason") - - if event_key == "sessionstart": - report("working" if has_initial_prompt() else "idle", session_id) - elif event_key in ("userpromptsubmit", "userpromptsubmitted"): - report("working", session_id) - elif event_key == "pretooluse": - report("blocked" if is_user_blocking_tool(tool_name) else "working", session_id) - elif event_key in ("posttooluse", "posttoolusefailure"): - if is_ignored_post_tool(tool_name): - pass - else: - report("working", session_id) - elif event_key == "notification": - if notification_type in ("permission_prompt", "elicitation_dialog"): - report("blocked", session_id) - elif notification_type == "agent_idle": - report("idle", session_id) - else: - pass - elif event_key in ("stop", "agentstop", "sessionstop"): - if not stop_reason or stop_reason == "end_turn": - report("idle", session_id) - else: - pass - elif event_key == "sessionend": - # Copilot can emit reason=complete at normal turn boundaries. Keep - # ownership until a real session exit so Herdr can still track later turns. - if reason in ("user_exit", "abort"): - release() - else: - pass - else: - pass + client.close() except Exception: pass PY diff --git a/src/integration/assets/droid/herdr-agent-state.ps1 b/src/integration/assets/droid/herdr-agent-state.ps1 index ad035d72..f3bb0f68 100644 --- a/src/integration/assets/droid/herdr-agent-state.ps1 +++ b/src/integration/assets/droid/herdr-agent-state.ps1 @@ -2,8 +2,11 @@ # managed by herdr; reinstalling or updating the integration overwrites this file. # add custom hooks beside this file instead of editing it. # HERDR_INTEGRATION_ID=droid -# HERDR_INTEGRATION_VERSION=1 +# 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 } @@ -11,16 +14,13 @@ $inputText = [Console]::In.ReadToEnd() try { $payload = if ([string]::IsNullOrWhiteSpace($inputText)) { $null } else { $inputText | ConvertFrom-Json } } catch { - exit 0 + $payload = $null } -if ($payload.hook_event_name -ne "SessionStart") { exit 0 } - -$sessionId = $payload.session_id -if ([string]::IsNullOrWhiteSpace($sessionId)) { exit 0 } +if ($null -eq $payload -or [string]::IsNullOrWhiteSpace($payload.session_id)) { exit 0 } $seq = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() try { - & herdr pane report-agent-session $env:HERDR_PANE_ID --source herdr:droid --agent droid --seq $seq --agent-session-id $sessionId 2>$null | Out-Null + & herdr pane report-agent-session $env:HERDR_PANE_ID --source herdr:droid --agent droid --agent-session-id $payload.session_id --seq $seq 2>$null | Out-Null } catch { } diff --git a/src/integration/assets/droid/herdr-agent-state.sh b/src/integration/assets/droid/herdr-agent-state.sh index 14109fc2..ddab463e 100644 --- a/src/integration/assets/droid/herdr-agent-state.sh +++ b/src/integration/assets/droid/herdr-agent-state.sh @@ -3,20 +3,26 @@ # managed by herdr; reinstalling or updating the integration overwrites this file. # add custom hooks beside this file instead of editing it. # HERDR_INTEGRATION_ID=droid -# HERDR_INTEGRATION_VERSION=1 +# HERDR_INTEGRATION_VERSION=2 set -eu +action="${1:-}" hook_input_file="$(mktemp "${TMPDIR:-/tmp}/herdr-droid-hook.XXXXXX")" || exit 0 trap 'rm -f "$hook_input_file"' EXIT HUP INT TERM cat >"$hook_input_file" 2>/dev/null || true +case "$action" in + session) ;; + *) exit 0 ;; +esac + [ "${HERDR_ENV:-}" = "1" ] || exit 0 [ -n "${HERDR_SOCKET_PATH:-}" ] || exit 0 [ -n "${HERDR_PANE_ID:-}" ] || exit 0 command -v python3 >/dev/null 2>&1 || exit 0 -HERDR_HOOK_INPUT_FILE="$hook_input_file" python3 - <<'PY' +HERDR_ACTION="$action" HERDR_HOOK_INPUT_FILE="$hook_input_file" python3 - <<'PY' import json import os import random @@ -41,15 +47,12 @@ if hook_input_file: except Exception: hook_input = {} -if hook_input.get("hook_event_name") != "SessionStart": - raise SystemExit(0) - session_id = hook_input.get("session_id") -agent_session_id = session_id if isinstance(session_id, str) and session_id else None -if not agent_session_id: +if not isinstance(session_id, str) or not session_id: raise SystemExit(0) request_id = f"{source}:{int(time.time() * 1000)}:{random.randrange(1_000_000):06d}" +report_seq = time.time_ns() request = { "id": request_id, "method": "pane.report_agent_session", @@ -57,8 +60,8 @@ request = { "pane_id": pane_id, "source": source, "agent": "droid", - "seq": time.time_ns(), - "agent_session_id": agent_session_id, + "agent_session_id": session_id, + "seq": report_seq, }, } diff --git a/src/integration/assets/kilo/herdr-agent-state.js b/src/integration/assets/kilo/herdr-agent-state.js new file mode 100644 index 00000000..aa1a0c1b --- /dev/null +++ b/src/integration/assets/kilo/herdr-agent-state.js @@ -0,0 +1,159 @@ +// installed by herdr +// managed by herdr; reinstalling or updating the integration overwrites this file. +// add custom hooks/plugins beside this file instead of editing it. +// HERDR_INTEGRATION_ID=kilo +// HERDR_INTEGRATION_VERSION=1 + +import net from "node:net"; + +const SOURCE = "herdr:kilo"; +const AGENT = "kilo"; +let reportSeq = Date.now() * 1000; + +function nextReportSeq() { + reportSeq += 1; + return reportSeq; +} + +function sessionIDFromProperties(properties) { + return typeof properties?.sessionID === "string" && properties.sessionID + ? properties.sessionID + : undefined; +} + +function stateFromSessionStatus(status) { + if (typeof status !== "string") { + return undefined; + } + switch (status.toLowerCase()) { + case "idle": + return "idle"; + case "active": + case "busy": + case "pending": + case "running": + case "streaming": + case "working": + return "working"; + default: + return undefined; + } +} + +function request(method, params) { + const paneId = process.env.HERDR_PANE_ID; + const socketPath = process.env.HERDR_SOCKET_PATH; + + if (!paneId || !socketPath) { + return Promise.resolve(); + } + + const requestId = `${SOURCE}:${Date.now()}:${Math.floor(Math.random() * 1_000_000) + .toString() + .padStart(6, "0")}`; + const request = { + id: requestId, + method, + params: { + pane_id: paneId, + source: SOURCE, + agent: AGENT, + seq: nextReportSeq(), + ...params, + }, + }; + + return new Promise((resolve) => { + const client = net.createConnection(socketPath, () => { + client.write(`${JSON.stringify(request)}\n`); + }); + + const finish = () => { + client.destroy(); + resolve(); + }; + + client.setTimeout(500, finish); + client.on("data", finish); + client.on("error", finish); + client.on("end", finish); + client.on("close", resolve); + }); +} + +function reportSession(sessionID) { + if (!sessionID) { + return Promise.resolve(); + } + return request("pane.report_agent_session", { agent_session_id: sessionID }); +} + +function reportState(state, sessionID) { + const params = { state }; + if (sessionID) { + params.agent_session_id = sessionID; + } + return request("pane.report_agent", params); +} + +function releaseAgent() { + return request("pane.release_agent", {}); +} + +export const HerdrAgentStatePlugin = async () => { + if ( + process.env.HERDR_ENV !== "1" || + !process.env.HERDR_SOCKET_PATH || + !process.env.HERDR_PANE_ID + ) { + return {}; + } + + return { + "chat.message": async ({ sessionID }) => { + await reportState("working", sessionID); + }, + event: async ({ event }) => { + const type = event?.type; + const properties = event?.properties ?? {}; + const sessionID = sessionIDFromProperties(properties); + + switch (type) { + case "session.created": + case "session.updated": + await reportSession(sessionID); + break; + case "session.status": { + const state = stateFromSessionStatus(properties.status); + if (state) { + await reportState(state, sessionID); + } else { + await reportSession(sessionID); + } + break; + } + case "tool.execute.before": + case "tool.execute.after": + case "permission.replied": + case "question.replied": + case "question.rejected": + case "session.compacted": + await reportState("working", sessionID); + break; + case "permission.asked": + case "question.asked": + case "session.error": + await reportState("blocked", sessionID); + break; + case "session.idle": + await reportState("idle", sessionID); + break; + case "session.deleted": + await releaseAgent(); + break; + default: + break; + } + }, + }; +}; diff --git a/src/integration/assets/kimi/herdr-agent-state.ps1 b/src/integration/assets/kimi/herdr-agent-state.ps1 index a230a627..6684d2a9 100644 --- a/src/integration/assets/kimi/herdr-agent-state.ps1 +++ b/src/integration/assets/kimi/herdr-agent-state.ps1 @@ -2,21 +2,25 @@ # managed by herdr; reinstalling or updating the integration overwrites this file. # add custom hooks beside this file instead of editing it. # HERDR_INTEGRATION_ID=kimi -# HERDR_INTEGRATION_VERSION=1 +# HERDR_INTEGRATION_VERSION=2 param([string]$Action = "") -if ($Action -notin @("working", "idle", "blocked", "release")) { exit 0 } +if ($Action -ne "session") { exit 0 } if ($env:HERDR_ENV -ne "1") { exit 0 } if ([string]::IsNullOrWhiteSpace($env:HERDR_PANE_ID)) { exit 0 } -[Console]::In.ReadToEnd() | Out-Null +$inputText = [Console]::In.ReadToEnd() +try { + $payload = if ([string]::IsNullOrWhiteSpace($inputText)) { $null } else { $inputText | ConvertFrom-Json } +} catch { + $payload = $null +} + +if ($null -eq $payload -or [string]::IsNullOrWhiteSpace($payload.session_id)) { exit 0 } + $seq = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() try { - if ($Action -eq "release") { - & herdr pane release-agent $env:HERDR_PANE_ID --source herdr:kimi --agent kimi --seq $seq 2>$null | Out-Null - } else { - & herdr pane report-agent $env:HERDR_PANE_ID --source herdr:kimi --agent kimi --state $Action --seq $seq 2>$null | Out-Null - } + & herdr pane report-agent-session $env:HERDR_PANE_ID --source herdr:kimi --agent kimi --agent-session-id $payload.session_id --seq $seq 2>$null | Out-Null } catch { } diff --git a/src/integration/assets/kimi/herdr-agent-state.sh b/src/integration/assets/kimi/herdr-agent-state.sh index 3e0a9c29..6332ca2a 100644 --- a/src/integration/assets/kimi/herdr-agent-state.sh +++ b/src/integration/assets/kimi/herdr-agent-state.sh @@ -3,15 +3,17 @@ # managed by herdr; reinstalling or updating the integration overwrites this file. # add custom hooks beside this file instead of editing it. # HERDR_INTEGRATION_ID=kimi -# HERDR_INTEGRATION_VERSION=1 +# HERDR_INTEGRATION_VERSION=2 set -eu action="${1:-}" -cat >/dev/null 2>/dev/null || true +hook_input_file="$(mktemp "${TMPDIR:-/tmp}/herdr-kimi-hook.XXXXXX")" || exit 0 +trap 'rm -f "$hook_input_file"' EXIT HUP INT TERM +cat >"$hook_input_file" 2>/dev/null || true case "$action" in - working|idle|blocked|release) ;; + session) ;; *) exit 0 ;; esac @@ -20,7 +22,7 @@ esac [ -n "${HERDR_PANE_ID:-}" ] || exit 0 command -v python3 >/dev/null 2>&1 || exit 0 -HERDR_ACTION="$action" python3 - <<'PY' +HERDR_ACTION="$action" HERDR_HOOK_INPUT_FILE="$hook_input_file" python3 - <<'PY' import json import os import random @@ -28,38 +30,40 @@ import socket import time source = "herdr:kimi" -action = os.environ.get("HERDR_ACTION", "") pane_id = os.environ.get("HERDR_PANE_ID") socket_path = os.environ.get("HERDR_SOCKET_PATH") +hook_input_file = os.environ.get("HERDR_HOOK_INPUT_FILE") if not pane_id or not socket_path: raise SystemExit(0) +hook_input = {} +if hook_input_file: + try: + with open(hook_input_file, encoding="utf-8") as handle: + content = handle.read() + if content.strip(): + hook_input = json.loads(content) + except Exception: + hook_input = {} + +session_id = hook_input.get("session_id") +if not isinstance(session_id, str) or not session_id: + raise SystemExit(0) + request_id = f"{source}:{int(time.time() * 1000)}:{random.randrange(1_000_000):06d}" report_seq = time.time_ns() -if action == "release": - request = { - "id": request_id, - "method": "pane.release_agent", - "params": { - "pane_id": pane_id, - "source": source, - "agent": "kimi", - "seq": report_seq, - }, - } -else: - request = { - "id": request_id, - "method": "pane.report_agent", - "params": { - "pane_id": pane_id, - "source": source, - "agent": "kimi", - "state": action, - "seq": report_seq, - }, - } +request = { + "id": request_id, + "method": "pane.report_agent_session", + "params": { + "pane_id": pane_id, + "source": source, + "agent": "kimi", + "agent_session_id": session_id, + "seq": report_seq, + }, +} try: client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) diff --git a/src/integration/assets/opencode/herdr-agent-state.js b/src/integration/assets/opencode/herdr-agent-state.js index c2fd79a6..1e9fff51 100644 --- a/src/integration/assets/opencode/herdr-agent-state.js +++ b/src/integration/assets/opencode/herdr-agent-state.js @@ -2,11 +2,12 @@ // managed by herdr; reinstalling or updating the integration overwrites this file. // add custom hooks/plugins beside this file instead of editing it. // HERDR_INTEGRATION_ID=opencode -// HERDR_INTEGRATION_VERSION=4 +// HERDR_INTEGRATION_VERSION=5 import net from "node:net"; const SOURCE = "herdr:opencode"; +const AGENT = "opencode"; let reportSeq = Date.now() * 1000; function nextReportSeq() { @@ -20,10 +21,26 @@ function sessionIDFromProperties(properties) { : undefined; } -function reportSession(sessionID) { - if (!sessionID) { - return Promise.resolve(); +function stateFromSessionStatus(status) { + if (typeof status !== "string") { + return undefined; } + switch (status.toLowerCase()) { + case "idle": + return "idle"; + case "active": + case "busy": + case "pending": + case "running": + case "streaming": + case "working": + return "working"; + default: + return undefined; + } +} + +function request(method, params) { const paneId = process.env.HERDR_PANE_ID; const socketPath = process.env.HERDR_SOCKET_PATH; @@ -36,13 +53,13 @@ function reportSession(sessionID) { .padStart(6, "0")}`; const request = { id: requestId, - method: "pane.report_agent_session", + method, params: { pane_id: paneId, source: SOURCE, - agent: "opencode", + agent: AGENT, seq: nextReportSeq(), - agent_session_id: sessionID, + ...params, }, }; @@ -64,6 +81,25 @@ function reportSession(sessionID) { }); } +function reportSession(sessionID) { + if (!sessionID) { + return Promise.resolve(); + } + return request("pane.report_agent_session", { agent_session_id: sessionID }); +} + +function reportState(state, sessionID) { + const params = { state }; + if (sessionID) { + params.agent_session_id = sessionID; + } + return request("pane.report_agent", params); +} + +function releaseAgent() { + return request("pane.release_agent", {}); +} + export const HerdrAgentStatePlugin = async () => { if ( process.env.HERDR_ENV !== "1" || @@ -74,6 +110,9 @@ export const HerdrAgentStatePlugin = async () => { } return { + "chat.message": async ({ sessionID }) => { + await reportState("working", sessionID); + }, event: async ({ event }) => { const type = event?.type; const properties = event?.properties ?? {}; @@ -82,9 +121,36 @@ export const HerdrAgentStatePlugin = async () => { switch (type) { case "session.created": case "session.updated": - case "session.status": await reportSession(sessionID); break; + case "session.status": { + const state = stateFromSessionStatus(properties.status); + if (state) { + await reportState(state, sessionID); + } else { + await reportSession(sessionID); + } + break; + } + case "tool.execute.before": + case "tool.execute.after": + case "permission.replied": + case "question.replied": + case "question.rejected": + case "session.compacted": + await reportState("working", sessionID); + break; + case "permission.asked": + case "question.asked": + case "session.error": + await reportState("blocked", sessionID); + break; + case "session.idle": + await reportState("idle", sessionID); + break; + case "session.deleted": + await releaseAgent(); + break; default: break; } diff --git a/src/integration/assets/qodercli/herdr-agent-state.ps1 b/src/integration/assets/qodercli/herdr-agent-state.ps1 index f7102817..df527f6d 100644 --- a/src/integration/assets/qodercli/herdr-agent-state.ps1 +++ b/src/integration/assets/qodercli/herdr-agent-state.ps1 @@ -2,11 +2,11 @@ # managed by herdr; reinstalling or updating the integration overwrites this file. # add custom hooks beside this file instead of editing it. # HERDR_INTEGRATION_ID=qodercli -# HERDR_INTEGRATION_VERSION=1 +# HERDR_INTEGRATION_VERSION=2 param([string]$Action = "") -if ($Action -notin @("working", "idle", "blocked", "release")) { exit 0 } +if ($Action -ne "session") { exit 0 } if ($env:HERDR_ENV -ne "1") { exit 0 } if ([string]::IsNullOrWhiteSpace($env:HERDR_PANE_ID)) { exit 0 } @@ -17,19 +17,10 @@ try { $payload = $null } -if ($payload.hook_event_name -eq "SubagentStop") { exit 0 } -if ($payload.agent_id -and $Action -in @("idle", "release")) { exit 0 } +if ($null -eq $payload -or [string]::IsNullOrWhiteSpace($payload.session_id)) { exit 0 } $seq = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() try { - if ($Action -eq "release") { - & herdr pane release-agent $env:HERDR_PANE_ID --source herdr:qodercli --agent qodercli --seq $seq 2>$null | Out-Null - } else { - $args = @("pane", "report-agent", $env:HERDR_PANE_ID, "--source", "herdr:qodercli", "--agent", "qodercli", "--state", $Action, "--seq", "$seq") - if (-not [string]::IsNullOrWhiteSpace($payload.session_id)) { - $args += @("--agent-session-id", $payload.session_id) - } - & herdr @args 2>$null | Out-Null - } + & herdr pane report-agent-session $env:HERDR_PANE_ID --source herdr:qodercli --agent qodercli --agent-session-id $payload.session_id --seq $seq 2>$null | Out-Null } catch { } diff --git a/src/integration/assets/qodercli/herdr-agent-state.sh b/src/integration/assets/qodercli/herdr-agent-state.sh index 8903c45c..b55d8194 100644 --- a/src/integration/assets/qodercli/herdr-agent-state.sh +++ b/src/integration/assets/qodercli/herdr-agent-state.sh @@ -3,17 +3,15 @@ # managed by herdr; reinstalling or updating the integration overwrites this file. # add custom hooks beside this file instead of editing it. # HERDR_INTEGRATION_ID=qodercli -# HERDR_INTEGRATION_VERSION=1 +# HERDR_INTEGRATION_VERSION=2 # -# Reports qodercli agent state changes to herdr. Registered as a Command hook +# Reports qodercli session identity to herdr. Registered as a Command hook # in ~/.qoder/settings.json by `herdr integration install qodercli` and -# invoked by qodercli's hook system on lifecycle events. +# invoked by qodercli's hook system on session start. # # qodercli (per https://docs.qoder.com/zh/cli/hooks) sends a JSON payload on -# stdin describing the hook event. The event name is read from the stdin -# payload's `hook_event_name` field, the same way -# `assets/claude/herdr-agent-state.sh` already consumes claude code's stdin -# payload. No environment variable is consulted for the event identity. +# stdin describing the hook event. This hook reads the session id from that +# payload. set -eu @@ -23,7 +21,7 @@ trap 'rm -f "$hook_input_file"' EXIT HUP INT TERM cat >"$hook_input_file" 2>/dev/null || true case "$action" in - working|idle|blocked|release) ;; + session) ;; *) exit 0 ;; esac @@ -40,7 +38,6 @@ import socket import time source = "herdr:qodercli" -action = os.environ.get("HERDR_ACTION", "") pane_id = os.environ.get("HERDR_PANE_ID") socket_path = os.environ.get("HERDR_SOCKET_PATH") hook_input_file = os.environ.get("HERDR_HOOK_INPUT_FILE") @@ -58,46 +55,23 @@ if hook_input_file: except Exception: hook_input = {} -# Per docs.qoder.com/zh/cli/hooks the payload always carries `hook_event_name`. -hook_event_name = str(hook_input.get("hook_event_name") or "") -is_subagent = bool(hook_input.get("agent_id")) -if hook_event_name == "SubagentStop": - # SubagentStop is a completion event; never let it revive an idle pane the - # way the parallel claude integration does. - raise SystemExit(0) -if is_subagent and action in ("idle", "release"): - # Subagent completion must not make the parent pane look done early. +session_id = hook_input.get("session_id") +if not isinstance(session_id, str) or not session_id: raise SystemExit(0) request_id = f"{source}:{int(time.time() * 1000)}:{random.randrange(1_000_000):06d}" report_seq = time.time_ns() -session_id = hook_input.get("session_id") -agent_session_id = session_id if isinstance(session_id, str) and session_id else None -if action == "release": - request = { - "id": request_id, - "method": "pane.release_agent", - "params": { - "pane_id": pane_id, - "source": source, - "agent": "qodercli", - "seq": report_seq, - }, - } -else: - request = { - "id": request_id, - "method": "pane.report_agent", - "params": { - "pane_id": pane_id, - "source": source, - "agent": "qodercli", - "state": action, - "seq": report_seq, - }, - } - if agent_session_id: - request["params"]["agent_session_id"] = agent_session_id +request = { + "id": request_id, + "method": "pane.report_agent_session", + "params": { + "pane_id": pane_id, + "source": source, + "agent": "qodercli", + "agent_session_id": session_id, + "seq": report_seq, + }, +} try: client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) diff --git a/src/integration/mod.rs b/src/integration/mod.rs index 07318213..36178b8d 100644 --- a/src/integration/mod.rs +++ b/src/integration/mod.rs @@ -51,23 +51,12 @@ const KIMI_HOOK_ASSET: &str = if cfg!(windows) { } else { include_str!("assets/kimi/herdr-agent-state.sh") }; -const KIMI_INTEGRATION_VERSION: u32 = 1; +const KIMI_INTEGRATION_VERSION: u32 = 2; const KIMI_CODE_HOME_ENV_VAR: &str = "KIMI_CODE_HOME"; const KIMI_CONFIG_BLOCK_BEGIN: &str = "# >>> herdr kimi integration"; const KIMI_CONFIG_BLOCK_END: &str = "# <<< herdr kimi integration"; const KIMI_MIN_VERSION: &str = "0.8.0"; -const KIMI_HOOK_EVENTS: [(&str, &str); 10] = [ - ("SessionStart", "idle"), - ("UserPromptSubmit", "working"), - ("PreToolUse", "working"), - ("PermissionRequest", "blocked"), - ("PermissionResult", "working"), - ("PostToolUse", "working"), - ("PostToolUseFailure", "working"), - ("Stop", "idle"), - ("StopFailure", "idle"), - ("SessionEnd", "release"), -]; +const KIMI_HOOK_EVENTS: [(&str, &str); 1] = [("SessionStart", "session")]; const COPILOT_HOOK_INSTALL_NAME: &str = if cfg!(windows) { "herdr-agent-state.ps1" } else { @@ -78,8 +67,20 @@ const COPILOT_HOOK_ASSET: &str = if cfg!(windows) { } else { include_str!("assets/copilot/herdr-agent-state.sh") }; -const COPILOT_INTEGRATION_VERSION: u32 = 1; +const COPILOT_INTEGRATION_VERSION: u32 = 2; const COPILOT_HOME_ENV_VAR: &str = "COPILOT_HOME"; +const COPILOT_HOOK_EVENTS: [&str; 1] = ["SessionStart"]; +const COPILOT_REMOVED_LIFECYCLE_HOOK_EVENTS: [&str; 9] = [ + "UserPromptSubmit", + "PreToolUse", + "PostToolUse", + "PostToolUseFailure", + "Stop", + "agentStop", + "SessionEnd", + "notification", + "sessionStart", +]; const DROID_HOOK_INSTALL_NAME: &str = if cfg!(windows) { "herdr-agent-state.ps1" } else { @@ -90,10 +91,25 @@ const DROID_HOOK_ASSET: &str = if cfg!(windows) { } else { include_str!("assets/droid/herdr-agent-state.sh") }; -const DROID_INTEGRATION_VERSION: u32 = 1; +const DROID_INTEGRATION_VERSION: u32 = 2; +const DROID_HOOK_EVENTS: [(&str, &str); 1] = [("SessionStart", "session")]; +const DROID_REMOVED_LIFECYCLE_HOOK_EVENTS: [(&str, &str); 9] = [ + ("SessionStart", "idle"), + ("UserPromptSubmit", "working"), + ("PreToolUse", "working"), + ("PostToolUse", "working"), + ("Notification", "blocked"), + ("Stop", "idle"), + ("SubagentStop", "working"), + ("PreCompact", "working"), + ("SessionEnd", "release"), +]; const OPENCODE_PLUGIN_INSTALL_NAME: &str = "herdr-agent-state.js"; const OPENCODE_PLUGIN_ASSET: &str = include_str!("assets/opencode/herdr-agent-state.js"); -const OPENCODE_INTEGRATION_VERSION: u32 = 4; +const OPENCODE_INTEGRATION_VERSION: u32 = 5; +const KILO_PLUGIN_INSTALL_NAME: &str = "herdr-agent-state.js"; +const KILO_PLUGIN_ASSET: &str = include_str!("assets/kilo/herdr-agent-state.js"); +const KILO_INTEGRATION_VERSION: u32 = 1; const HERMES_PLUGIN_INSTALL_NAME: &str = "herdr-agent-state"; const HERMES_PLUGIN_MANIFEST_INSTALL_NAME: &str = "plugin.yaml"; const HERMES_PLUGIN_INIT_INSTALL_NAME: &str = "__init__.py"; @@ -110,8 +126,23 @@ const QODERCLI_HOOK_ASSET: &str = if cfg!(windows) { } else { include_str!("assets/qodercli/herdr-agent-state.sh") }; -const QODERCLI_INTEGRATION_VERSION: u32 = 1; +const QODERCLI_INTEGRATION_VERSION: u32 = 2; const QODERCLI_CONFIG_DIR_ENV_VAR: &str = "QODER_CONFIG_DIR"; +const QODERCLI_HOOK_EVENTS: [(&str, &str); 1] = [("SessionStart", "session")]; +const QODERCLI_REMOVED_LIFECYCLE_HOOK_EVENTS: [(&str, &str); 12] = [ + ("SessionStart", "idle"), + ("UserPromptSubmit", "working"), + ("PreToolUse", "working"), + ("PostToolUse", "working"), + ("PostToolUseFailure", "working"), + ("SubagentStart", "working"), + ("SubagentStop", "working"), + ("PreCompact", "working"), + ("Notification", "blocked"), + ("PermissionRequest", "blocked"), + ("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_INTEGRATION_VERSION: u32 = 1; @@ -157,6 +188,11 @@ pub(crate) struct OpenCodeInstallPaths { } #[derive(Debug)] +pub(crate) struct KiloInstallPaths { + pub plugin_path: PathBuf, +} + +#[derive(Debug)] pub(crate) struct OmpInstallPaths { pub extension_path: PathBuf, pub removed_legacy_pi_extension: bool, @@ -300,6 +336,12 @@ pub(crate) struct OpenCodeUninstallResult { } #[derive(Debug)] +pub(crate) struct KiloUninstallResult { + pub plugin_path: PathBuf, + pub removed_plugin: bool, +} + +#[derive(Debug)] pub(crate) struct HermesUninstallResult { pub plugin_dir: PathBuf, pub config_path: PathBuf, @@ -423,6 +465,13 @@ pub(crate) fn install_target( installed.plugin_path.display() )] } + crate::api::schema::IntegrationTarget::Kilo => { + let installed = install_kilo()?; + vec![format!( + "installed kilo integration plugin to {}", + installed.plugin_path.display() + )] + } crate::api::schema::IntegrationTarget::Hermes => { let installed = install_hermes()?; vec![ @@ -661,6 +710,20 @@ pub(crate) fn uninstall_target( )] } } + crate::api::schema::IntegrationTarget::Kilo => { + let result = uninstall_kilo()?; + if result.removed_plugin { + vec![format!( + "removed kilo integration plugin at {}", + result.plugin_path.display() + )] + } else { + vec![format!( + "no kilo integration plugin found at {}", + result.plugin_path.display() + )] + } + } crate::api::schema::IntegrationTarget::Hermes => { let result = uninstall_hermes()?; let mut messages = Vec::new(); @@ -760,6 +823,7 @@ pub(crate) fn integration_target_label( crate::api::schema::IntegrationTarget::Droid => "droid", crate::api::schema::IntegrationTarget::Kimi => "kimi", crate::api::schema::IntegrationTarget::Opencode => "opencode", + crate::api::schema::IntegrationTarget::Kilo => "kilo", crate::api::schema::IntegrationTarget::Hermes => "hermes", crate::api::schema::IntegrationTarget::Qodercli => "qodercli", crate::api::schema::IntegrationTarget::Cursor => "cursor", @@ -782,6 +846,7 @@ fn integration_target_command_names( crate::api::schema::IntegrationTarget::Droid => &["droid"], crate::api::schema::IntegrationTarget::Kimi => &["kimi"], crate::api::schema::IntegrationTarget::Opencode => &["opencode"], + crate::api::schema::IntegrationTarget::Kilo => &["kilo", "kilo-code"], crate::api::schema::IntegrationTarget::Hermes => &["hermes"], crate::api::schema::IntegrationTarget::Qodercli => qodercli_command_names(), crate::api::schema::IntegrationTarget::Cursor => cursor_command_names(), @@ -989,7 +1054,7 @@ fn integration_specs() -> [( crate::api::schema::IntegrationTarget, io::Result<PathBuf>, u32, -); 11] { +); 12] { [ ( crate::api::schema::IntegrationTarget::Pi, @@ -1032,6 +1097,11 @@ fn integration_specs() -> [( OPENCODE_INTEGRATION_VERSION, ), ( + crate::api::schema::IntegrationTarget::Kilo, + kilo_dir().map(|dir| dir.join("plugin").join(KILO_PLUGIN_INSTALL_NAME)), + KILO_INTEGRATION_VERSION, + ), + ( crate::api::schema::IntegrationTarget::Hermes, hermes_plugin_dir().map(|dir| dir.join(HERMES_PLUGIN_INIT_INSTALL_NAME)), HERMES_INTEGRATION_VERSION, @@ -1385,34 +1455,15 @@ pub(crate) fn install_copilot() -> io::Result<CopilotInstallPaths> { "copilot settings hooks", )?; let command = hook_command(&hook_path, None); - for event in [ - "SessionStart", - "UserPromptSubmit", - "PreToolUse", - "PostToolUse", - "PostToolUseFailure", - "Stop", - "agentStop", - "SessionEnd", - "notification", - ] { + for event in COPILOT_REMOVED_LIFECYCLE_HOOK_EVENTS { remove_direct_hook_commands(hooks, event, &hook_path, None)?; } - ensure_direct_command_hook(hooks, "SessionStart", command.clone(), 10, None)?; - ensure_direct_command_hook(hooks, "UserPromptSubmit", command.clone(), 10, None)?; - ensure_direct_command_hook(hooks, "PreToolUse", command.clone(), 10, None)?; - ensure_direct_command_hook(hooks, "PostToolUse", command.clone(), 10, None)?; - ensure_direct_command_hook(hooks, "PostToolUseFailure", command.clone(), 10, None)?; - ensure_direct_command_hook(hooks, "Stop", command.clone(), 10, None)?; - ensure_direct_command_hook(hooks, "agentStop", command.clone(), 10, None)?; - ensure_direct_command_hook(hooks, "SessionEnd", command.clone(), 10, None)?; - ensure_direct_command_hook( - hooks, - "notification", - command, - 10, - Some("permission_prompt|elicitation_dialog|agent_idle"), - )?; + for event in COPILOT_HOOK_EVENTS { + remove_direct_hook_commands(hooks, event, &hook_path, None)?; + } + for event in COPILOT_HOOK_EVENTS { + ensure_direct_command_hook(hooks, event, command.clone(), 10, None)?; + } remove_legacy_bash_hook_file(&hook_path)?; fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?; @@ -1458,13 +1509,21 @@ pub(crate) fn install_droid() -> io::Result<DroidInstallPaths> { "droid settings hooks", )?; remove_hook_commands(hooks, "SessionStart", &hook_path, None)?; - ensure_command_hook( - hooks, - "SessionStart", - hook_command(&hook_path, None), - 10, - None, - )?; + for (event, action) in DROID_REMOVED_LIFECYCLE_HOOK_EVENTS { + remove_hook_commands(hooks, event, &hook_path, Some(action))?; + } + for (event, action) in DROID_HOOK_EVENTS { + remove_hook_commands(hooks, event, &hook_path, Some(action))?; + } + for (event, action) in DROID_HOOK_EVENTS { + ensure_command_hook( + hooks, + event, + hook_command(&hook_path, Some(action)), + 10, + None, + )?; + } remove_legacy_bash_hook_file(&hook_path)?; fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?; @@ -1483,6 +1542,14 @@ pub(crate) fn install_droid() -> io::Result<DroidInstallPaths> { "droid hooks file hooks", )? { updated_legacy_hooks = remove_hook_commands(hooks, "SessionStart", &hook_path, None)?; + for (event, action) in DROID_REMOVED_LIFECYCLE_HOOK_EVENTS { + updated_legacy_hooks |= + remove_hook_commands(hooks, event, &hook_path, Some(action))?; + } + for (event, action) in DROID_HOOK_EVENTS { + updated_legacy_hooks |= + remove_hook_commands(hooks, event, &hook_path, Some(action))?; + } } if updated_legacy_hooks { fs::write(&hooks_path, serde_json::to_string_pretty(&hooks_file)?)?; @@ -1515,6 +1582,24 @@ pub(crate) fn install_opencode() -> io::Result<OpenCodeInstallPaths> { Ok(OpenCodeInstallPaths { plugin_path }) } +pub(crate) fn install_kilo() -> io::Result<KiloInstallPaths> { + let dir = kilo_dir()?; + if !dir.is_dir() { + return Err(io::Error::other(format!( + "kilo config directory not found at {}. install kilo first", + dir.display() + ))); + } + + let plugins_dir = dir.join("plugin"); + fs::create_dir_all(&plugins_dir)?; + + let plugin_path = plugins_dir.join(KILO_PLUGIN_INSTALL_NAME); + fs::write(&plugin_path, KILO_PLUGIN_ASSET)?; + + Ok(KiloInstallPaths { plugin_path }) +} + pub(crate) fn install_hermes() -> io::Result<HermesInstallPaths> { let dir = hermes_dir()?; if !dir.is_dir() { @@ -1724,20 +1809,12 @@ pub(crate) fn uninstall_copilot() -> io::Result<CopilotUninstallResult> { "copilot settings", "copilot settings hooks", )? { - updated_settings |= - remove_direct_hook_commands(hooks, "SessionStart", &hook_path, None)?; - updated_settings |= - remove_direct_hook_commands(hooks, "UserPromptSubmit", &hook_path, None)?; - updated_settings |= remove_direct_hook_commands(hooks, "PreToolUse", &hook_path, None)?; - updated_settings |= - remove_direct_hook_commands(hooks, "PostToolUse", &hook_path, None)?; - updated_settings |= - remove_direct_hook_commands(hooks, "PostToolUseFailure", &hook_path, None)?; - updated_settings |= remove_direct_hook_commands(hooks, "Stop", &hook_path, None)?; - updated_settings |= remove_direct_hook_commands(hooks, "agentStop", &hook_path, None)?; - updated_settings |= remove_direct_hook_commands(hooks, "SessionEnd", &hook_path, None)?; - updated_settings |= - remove_direct_hook_commands(hooks, "notification", &hook_path, None)?; + for event in COPILOT_HOOK_EVENTS { + updated_settings |= remove_direct_hook_commands(hooks, event, &hook_path, None)?; + } + for event in COPILOT_REMOVED_LIFECYCLE_HOOK_EVENTS { + updated_settings |= remove_direct_hook_commands(hooks, event, &hook_path, None)?; + } } if updated_settings { @@ -1776,6 +1853,12 @@ pub(crate) fn uninstall_droid() -> io::Result<DroidUninstallResult> { "droid hooks file hooks", )? { updated_hooks |= remove_hook_commands(hooks, "SessionStart", &hook_path, None)?; + for (event, action) in DROID_REMOVED_LIFECYCLE_HOOK_EVENTS { + updated_hooks |= remove_hook_commands(hooks, event, &hook_path, Some(action))?; + } + for (event, action) in DROID_HOOK_EVENTS { + updated_hooks |= remove_hook_commands(hooks, event, &hook_path, Some(action))?; + } } if updated_hooks { @@ -1798,6 +1881,12 @@ pub(crate) fn uninstall_droid() -> io::Result<DroidUninstallResult> { "droid settings hooks", )? { updated_settings = remove_hook_commands(hooks, "SessionStart", &hook_path, None)?; + for (event, action) in DROID_REMOVED_LIFECYCLE_HOOK_EVENTS { + updated_settings |= remove_hook_commands(hooks, event, &hook_path, Some(action))?; + } + for (event, action) in DROID_HOOK_EVENTS { + updated_settings |= remove_hook_commands(hooks, event, &hook_path, Some(action))?; + } } if updated_settings { @@ -1830,6 +1919,16 @@ pub(crate) fn uninstall_opencode() -> io::Result<OpenCodeUninstallResult> { }) } +pub(crate) fn uninstall_kilo() -> io::Result<KiloUninstallResult> { + let plugin_path = kilo_dir()?.join("plugin").join(KILO_PLUGIN_INSTALL_NAME); + let removed_plugin = remove_file_if_exists(&plugin_path)?; + + Ok(KiloUninstallResult { + plugin_path, + removed_plugin, + }) +} + pub(crate) fn uninstall_hermes() -> io::Result<HermesUninstallResult> { let dir = hermes_dir()?; let plugin_dir = hermes_plugin_dir()?; @@ -1874,9 +1973,7 @@ pub(crate) fn install_qodercli() -> io::Result<QodercliInstallPaths> { // settings.json (per https://docs.qoder.com/zh/cli/hooks): a top-level // `hooks` object keyed by event name, each entry holding a matcher + a // list of `{type: "command", command, timeout?}` invocations. The hook - // script reads the event payload from stdin via `hook_event_name` so the - // installation never depends on a `QODER_HOOK_EVENT` environment - // variable. + // script reads the event payload from stdin via `hook_event_name`. let settings_path = dir.join("settings.json"); let mut settings = if settings_path.is_file() { serde_json::from_str::<Value>(&fs::read_to_string(&settings_path)?).map_err(|err| { @@ -1895,57 +1992,21 @@ pub(crate) fn install_qodercli() -> io::Result<QodercliInstallPaths> { "qodercli settings", "qodercli settings hooks", )?; - // SubagentStop is intentionally *not* mapped to working: the hook script - // returns early on it (mirroring assets/claude/herdr-agent-state.sh) so - // that recap/away-summary frames cannot revive an idle pane. - remove_hook_commands(hooks, "SessionStart", &hook_path, Some("idle"))?; - remove_hook_commands(hooks, "UserPromptSubmit", &hook_path, Some("working"))?; - remove_hook_commands(hooks, "PreToolUse", &hook_path, Some("working"))?; - remove_hook_commands(hooks, "PermissionRequest", &hook_path, Some("blocked"))?; - remove_hook_commands(hooks, "Stop", &hook_path, Some("idle"))?; - remove_hook_commands(hooks, "SessionEnd", &hook_path, Some("release"))?; - ensure_command_hook( - hooks, - "SessionStart", - hook_command(&hook_path, Some("idle")), - 10, - Some("*"), - )?; - ensure_command_hook( - hooks, - "UserPromptSubmit", - hook_command(&hook_path, Some("working")), - 10, - Some("*"), - )?; - ensure_command_hook( - hooks, - "PreToolUse", - hook_command(&hook_path, Some("working")), - 10, - Some("*"), - )?; - ensure_command_hook( - hooks, - "PermissionRequest", - hook_command(&hook_path, Some("blocked")), - 10, - Some("*"), - )?; - ensure_command_hook( - hooks, - "Stop", - hook_command(&hook_path, Some("idle")), - 10, - Some("*"), - )?; - ensure_command_hook( - hooks, - "SessionEnd", - hook_command(&hook_path, Some("release")), - 10, - Some("*"), - )?; + for (event, action) in QODERCLI_REMOVED_LIFECYCLE_HOOK_EVENTS { + remove_hook_commands(hooks, event, &hook_path, Some(action))?; + } + for (event, action) in QODERCLI_HOOK_EVENTS { + remove_hook_commands(hooks, event, &hook_path, Some(action))?; + } + for (event, action) in QODERCLI_HOOK_EVENTS { + ensure_command_hook( + hooks, + event, + hook_command(&hook_path, Some(action)), + 10, + Some("*"), + )?; + } remove_legacy_bash_hook_file(&hook_path)?; fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?; @@ -2035,17 +2096,12 @@ pub(crate) fn uninstall_qodercli() -> io::Result<QodercliUninstallResult> { "qodercli settings", "qodercli settings hooks", )? { - updated_settings |= - remove_hook_commands(hooks, "SessionStart", &hook_path, Some("idle"))?; - updated_settings |= - remove_hook_commands(hooks, "UserPromptSubmit", &hook_path, Some("working"))?; - updated_settings |= - remove_hook_commands(hooks, "PreToolUse", &hook_path, Some("working"))?; - updated_settings |= - remove_hook_commands(hooks, "PermissionRequest", &hook_path, Some("blocked"))?; - updated_settings |= remove_hook_commands(hooks, "Stop", &hook_path, Some("idle"))?; - updated_settings |= - remove_hook_commands(hooks, "SessionEnd", &hook_path, Some("release"))?; + for (event, action) in QODERCLI_REMOVED_LIFECYCLE_HOOK_EVENTS { + updated_settings |= remove_hook_commands(hooks, event, &hook_path, Some(action))?; + } + for (event, action) in QODERCLI_HOOK_EVENTS { + updated_settings |= remove_hook_commands(hooks, event, &hook_path, Some(action))?; + } } if updated_settings { @@ -2206,8 +2262,8 @@ fn ensure_command_hook( // Claude and Codex use nested hook groups: // { "matcher": "...", "hooks": [{ "type": "command", ... }] } -// Copilot and Qoder CLI use the flatter settings shape: -// { "type": "command", "matcher": "...", "command": "...", ... } +// Copilot uses the flatter settings shape: +// { "type": "command", "matcher": "...", "bash": "...", ... } // Keep the helpers separate so install/uninstall preserves unrelated hooks in // each agent's native format instead of normalizing user configuration. fn ensure_direct_command_hook( @@ -2223,13 +2279,18 @@ fn ensure_direct_command_hook( .as_array_mut() .ok_or_else(|| io::Error::other(format!("hook entries for {event} must be an array")))?; + let command_field = direct_command_field(); if let Some(entry) = entries.iter_mut().find(|entry| { entry.get("type").and_then(Value::as_str) == Some("command") - && entry.get("command").and_then(Value::as_str) == Some(command.as_str()) + && is_matching_direct_command_entry(entry, command.as_str()) }) { let Some(entry_object) = entry.as_object_mut() else { return Ok(()); }; + entry_object.remove("command"); + entry_object.remove("bash"); + entry_object.remove("powershell"); + entry_object.insert(command_field.to_string(), Value::String(command.clone())); entry_object.insert("timeoutSec".to_string(), Value::Number(timeout_sec.into())); match matcher { Some(matcher) => { @@ -2247,12 +2308,26 @@ fn ensure_direct_command_hook( if let Some(matcher) = matcher { entry.insert("matcher".to_string(), Value::String(matcher.to_string())); } - entry.insert("command".to_string(), Value::String(command)); + entry.insert(command_field.to_string(), Value::String(command)); entry.insert("timeoutSec".to_string(), Value::Number(timeout_sec.into())); entries.push(Value::Object(entry)); Ok(()) } +fn direct_command_field() -> &'static str { + if cfg!(windows) { + "powershell" + } else { + "bash" + } +} + +fn is_matching_direct_command_entry(entry: &Value, command: &str) -> bool { + entry.get("command").and_then(Value::as_str) == Some(command) + || entry.get("bash").and_then(Value::as_str) == Some(command) + || entry.get("powershell").and_then(Value::as_str) == Some(command) +} + fn remove_command_hook( hooks: &mut Map<String, Value>, event: &str, @@ -2311,7 +2386,7 @@ fn remove_direct_command_hook( let before = entries.len(); entries.retain(|entry| { !(entry.get("type").and_then(Value::as_str) == Some("command") - && entry.get("command").and_then(Value::as_str) == Some(command)) + && is_matching_direct_command_entry(entry, command)) }); let removed = entries.len() != before; if entries.is_empty() { @@ -3061,6 +3136,10 @@ fn opencode_dir() -> io::Result<PathBuf> { Ok(home_dir()?.join(".config/opencode")) } +fn kilo_dir() -> io::Result<PathBuf> { + Ok(home_dir()?.join(".config/kilo")) +} + fn hermes_dir() -> io::Result<PathBuf> { Ok(home_dir()?.join(".hermes")) } @@ -3192,6 +3271,7 @@ mod tests { assert!(!integration_target_supported(IntegrationTarget::Pi)); assert!(!integration_target_supported(IntegrationTarget::Omp)); assert!(!integration_target_supported(IntegrationTarget::Opencode)); + assert!(!integration_target_supported(IntegrationTarget::Kilo)); assert!(!integration_target_supported(IntegrationTarget::Hermes)); assert!(!integration_target_supported(IntegrationTarget::Cursor)); @@ -3218,12 +3298,14 @@ mod tests { fs::write(bin.join("pi.cmd"), "@echo off\r\n").unwrap(); fs::write(bin.join("omp.cmd"), "@echo off\r\n").unwrap(); fs::write(bin.join("opencode.cmd"), "@echo off\r\n").unwrap(); + fs::write(bin.join("kilo.cmd"), "@echo off\r\n").unwrap(); fs::write(bin.join("hermes.exe"), "").unwrap(); fs::write(bin.join("cursor-agent.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)); @@ -4362,7 +4444,7 @@ mod tests { ); assert_eq!(installed.config_path, kimi_dir.join("config.toml")); assert_eq!(hook_content, KIMI_HOOK_ASSET); - assert_eq!(hooks.len(), 11); + assert_eq!(hooks.len(), KIMI_HOOK_EVENTS.len() + 1); assert!(config.contains("default_model = \"moonshot\"")); assert!(config.contains("command = \"echo keep\"")); assert!(config.contains(KIMI_CONFIG_BLOCK_BEGIN)); @@ -4481,9 +4563,17 @@ mod tests { let home = base.join("home"); let copilot_dir = home.join(".copilot"); fs::create_dir_all(&copilot_dir).unwrap(); + let hook_path = copilot_dir.join("hooks").join(COPILOT_HOOK_INSTALL_NAME); + let stale_session_start_command = format!( + "bash {}", + shell_single_quote(&hook_path.display().to_string()) + ); fs::write( copilot_dir.join("settings.json"), - r#"{"theme":"dark","hooks":{"PreToolUse":[{"type":"command","command":"echo keep","timeoutSec":10}]}}"#, + format!( + r#"{{"theme":"dark","hooks":{{"PreToolUse":[{{"type":"command","command":"echo keep","timeoutSec":10}}],"sessionStart":[{{"type":"command","bash":{},"timeoutSec":10}}]}}}}"#, + serde_json::to_string(&stale_session_start_command).unwrap() + ), ) .unwrap(); std::env::set_var("HOME", &home); @@ -4500,35 +4590,28 @@ mod tests { assert_eq!(installed.settings_path, copilot_dir.join("settings.json")); assert_eq!(hook_content, COPILOT_HOOK_ASSET); assert_eq!(settings["theme"], "dark"); - assert_eq!(settings["hooks"]["PreToolUse"].as_array().unwrap().len(), 2); + assert_eq!(settings["hooks"]["PreToolUse"].as_array().unwrap().len(), 1); assert_eq!(settings["hooks"]["PreToolUse"][0]["command"], "echo keep"); - assert!(settings["hooks"]["PreToolUse"][1]["command"] - .as_str() - .unwrap() - .contains(COPILOT_HOOK_INSTALL_NAME)); - assert!(settings["hooks"]["PostToolUse"][0].get("matcher").is_none()); - assert!(settings["hooks"]["PostToolUseFailure"][0] - .get("matcher") - .is_none()); - assert_eq!( - settings["hooks"]["notification"][0]["matcher"], - "permission_prompt|elicitation_dialog|agent_idle" - ); - assert!(settings["hooks"]["Stop"][0]["command"] - .as_str() - .unwrap() - .contains(COPILOT_HOOK_INSTALL_NAME)); - assert!(settings["hooks"]["agentStop"][0]["command"] + assert!(settings["hooks"]["SessionStart"][0][direct_command_field()] .as_str() .unwrap() .contains(COPILOT_HOOK_INSTALL_NAME)); + for event in COPILOT_REMOVED_LIFECYCLE_HOOK_EVENTS { + if let Some(entries) = settings["hooks"].get(event) { + assert!( + !entries.to_string().contains(COPILOT_HOOK_INSTALL_NAME), + "expected herdr hooks.{event} entries to be removed" + ); + } + } + assert!(settings["hooks"].get("sessionStart").is_none()); std::env::remove_var("HOME"); let _ = fs::remove_dir_all(base); } #[test] - fn copilot_v1_integration_status_is_current() { + fn copilot_v1_integration_status_is_outdated() { let _lock = integration_env_lock(); let base = unique_base(); let home = base.join("home"); @@ -4550,8 +4633,8 @@ mod tests { assert_eq!(copilot.path, hook_path); assert_eq!(copilot.installed_version, Some(1)); - assert_eq!(copilot.expected_version, 1); - assert_eq!(copilot.state, IntegrationStatusKind::Current); + assert_eq!(copilot.expected_version, COPILOT_INTEGRATION_VERSION); + assert_eq!(copilot.state, IntegrationStatusKind::Outdated); std::env::remove_var("HOME"); let _ = fs::remove_dir_all(base); @@ -4580,22 +4663,13 @@ mod tests { settings["hooks"]["SessionStart"].as_array().unwrap().len(), 1 ); - assert_eq!(settings["hooks"]["PreToolUse"].as_array().unwrap().len(), 1); - assert_eq!( - settings["hooks"]["PostToolUse"].as_array().unwrap().len(), - 1 - ); - assert_eq!( - settings["hooks"]["PostToolUseFailure"] - .as_array() - .unwrap() - .len(), - 1 - ); - assert_eq!( - settings["hooks"]["notification"].as_array().unwrap().len(), - 1 - ); + for event in COPILOT_REMOVED_LIFECYCLE_HOOK_EVENTS { + assert!( + settings["hooks"].get(event).is_none(), + "expected hooks.{event} to be absent" + ); + } + assert!(settings["hooks"].get("sessionStart").is_none()); clear_integration_path_env(); let _ = fs::remove_dir_all(base); @@ -4618,14 +4692,14 @@ mod tests { let settings = serde_json::json!({ "hooks": { "PreToolUse": [ - {"type": "command", "command": command, "timeoutSec": 10}, + {"type": "command", direct_command_field(): command, "timeoutSec": 10}, {"type": "command", "command": "echo keep", "timeoutSec": 10} ], - "PostToolUse": [{"type": "command", "command": command, "timeoutSec": 10}], + "PostToolUse": [{"type": "command", direct_command_field(): command, "timeoutSec": 10}], "notification": [{ "type": "command", "matcher": "permission_prompt|elicitation_dialog|agent_idle", - "command": command, + direct_command_field(): command, "timeoutSec": 10 }] } @@ -4721,6 +4795,15 @@ mod tests { assert!(settings["hooks"]["SessionStart"][0] .get("matcher") .is_none()); + for (event, action) in DROID_HOOK_EVENTS { + let command = settings["hooks"][event][0]["hooks"][0]["command"] + .as_str() + .unwrap(); + assert!( + command.contains(DROID_HOOK_INSTALL_NAME) && command.ends_with(action), + "expected droid {event} hook command to end with {action}, got {command}" + ); + } assert_eq!(legacy_hooks["hooks"]["PreToolUse"][0]["matcher"], "Read"); assert!(legacy_hooks["hooks"].get("SessionStart").is_none()); @@ -4743,17 +4826,20 @@ mod tests { let settings: Value = serde_json::from_str(&fs::read_to_string(droid_dir.join("settings.json")).unwrap()) .unwrap(); - assert_eq!( - settings["hooks"]["SessionStart"].as_array().unwrap().len(), - 1 - ); + for (event, _) in DROID_HOOK_EVENTS { + assert_eq!( + settings["hooks"][event].as_array().unwrap().len(), + 1, + "expected hooks.{event} to be idempotent" + ); + } std::env::remove_var("HOME"); let _ = fs::remove_dir_all(base); } #[test] - fn droid_v1_integration_status_is_current() { + fn droid_v1_integration_status_is_outdated() { let _lock = integration_env_lock(); let base = unique_base(); let home = base.join("home"); @@ -4775,8 +4861,8 @@ mod tests { assert_eq!(droid.path, hook_path); assert_eq!(droid.installed_version, Some(1)); - assert_eq!(droid.expected_version, 1); - assert_eq!(droid.state, IntegrationStatusKind::Current); + assert_eq!(droid.expected_version, DROID_INTEGRATION_VERSION); + assert_eq!(droid.state, IntegrationStatusKind::Outdated); std::env::remove_var("HOME"); let _ = fs::remove_dir_all(base); @@ -4925,6 +5011,67 @@ mod tests { } #[test] + fn install_kilo_writes_plugin_to_plugin_dir() { + let _lock = integration_env_lock(); + let base = unique_base(); + let home = base.join("home"); + let kilo_dir = home.join(".config/kilo"); + fs::create_dir_all(&kilo_dir).unwrap(); + std::env::set_var("HOME", &home); + + let installed = install_kilo().unwrap(); + let plugin_content = fs::read_to_string(&installed.plugin_path).unwrap(); + + assert_eq!( + installed.plugin_path, + kilo_dir.join("plugin").join(KILO_PLUGIN_INSTALL_NAME) + ); + assert_eq!(plugin_content, KILO_PLUGIN_ASSET); + + std::env::remove_var("HOME"); + let _ = fs::remove_dir_all(base); + } + + #[test] + fn uninstall_kilo_removes_plugin_when_present() { + let _lock = integration_env_lock(); + let base = unique_base(); + let home = base.join("home"); + let kilo_plugin_dir = home.join(".config/kilo/plugin"); + fs::create_dir_all(&kilo_plugin_dir).unwrap(); + fs::write( + kilo_plugin_dir.join(KILO_PLUGIN_INSTALL_NAME), + KILO_PLUGIN_ASSET, + ) + .unwrap(); + std::env::set_var("HOME", &home); + + let result = uninstall_kilo().unwrap(); + + assert!(result.removed_plugin); + assert!(!result.plugin_path.exists()); + + std::env::remove_var("HOME"); + let _ = fs::remove_dir_all(base); + } + + #[test] + fn install_kilo_errors_when_config_dir_missing() { + let _lock = integration_env_lock(); + let base = unique_base(); + let home = base.join("home"); + fs::create_dir_all(&home).unwrap(); + std::env::set_var("HOME", &home); + + let err = install_kilo().unwrap_err().to_string(); + + assert!(err.contains("kilo config directory not found")); + + std::env::remove_var("HOME"); + let _ = fs::remove_dir_all(base); + } + + #[test] fn install_hermes_writes_plugin_and_enables_it() { let _lock = integration_env_lock(); let base = unique_base(); @@ -5216,34 +5363,37 @@ mod tests { assert!(!CODEX_HOOK_ASSET.contains("\"state\": action")); assert!(!CODEX_HOOK_ASSET.contains("pane.release_agent")); assert!(KIMI_HOOK_ASSET.contains("source = \"herdr:kimi\"")); - assert!(KIMI_HOOK_ASSET.contains("pane.report_agent")); - assert!(KIMI_HOOK_ASSET.contains("pane.release_agent")); - assert!(!KIMI_HOOK_ASSET.contains("agent_session_id")); + assert!(KIMI_HOOK_ASSET.contains("agent_session_id")); + assert!(KIMI_HOOK_ASSET.contains("pane.report_agent_session")); + assert!(!KIMI_HOOK_ASSET.contains("\"state\": action")); + assert!(!KIMI_HOOK_ASSET.contains("pane.release_agent")); assert!(COPILOT_HOOK_ASSET.contains("agent_session_id")); - assert!(COPILOT_HOOK_ASSET.contains("notification_type")); - assert!(COPILOT_HOOK_ASSET.contains("ask_user")); - assert!(COPILOT_HOOK_ASSET.contains("exit_plan_mode")); - assert!(DROID_HOOK_ASSET.contains("hook_event_name")); - assert!(DROID_HOOK_ASSET.contains("SessionStart")); + assert!(COPILOT_HOOK_ASSET.contains("pane.report_agent_session")); + assert!(!COPILOT_HOOK_ASSET.contains("\"state\":")); + assert!(!COPILOT_HOOK_ASSET.contains("pane.release_agent")); assert!(DROID_HOOK_ASSET.contains("agent_session_id")); assert!(DROID_HOOK_ASSET.contains("pane.report_agent_session")); - assert!(!DROID_HOOK_ASSET.contains("\"state\":")); + assert!(!DROID_HOOK_ASSET.contains("\"state\": action")); assert!(!DROID_HOOK_ASSET.contains("pane.release_agent")); assert!(OPENCODE_PLUGIN_ASSET.contains("properties?.sessionID")); - assert!(OPENCODE_PLUGIN_ASSET.contains("agent_session_id: sessionID")); + assert!(OPENCODE_PLUGIN_ASSET.contains("params.agent_session_id = sessionID")); assert!(OPENCODE_PLUGIN_ASSET.contains("pane.report_agent_session")); - assert!(!OPENCODE_PLUGIN_ASSET.contains("reportState")); - assert!(!OPENCODE_PLUGIN_ASSET.contains("pane.release_agent")); + assert!(OPENCODE_PLUGIN_ASSET.contains("reportState")); + assert!(OPENCODE_PLUGIN_ASSET.contains("pane.release_agent")); + assert!(KILO_PLUGIN_ASSET.contains("SOURCE = \"herdr:kilo\"")); + assert!(KILO_PLUGIN_ASSET.contains("AGENT = \"kilo\"")); + assert!(KILO_PLUGIN_ASSET.contains("pane.report_agent_session")); + assert!(KILO_PLUGIN_ASSET.contains("reportState")); + assert!(KILO_PLUGIN_ASSET.contains("pane.release_agent")); assert!(HERMES_PLUGIN_INIT_ASSET.contains("session_id = _session_id(kwargs)")); assert!(HERMES_PLUGIN_INIT_ASSET.contains("agent_session_id")); assert!(HERMES_PLUGIN_INIT_ASSET.contains("pane.report_agent\",")); assert!(HERMES_PLUGIN_INIT_ASSET.contains("pane.release_agent")); - // Qoder hook reads the event from the stdin JSON payload (per - // https://docs.qoder.com/zh/cli/hooks). Make sure the bundled script - // never reaches for a QODER_HOOK_EVENT environment variable. assert!(QODERCLI_HOOK_ASSET.contains("HERDR_HOOK_INPUT_FILE")); - assert!(QODERCLI_HOOK_ASSET.contains("hook_event_name")); assert!(QODERCLI_HOOK_ASSET.contains("agent_session_id")); + assert!(QODERCLI_HOOK_ASSET.contains("pane.report_agent_session")); + assert!(!QODERCLI_HOOK_ASSET.contains("\"state\": action")); + assert!(!QODERCLI_HOOK_ASSET.contains("pane.release_agent")); assert!(!QODERCLI_HOOK_ASSET.contains("QODER_HOOK_EVENT")); assert!(CURSOR_HOOK_ASSET.contains("HERDR_INTEGRATION_ID=cursor")); assert!(CURSOR_HOOK_ASSET.contains("conversation_id")); @@ -5285,18 +5435,16 @@ mod tests { .get("hooks") .and_then(Value::as_object) .expect("hooks should be present"); - for event in [ - "SessionStart", - "UserPromptSubmit", - "PreToolUse", - "PermissionRequest", - "Stop", - "SessionEnd", - ] { + for (event, action) in QODERCLI_HOOK_EVENTS { assert!( hooks.contains_key(event), "expected hooks.{event} to be registered" ); + let command = hooks[event][0]["hooks"][0]["command"].as_str().unwrap(); + assert!( + command.contains(QODERCLI_HOOK_INSTALL_NAME) && command.ends_with(action), + "expected qodercli {event} hook command to end with {action}, got {command}" + ); } // Pre-existing settings keys must be preserved. assert!(settings.get("permissions").is_some()); @@ -5320,14 +5468,7 @@ mod tests { serde_json::from_str(&fs::read_to_string(qoder_dir.join("settings.json")).unwrap()) .unwrap(); let hooks = settings.get("hooks").and_then(Value::as_object).unwrap(); - for event in [ - "SessionStart", - "UserPromptSubmit", - "PreToolUse", - "PermissionRequest", - "Stop", - "SessionEnd", - ] { + for (event, _) in QODERCLI_HOOK_EVENTS { let entries = hooks.get(event).and_then(Value::as_array).unwrap(); assert_eq!( entries.len(), @@ -5353,7 +5494,7 @@ mod tests { let mut settings: Value = serde_json::from_str(&fs::read_to_string(qoder_dir.join("settings.json")).unwrap()) .unwrap(); - settings["hooks"]["UserPromptSubmit"] + settings["hooks"]["SessionStart"] .as_array_mut() .unwrap() .push(json!({ @@ -5374,10 +5515,7 @@ mod tests { serde_json::from_str(&fs::read_to_string(qoder_dir.join("settings.json")).unwrap()) .unwrap(); let hooks = settings.get("hooks").and_then(Value::as_object).unwrap(); - let remaining = hooks - .get("UserPromptSubmit") - .and_then(Value::as_array) - .unwrap(); + let remaining = hooks.get("SessionStart").and_then(Value::as_array).unwrap(); assert_eq!(remaining.len(), 1); let cmd = remaining[0]["hooks"][0]["command"].as_str().unwrap(); assert_eq!(cmd, "echo user-defined"); diff --git a/src/terminal/state.rs b/src/terminal/state.rs index afd94ecf..74b7973d 100644 --- a/src/terminal/state.rs +++ b/src/terminal/state.rs @@ -1539,10 +1539,10 @@ mod tests { fn fallback_idle_does_not_override_full_lifecycle_hook_working() { let now = Instant::now(); let mut terminal = test_terminal(); - terminal.set_detected_state(Some(Agent::Kimi), AgentState::Working); + terminal.set_detected_state(Some(Agent::OpenCode), AgentState::Working); terminal.set_hook_authority_with_custom_status_at( - "herdr:kimi".into(), - "kimi".into(), + "herdr:opencode".into(), + "opencode".into(), AgentState::Working, None, Some("thinking".into()), @@ -1551,7 +1551,7 @@ mod tests { now, ); terminal.set_detected_state_with_screen_signals_at( - Some(Agent::Kimi), + Some(Agent::OpenCode), AgentState::Idle, false, true, @@ -1634,10 +1634,10 @@ mod tests { fn pty_working_fallback_is_ignored_under_full_lifecycle_hook_authority() { let now = Instant::now(); let mut terminal = test_terminal(); - terminal.set_detected_state(Some(Agent::Qodercli), AgentState::Idle); + terminal.set_detected_state(Some(Agent::Kilo), AgentState::Idle); terminal.set_hook_authority_with_custom_status_at( - "herdr:qodercli".into(), - "qodercli".into(), + "herdr:kilo".into(), + "kilo".into(), AgentState::Idle, None, None, @@ -1647,7 +1647,7 @@ mod tests { ); let change = terminal.set_detected_state_with_screen_signals_at( - Some(Agent::Qodercli), + Some(Agent::Kilo), AgentState::Working, false, false, @@ -1881,10 +1881,10 @@ mod tests { #[test] fn detected_agent_clear_clears_matching_hook_authority() { let mut terminal = test_terminal(); - terminal.set_detected_state(Some(Agent::OpenCode), AgentState::Idle); + terminal.set_detected_state(Some(Agent::Cursor), AgentState::Idle); terminal.set_hook_authority( - "herdr:opencode".into(), - "opencode".into(), + "herdr:cursor".into(), + "cursor".into(), AgentState::Idle, None, None, @@ -2287,20 +2287,20 @@ mod tests { let mut terminal = test_terminal(); terminal .set_hook_authority_with_session_ref( - "herdr:copilot".into(), - "copilot".into(), + "herdr:opencode".into(), + "opencode".into(), AgentState::Working, None, None, - crate::agent_resume::AgentSessionRef::id("copilot-session"), + crate::agent_resume::AgentSessionRef::id("opencode-session"), Some(20), ) .expect("initial session should be accepted"); let mutation = terminal .set_hook_authority_with_session_ref( - "herdr:copilot".into(), - "copilot".into(), + "herdr:opencode".into(), + "opencode".into(), AgentState::Blocked, Some("needs approval".into()), None, @@ -2317,7 +2317,7 @@ mod tests { .as_ref() .and_then(|authority| authority.session_ref.as_ref()) .map(|session_ref| session_ref.value.as_str()), - Some("copilot-session") + Some("opencode-session") ); } diff --git a/tests/cli_wrapper.rs b/tests/cli_wrapper.rs index 4407f54f..117afe97 100644 --- a/tests/cli_wrapper.rs +++ b/tests/cli_wrapper.rs @@ -622,166 +622,41 @@ fn codex_hook_reports_session_id_from_stdin() { } #[test] -fn copilot_hook_maps_turn_lifecycle() { - let session_start_idle = run_copilot_hook( +fn copilot_hook_reports_session_id_from_stdin() { + let request = run_copilot_hook( r#"{"hook_event_name":"SessionStart","session_id":"copilot-session","source":"resume"}"#, ) - .expect("copilot session start without prompt should report idle"); - assert_eq!(session_start_idle["method"], "pane.report_agent"); - assert_eq!(session_start_idle["params"]["state"], "idle"); - assert_eq!( - session_start_idle["params"]["agent_session_id"], - "copilot-session" - ); + .expect("copilot session start should report session identity"); - let session_start_working = run_copilot_hook( - r#"{"hook_event_name":"SessionStart","sessionId":"copilot-session","initialPrompt":"run tests"}"#, - ) - .expect("copilot session start with prompt should report working"); - assert_eq!(session_start_working["method"], "pane.report_agent"); - assert_eq!(session_start_working["params"]["state"], "working"); - assert_eq!( - session_start_working["params"]["agent_session_id"], - "copilot-session" - ); - - let working = run_copilot_hook( - r#"{"hook_event_name":"UserPromptSubmit","session_id":"copilot-session","prompt":"run tests"}"#, - ) - .expect("copilot prompt should report working"); - assert_eq!(working["method"], "pane.report_agent"); - assert_eq!(working["params"]["agent"], "copilot"); - assert_eq!(working["params"]["state"], "working"); - assert_eq!(working["params"]["agent_session_id"], "copilot-session"); - - let idle = run_copilot_hook( - r#"{"hook_event_name":"agentStop","session_id":"copilot-session","stop_reason":"end_turn"}"#, - ) - .expect("copilot stop should report idle"); - assert_eq!(idle["method"], "pane.report_agent"); - assert_eq!(idle["params"]["state"], "idle"); -} + assert_eq!(request["method"], "pane.report_agent_session"); + assert_eq!(request["params"]["agent"], "copilot"); + assert_eq!(request["params"]["agent_session_id"], "copilot-session"); + assert!(request["params"].get("state").is_none()); -#[test] -fn copilot_hook_maps_camel_case_payloads() { - let session_start = run_copilot_hook( + let camel = run_copilot_hook( r#"{"sessionId":"copilot-camel-session","source":"new","initialPrompt":"run tests"}"#, ) - .expect("copilot camelCase session start should report working"); - assert_eq!(session_start["method"], "pane.report_agent"); - assert_eq!(session_start["params"]["state"], "working"); - assert_eq!( - session_start["params"]["agent_session_id"], - "copilot-camel-session" - ); - - let prompt = - run_copilot_hook(r#"{"sessionId":"copilot-camel-session","prompt":"continue the task"}"#) - .expect("copilot camelCase prompt should report working"); - assert_eq!(prompt["method"], "pane.report_agent"); - assert_eq!(prompt["params"]["state"], "working"); - - let blocker = run_copilot_hook( - r#"{"sessionId":"copilot-camel-session","toolName":"ask_user","toolArgs":{}}"#, - ) - .expect("copilot camelCase ask_user should report blocked"); - assert_eq!(blocker["method"], "pane.report_agent"); - assert_eq!(blocker["params"]["state"], "blocked"); + .expect("copilot camelCase session start should report session identity"); - let answered = run_copilot_hook( - r#"{"sessionId":"copilot-camel-session","toolName":"ask_user","toolArgs":{},"toolResult":{"resultType":"success","textResultForLlm":"ok"}}"#, - ) - .expect("copilot camelCase postToolUse should report working"); - assert_eq!(answered["method"], "pane.report_agent"); - assert_eq!(answered["params"]["state"], "working"); - - let idle = run_copilot_hook( - r#"{"sessionId":"copilot-camel-session","stopReason":"end_turn","transcriptPath":"/tmp/transcript.jsonl"}"#, - ) - .expect("copilot camelCase agentStop should report idle"); - assert_eq!(idle["method"], "pane.report_agent"); - assert_eq!(idle["params"]["state"], "idle"); - - let user_exit = - run_copilot_hook(r#"{"sessionId":"copilot-camel-session","reason":"user_exit"}"#) - .expect("copilot camelCase user exit should release"); - assert_eq!(user_exit["method"], "pane.release_agent"); - assert_eq!(user_exit["params"]["agent"], "copilot"); + assert_eq!(camel["method"], "pane.report_agent_session"); + assert_eq!(camel["params"]["agent_session_id"], "copilot-camel-session"); + assert!(camel["params"].get("state").is_none()); } #[test] -fn copilot_hook_maps_user_prompts_and_notifications() { - let ask_user = run_copilot_hook( +fn copilot_hook_does_not_report_lifecycle_state() { + for payload in [ + r#"{"hook_event_name":"UserPromptSubmit","session_id":"copilot-session","prompt":"run tests"}"#, r#"{"hook_event_name":"PreToolUse","session_id":"copilot-session","tool_name":"ask_user"}"#, - ) - .expect("copilot ask_user should report blocked"); - assert_eq!(ask_user["method"], "pane.report_agent"); - assert_eq!(ask_user["params"]["state"], "blocked"); - - let stale_report_intent = run_copilot_hook( - r#"{"hook_event_name":"PostToolUse","session_id":"copilot-session","tool_name":"report_intent","tool_result":{"text_result_for_llm":"ok"}}"#, - ); - assert!( - stale_report_intent.is_none(), - "postToolUse report_intent must not clear a pending ask_user prompt" - ); - - let ask_user_answered = run_copilot_hook( - r#"{"hook_event_name":"PostToolUse","session_id":"copilot-session","tool_name":"ask_user","tool_result":{"text_result_for_llm":"Blue"}}"#, - ) - .expect("copilot answered ask_user should report working"); - assert_eq!(ask_user_answered["method"], "pane.report_agent"); - assert_eq!(ask_user_answered["params"]["state"], "working"); - - let exit_plan_mode = run_copilot_hook( - r#"{"hook_event_name":"PreToolUse","session_id":"copilot-session","tool_name":"exit_plan_mode"}"#, - ) - .expect("copilot exit_plan_mode should report blocked"); - assert_eq!(exit_plan_mode["method"], "pane.report_agent"); - assert_eq!(exit_plan_mode["params"]["state"], "blocked"); - - let exit_plan_answered = run_copilot_hook( - r#"{"hook_event_name":"PostToolUse","session_id":"copilot-session","tool_name":"exit_plan_mode","tool_result":{"text_result_for_llm":"Approved"}}"#, - ) - .expect("copilot answered exit_plan_mode should report working"); - assert_eq!(exit_plan_answered["method"], "pane.report_agent"); - assert_eq!(exit_plan_answered["params"]["state"], "working"); - - let notification = run_copilot_hook( r#"{"hook_event_name":"notification","session_id":"copilot-session","notification_type":"permission_prompt"}"#, - ) - .expect("copilot permission notification should report blocked"); - assert_eq!(notification["method"], "pane.report_agent"); - assert_eq!(notification["params"]["state"], "blocked"); - - let permission_approved_tool = run_copilot_hook( - r#"{"hook_event_name":"PostToolUse","session_id":"copilot-session","tool_name":"bash","tool_result":{"text_result_for_llm":"ok"}}"#, - ) - .expect("copilot completed tool should report working after permission prompt"); - assert_eq!(permission_approved_tool["method"], "pane.report_agent"); - assert_eq!(permission_approved_tool["params"]["state"], "working"); - - let agent_idle = run_copilot_hook( - r#"{"hook_event_name":"notification","session_id":"copilot-session","notification_type":"agent_idle"}"#, - ) - .expect("copilot agent_idle notification should report idle"); - assert_eq!(agent_idle["method"], "pane.report_agent"); - assert_eq!(agent_idle["params"]["state"], "idle"); -} - -#[test] -fn copilot_hook_releases_on_user_exit_only() { - let complete = run_copilot_hook( - r#"{"hook_event_name":"SessionEnd","session_id":"copilot-session","reason":"complete"}"#, - ); - assert!(complete.is_none()); - - let user_exit = run_copilot_hook( + r#"{"hook_event_name":"agentStop","session_id":"copilot-session","stop_reason":"end_turn"}"#, r#"{"hook_event_name":"SessionEnd","session_id":"copilot-session","reason":"user_exit"}"#, - ) - .expect("copilot user exit should release"); - assert_eq!(user_exit["method"], "pane.release_agent"); - assert_eq!(user_exit["params"]["agent"], "copilot"); + ] { + assert!( + run_copilot_hook(payload).is_none(), + "copilot session-only hook should ignore lifecycle payload {payload}" + ); + } } #[test] |
