diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-04 23:09:15 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-06-04 23:17:45 +0300 |
| commit | 9b2749776a97bf373e50819dd8a66f290e421551 (patch) | |
| tree | 12d94fda11175c63c613403ddb58d2fa5d515445 | |
| parent | 9c18014c09ca419e517dc3ff5684287cd0f47d43 (diff) | |
feat: add droid integration
| -rw-r--r-- | docs/next/CHANGELOG.md | 3 | ||||
| -rw-r--r-- | docs/next/README.md | 7 | ||||
| -rw-r--r-- | docs/next/website/src/content/docs/agents.mdx | 3 | ||||
| -rw-r--r-- | docs/next/website/src/content/docs/cli-reference.mdx | 2 | ||||
| -rw-r--r-- | docs/next/website/src/content/docs/configuration.mdx | 2 | ||||
| -rw-r--r-- | docs/next/website/src/content/docs/integrations.mdx | 26 | ||||
| -rw-r--r-- | docs/next/website/src/content/docs/session-state.mdx | 1 | ||||
| -rw-r--r-- | src/agent_resume.rs | 31 | ||||
| -rw-r--r-- | src/api/schema.rs | 1 | ||||
| -rw-r--r-- | src/cli/integration.rs | 9 | ||||
| -rw-r--r-- | src/integration/assets/droid/herdr-agent-state.sh | 77 | ||||
| -rw-r--r-- | src/integration/mod.rs | 429 |
12 files changed, 576 insertions, 15 deletions
diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md index 824e52c5..32b61d17 100644 --- a/docs/next/CHANGELOG.md +++ b/docs/next/CHANGELOG.md @@ -6,6 +6,9 @@ - Resizing restored panes no longer aborts the server when libghostty-vt reflows a terminal whose pre-resize cursor row is past the new height. (#465) - Full-screen TUIs such as Neovim now receive resize-generated terminal responses after Herdr internal pane resizes, so grown panes redraw without waiting for extra input. (#471) +### 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>`. + ## [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. diff --git a/docs/next/README.md b/docs/next/README.md index 3a06bd6d..031765d8 100644 --- a/docs/next/README.md +++ b/docs/next/README.md @@ -145,7 +145,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, 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, 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. ## lives in your terminal @@ -192,7 +192,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, 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, 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: ```bash herdr integration install pi @@ -200,6 +200,7 @@ herdr integration install omp herdr integration install claude herdr integration install codex herdr integration install copilot +herdr integration install droid herdr integration install kimi herdr integration install opencode herdr integration install hermes @@ -249,7 +250,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, github copilot cli, kimi code cli, opencode, hermes, qodercli integrations +- [integrations](https://herdr.dev/docs/integrations/) — pi, omp, claude code, codex, github copilot cli, droid, kimi code cli, opencode, 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 34cba36f..7d7bcf08 100644 --- a/docs/next/website/src/content/docs/agents.mdx +++ b/docs/next/website/src/content/docs/agents.mdx @@ -39,7 +39,7 @@ Herdr combines three signals: 2. terminal output heuristics 3. integration session identity or state reports -Process detection tells Herdr which pane owns an agent. Heuristics infer native agent state from terminal screen snapshots. Claude Code, Codex, and OpenCode integrations provide session identity for restore; Pi, OMP, GitHub Copilot CLI, Kimi Code CLI, Hermes Agent, Qoder CLI, and custom socket integrations can report state when they define state outside the native terminal UI. +Process detection tells Herdr which pane owns an agent. Heuristics infer native agent state from terminal screen snapshots. Claude Code, Codex, Droid, and OpenCode integrations provide session identity for restore; Pi, OMP, GitHub Copilot CLI, Kimi Code CLI, Hermes Agent, Qoder CLI, and custom socket integrations can report state when they define state outside the native terminal UI. Herdr can run inside tmux as the outer terminal environment. Agent detection does not inspect tmux sessions launched inside a Herdr pane. If a shell framework auto-enters tmux inside Herdr, Herdr sees `tmux` as the pane process instead of the agent behind it. @@ -61,6 +61,7 @@ herdr integration install omp herdr integration install claude herdr integration install codex herdr integration install copilot +herdr integration install droid herdr integration install kimi herdr integration install opencode herdr integration install hermes diff --git a/docs/next/website/src/content/docs/cli-reference.mdx b/docs/next/website/src/content/docs/cli-reference.mdx index 50ed8c34..bf3c37d3 100644 --- a/docs/next/website/src/content/docs/cli-reference.mdx +++ b/docs/next/website/src/content/docs/cli-reference.mdx @@ -211,6 +211,7 @@ herdr integration install omp herdr integration install claude herdr integration install codex herdr integration install copilot +herdr integration install droid herdr integration install kimi herdr integration install opencode herdr integration install hermes @@ -220,6 +221,7 @@ herdr integration uninstall omp herdr integration uninstall claude herdr integration uninstall codex herdr integration uninstall copilot +herdr integration uninstall droid herdr integration uninstall kimi herdr integration uninstall opencode herdr integration uninstall hermes diff --git a/docs/next/website/src/content/docs/configuration.mdx b/docs/next/website/src/content/docs/configuration.mdx index b174cb42..7dc5c6fc 100644 --- a/docs/next/website/src/content/docs/configuration.mdx +++ b/docs/next/website/src/content/docs/configuration.mdx @@ -402,7 +402,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, 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, 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. 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 79ec5c94..264e559e 100644 --- a/docs/next/website/src/content/docs/integrations.mdx +++ b/docs/next/website/src/content/docs/integrations.mdx @@ -1,9 +1,9 @@ --- title: Integrations -description: Install Herdr integrations for Pi, OMP, Claude Code, Codex, GitHub Copilot CLI, Kimi Code CLI, OpenCode, Hermes Agent, and Qoder CLI. +description: Install Herdr integrations for Pi, OMP, Claude Code, Codex, GitHub Copilot CLI, Droid, Kimi Code CLI, OpenCode, Hermes Agent, and Qoder CLI. --- -Herdr detects supported agents automatically. Official integrations can add native session identity for restore, semantic state reports, or both. Claude Code, Codex, and OpenCode state comes from screen detection even when their session identity integrations are installed. +Herdr detects supported agents automatically. Official integrations can add native session identity for restore, semantic state reports, or both. Claude Code, Codex, Droid, and OpenCode state comes from screen detection even when their session identity integrations are installed. Use integrations when you want native agent session restore, direct state reports from Pi/OMP/Copilot/Kimi/Hermes/Qoder-style hooks or plugins, or both. @@ -17,6 +17,7 @@ herdr integration install omp herdr integration install claude herdr integration install codex herdr integration install copilot +herdr integration install droid herdr integration install kimi herdr integration install opencode herdr integration install hermes @@ -31,6 +32,7 @@ herdr integration uninstall omp herdr integration uninstall claude herdr integration uninstall codex herdr integration uninstall copilot +herdr integration uninstall droid herdr integration uninstall kimi herdr integration uninstall opencode herdr integration uninstall hermes @@ -47,11 +49,11 @@ Herdr combines three signals: | Screen heuristics | Infers visible native agent state such as `working` or `blocked`. | | Integration events | Reports native session identity, semantic state, or both depending on the integration. | -Claude Code, Codex, and OpenCode state detection is screen-read first. Their Herdr-owned integrations do not author `idle`, `working`, or `blocked` state. Pi, GitHub Copilot CLI, and Hermes Agent report semantic state and session identity. OMP, Kimi Code CLI, and Qoder CLI report semantic state but do not provide native session restore. Custom socket integrations can report state when they define state that is not visible in the native terminal UI. +Claude Code, Codex, Droid, and OpenCode state detection is screen-read first. Their Herdr-owned integrations do not author `idle`, `working`, or `blocked` state. Pi, GitHub Copilot CLI, and Hermes Agent report semantic state and session identity. OMP, Kimi Code CLI, and Qoder CLI report semantic state but do not provide native session restore. Custom socket integrations can 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, GitHub Copilot CLI, 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, GitHub Copilot CLI, Droid, Pi, Hermes Agent, and OpenCode 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`, GitHub Copilot CLI 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 `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`. ## Pi @@ -139,6 +141,20 @@ Herdr uses `~/.kimi-code` by default, or `KIMI_CODE_HOME` when set. The Kimi Cod Native screen heuristics remain available when the hook is not installed. +## Droid + +Install the Droid hook: + +```bash +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 screen detection. + +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. + +After Droid emits a session start event, Herdr can use the reported session id to resume the pane with `droid --resume <id>`. + ## OpenCode Install the OpenCode plugin: diff --git a/docs/next/website/src/content/docs/session-state.mdx b/docs/next/website/src/content/docs/session-state.mdx index c7301730..53a6a42a 100644 --- a/docs/next/website/src/content/docs/session-state.mdx +++ b/docs/next/website/src/content/docs/session-state.mdx @@ -59,6 +59,7 @@ Native session restore requires these Herdr integration versions or newer: | Claude Code | `5` | `claude --resume <id>` | | Codex | `5` | `codex resume <id>` | | GitHub Copilot CLI | `1` | `copilot --resume=<id>` | +| Droid | `1` | `droid --resume <id>` | | OpenCode | `4` | `opencode --session <id>` | | Hermes Agent | `2` | `hermes --resume <id>` | diff --git a/src/agent_resume.rs b/src/agent_resume.rs index bfd72d7d..5cbf5741 100644 --- a/src/agent_resume.rs +++ b/src/agent_resume.rs @@ -72,7 +72,10 @@ pub fn session_ref_from_report( pub fn is_reserved_native_state_source(source: &str, agent: &str) -> bool { matches!( (source, agent), - ("herdr:claude", "claude") | ("herdr:codex", "codex") | ("herdr:opencode", "opencode") + ("herdr:claude", "claude") + | ("herdr:codex", "codex") + | ("herdr:droid", "droid") + | ("herdr:opencode", "opencode") ) } @@ -116,6 +119,9 @@ pub fn plan(source: &str, agent: &str, session_ref: &AgentSessionRef) -> Option< ("herdr:copilot", "copilot", AgentSessionRefKind::Id) => { vec!["copilot".into(), format!("--resume={}", session_ref.value)] } + ("herdr:droid", "droid", AgentSessionRefKind::Id) => { + vec!["droid".into(), "--resume".into(), session_ref.value.clone()] + } ("herdr:pi", "pi", AgentSessionRefKind::Path | AgentSessionRefKind::Id) => { vec!["pi".into(), "--session".into(), session_ref.value.clone()] } @@ -156,6 +162,7 @@ fn is_official_agent_source(source: &str, agent: &str) -> bool { ("herdr:claude", "claude") | ("herdr:codex", "codex") | ("herdr:copilot", "copilot") + | ("herdr:droid", "droid") | ("herdr:pi", "pi") | ("herdr:hermes", "hermes") | ("herdr:opencode", "opencode") @@ -211,6 +218,16 @@ mod tests { ); assert_eq!( plan( + "herdr:droid", + "droid", + &AgentSessionRef::id("droid-session").unwrap() + ) + .unwrap() + .argv, + vec!["droid", "--resume", "droid-session"] + ); + assert_eq!( + plan( "herdr:pi", "pi", &AgentSessionRef::path("/tmp/pi-session.jsonl").unwrap() @@ -295,6 +312,18 @@ mod tests { Some("/tmp/copilot-session".into()) ) .is_none()); + + let session_ref = + session_ref_from_report("herdr:droid", "droid", Some("droid-id".into()), None).unwrap(); + assert_eq!(session_ref.kind, AgentSessionRefKind::Id); + assert_eq!(session_ref.value, "droid-id"); + assert!(session_ref_from_report( + "herdr:droid", + "droid", + None, + Some("/tmp/droid-session".into()) + ) + .is_none()); } #[test] diff --git a/src/api/schema.rs b/src/api/schema.rs index 77af06b6..a0c3ca24 100644 --- a/src/api/schema.rs +++ b/src/api/schema.rs @@ -524,6 +524,7 @@ pub enum IntegrationTarget { Claude, Codex, Copilot, + Droid, Kimi, Opencode, Hermes, diff --git a/src/cli/integration.rs b/src/cli/integration.rs index 8001172e..148085d2 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|kimi|opencode|hermes|qodercli>" + "usage: herdr integration {action} <pi|omp|claude|codex|copilot|droid|kimi|opencode|hermes|qodercli>" ); return Ok(None); }; if args.len() != 1 { eprintln!( - "usage: herdr integration {action} <pi|omp|claude|codex|copilot|kimi|opencode|hermes|qodercli>" + "usage: herdr integration {action} <pi|omp|claude|codex|copilot|droid|kimi|opencode|hermes|qodercli>" ); return Ok(None); } @@ -120,6 +120,7 @@ fn parse_integration_target( "claude" => IntegrationTarget::Claude, "codex" => IntegrationTarget::Codex, "copilot" => IntegrationTarget::Copilot, + "droid" => IntegrationTarget::Droid, "kimi" => IntegrationTarget::Kimi, "opencode" => IntegrationTarget::Opencode, "hermes" => IntegrationTarget::Hermes, @@ -127,7 +128,7 @@ fn parse_integration_target( _ => { eprintln!("unknown integration target: {target}"); eprintln!( - "currently supported: pi, omp, claude, codex, copilot, kimi, opencode, hermes, qodercli" + "currently supported: pi, omp, claude, codex, copilot, droid, kimi, opencode, hermes, qodercli" ); return Ok(None); } @@ -143,6 +144,7 @@ fn print_integration_help() { eprintln!(" herdr integration install claude"); eprintln!(" herdr integration install codex"); eprintln!(" herdr integration install copilot"); + eprintln!(" herdr integration install droid"); eprintln!(" herdr integration install kimi"); eprintln!(" herdr integration install opencode"); eprintln!(" herdr integration install hermes"); @@ -152,6 +154,7 @@ fn print_integration_help() { eprintln!(" herdr integration uninstall claude"); eprintln!(" herdr integration uninstall codex"); eprintln!(" herdr integration uninstall copilot"); + eprintln!(" herdr integration uninstall droid"); eprintln!(" herdr integration uninstall kimi"); eprintln!(" herdr integration uninstall opencode"); eprintln!(" herdr integration uninstall hermes"); diff --git a/src/integration/assets/droid/herdr-agent-state.sh b/src/integration/assets/droid/herdr-agent-state.sh new file mode 100644 index 00000000..14109fc2 --- /dev/null +++ b/src/integration/assets/droid/herdr-agent-state.sh @@ -0,0 +1,77 @@ +#!/bin/sh +# installed by herdr +# managed by herdr; reinstalling or updating the integration overwrites this file. +# add custom hooks beside this file instead of editing it. +# HERDR_INTEGRATION_ID=droid +# HERDR_INTEGRATION_VERSION=1 + +set -eu + +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 + +[ "${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' +import json +import os +import random +import socket +import time + +source = "herdr:droid" +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 = {} + +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: + raise SystemExit(0) + +request_id = f"{source}:{int(time.time() * 1000)}:{random.randrange(1_000_000):06d}" +request = { + "id": request_id, + "method": "pane.report_agent_session", + "params": { + "pane_id": pane_id, + "source": source, + "agent": "droid", + "seq": time.time_ns(), + "agent_session_id": agent_session_id, + }, +} + +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()) + try: + client.recv(4096) + except Exception: + pass + client.close() +except Exception: + pass +PY diff --git a/src/integration/mod.rs b/src/integration/mod.rs index f71ebce3..a046956c 100644 --- a/src/integration/mod.rs +++ b/src/integration/mod.rs @@ -48,6 +48,9 @@ const COPILOT_HOOK_INSTALL_NAME: &str = "herdr-agent-state.sh"; const COPILOT_HOOK_ASSET: &str = include_str!("assets/copilot/herdr-agent-state.sh"); const COPILOT_INTEGRATION_VERSION: u32 = 1; const COPILOT_HOME_ENV_VAR: &str = "COPILOT_HOME"; +const DROID_HOOK_INSTALL_NAME: &str = "herdr-agent-state.sh"; +const DROID_HOOK_ASSET: &str = include_str!("assets/droid/herdr-agent-state.sh"); +const DROID_INTEGRATION_VERSION: u32 = 1; 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; @@ -89,6 +92,14 @@ pub(crate) struct CopilotInstallPaths { } #[derive(Debug)] +pub(crate) struct DroidInstallPaths { + pub hook_path: PathBuf, + pub hooks_path: PathBuf, + pub settings_path: PathBuf, + pub updated_legacy_hooks: bool, +} + +#[derive(Debug)] pub(crate) struct OpenCodeInstallPaths { pub plugin_path: PathBuf, } @@ -207,6 +218,16 @@ pub(crate) struct CopilotUninstallResult { } #[derive(Debug)] +pub(crate) struct DroidUninstallResult { + pub hook_path: PathBuf, + pub hooks_path: PathBuf, + pub settings_path: PathBuf, + pub removed_hook_file: bool, + pub updated_hooks: bool, + pub updated_settings: bool, +} + +#[derive(Debug)] pub(crate) struct OpenCodeUninstallResult { pub plugin_path: PathBuf, pub removed_plugin: bool, @@ -302,6 +323,26 @@ pub(crate) fn install_target( format!("requires kimi code {KIMI_MIN_VERSION} or newer"), ] } + crate::api::schema::IntegrationTarget::Droid => { + let installed = install_droid()?; + let mut messages = vec![ + format!( + "installed droid integration hook to {}", + installed.hook_path.display() + ), + format!( + "ensured droid hooks at {}", + installed.settings_path.display() + ), + ]; + if installed.updated_legacy_hooks { + messages.push(format!( + "removed legacy herdr droid hook entries from {}", + installed.hooks_path.display() + )); + } + messages + } crate::api::schema::IntegrationTarget::Opencode => { let installed = install_opencode()?; vec![format!( @@ -485,6 +526,44 @@ pub(crate) fn uninstall_target( } messages } + crate::api::schema::IntegrationTarget::Droid => { + let result = uninstall_droid()?; + let mut messages = Vec::new(); + if result.removed_hook_file { + messages.push(format!( + "removed droid hook at {}", + result.hook_path.display() + )); + } else { + messages.push(format!( + "no droid hook found at {}", + result.hook_path.display() + )); + } + if result.updated_hooks { + messages.push(format!( + "removed legacy herdr droid hook entries from {}", + result.hooks_path.display() + )); + } else { + messages.push(format!( + "no legacy herdr droid hook entries found in {}", + result.hooks_path.display() + )); + } + if result.updated_settings { + messages.push(format!( + "removed herdr droid hook entries from {}", + result.settings_path.display() + )); + } else { + messages.push(format!( + "no herdr droid hook entries found in {}", + result.settings_path.display() + )); + } + messages + } crate::api::schema::IntegrationTarget::Opencode => { let result = uninstall_opencode()?; if result.removed_plugin { @@ -568,6 +647,7 @@ pub(crate) fn integration_target_label( crate::api::schema::IntegrationTarget::Claude => "claude", crate::api::schema::IntegrationTarget::Codex => "codex", crate::api::schema::IntegrationTarget::Copilot => "copilot", + crate::api::schema::IntegrationTarget::Droid => "droid", crate::api::schema::IntegrationTarget::Kimi => "kimi", crate::api::schema::IntegrationTarget::Opencode => "opencode", crate::api::schema::IntegrationTarget::Hermes => "hermes", @@ -582,6 +662,7 @@ fn integration_target_command(target: crate::api::schema::IntegrationTarget) -> crate::api::schema::IntegrationTarget::Claude => "claude", crate::api::schema::IntegrationTarget::Codex => "codex", crate::api::schema::IntegrationTarget::Copilot => "copilot", + crate::api::schema::IntegrationTarget::Droid => "droid", crate::api::schema::IntegrationTarget::Kimi => "kimi", crate::api::schema::IntegrationTarget::Opencode => "opencode", crate::api::schema::IntegrationTarget::Hermes => "hermes", @@ -659,7 +740,7 @@ fn integration_specs() -> [( crate::api::schema::IntegrationTarget, io::Result<PathBuf>, u32, -); 9] { +); 10] { [ ( crate::api::schema::IntegrationTarget::Pi, @@ -687,6 +768,11 @@ fn integration_specs() -> [( COPILOT_INTEGRATION_VERSION, ), ( + crate::api::schema::IntegrationTarget::Droid, + droid_dir().map(|dir| dir.join("hooks").join(DROID_HOOK_INSTALL_NAME)), + DROID_INTEGRATION_VERSION, + ), + ( crate::api::schema::IntegrationTarget::Kimi, kimi_dir().map(|dir| dir.join("hooks").join(KIMI_HOOK_INSTALL_NAME)), KIMI_INTEGRATION_VERSION, @@ -1106,6 +1192,81 @@ pub(crate) fn install_copilot() -> io::Result<CopilotInstallPaths> { }) } +pub(crate) fn install_droid() -> io::Result<DroidInstallPaths> { + let dir = droid_dir()?; + if !dir.is_dir() { + return Err(io::Error::other(format!( + "droid config directory not found at {}. install droid first", + dir.display() + ))); + } + + let hooks_dir = dir.join("hooks"); + fs::create_dir_all(&hooks_dir)?; + + let hook_path = hooks_dir.join(DROID_HOOK_INSTALL_NAME); + fs::write(&hook_path, DROID_HOOK_ASSET)?; + make_executable(&hook_path)?; + + 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| { + io::Error::other(format!( + "failed to parse {}: {err}", + settings_path.display() + )) + })? + } else { + json!({}) + }; + + let hooks = ensure_hooks_object( + &mut settings, + &settings_path, + "droid settings", + "droid settings hooks", + )?; + let quoted_hook_path = shell_single_quote(&hook_path.display().to_string()); + remove_command_hook(hooks, "SessionStart", &format!("bash {quoted_hook_path}"))?; + ensure_command_hook( + hooks, + "SessionStart", + format!("bash {quoted_hook_path}"), + 10, + None, + )?; + + fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?; + + let hooks_path = dir.join("hooks.json"); + let mut updated_legacy_hooks = false; + if hooks_path.is_file() { + let mut hooks_file = serde_json::from_str::<Value>(&fs::read_to_string(&hooks_path)?) + .map_err(|err| { + io::Error::other(format!("failed to parse {}: {err}", hooks_path.display())) + })?; + if let Some(hooks) = hooks_object_if_present( + &mut hooks_file, + &hooks_path, + "droid hooks file", + "droid hooks file hooks", + )? { + updated_legacy_hooks = + remove_command_hook(hooks, "SessionStart", &format!("bash {quoted_hook_path}"))?; + } + if updated_legacy_hooks { + fs::write(&hooks_path, serde_json::to_string_pretty(&hooks_file)?)?; + } + } + + Ok(DroidInstallPaths { + hook_path, + hooks_path, + settings_path, + updated_legacy_hooks, + }) +} + pub(crate) fn install_opencode() -> io::Result<OpenCodeInstallPaths> { let dir = opencode_dir()?; if !dir.is_dir() { @@ -1407,6 +1568,71 @@ pub(crate) fn uninstall_copilot() -> io::Result<CopilotUninstallResult> { }) } +pub(crate) fn uninstall_droid() -> io::Result<DroidUninstallResult> { + let droid_dir = droid_dir()?; + let hook_path = droid_dir.join("hooks").join(DROID_HOOK_INSTALL_NAME); + let hooks_path = droid_dir.join("hooks.json"); + let settings_path = droid_dir.join("settings.json"); + let mut updated_hooks = false; + let mut updated_settings = false; + let quoted_hook_path = shell_single_quote(&hook_path.display().to_string()); + + if hooks_path.is_file() { + let mut hooks_file = serde_json::from_str::<Value>(&fs::read_to_string(&hooks_path)?) + .map_err(|err| { + io::Error::other(format!("failed to parse {}: {err}", hooks_path.display())) + })?; + + if let Some(hooks) = hooks_object_if_present( + &mut hooks_file, + &hooks_path, + "droid hooks file", + "droid hooks file hooks", + )? { + updated_hooks |= + remove_command_hook(hooks, "SessionStart", &format!("bash {quoted_hook_path}"))?; + } + + if updated_hooks { + fs::write(&hooks_path, serde_json::to_string_pretty(&hooks_file)?)?; + } + } + + if settings_path.is_file() { + let mut settings = serde_json::from_str::<Value>(&fs::read_to_string(&settings_path)?) + .map_err(|err| { + io::Error::other(format!( + "failed to parse {}: {err}", + settings_path.display() + )) + })?; + if let Some(hooks) = hooks_object_if_present( + &mut settings, + &settings_path, + "droid settings", + "droid settings hooks", + )? { + updated_settings = + remove_command_hook(hooks, "SessionStart", &format!("bash {quoted_hook_path}"))?; + } + + if updated_settings { + fs::write(&settings_path, serde_json::to_string_pretty(&settings)?)?; + } + } + + let removed_hook_file = remove_file_if_exists(&hook_path)?; + + Ok(DroidUninstallResult { + hook_path, + hooks_path, + settings_path, + removed_hook_file, + updated_hooks, + updated_settings, + }) +} + pub(crate) fn uninstall_opencode() -> io::Result<OpenCodeUninstallResult> { let plugin_path = opencode_dir()? .join("plugins") @@ -2187,6 +2413,10 @@ fn copilot_dir() -> io::Result<PathBuf> { config_dir_from_env_or_home(COPILOT_HOME_ENV_VAR, &[".copilot"]) } +fn droid_dir() -> io::Result<PathBuf> { + Ok(home_dir()?.join(".factory")) +} + fn config_dir_from_env_or_home( env_var: &str, home_relative_segments: &[&str], @@ -3457,6 +3687,197 @@ mod tests { } #[test] + fn install_droid_writes_hook_to_settings_and_cleans_legacy_hooks_json() { + let _lock = integration_env_lock(); + let base = unique_base(); + let home = base.join("home"); + let droid_dir = home.join(".factory"); + let legacy_hook_path = droid_dir.join("hooks").join(DROID_HOOK_INSTALL_NAME); + fs::create_dir_all(legacy_hook_path.parent().unwrap()).unwrap(); + fs::create_dir_all(&droid_dir).unwrap(); + let legacy_command = format!( + "bash {}", + shell_single_quote(&legacy_hook_path.display().to_string()) + ); + fs::write( + droid_dir.join("hooks.json"), + format!( + r#"{{"hooks":{{"SessionStart":[{{"hooks":[{{"type":"command","command":"{}","timeout":10}}]}}],"PreToolUse":[{{"matcher":"Read","hooks":[{{"type":"command","command":"echo keep","timeout":10}}]}}]}}}}"#, + legacy_command, + ), + ) + .unwrap(); + fs::write( + droid_dir.join("settings.json"), + r#"{"theme":"factory-dark"}"#, + ) + .unwrap(); + std::env::set_var("HOME", &home); + + let installed = install_droid().unwrap(); + let hook_content = fs::read_to_string(&installed.hook_path).unwrap(); + let settings: Value = + serde_json::from_str(&fs::read_to_string(&installed.settings_path).unwrap()).unwrap(); + let legacy_hooks: Value = + serde_json::from_str(&fs::read_to_string(&installed.hooks_path).unwrap()).unwrap(); + + assert_eq!( + installed.hook_path, + droid_dir.join("hooks").join(DROID_HOOK_INSTALL_NAME) + ); + assert_eq!(installed.hooks_path, droid_dir.join("hooks.json")); + assert_eq!(installed.settings_path, droid_dir.join("settings.json")); + assert!(installed.updated_legacy_hooks); + assert_eq!(hook_content, DROID_HOOK_ASSET); + assert_eq!(settings["theme"], "factory-dark"); + assert!(settings["hooks"]["SessionStart"][0]["hooks"][0]["command"] + .as_str() + .unwrap() + .contains(DROID_HOOK_INSTALL_NAME)); + assert!(settings["hooks"]["SessionStart"][0] + .get("matcher") + .is_none()); + assert_eq!(legacy_hooks["hooks"]["PreToolUse"][0]["matcher"], "Read"); + assert!(legacy_hooks["hooks"].get("SessionStart").is_none()); + + std::env::remove_var("HOME"); + let _ = fs::remove_dir_all(base); + } + + #[test] + fn install_droid_is_idempotent_for_hook_entries() { + let _lock = integration_env_lock(); + let base = unique_base(); + let home = base.join("home"); + let droid_dir = home.join(".factory"); + fs::create_dir_all(&droid_dir).unwrap(); + std::env::set_var("HOME", &home); + + install_droid().unwrap(); + install_droid().unwrap(); + + 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 + ); + + std::env::remove_var("HOME"); + let _ = fs::remove_dir_all(base); + } + + #[test] + fn droid_v1_integration_status_is_current() { + let _lock = integration_env_lock(); + let base = unique_base(); + let home = base.join("home"); + let droid_hooks_dir = home.join(".factory").join("hooks"); + fs::create_dir_all(&droid_hooks_dir).unwrap(); + let hook_path = droid_hooks_dir.join(DROID_HOOK_INSTALL_NAME); + fs::write( + &hook_path, + "#!/bin/sh\n# HERDR_INTEGRATION_ID=droid\n# HERDR_INTEGRATION_VERSION=1\n", + ) + .unwrap(); + std::env::set_var("HOME", &home); + + let statuses = installed_integration_statuses(); + let droid = statuses + .iter() + .find(|status| status.target == crate::api::schema::IntegrationTarget::Droid) + .unwrap(); + + 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); + + std::env::remove_var("HOME"); + let _ = fs::remove_dir_all(base); + } + + #[test] + fn uninstall_droid_removes_herdr_hooks_and_preserves_others() { + let _lock = integration_env_lock(); + let base = unique_base(); + let home = base.join("home"); + let droid_dir = home.join(".factory"); + let hooks_dir = droid_dir.join("hooks"); + fs::create_dir_all(&hooks_dir).unwrap(); + let hook_path = hooks_dir.join(DROID_HOOK_INSTALL_NAME); + fs::write(&hook_path, DROID_HOOK_ASSET).unwrap(); + let command = format!( + "bash {}", + shell_single_quote(&hook_path.display().to_string()) + ); + fs::write( + droid_dir.join("hooks.json"), + format!( + r#"{{"hooks":{{"SessionStart":[{{"hooks":[{{"type":"command","command":"{}","timeout":10}},{{"type":"command","command":"echo keep","timeout":10}}]}}],"PreToolUse":[{{"matcher":"Read","hooks":[{{"type":"command","command":"echo read","timeout":10}}]}}]}}}}"#, + command, + ), + ) + .unwrap(); + fs::write( + droid_dir.join("settings.json"), + format!( + r#"{{"hooks":{{"SessionStart":[{{"hooks":[{{"type":"command","command":"{}","timeout":10}}]}}],"PostToolUse":[{{"matcher":"Edit","hooks":[{{"type":"command","command":"echo post","timeout":10}}]}}]}}}}"#, + command, + ), + ) + .unwrap(); + std::env::set_var("HOME", &home); + + let result = uninstall_droid().unwrap(); + let hooks: Value = + serde_json::from_str(&fs::read_to_string(droid_dir.join("hooks.json")).unwrap()) + .unwrap(); + let settings: Value = + serde_json::from_str(&fs::read_to_string(droid_dir.join("settings.json")).unwrap()) + .unwrap(); + + assert!(result.removed_hook_file); + assert!(result.updated_hooks); + assert!(result.updated_settings); + assert!(!result.hook_path.exists()); + assert_eq!( + hooks["hooks"]["SessionStart"][0]["hooks"] + .as_array() + .unwrap() + .len(), + 1 + ); + assert_eq!( + hooks["hooks"]["SessionStart"][0]["hooks"][0]["command"], + "echo keep" + ); + assert_eq!(hooks["hooks"]["PreToolUse"][0]["matcher"], "Read"); + assert!(settings["hooks"].get("SessionStart").is_none()); + assert_eq!(settings["hooks"]["PostToolUse"][0]["matcher"], "Edit"); + + std::env::remove_var("HOME"); + let _ = fs::remove_dir_all(base); + } + + #[test] + fn install_droid_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_droid().unwrap_err().to_string(); + + assert!(err.contains("droid config directory not found")); + + std::env::remove_var("HOME"); + let _ = fs::remove_dir_all(base); + } + + #[test] fn install_opencode_writes_plugin_to_plugins_dir() { let _lock = integration_env_lock(); let base = unique_base(); @@ -3647,6 +4068,12 @@ mod tests { 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!(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("pane.release_agent")); assert!(OPENCODE_PLUGIN_ASSET.contains("properties?.sessionID")); assert!(OPENCODE_PLUGIN_ASSET.contains("agent_session_id: sessionID")); assert!(OPENCODE_PLUGIN_ASSET.contains("pane.report_agent_session")); |
