feat: add antigravity-cli integration (#2087)

* feat: add antigravity_cli integration harness

Register the `antigravity_cli` target, resolve its configuration
directory to `~/.gemini/antigravity-cli/`, wire target actions,
and add lifecycle hook scripts for Unix and Windows.

* feat: support session resume for antigravity-cli

* fix: correct antigravity-cli hook config path, shape, and agent label

Antigravity CLI reads global customizations from ~/.gemini/config, keys
hooks.json by hook name, and only accepts the matcher/hooks wrapper for
the tool events. The previous install wrote event arrays at the top level
of ~/.gemini/antigravity-cli/hooks.json, which agy never reads and would
reject anyway, so no Herdr hook ever ran.

Session reports were dropped for a third reason: the hooks reported the
agent as antigravity-cli, but the server normalizes that to the canonical
label agy before matching, so is_official_agent_source never matched and
agy --conversation resume was unreachable.

- Resolve the config dir to ~/.gemini/config
- Nest Herdr entries under a Herdr-owned "herdr" block that install
  rewrites and uninstall removes, leaving other named hooks untouched
- Emit grouped entries for PreToolUse/PostToolUse and flat handler lists
  for PreInvocation/PostInvocation/Stop
- Report the canonical agy label from both hook assets and match it in
  agent_resume
- Compile-gate the hook asset constants and stop shadowing $args in the
  PowerShell hook
- Document the real directory, that it must already exist, and the
  named-block behavior

refs #1011
refs #1571

* fix: make antigravity-cli integration session-only

antigravity cli cannot express lifecycle safely: there is no blocked
event, postinvocation is skipped on interruption, and stop fires at the
end of a turn rather than on process exit, which left stale state and
released authority at the wrong time.

report only the conversation on preinvocation and let screen detection
own agent state. resume via `agy --conversation <id>` is unchanged.

* fix: update antigravity session after conversation switch

---------

Co-authored-by: Can Celik <ogulcancelik@gmail.com>
This commit is contained in:
Ludovico Magnocavallo 2026-08-01 18:32:47 +02:00 committed by GitHub
parent 219e0be351
commit 679584fdd4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 742 additions and 131 deletions

View File

@ -2119,6 +2119,7 @@
"qodercli",
"cursor",
"mastracode",
"antigravity_cli",
"grok"
],
"type": "string"
@ -7113,6 +7114,7 @@
"qodercli",
"cursor",
"mastracode",
"antigravity_cli",
"grok"
],
"type": "string"

View File

@ -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 <id>`.
## 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 <id>` after a Herdr server restart.
## Grok CLI
Install the Grok CLI hook:

View File

@ -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 <path-or-id>` |
| Antigravity CLI | `1` | `agy --conversation <id>` |
| OMP | `3` | `omp --resume=<path-or-id>` |
| Claude Code | `6` | `claude --resume <id>` |
| Codex | `5` | `codex resume <id>` |

View File

@ -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());
}
}

View File

@ -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,
];
}

View File

@ -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]");
}

View File

@ -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]

View File

@ -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();

View File

@ -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

View File

@ -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

View File

@ -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<PathBuf> {
Ok(home_dir()?.join(".mastracode"))
}
pub(crate) fn antigravity_cli_dir() -> io::Result<PathBuf> {
// 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<PathBuf> {
// 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.

View File

@ -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:";

View File

@ -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<PathBuf>,
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)),

View File

@ -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<MastracodeUninstallResult> {
})
}
pub(crate) fn install_antigravity_cli() -> io::Result<AntigravityCliInstallPaths> {
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::<Value>(&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<AntigravityCliUninstallResult> {
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::<Value>(&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 {

View File

@ -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<_>>(),
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();

View File

@ -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,
}

View File

@ -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]