diff --git a/docs/next/api/herdr-api.schema.json b/docs/next/api/herdr-api.schema.json index b4704cd1..1a91870b 100644 --- a/docs/next/api/herdr-api.schema.json +++ b/docs/next/api/herdr-api.schema.json @@ -2119,6 +2119,7 @@ "qodercli", "cursor", "mastracode", + "antigravity_cli", "grok" ], "type": "string" @@ -7113,6 +7114,7 @@ "qodercli", "cursor", "mastracode", + "antigravity_cli", "grok" ], "type": "string" diff --git a/docs/next/website/src/content/docs/integrations.mdx b/docs/next/website/src/content/docs/integrations.mdx index fcc908a7..38cea6a1 100644 --- a/docs/next/website/src/content/docs/integrations.mdx +++ b/docs/next/website/src/content/docs/integrations.mdx @@ -60,9 +60,9 @@ Herdr uses integrations in two different ways: 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, Devin CLI, Droid, Kimi Code CLI, Qoder CLI, Cursor Agent CLI, Grok CLI, GitHub Copilot CLI, Pi, OMP, Hermes Agent, OpenCode, Kilo Code CLI, and MastraCode 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, Devin CLI, Droid, Kimi Code CLI, Qoder CLI, Cursor Agent CLI, Grok CLI, GitHub Copilot CLI, Pi, OMP, Hermes Agent, OpenCode, Kilo Code CLI, MastraCode, and Antigravity CLI panes after a Herdr server restart unless `[session] resume_agents_on_restore = false` disables it. -Native session restore requires current Herdr integrations: Pi integration version `2`, OMP version `3`, Claude Code version `6`, Codex version `5`, GitHub Copilot CLI version `2`, Devin CLI version `2`, Droid version `2`, Kimi Code CLI version `3`, Qoder CLI version `2`, Cursor Agent CLI version `1`, Grok CLI version `1`, OpenCode version `5`, Kilo Code CLI version `1`, Hermes Agent version `2`, or MastraCode version `1`. Check installed versions with `herdr integration status`. +Native session restore requires current Herdr integrations: Pi integration version `2`, OMP version `3`, Claude Code version `6`, Codex version `5`, GitHub Copilot CLI version `2`, Devin CLI version `2`, Droid version `2`, Kimi Code CLI version `3`, Qoder CLI version `2`, Cursor Agent CLI version `1`, Grok CLI version `1`, OpenCode version `5`, Kilo Code CLI version `1`, Hermes Agent version `2`, MastraCode version `1`, or Antigravity CLI version `1`. Check installed versions with `herdr integration status`. ## Pi @@ -258,6 +258,20 @@ Herdr uses `~/.mastracode`. Install writes `hooks/herdr-agent-state.sh` and adds Herdr resumes stored MastraCode threads with `mastracode --thread `. +## Antigravity CLI + +Install the Antigravity CLI hook: + +```bash +herdr integration install antigravity-cli +``` + +Herdr uses `~/.gemini/config/` by default, or `ANTIGRAVITY_CLI_CONFIG_DIR` when set. This is the directory Antigravity CLI reads global customizations from, and it must already exist. Install writes `hooks/herdr-agent-state.sh` (or `herdr-agent-state.ps1` on Windows) and adds a Herdr-owned `herdr` block to `hooks.json`. Antigravity CLI keys `hooks.json` by hook name, so install rewrites only that block and leaves other named hooks untouched. Uninstall removes the `herdr` block and deletes the hook script. + +This integration is session-only. It reports the conversation the pane is running and does not report agent state, so Herdr keeps deriving working, idle, and blocked from what Antigravity CLI draws on screen. + +The hook runs on `PreInvocation`, so Herdr learns the conversation once the first prompt is sent. From then on Herdr can resume the pane with `agy --conversation ` after a Herdr server restart. + ## Grok CLI Install the Grok CLI hook: diff --git a/docs/next/website/src/content/docs/session-state.mdx b/docs/next/website/src/content/docs/session-state.mdx index 222e280b..468557b4 100644 --- a/docs/next/website/src/content/docs/session-state.mdx +++ b/docs/next/website/src/content/docs/session-state.mdx @@ -67,6 +67,7 @@ Native session restore requires these Herdr integration versions or newer: | Agent | Minimum Herdr integration version | Resume command | | --- | --- | --- | | Pi | `2` | `pi --session ` | +| Antigravity CLI | `1` | `agy --conversation ` | | OMP | `3` | `omp --resume=` | | Claude Code | `6` | `claude --resume ` | | Codex | `5` | `codex resume ` | diff --git a/src/agent_resume.rs b/src/agent_resume.rs index f1389b54..55b7d51f 100644 --- a/src/agent_resume.rs +++ b/src/agent_resume.rs @@ -187,6 +187,13 @@ pub fn plan(source: &str, agent: &str, session_ref: &AgentSessionRef) -> Option< session_ref.value.clone(), ] } + ("herdr:antigravity_cli", "agy", AgentSessionRefKind::Id) => { + vec![ + "agy".into(), + "--conversation".into(), + session_ref.value.clone(), + ] + } ("herdr:grok", "grok", AgentSessionRefKind::Id) => { vec!["grok".into(), "--resume".into(), session_ref.value.clone()] } @@ -224,6 +231,7 @@ pub(crate) fn is_official_agent_source(source: &str, agent: &str) -> bool { | ("herdr:qodercli", "qodercli") | ("herdr:kilo", "kilo") | ("herdr:cursor", "cursor") + | ("herdr:antigravity_cli", "agy") | ("herdr:grok", "grok") ) } @@ -407,6 +415,16 @@ mod tests { .argv, vec!["cursor-agent", "--resume", "cursor-session"] ); + assert_eq!( + plan( + "herdr:antigravity_cli", + "agy", + &AgentSessionRef::id("agy-session").unwrap() + ) + .unwrap() + .argv, + vec!["agy", "--conversation", "agy-session"] + ); assert_eq!( plan( "herdr:grok", @@ -543,6 +561,12 @@ mod tests { .unwrap(); assert_eq!(session_ref.kind, AgentSessionRefKind::Id); assert_eq!(session_ref.value, "qoder-id"); + + let session_ref = + session_ref_from_report("herdr:antigravity_cli", "agy", Some("agy-id".into()), None) + .unwrap(); + assert_eq!(session_ref.kind, AgentSessionRefKind::Id); + assert_eq!(session_ref.value, "agy-id"); } #[test] @@ -676,5 +700,19 @@ mod tests { "devin-session" ) .is_some()); + assert!(session_ref_from_snapshot( + "herdr:antigravity_cli", + "agy", + AgentSessionRefKind::Id, + "agy-session" + ) + .is_some()); + let agy_session = absolute_test_path("agy-session"); + assert!(plan( + "herdr:antigravity_cli", + "agy", + &AgentSessionRef::path(&agy_session).unwrap() + ) + .is_none()); } } diff --git a/src/api/schema/integrations.rs b/src/api/schema/integrations.rs index 8681de5d..ef55f8e2 100644 --- a/src/api/schema/integrations.rs +++ b/src/api/schema/integrations.rs @@ -27,11 +27,12 @@ pub enum IntegrationTarget { Qodercli, Cursor, Mastracode, + AntigravityCli, Grok, } impl IntegrationTarget { - pub(crate) const ALL: [Self; 15] = [ + pub(crate) const ALL: [Self; 16] = [ Self::Pi, Self::Omp, Self::Claude, @@ -46,6 +47,7 @@ impl IntegrationTarget { Self::Qodercli, Self::Cursor, Self::Mastracode, + Self::AntigravityCli, Self::Grok, ]; } diff --git a/src/cli/integration.rs b/src/cli/integration.rs index 265bbf95..236cebe7 100644 --- a/src/cli/integration.rs +++ b/src/cli/integration.rs @@ -129,11 +129,12 @@ fn parse_integration_target( "qodercli" => IntegrationTarget::Qodercli, "cursor" => IntegrationTarget::Cursor, "mastracode" => IntegrationTarget::Mastracode, + "antigravity-cli" | "antigravity_cli" => IntegrationTarget::AntigravityCli, "grok" => IntegrationTarget::Grok, _ => { eprintln!("unknown integration target: {target}"); eprintln!( - "currently supported: pi, omp, claude, codex, copilot, devin, droid, kimi, opencode, kilo, hermes, qodercli, cursor, mastracode, grok" + "currently supported: pi, omp, claude, codex, copilot, devin, droid, kimi, opencode, kilo, hermes, qodercli, cursor, mastracode, antigravity-cli, grok" ); return Ok(None); } @@ -158,6 +159,7 @@ fn print_integration_help() { eprintln!(" herdr integration install qodercli"); eprintln!(" herdr integration install cursor"); eprintln!(" herdr integration install mastracode"); + eprintln!(" herdr integration install antigravity-cli"); eprintln!(" herdr integration install grok"); eprintln!(" herdr integration uninstall pi"); eprintln!(" herdr integration uninstall omp"); @@ -173,6 +175,7 @@ fn print_integration_help() { eprintln!(" herdr integration uninstall qodercli"); eprintln!(" herdr integration uninstall cursor"); eprintln!(" herdr integration uninstall mastracode"); + eprintln!(" herdr integration uninstall antigravity-cli"); eprintln!(" herdr integration uninstall grok"); eprintln!(" herdr integration status [--outdated-only]"); } diff --git a/src/detect/mod.rs b/src/detect/mod.rs index 33c4361c..389407a2 100644 --- a/src/detect/mod.rs +++ b/src/detect/mod.rs @@ -293,7 +293,10 @@ pub(crate) fn full_lifecycle_hook_authority(source: &str, agent_label: &str) -> } pub(crate) fn session_identity_only_integration(source: &str, agent_label: &str) -> bool { - (source, agent_label) == ("herdr:hermes", "hermes") + matches!( + (source, agent_label), + ("herdr:hermes", "hermes") | ("herdr:antigravity_cli", "agy") + ) } // --------------------------------------------------------------------------- @@ -771,10 +774,15 @@ mod tests { } #[test] - fn hermes_session_integration_leaves_state_to_screen_detection() { - assert!(!full_lifecycle_hook_authority("herdr:hermes", "hermes")); - assert!(session_identity_only_integration("herdr:hermes", "hermes")); - assert!(Agent::SCREEN_MANIFEST_AGENTS.contains(&Agent::Hermes)); + fn session_identity_integrations_leave_state_to_screen_detection() { + for (source, label, agent) in [ + ("herdr:hermes", "hermes", Agent::Hermes), + ("herdr:antigravity_cli", "agy", Agent::Antigravity), + ] { + assert!(!full_lifecycle_hook_authority(source, label)); + assert!(session_identity_only_integration(source, label)); + assert!(Agent::SCREEN_MANIFEST_AGENTS.contains(&agent)); + } } #[test] diff --git a/src/integration/actions.rs b/src/integration/actions.rs index c00e10b7..3cb73774 100644 --- a/src/integration/actions.rs +++ b/src/integration/actions.rs @@ -2,12 +2,13 @@ use std::io; use super::registry::{integration_target_label, integration_target_supported}; use super::targets::{ - install_claude, install_codex, install_copilot, install_cursor, install_devin, install_droid, - install_grok, install_hermes, install_kilo, install_kimi, install_mastracode, install_omp, - install_opencode, install_pi, install_qodercli, uninstall_claude, uninstall_codex, - uninstall_copilot, uninstall_cursor, uninstall_devin, uninstall_droid, uninstall_grok, - uninstall_hermes, uninstall_kilo, uninstall_kimi, uninstall_mastracode, uninstall_omp, - uninstall_opencode, uninstall_pi, uninstall_qodercli, + install_antigravity_cli, install_claude, install_codex, install_copilot, install_cursor, + install_devin, install_droid, install_grok, install_hermes, install_kilo, install_kimi, + install_mastracode, install_omp, install_opencode, install_pi, install_qodercli, + uninstall_antigravity_cli, uninstall_claude, uninstall_codex, uninstall_copilot, + uninstall_cursor, uninstall_devin, uninstall_droid, uninstall_grok, uninstall_hermes, + uninstall_kilo, uninstall_kimi, uninstall_mastracode, uninstall_omp, uninstall_opencode, + uninstall_pi, uninstall_qodercli, }; use super::version::{agent_version_requirement, enforce_agent_version}; use super::{KIMI_MIN_VERSION, PI_EXTENSION_INSTALL_NAME}; @@ -204,6 +205,19 @@ fn install_target_inner(target: crate::api::schema::IntegrationTarget) -> io::Re ), ] } + crate::api::schema::IntegrationTarget::AntigravityCli => { + let installed = install_antigravity_cli()?; + vec![ + format!( + "installed antigravity-cli integration hook to {}", + installed.hook_path.display() + ), + format!( + "ensured antigravity-cli hooks at {}", + installed.hooks_path.display() + ), + ] + } crate::api::schema::IntegrationTarget::Grok => { let installed = install_grok()?; vec![ @@ -571,6 +585,33 @@ pub(crate) fn uninstall_target( } messages } + crate::api::schema::IntegrationTarget::AntigravityCli => { + let result = uninstall_antigravity_cli()?; + let mut messages = Vec::new(); + if result.removed_hook_file { + messages.push(format!( + "removed antigravity-cli hook at {}", + result.hook_path.display() + )); + } else { + messages.push(format!( + "no antigravity-cli hook found at {}", + result.hook_path.display() + )); + } + if result.updated_hooks { + messages.push(format!( + "removed herdr antigravity-cli hook entries from {}", + result.hooks_path.display() + )); + } else { + messages.push(format!( + "no herdr antigravity-cli hook entries found in {}", + result.hooks_path.display() + )); + } + messages + } crate::api::schema::IntegrationTarget::Grok => { let result = uninstall_grok()?; let mut messages = Vec::new(); diff --git a/src/integration/assets/antigravity_cli/herdr-agent-state.ps1 b/src/integration/assets/antigravity_cli/herdr-agent-state.ps1 new file mode 100644 index 00000000..aecb42a9 --- /dev/null +++ b/src/integration/assets/antigravity_cli/herdr-agent-state.ps1 @@ -0,0 +1,57 @@ +# 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=antigravity_cli +# HERDR_INTEGRATION_VERSION=1 + +# Session-only: this hook reports the Antigravity conversation so Herdr can +# resume the pane. Lifecycle state comes from Herdr's screen detection. + +param([string]$Action = "") + +# Antigravity CLI expects a JSON object on stdout and this hook never injects +# anything, so every exit path emits an empty object. +function Exit-Hook { + Write-Output "{}" + exit 0 +} + +if ($Action -ne "session") { Exit-Hook } +if ($env:HERDR_ENV -ne "1") { Exit-Hook } +if ([string]::IsNullOrWhiteSpace($env:HERDR_PANE_ID)) { Exit-Hook } + +$inputText = [Console]::In.ReadToEnd() +try { + $payload = if ([string]::IsNullOrWhiteSpace($inputText)) { $null } else { $inputText | ConvertFrom-Json } +} catch { + Exit-Hook +} + +if ($null -eq $payload) { Exit-Hook } + +$conversationId = if ($payload.conversationId -is [string]) { $payload.conversationId } else { $null } +if ([string]::IsNullOrWhiteSpace($conversationId)) { Exit-Hook } + +$seq = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() +try { + $sessionArgs = @( + "pane", + "report-agent-session", + $env:HERDR_PANE_ID, + "--source", + "herdr:antigravity_cli", + "--agent", + "agy", + "--seq", + "$seq", + "--agent-session-id", + "$conversationId" + ) + if ($payload.transcriptPath -is [string] -and -not [string]::IsNullOrWhiteSpace($payload.transcriptPath)) { + $sessionArgs += @("--agent-session-path", "$($payload.transcriptPath)") + } + & herdr @sessionArgs 2>$null | Out-Null +} catch { +} + +Exit-Hook diff --git a/src/integration/assets/antigravity_cli/herdr-agent-state.sh b/src/integration/assets/antigravity_cli/herdr-agent-state.sh new file mode 100644 index 00000000..dd3db028 --- /dev/null +++ b/src/integration/assets/antigravity_cli/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=antigravity_cli +# HERDR_INTEGRATION_VERSION=1 + +# Session-only: this hook reports the Antigravity conversation so Herdr can +# resume the pane. Lifecycle state comes from Herdr's screen detection. + +set -eu + +# Antigravity CLI expects a JSON object on stdout and this hook never injects +# anything, so every exit path emits an empty object. +emit_and_exit() { + printf '{}\n' + exit 0 +} + +[ "${1:-}" = "session" ] || emit_and_exit +[ "${HERDR_ENV:-}" = "1" ] || emit_and_exit +[ -n "${HERDR_SOCKET_PATH:-}" ] || emit_and_exit +[ -n "${HERDR_PANE_ID:-}" ] || emit_and_exit +command -v python3 >/dev/null 2>&1 || emit_and_exit + +python3 -c ' +import json +import os +import socket +import sys +import time + +try: + payload = json.load(sys.stdin) +except Exception: + raise SystemExit(0) + +if not isinstance(payload, dict): + raise SystemExit(0) + +def text(name): + value = payload.get(name) + return value if isinstance(value, str) and value else None + +session_id = text("conversationId") +if session_id is None: + raise SystemExit(0) + +seq = time.time_ns() +params = { + "pane_id": os.environ["HERDR_PANE_ID"], + "source": "herdr:antigravity_cli", + "agent": "agy", + "seq": seq, + "agent_session_id": session_id, +} + +transcript_path = text("transcriptPath") +if transcript_path is not None: + params["agent_session_path"] = transcript_path + +request = json.dumps({ + "id": f"herdr:antigravity_cli:{seq}", + "method": "pane.report_agent_session", + "params": params, +}) +try: + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client: + client.settimeout(0.5) + client.connect(os.environ["HERDR_SOCKET_PATH"]) + client.sendall((request + "\n").encode()) + client.recv(4096) +except Exception: + pass +' 2>/dev/null || true + +emit_and_exit diff --git a/src/integration/env.rs b/src/integration/env.rs index 2d0feeb0..d76ce66c 100644 --- a/src/integration/env.rs +++ b/src/integration/env.rs @@ -17,6 +17,7 @@ pub(crate) const KIMI_CODE_HOME_ENV_VAR: &str = "KIMI_CODE_HOME"; pub(crate) const COPILOT_HOME_ENV_VAR: &str = "COPILOT_HOME"; pub(crate) const QODERCLI_CONFIG_DIR_ENV_VAR: &str = "QODER_CONFIG_DIR"; pub(crate) const CURSOR_CONFIG_DIR_ENV_VAR: &str = "CURSOR_CONFIG_DIR"; +pub(crate) const ANTIGRAVITY_CLI_CONFIG_DIR_ENV_VAR: &str = "ANTIGRAVITY_CLI_CONFIG_DIR"; pub(crate) const GROK_CONFIG_DIR_ENV_VAR: &str = "GROK_CONFIG_DIR"; /// The grok CLI's own config-home override (documented alongside /// `$GROK_HOME/config.toml` and `$GROK_HOME/auth.json`). @@ -142,6 +143,13 @@ pub(crate) fn mastracode_dir() -> io::Result { Ok(home_dir()?.join(".mastracode")) } +pub(crate) fn antigravity_cli_dir() -> io::Result { + // Antigravity CLI discovers global customizations (hooks.json included) + // from ~/.gemini/config; ~/.gemini/antigravity-cli holds runtime data and + // is never read for hooks. + config_dir_from_env_or_home(ANTIGRAVITY_CLI_CONFIG_DIR_ENV_VAR, &[".gemini", "config"]) +} + pub(crate) fn grok_dir() -> io::Result { // GROK_CONFIG_DIR is a herdr-level override only (primarily a test // seam); the grok CLI does not honor it, so it stays first and explicit. diff --git a/src/integration/mod.rs b/src/integration/mod.rs index b691dce9..045d1bce 100644 --- a/src/integration/mod.rs +++ b/src/integration/mod.rs @@ -196,6 +196,32 @@ const QODERCLI_REMOVED_LIFECYCLE_HOOK_EVENTS: [(&str, &str); 12] = [ 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; +#[cfg(windows)] +const ANTIGRAVITY_CLI_HOOK_INSTALL_NAME: &str = "herdr-agent-state.ps1"; +#[cfg(not(windows))] +const ANTIGRAVITY_CLI_HOOK_INSTALL_NAME: &str = "herdr-agent-state.sh"; +#[cfg(windows)] +const ANTIGRAVITY_CLI_HOOK_ASSET: &str = + include_str!("assets/antigravity_cli/herdr-agent-state.ps1"); +#[cfg(not(windows))] +const ANTIGRAVITY_CLI_HOOK_ASSET: &str = + include_str!("assets/antigravity_cli/herdr-agent-state.sh"); +const ANTIGRAVITY_CLI_INTEGRATION_VERSION: u32 = 1; +/// Antigravity CLI keys `hooks.json` by hook name, so every Herdr entry lives +/// under one Herdr-owned block that install rewrites and uninstall removes. +const ANTIGRAVITY_CLI_HOOK_BLOCK_NAME: &str = "herdr"; +const ANTIGRAVITY_CLI_HOOK_TIMEOUT_SEC: u64 = 10; +/// `(event, reported action)`. Session-only: `PreInvocation` is the only event +/// we need because it carries `conversationId`. The others cannot express +/// lifecycle safely — Antigravity CLI has no blocked event, `PostInvocation` is +/// skipped on interruption, and `Stop` is end-of-turn rather than process exit. +/// Screen detection owns agent state instead. +/// +/// `PreInvocation` takes a flat handler list; only the `PreToolUse`/`PostToolUse` +/// events accept a `matcher`/`hooks` wrapper, and sending one here would +/// invalidate the whole file. +const ANTIGRAVITY_CLI_HOOK_EVENTS: [(&str, &str); 1] = [("PreInvocation", "session")]; +const INTEGRATION_VERSION_MARKER: &str = "HERDR_INTEGRATION_VERSION="; const MASTRACODE_HOOK_INSTALL_NAME: &str = "herdr-agent-state.sh"; const MASTRACODE_HOOK_ASSET: &str = include_str!("assets/mastracode/herdr-agent-state.sh"); const MASTRACODE_INTEGRATION_VERSION: u32 = 2; @@ -219,7 +245,6 @@ const GROK_HOOK_INSTALL_NAME: &str = "herdr-agent-state.sh"; const GROK_HOOK_CONFIG_INSTALL_NAME: &str = "herdr.json"; const GROK_HOOK_ASSET: &str = include_str!("assets/grok/herdr-agent-state.sh"); const GROK_INTEGRATION_VERSION: u32 = 1; -const INTEGRATION_VERSION_MARKER: &str = "HERDR_INTEGRATION_VERSION="; pub(crate) const INSTALL_WARNING_PREFIX: &str = "warning:"; diff --git a/src/integration/registry.rs b/src/integration/registry.rs index 10500df5..7041eb1b 100644 --- a/src/integration/registry.rs +++ b/src/integration/registry.rs @@ -22,6 +22,7 @@ pub(crate) fn integration_target_label( crate::api::schema::IntegrationTarget::Qodercli => "qodercli", crate::api::schema::IntegrationTarget::Cursor => "cursor", crate::api::schema::IntegrationTarget::Mastracode => "mastracode", + crate::api::schema::IntegrationTarget::AntigravityCli => "antigravity-cli", crate::api::schema::IntegrationTarget::Grok => "grok", } } @@ -50,6 +51,7 @@ pub(crate) fn integration_target_command_names( crate::api::schema::IntegrationTarget::Qodercli => qodercli_command_names(), crate::api::schema::IntegrationTarget::Cursor => cursor_command_names(), crate::api::schema::IntegrationTarget::Mastracode => &["mastracode"], + crate::api::schema::IntegrationTarget::AntigravityCli => &["agy"], crate::api::schema::IntegrationTarget::Grok => &["grok"], } } @@ -73,6 +75,7 @@ pub(crate) fn integration_target_supported(target: crate::api::schema::Integrati | crate::api::schema::IntegrationTarget::Droid | crate::api::schema::IntegrationTarget::Kimi | crate::api::schema::IntegrationTarget::Qodercli + | crate::api::schema::IntegrationTarget::AntigravityCli ) } @@ -259,7 +262,7 @@ fn integration_specs() -> [( crate::api::schema::IntegrationTarget, io::Result, u32, -); 15] { +); 16] { [ ( crate::api::schema::IntegrationTarget::Pi, @@ -334,6 +337,14 @@ fn integration_specs() -> [( mastracode_dir().map(|dir| dir.join("hooks").join(super::MASTRACODE_HOOK_INSTALL_NAME)), super::MASTRACODE_INTEGRATION_VERSION, ), + ( + crate::api::schema::IntegrationTarget::AntigravityCli, + antigravity_cli_dir().map(|dir| { + dir.join("hooks") + .join(super::ANTIGRAVITY_CLI_HOOK_INSTALL_NAME) + }), + super::ANTIGRAVITY_CLI_INTEGRATION_VERSION, + ), ( crate::api::schema::IntegrationTarget::Grok, grok_dir().map(|dir| dir.join("hooks").join(super::GROK_HOOK_INSTALL_NAME)), diff --git a/src/integration/targets.rs b/src/integration/targets.rs index 87000eae..575c7e46 100644 --- a/src/integration/targets.rs +++ b/src/integration/targets.rs @@ -2,7 +2,7 @@ use std::fs; use std::io; use std::path::{Path, PathBuf}; -use serde_json::{json, Value}; +use serde_json::{json, Map, Value}; use super::command::{hook_command, shell_single_quote}; use super::config_edit::{ @@ -13,32 +13,33 @@ use super::config_edit::{ remove_hook_commands, remove_kimi_config_block, remove_simple_command_hook, }; use super::env::{ - claude_dir, codex_dir, copilot_dir, cursor_dir, devin_dir, droid_dir, grok_dir, hermes_dir, - hermes_plugin_dir, kilo_dir, kimi_dir, mastracode_dir, omp_extension_dir, opencode_dir, - pi_extension_dir, qodercli_dir, + antigravity_cli_dir, claude_dir, codex_dir, copilot_dir, cursor_dir, devin_dir, droid_dir, + grok_dir, hermes_dir, hermes_plugin_dir, kilo_dir, kimi_dir, mastracode_dir, omp_extension_dir, + opencode_dir, pi_extension_dir, qodercli_dir, }; use super::file_ops::{ make_executable, remove_dir_all_if_exists, remove_file_if_exists, remove_legacy_bash_hook_file, }; use super::types::{ - ClaudeInstallPaths, ClaudeUninstallResult, CodexInstallPaths, CodexUninstallResult, - CopilotInstallPaths, CopilotUninstallResult, CursorInstallPaths, CursorUninstallResult, - DevinInstallPaths, DevinUninstallResult, DroidInstallPaths, DroidUninstallResult, - GrokInstallPaths, GrokUninstallResult, HermesInstallPaths, HermesUninstallResult, - KiloInstallPaths, KiloUninstallResult, KimiInstallPaths, KimiUninstallResult, - MastracodeInstallPaths, MastracodeUninstallResult, OmpInstallPaths, OmpUninstallResult, - OpenCodeInstallPaths, OpenCodeUninstallResult, PiUninstallResult, QodercliInstallPaths, - QodercliUninstallResult, + AntigravityCliInstallPaths, AntigravityCliUninstallResult, ClaudeInstallPaths, + ClaudeUninstallResult, CodexInstallPaths, CodexUninstallResult, CopilotInstallPaths, + CopilotUninstallResult, CursorInstallPaths, CursorUninstallResult, DevinInstallPaths, + DevinUninstallResult, DroidInstallPaths, DroidUninstallResult, GrokInstallPaths, + GrokUninstallResult, HermesInstallPaths, HermesUninstallResult, KiloInstallPaths, + KiloUninstallResult, KimiInstallPaths, KimiUninstallResult, MastracodeInstallPaths, + MastracodeUninstallResult, OmpInstallPaths, OmpUninstallResult, OpenCodeInstallPaths, + OpenCodeUninstallResult, PiUninstallResult, QodercliInstallPaths, QodercliUninstallResult, }; use super::{ - CLAUDE_HOOK_ASSET, CLAUDE_HOOK_INSTALL_NAME, CODEX_HOOK_ASSET, CODEX_HOOK_INSTALL_NAME, - COPILOT_HOOK_ASSET, COPILOT_HOOK_EVENTS, COPILOT_HOOK_INSTALL_NAME, - COPILOT_REMOVED_LIFECYCLE_HOOK_EVENTS, CURSOR_HOOK_ASSET, CURSOR_HOOK_INSTALL_NAME, - DEVIN_HOOK_ASSET, DEVIN_HOOK_EVENTS, DEVIN_HOOK_INSTALL_NAME, - DEVIN_REMOVED_LIFECYCLE_HOOK_EVENTS, DROID_HOOK_ASSET, DROID_HOOK_EVENTS, - DROID_HOOK_INSTALL_NAME, DROID_REMOVED_LIFECYCLE_HOOK_EVENTS, GROK_HOOK_ASSET, - GROK_HOOK_CONFIG_INSTALL_NAME, GROK_HOOK_INSTALL_NAME, HERMES_PLUGIN_INIT_ASSET, - HERMES_PLUGIN_INIT_INSTALL_NAME, HERMES_PLUGIN_MANIFEST_ASSET, + ANTIGRAVITY_CLI_HOOK_ASSET, ANTIGRAVITY_CLI_HOOK_BLOCK_NAME, ANTIGRAVITY_CLI_HOOK_EVENTS, + ANTIGRAVITY_CLI_HOOK_INSTALL_NAME, ANTIGRAVITY_CLI_HOOK_TIMEOUT_SEC, CLAUDE_HOOK_ASSET, + CLAUDE_HOOK_INSTALL_NAME, CODEX_HOOK_ASSET, CODEX_HOOK_INSTALL_NAME, COPILOT_HOOK_ASSET, + COPILOT_HOOK_EVENTS, COPILOT_HOOK_INSTALL_NAME, COPILOT_REMOVED_LIFECYCLE_HOOK_EVENTS, + CURSOR_HOOK_ASSET, CURSOR_HOOK_INSTALL_NAME, DEVIN_HOOK_ASSET, DEVIN_HOOK_EVENTS, + DEVIN_HOOK_INSTALL_NAME, DEVIN_REMOVED_LIFECYCLE_HOOK_EVENTS, DROID_HOOK_ASSET, + DROID_HOOK_EVENTS, DROID_HOOK_INSTALL_NAME, DROID_REMOVED_LIFECYCLE_HOOK_EVENTS, + GROK_HOOK_ASSET, GROK_HOOK_CONFIG_INSTALL_NAME, GROK_HOOK_INSTALL_NAME, + HERMES_PLUGIN_INIT_ASSET, HERMES_PLUGIN_INIT_INSTALL_NAME, HERMES_PLUGIN_MANIFEST_ASSET, HERMES_PLUGIN_MANIFEST_INSTALL_NAME, KILO_PLUGIN_ASSET, KILO_PLUGIN_INSTALL_NAME, KIMI_HOOK_ASSET, KIMI_HOOK_INSTALL_NAME, MASTRACODE_HOOK_ASSET, MASTRACODE_HOOK_EVENTS, MASTRACODE_HOOK_INSTALL_NAME, MASTRACODE_HOOK_TIMEOUT_MS, MASTRACODE_REMOVED_HOOK_EVENTS, @@ -1203,6 +1204,106 @@ pub(crate) fn uninstall_mastracode() -> io::Result { }) } +pub(crate) fn install_antigravity_cli() -> io::Result { + let dir = antigravity_cli_dir()?; + if !dir.is_dir() { + return Err(io::Error::other(format!( + "antigravity cli config directory not found at {}. install antigravity cli first", + dir.display() + ))); + } + + let hooks_dir = dir.join("hooks"); + fs::create_dir_all(&hooks_dir)?; + + let hook_path = hooks_dir.join(ANTIGRAVITY_CLI_HOOK_INSTALL_NAME); + fs::write(&hook_path, ANTIGRAVITY_CLI_HOOK_ASSET)?; + make_executable(&hook_path)?; + + let hooks_path = dir.join("hooks.json"); + let mut hooks_file = if hooks_path.is_file() { + serde_json::from_str::(&fs::read_to_string(&hooks_path)?).map_err(|err| { + io::Error::other(format!("failed to parse {}: {err}", hooks_path.display())) + })? + } else { + json!({}) + }; + + let hooks = hooks_file.as_object_mut().ok_or_else(|| { + io::Error::other(format!( + "antigravity cli hooks file at {} must be a JSON object", + hooks_path.display() + )) + })?; + + // The Herdr block is Herdr-owned, so rewrite it wholesale and leave every + // other named hook untouched. + hooks.insert( + ANTIGRAVITY_CLI_HOOK_BLOCK_NAME.to_string(), + antigravity_cli_hook_block(&hook_path), + ); + + fs::write(&hooks_path, serde_json::to_string_pretty(&hooks_file)?)?; + + Ok(AntigravityCliInstallPaths { + hook_path, + hooks_path, + }) +} + +/// Builds the Herdr-owned `hooks.json` block for Antigravity CLI. +/// +/// Every event Herdr registers takes a flat handler list; the `matcher`/`hooks` +/// group is only valid for the tool events, which Herdr does not use. +fn antigravity_cli_hook_block(hook_path: &Path) -> Value { + let mut block = Map::new(); + for (event, action) in ANTIGRAVITY_CLI_HOOK_EVENTS { + let handler = json!({ + "type": "command", + "command": hook_command(hook_path, Some(action)), + "timeout": ANTIGRAVITY_CLI_HOOK_TIMEOUT_SEC, + }); + block.insert(event.to_string(), json!([handler])); + } + Value::Object(block) +} + +pub(crate) fn uninstall_antigravity_cli() -> io::Result { + let dir = antigravity_cli_dir()?; + let hook_path = dir.join("hooks").join(ANTIGRAVITY_CLI_HOOK_INSTALL_NAME); + let hooks_path = dir.join("hooks.json"); + let mut updated_hooks = false; + + if hooks_path.is_file() { + let mut hooks_file = serde_json::from_str::(&fs::read_to_string(&hooks_path)?) + .map_err(|err| { + io::Error::other(format!("failed to parse {}: {err}", hooks_path.display())) + })?; + + let hooks = hooks_file.as_object_mut().ok_or_else(|| { + io::Error::other(format!( + "antigravity cli hooks file at {} must be a JSON object", + hooks_path.display() + )) + })?; + + updated_hooks = hooks.remove(ANTIGRAVITY_CLI_HOOK_BLOCK_NAME).is_some(); + + if updated_hooks { + fs::write(&hooks_path, serde_json::to_string_pretty(&hooks_file)?)?; + } + } + + let removed_hook_file = remove_file_if_exists(&hook_path)?; + + Ok(AntigravityCliUninstallResult { + hook_path, + hooks_path, + removed_hook_file, + updated_hooks, + }) +} + /// The complete Herdr-owned Grok hook config. Installation and status share /// this value so any config drift is reported as outdated. pub(crate) fn grok_hook_config(hook_path: &Path) -> Value { diff --git a/src/integration/tests.rs b/src/integration/tests.rs index ff616264..7abf7be2 100644 --- a/src/integration/tests.rs +++ b/src/integration/tests.rs @@ -102,6 +102,7 @@ fn clear_integration_path_env() { std::env::remove_var("XDG_CONFIG_HOME"); std::env::remove_var(QODERCLI_CONFIG_DIR_ENV_VAR); std::env::remove_var(CURSOR_CONFIG_DIR_ENV_VAR); + std::env::remove_var(ANTIGRAVITY_CLI_CONFIG_DIR_ENV_VAR); std::env::remove_var(GROK_CONFIG_DIR_ENV_VAR); std::env::remove_var(GROK_HOME_ENV_VAR); } @@ -3644,6 +3645,154 @@ fn uninstall_mastracode_errors_when_event_value_not_array() { let _ = fs::remove_dir_all(base); } +#[test] +fn install_antigravity_cli_writes_hook_and_updates_hooks_json() { + let _lock = integration_env_lock(); + let base = unique_base(); + let agy_dir = base.join(".gemini").join("config"); + fs::create_dir_all(&agy_dir).unwrap(); + fs::write( + agy_dir.join("hooks.json"), + r#"{"lint-checker":{"PreInvocation":[{"type":"command","command":"echo keep-me"}]}}"#, + ) + .unwrap(); + std::env::set_var(ANTIGRAVITY_CLI_CONFIG_DIR_ENV_VAR, &agy_dir); + + let installed = install_antigravity_cli().unwrap(); + + assert_eq!( + installed.hook_path, + agy_dir + .join("hooks") + .join(ANTIGRAVITY_CLI_HOOK_INSTALL_NAME) + ); + assert_eq!(installed.hooks_path, agy_dir.join("hooks.json")); + assert_eq!( + fs::read_to_string(&installed.hook_path).unwrap(), + ANTIGRAVITY_CLI_HOOK_ASSET + ); + + let hooks_file: Value = + serde_json::from_str(&fs::read_to_string(agy_dir.join("hooks.json")).unwrap()).unwrap(); + let hooks = hooks_file.as_object().unwrap(); + + // Herdr entries live under a named hook block; Antigravity CLI rejects a + // file whose top level maps event names straight to arrays. + let block = hooks + .get(ANTIGRAVITY_CLI_HOOK_BLOCK_NAME) + .and_then(Value::as_object) + .unwrap(); + + for (event, action) in ANTIGRAVITY_CLI_HOOK_EVENTS { + let entries = block.get(event).and_then(Value::as_array).unwrap(); + assert_eq!(entries.len(), 1, "{event} should hold one Herdr entry"); + let handler = &entries[0]; + + // Handlers must be a flat list; the matcher/hooks wrapper is only + // valid for tool events and invalidates the whole file here. + assert!( + handler.get("matcher").is_none() && handler.get("hooks").is_none(), + "{event} must be a flat handler, got {handler}" + ); + + assert_eq!(handler.get("type").and_then(Value::as_str), Some("command")); + assert_eq!( + handler.get("timeout").and_then(Value::as_u64), + Some(ANTIGRAVITY_CLI_HOOK_TIMEOUT_SEC) + ); + let command = handler.get("command").and_then(Value::as_str).unwrap(); + assert!(command.contains("herdr-agent-state")); + assert!(command.ends_with(action)); + } + + // The integration is session-only. Antigravity CLI cannot express blocked + // state, skips PostInvocation on interruption, and fires Stop at end of + // turn rather than process exit, so Herdr never claims lifecycle authority + // here and screen detection owns agent state. + for event in ["PreToolUse", "PostToolUse", "PostInvocation", "Stop"] { + assert!( + block.get(event).is_none(), + "{event} must not be registered; lifecycle stays with screen detection" + ); + } + + // Other named hooks are left untouched. + assert_eq!( + hooks + .get("lint-checker") + .and_then(|block| block.get("PreInvocation")) + .and_then(Value::as_array) + .and_then(|entries| entries.first()) + .and_then(|entry| entry.get("command")) + .and_then(Value::as_str), + Some("echo keep-me") + ); + + std::env::remove_var(ANTIGRAVITY_CLI_CONFIG_DIR_ENV_VAR); + let _ = fs::remove_dir_all(base); +} + +#[test] +fn install_antigravity_cli_rewrites_stale_herdr_block() { + let _lock = integration_env_lock(); + let base = unique_base(); + let agy_dir = base.join(".gemini").join("config"); + fs::create_dir_all(&agy_dir).unwrap(); + // An older Herdr install claimed lifecycle authority, wrapped events in + // matcher/hooks, and left entries Antigravity CLI now rejects. + fs::write( + agy_dir.join("hooks.json"), + r#"{"herdr":{"Stop":[{"matcher":"*","hooks":[{"type":"command","command":"stale"}]}],"PostInvocation":[{"type":"command","command":"stale idle"}],"Legacy":[]}}"#, + ) + .unwrap(); + std::env::set_var(ANTIGRAVITY_CLI_CONFIG_DIR_ENV_VAR, &agy_dir); + + install_antigravity_cli().unwrap(); + + let hooks_file: Value = + serde_json::from_str(&fs::read_to_string(agy_dir.join("hooks.json")).unwrap()).unwrap(); + let block = hooks_file + .get(ANTIGRAVITY_CLI_HOOK_BLOCK_NAME) + .and_then(Value::as_object) + .unwrap(); + + // The block is Herdr-owned and rewritten wholesale, so a stale lifecycle + // install is migrated to session-only rather than merged with. + assert_eq!( + block.keys().map(String::as_str).collect::>(), + vec!["PreInvocation"], + "stale lifecycle events should be gone" + ); + let entries = block + .get("PreInvocation") + .and_then(Value::as_array) + .unwrap(); + assert_eq!(entries.len(), 1); + assert!(entries[0].get("hooks").is_none()); + assert!(entries[0] + .get("command") + .and_then(Value::as_str) + .is_some_and(|command| command.contains("herdr-agent-state"))); + + std::env::remove_var(ANTIGRAVITY_CLI_CONFIG_DIR_ENV_VAR); + let _ = fs::remove_dir_all(base); +} + +#[test] +fn install_antigravity_cli_errors_when_config_dir_missing() { + let _lock = integration_env_lock(); + let base = unique_base(); + let agy_dir = base.join(".gemini").join("config"); + std::env::set_var(ANTIGRAVITY_CLI_CONFIG_DIR_ENV_VAR, &agy_dir); + + let err = install_antigravity_cli().unwrap_err(); + assert!(err.to_string().contains("install antigravity cli first")); + assert!(!agy_dir.exists(), "install must not create the config dir"); + + std::env::remove_var(ANTIGRAVITY_CLI_CONFIG_DIR_ENV_VAR); + let _ = fs::remove_dir_all(base); +} + #[test] fn grok_v1_integration_status_is_current() { let _lock = integration_env_lock(); @@ -3755,6 +3904,41 @@ fn grok_status_reports_outdated_when_hook_config_missing_or_broken() { let _ = fs::remove_dir_all(base); } +#[test] +fn uninstall_antigravity_cli_removes_hooks_json_entries_and_hook_file() { + let _lock = integration_env_lock(); + let base = unique_base(); + let agy_dir = base.join(".gemini").join("config"); + fs::create_dir_all(&agy_dir).unwrap(); + fs::write( + agy_dir.join("hooks.json"), + r#"{"lint-checker":{"PreInvocation":[{"type":"command","command":"echo keep-me"}]}}"#, + ) + .unwrap(); + std::env::set_var(ANTIGRAVITY_CLI_CONFIG_DIR_ENV_VAR, &agy_dir); + + // Install first + let installed = install_antigravity_cli().unwrap(); + assert!(installed.hook_path.is_file()); + + // Uninstall + let result = uninstall_antigravity_cli().unwrap(); + assert!(result.removed_hook_file); + assert!(!installed.hook_path.is_file()); + assert!(result.updated_hooks); + + let hooks_file: Value = + serde_json::from_str(&fs::read_to_string(agy_dir.join("hooks.json")).unwrap()).unwrap(); + let hooks = hooks_file.as_object().unwrap(); + + // The Herdr block is gone and unrelated named hooks survive. + assert!(hooks.get(ANTIGRAVITY_CLI_HOOK_BLOCK_NAME).is_none()); + assert!(hooks.contains_key("lint-checker")); + + std::env::remove_var(ANTIGRAVITY_CLI_CONFIG_DIR_ENV_VAR); + let _ = fs::remove_dir_all(base); +} + #[test] fn grok_dir_honors_grok_home_after_config_dir_seam() { let _lock = integration_env_lock(); diff --git a/src/integration/types.rs b/src/integration/types.rs index 93138a3d..09a235b0 100644 --- a/src/integration/types.rs +++ b/src/integration/types.rs @@ -241,3 +241,17 @@ pub(crate) struct HermesUninstallResult { pub removed_plugin_dir: bool, pub updated_config: bool, } + +#[derive(Debug)] +pub(crate) struct AntigravityCliInstallPaths { + pub hook_path: PathBuf, + pub hooks_path: PathBuf, +} + +#[derive(Debug)] +pub(crate) struct AntigravityCliUninstallResult { + pub hook_path: PathBuf, + pub hooks_path: PathBuf, + pub removed_hook_file: bool, + pub updated_hooks: bool, +} diff --git a/src/terminal/state.rs b/src/terminal/state.rs index e806967e..5ed5adc4 100644 --- a/src/terminal/state.rs +++ b/src/terminal/state.rs @@ -1234,7 +1234,7 @@ impl TerminalState { && current_kind == crate::agent_resume::AgentSessionRefKind::Id && session_ref.kind == crate::agent_resume::AgentSessionRefKind::Id && current_value != session_ref.value - && !Self::session_start_source_allows_session_replacement( + && !Self::session_report_allows_session_replacement( source, agent_label, session_start_source, @@ -1267,7 +1267,7 @@ impl TerminalState { }) } - fn session_start_source_allows_session_replacement( + fn session_report_allows_session_replacement( source: &str, agent_label: &str, session_start_source: Option<&str>, @@ -1291,6 +1291,7 @@ impl TerminalState { "omp", Some("startup" | "new" | "resume" | "fork") ) + | ("herdr:antigravity_cli", "agy", None) ) } @@ -1413,12 +1414,12 @@ impl TerminalState { if self.known_agent_label_conflicts_with_detected_agent(&agent_label) { return None; } - let session_replacement_allowed = Self::session_start_source_allows_session_replacement( + let session_replacement_allowed = Self::session_report_allows_session_replacement( &source, &agent_label, session_start_source.as_deref(), ); - let replacing_hermes_session = + let replacing_identity_only_session = crate::detect::session_identity_only_integration(&source, &agent_label) && session_replacement_allowed && self.current_session_identity_for_persistence().is_some_and( @@ -1430,7 +1431,7 @@ impl TerminalState { && current_value != session_ref.value }, ); - if replacing_hermes_session && !process_present { + if replacing_identity_only_session && !process_present { return None; } let owner_conflicts = self.current_session_owner_conflicts(&source, &agent_label); @@ -2309,101 +2310,125 @@ mod tests { } #[test] - fn hermes_session_claim_leaves_state_to_detection() { - let mut terminal = test_terminal(); - terminal.set_detected_state(Some(Agent::Hermes), AgentState::Idle); - let session_ref = crate::agent_resume::AgentSessionRef::id("hermes-root").unwrap(); + fn session_identity_claims_leave_state_to_detection() { + for (source, label, agent, start_source, replacement_source) in [ + ( + "herdr:hermes", + "hermes", + Agent::Hermes, + Some("startup"), + Some("resume"), + ), + ( + "herdr:antigravity_cli", + "agy", + Agent::Antigravity, + None, + None, + ), + ] { + let mut terminal = test_terminal(); + terminal.set_detected_state(Some(agent), AgentState::Idle); + let first_ref = + crate::agent_resume::AgentSessionRef::id(format!("{label}-root")).unwrap(); + let first = terminal.set_agent_session_ref_for_session_start( + source.into(), + label.into(), + Some(first_ref.clone()), + Some(10), + start_source.map(str::to_string), + ); - let session = terminal.set_agent_session_ref_for_session_start( - "herdr:hermes".into(), - "hermes".into(), - Some(session_ref.clone()), - Some(10), - Some("startup".into()), - ); + assert!(first.is_some(), "{label} should accept its session"); + assert!(terminal.hook_authority.is_none()); + assert_eq!(terminal.state, AgentState::Idle); + assert_eq!( + terminal + .persisted_agent_session + .as_ref() + .map(|session| &session.session_ref), + Some(&first_ref) + ); - assert!(session.is_some()); - assert!(terminal.hook_authority.is_none()); - assert_eq!( - terminal - .persisted_agent_session - .as_ref() - .map(|session| &session.session_ref), - Some(&session_ref) - ); + terminal.set_detected_state(Some(agent), AgentState::Working); + let replacement_ref = + crate::agent_resume::AgentSessionRef::id(format!("{label}-replacement")).unwrap(); + let replacement = terminal.set_agent_session_ref_for_session_start( + source.into(), + label.into(), + Some(replacement_ref.clone()), + Some(11), + start_source.map(str::to_string), + ); - terminal.set_detected_state(Some(Agent::Hermes), AgentState::Working); + assert!( + replacement.is_some_and(|mutation| mutation.session_ref_changed), + "{label} should replace its detected session" + ); + assert!(terminal.hook_authority.is_none()); + assert_eq!(terminal.state, AgentState::Working); + assert_eq!( + terminal + .persisted_agent_session + .as_ref() + .map(|session| &session.session_ref), + Some(&replacement_ref) + ); - assert_eq!(terminal.state, AgentState::Working); - assert!(terminal.hook_authority.is_none()); + let legacy_state = terminal.set_hook_authority_with_session_ref( + source.into(), + label.into(), + AgentState::Blocked, + None, + Some(replacement_ref.clone()), + Some(12), + ); + assert!(legacy_state.is_none()); + assert!(terminal.hook_authority.is_none()); + assert_eq!(terminal.state, AgentState::Working); - let replacement_ref = - crate::agent_resume::AgentSessionRef::id("hermes-replacement").unwrap(); - let replacement = terminal.set_agent_session_ref_for_session_start( - "herdr:hermes".into(), - "hermes".into(), - Some(replacement_ref.clone()), - Some(11), - Some("startup".into()), - ); + terminal.set_detected_state(None, AgentState::Unknown); + let background_ref = + crate::agent_resume::AgentSessionRef::id(format!("{label}-background")).unwrap(); + let background_replacement = terminal.set_agent_session_ref_for_session_start( + source.into(), + label.into(), + Some(background_ref.clone()), + Some(13), + replacement_source.map(str::to_string), + ); + assert!( + background_replacement.is_none(), + "{label} should reject a background replacement" + ); + assert_eq!( + terminal + .persisted_agent_session + .as_ref() + .map(|session| &session.session_ref), + Some(&replacement_ref) + ); - assert!(replacement.is_some()); - assert_eq!(terminal.state, AgentState::Working); - assert!(terminal.hook_authority.is_none()); - assert_eq!( - terminal - .persisted_agent_session - .as_ref() - .map(|session| &session.session_ref), - Some(&replacement_ref) - ); - - let legacy_state = terminal.set_hook_authority_with_session_ref( - "herdr:hermes".into(), - "hermes".into(), - AgentState::Blocked, - None, - Some(replacement_ref.clone()), - Some(12), - ); - assert!(legacy_state.is_none()); - assert_eq!(terminal.state, AgentState::Working); - assert!(terminal.hook_authority.is_none()); - - terminal.set_detected_state(None, AgentState::Unknown); - let background_replacement = terminal.set_agent_session_ref_for_session_start( - "herdr:hermes".into(), - "hermes".into(), - crate::agent_resume::AgentSessionRef::id("hermes-background"), - Some(13), - Some("resume".into()), - ); - assert!(background_replacement.is_none()); - assert_eq!( - terminal - .persisted_agent_session - .as_ref() - .map(|session| &session.session_ref), - Some(&replacement_ref) - ); - - terminal.set_detected_state(Some(Agent::Hermes), AgentState::Idle); - let retried_ref = crate::agent_resume::AgentSessionRef::id("hermes-background").unwrap(); - let retried_replacement = terminal.set_agent_session_ref_for_session_start( - "herdr:hermes".into(), - "hermes".into(), - Some(retried_ref.clone()), - Some(14), - Some("resume".into()), - ); - assert!(retried_replacement.is_some()); - assert_eq!( - terminal - .persisted_agent_session - .as_ref() - .map(|session| &session.session_ref), - Some(&retried_ref) - ); + terminal.set_detected_state(Some(agent), AgentState::Idle); + let retried_replacement = terminal.set_agent_session_ref_for_session_start( + source.into(), + label.into(), + Some(background_ref.clone()), + Some(14), + replacement_source.map(str::to_string), + ); + assert!( + retried_replacement.is_some_and(|mutation| mutation.session_ref_changed), + "{label} should replace the session once detected" + ); + assert_eq!( + terminal + .persisted_agent_session + .as_ref() + .map(|session| &session.session_ref), + Some(&background_ref) + ); + } } #[test]