fix: stabilize integration agent state reports

This commit is contained in:
Ogulcan Celik
2026-05-11 16:13:17 +03:00
parent e201a64763
commit 55cdf2e8f8
15 changed files with 920 additions and 213 deletions
+9
View File
@@ -201,6 +201,8 @@ pub struct PaneReportAgentParams {
pub state: PaneAgentState,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub seq: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -208,6 +210,8 @@ pub struct PaneClearAgentAuthorityParams {
pub pane_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub source: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub seq: Option<u64>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
@@ -215,6 +219,8 @@ pub struct PaneReleaseAgentParams {
pub pane_id: String,
pub source: String,
pub agent: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub seq: Option<u64>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
@@ -717,6 +723,7 @@ mod tests {
agent: "pi".into(),
state: PaneAgentState::Working,
message: Some("thinking".into()),
seq: Some(42),
}),
};
@@ -732,6 +739,7 @@ mod tests {
method: Method::PaneClearAgentAuthority(PaneClearAgentAuthorityParams {
pane_id: "1-1".into(),
source: Some("herdr:pi".into()),
seq: Some(42),
}),
};
@@ -748,6 +756,7 @@ mod tests {
pane_id: "1-1".into(),
source: "herdr:pi".into(),
agent: "pi".into(),
seq: Some(42),
}),
};
+15 -4
View File
@@ -599,23 +599,33 @@ impl AppState {
agent_label,
state,
message,
seq,
} => self
.update_pane_state(pane_id, |pane| {
pane.set_hook_authority(source, agent_label, state, message)
pane.set_hook_authority(source, agent_label, state, message, seq)
})
.into_iter()
.collect(),
AppEvent::HookAuthorityCleared { pane_id, source } => self
.update_pane_state(pane_id, |pane| pane.clear_hook_authority(source.as_deref()))
AppEvent::HookAuthorityCleared {
pane_id,
source,
seq,
} => self
.update_pane_state(pane_id, |pane| {
pane.clear_hook_authority(source.as_deref(), seq)
})
.into_iter()
.collect(),
AppEvent::HookAgentReleased {
pane_id,
source,
agent_label,
seq,
..
} => self
.update_pane_state(pane_id, |pane| pane.release_agent(&source, &agent_label))
.update_pane_state(pane_id, |pane| {
pane.release_agent(&source, &agent_label, seq)
})
.into_iter()
.collect(),
// Intercepted in App::handle_internal_event before reaching this
@@ -1153,6 +1163,7 @@ mod tests {
agent_label: "hermes".into(),
state: AgentState::Blocked,
message: None,
seq: None,
});
let toast = state.toast.as_ref().unwrap();
+3
View File
@@ -1019,6 +1019,7 @@ impl App {
agent_label,
state: detect_state_from_api(params.state),
message: params.message,
seq: params.seq,
});
SuccessResponse {
id: request.id,
@@ -1039,6 +1040,7 @@ impl App {
self.handle_internal_event(crate::events::AppEvent::HookAuthorityCleared {
pane_id,
source: params.source,
seq: params.seq,
});
SuccessResponse {
id: request.id,
@@ -1071,6 +1073,7 @@ impl App {
source: params.source,
known_agent: crate::detect::parse_agent_label(&agent_label),
agent_label,
seq: params.seq,
});
SuccessResponse {
id: request.id,
+38
View File
@@ -319,6 +319,7 @@ fn run_integration_command(args: &[String]) -> std::io::Result<i32> {
match subcommand {
"install" => integration_install(&args[1..]),
"uninstall" => integration_uninstall(&args[1..]),
"status" => integration_status(&args[1..]),
"help" | "--help" | "-h" => {
print_integration_help();
Ok(0)
@@ -983,6 +984,42 @@ fn pane_run(args: &[String]) -> std::io::Result<i32> {
}))
}
fn integration_status(args: &[String]) -> std::io::Result<i32> {
let outdated_only = match args {
[] => false,
[flag] if flag == "--outdated-only" => true,
_ => {
eprintln!("usage: herdr integration status [--outdated-only]");
return Ok(2);
}
};
if outdated_only {
crate::integration::print_outdated_update_notice();
return Ok(0);
}
for status in crate::integration::installed_integration_statuses() {
let target = crate::integration::integration_target_label(status.target);
let version = match status.installed_version {
Some(version) => format!("v{version}"),
None => "legacy".to_string(),
};
let state = match status.state {
crate::integration::IntegrationStatusKind::NotInstalled => "not installed".to_string(),
crate::integration::IntegrationStatusKind::Current => {
format!("current ({version})")
}
crate::integration::IntegrationStatusKind::Outdated => {
format!("outdated ({version} < v{})", status.expected_version)
}
};
println!("{target}: {state} ({})", status.path.display());
}
Ok(0)
}
fn integration_install(args: &[String]) -> std::io::Result<i32> {
let Some(target) = parse_integration_target(args, "install")? else {
return Ok(2);
@@ -1488,6 +1525,7 @@ fn print_integration_help() {
eprintln!(" herdr integration uninstall claude");
eprintln!(" herdr integration uninstall codex");
eprintln!(" herdr integration uninstall opencode");
eprintln!(" herdr integration status [--outdated-only]");
}
fn print_session_help() {
+3
View File
@@ -25,11 +25,13 @@ pub enum AppEvent {
agent_label: String,
state: AgentState,
message: Option<String>,
seq: Option<u64>,
},
/// Hook authority was explicitly cleared for a pane.
HookAuthorityCleared {
pane_id: PaneId,
source: Option<String>,
seq: Option<u64>,
},
/// The current detected agent gracefully released this pane back to the shell.
HookAgentReleased {
@@ -37,6 +39,7 @@ pub enum AppEvent {
source: String,
agent_label: String,
known_agent: Option<Agent>,
seq: Option<u64>,
},
/// A new version is available and ready to install explicitly.
UpdateReady { version: String },
@@ -1,6 +1,8 @@
#!/bin/sh
# installed by herdr
# safe to edit. this hook only activates inside herdr-managed panes.
# HERDR_INTEGRATION_ID=claude
# HERDR_INTEGRATION_VERSION=1
set -eu
@@ -50,6 +52,7 @@ if is_subagent and action in ("idle", "release"):
action = "working"
request_id = f"{source}:{int(time.time() * 1000)}:{random.randrange(1_000_000):06d}"
report_seq = time.time_ns()
if action == "release":
request = {
"id": request_id,
@@ -58,6 +61,7 @@ if action == "release":
"pane_id": pane_id,
"source": source,
"agent": "claude",
"seq": report_seq,
},
}
else:
@@ -69,6 +73,7 @@ else:
"source": source,
"agent": "claude",
"state": action,
"seq": report_seq,
},
}
@@ -1,6 +1,8 @@
#!/bin/sh
# installed by herdr
# safe to edit. this hook only activates inside herdr-managed panes.
# HERDR_INTEGRATION_ID=codex
# HERDR_INTEGRATION_VERSION=1
set -eu
@@ -33,6 +35,7 @@ if not pane_id or not socket_path:
raise SystemExit(0)
request_id = f"{source}:{int(time.time() * 1000)}:{random.randrange(1_000_000):06d}"
report_seq = time.time_ns()
if action == "release":
request = {
"id": request_id,
@@ -41,6 +44,7 @@ if action == "release":
"pane_id": pane_id,
"source": source,
"agent": "codex",
"seq": report_seq,
},
}
else:
@@ -52,6 +56,7 @@ else:
"source": source,
"agent": "codex",
"state": action,
"seq": report_seq,
},
}
@@ -1,6 +1,17 @@
// installed by herdr
// safe to edit. this plugin only activates inside herdr-managed panes.
// HERDR_INTEGRATION_ID=opencode
// HERDR_INTEGRATION_VERSION=1
import net from "node:net";
const SOURCE = "herdr:opencode";
let reportSeq = Date.now() * 1000;
function nextReportSeq() {
reportSeq += 1;
return reportSeq;
}
function reportState(action) {
const paneId = process.env.HERDR_PANE_ID;
@@ -22,12 +33,14 @@ function reportState(action) {
pane_id: paneId,
source: SOURCE,
agent: "opencode",
seq: nextReportSeq(),
}
: {
pane_id: paneId,
source: SOURCE,
agent: "opencode",
state: action,
seq: nextReportSeq(),
},
};
+166 -6
View File
@@ -1,5 +1,7 @@
// installed by herdr
// safe to edit. this integration only activates inside herdr-managed panes.
// HERDR_INTEGRATION_ID=pi
// HERDR_INTEGRATION_VERSION=1
// @ts-nocheck
import { createConnection } from "node:net";
@@ -37,7 +39,38 @@ function sendRequest(request: unknown): Promise<void> {
});
}
function sendState(state: "working" | "blocked" | "idle", message?: string): Promise<void> {
type AgentState = "working" | "blocked" | "idle";
type QueuedState = {
state: AgentState;
message?: string;
seq: number;
};
const idleDebounceMs = parseDurationEnv("HERDR_PI_IDLE_DEBOUNCE_MS", 250);
const retryGraceMs = parseDurationEnv("HERDR_PI_RETRY_GRACE_MS", 2500);
const retryableErrorPattern =
/overloaded|provider.?returned.?error|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server.?error|internal.?error|network.?error|connection.?error|connection.?refused|connection.?lost|websocket.?closed|websocket.?error|other side closed|fetch failed|upstream.?connect|reset before headers|socket hang up|ended without|http2 request did not get a response|timed? out|timeout|terminated|retry delay/i;
let reportSeq = Date.now() * 1000;
function nextReportSeq(): number {
reportSeq += 1;
return reportSeq;
}
function parseDurationEnv(name: string, fallback: number): number {
const raw = process.env[name];
if (!raw) {
return fallback;
}
const parsed = Number.parseInt(raw, 10);
if (!Number.isFinite(parsed) || parsed < 0) {
return fallback;
}
return parsed;
}
function sendState(state: AgentState, message?: string, seq = nextReportSeq()): Promise<void> {
return sendRequest({
id: `${source}:${Date.now()}:${Math.random().toString(36).slice(2)}`,
method: "pane.report_agent",
@@ -47,10 +80,65 @@ function sendState(state: "working" | "blocked" | "idle", message?: string): Pro
agent: "pi",
state,
message,
seq,
},
});
}
let sendInFlight = false;
let queuedState: QueuedState | undefined;
function queueState(state: AgentState, message?: string): void {
queuedState = { state, message, seq: nextReportSeq() };
if (!sendInFlight) {
void drainStateQueue();
}
}
async function drainStateQueue(): Promise<void> {
if (sendInFlight) {
return;
}
sendInFlight = true;
try {
while (queuedState) {
const next = queuedState;
queuedState = undefined;
await sendState(next.state, next.message, next.seq);
}
} finally {
sendInFlight = false;
if (queuedState) {
void drainStateQueue();
}
}
}
function lastAssistantMessage(messages: unknown[]): any | undefined {
for (let i = messages.length - 1; i >= 0; i -= 1) {
const message = messages[i] as any;
if (message?.role === "assistant") {
return message;
}
}
return undefined;
}
function retryableErrorMessage(event: any): string | undefined {
const messages = Array.isArray(event?.messages) ? event.messages : [];
const assistant = lastAssistantMessage(messages);
if (assistant?.stopReason !== "error") {
return undefined;
}
const errorMessage = String(assistant.errorMessage ?? "");
if (!retryableErrorPattern.test(errorMessage)) {
return undefined;
}
return errorMessage || "retryable provider error";
}
function releaseAgent(): Promise<void> {
return sendRequest({
id: `${source}:release:${Date.now()}:${Math.random().toString(36).slice(2)}`,
@@ -59,6 +147,7 @@ function releaseAgent(): Promise<void> {
pane_id: paneId,
source,
agent: "pi",
seq: nextReportSeq(),
},
});
}
@@ -69,16 +158,43 @@ export default function (pi) {
}
let agentActive = false;
let retryHoldActive = false;
let failureBlocked = false;
let failureMessage: string | undefined;
let blockedCount = 0;
let blockedMessage: string | undefined;
let lastState: "working" | "blocked" | "idle" | undefined;
let lastState: AgentState | undefined;
let lastMessage: string | undefined;
let idleTimer: ReturnType<typeof setTimeout> | undefined;
let retryTimer: ReturnType<typeof setTimeout> | undefined;
function clearTimer(timer: ReturnType<typeof setTimeout> | undefined) {
if (timer) {
clearTimeout(timer);
}
}
function clearPendingTimers() {
clearTimer(idleTimer);
clearTimer(retryTimer);
idleTimer = undefined;
retryTimer = undefined;
}
function clearFailureState() {
retryHoldActive = false;
failureBlocked = false;
failureMessage = undefined;
}
function desiredState() {
if (blockedCount > 0) {
return { state: "blocked" as const, message: blockedMessage };
}
if (agentActive) {
if (failureBlocked) {
return { state: "blocked" as const, message: failureMessage };
}
if (agentActive || retryHoldActive) {
return { state: "working" as const, message: undefined };
}
return { state: "idle" as const, message: undefined };
@@ -91,7 +207,33 @@ export default function (pi) {
}
lastState = next.state;
lastMessage = next.message;
void sendState(next.state, next.message);
queueState(next.state, next.message);
}
function scheduleIdle() {
clearPendingTimers();
clearFailureState();
idleTimer = setTimeout(() => {
idleTimer = undefined;
publishState();
}, idleDebounceMs);
idleTimer.unref?.();
}
function holdForRetry(message: string) {
clearPendingTimers();
retryHoldActive = true;
failureBlocked = false;
failureMessage = message;
publishState();
retryTimer = setTimeout(() => {
retryTimer = undefined;
retryHoldActive = false;
failureBlocked = true;
publishState();
}, retryGraceMs);
retryTimer.unref?.();
}
pi.events.on("herdr:blocked", (data) => {
@@ -104,22 +246,40 @@ export default function (pi) {
return;
}
clearPendingTimers();
blockedCount += 1;
blockedMessage = data.label;
publishState();
});
pi.on("agent_start", () => {
clearPendingTimers();
clearFailureState();
agentActive = true;
publishState();
});
pi.on("agent_end", () => {
pi.on("agent_end", (event) => {
if (!agentActive) {
// Pi can emit duplicate/late end events while auto-retry is already
// holding the pane in Working. Do not let an unqualified duplicate end
// cancel the retry hold and publish a false Idle.
return;
}
agentActive = false;
publishState();
const retryableMessage = retryableErrorMessage(event);
if (retryableMessage) {
holdForRetry(retryableMessage);
return;
}
scheduleIdle();
});
pi.on("session_shutdown", async () => {
clearPendingTimers();
await releaseAgent();
});
}
+218 -21
View File
@@ -1,6 +1,8 @@
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
#[cfg(test)]
use std::sync::{Mutex, MutexGuard, OnceLock};
use portable_pty::CommandBuilder;
use serde_json::{json, Map, Value};
@@ -10,12 +12,17 @@ use crate::layout::PaneId;
pub(crate) const HERDR_PANE_ID_ENV_VAR: &str = "HERDR_PANE_ID";
const PI_EXTENSION_INSTALL_NAME: &str = "herdr-agent-state.ts";
const PI_EXTENSION_ASSET: &str = include_str!("assets/pi/herdr-agent-state.ts");
const PI_INTEGRATION_VERSION: u32 = 1;
const CLAUDE_HOOK_INSTALL_NAME: &str = "herdr-agent-state.sh";
const CLAUDE_HOOK_ASSET: &str = include_str!("assets/claude/herdr-agent-state.sh");
const CLAUDE_INTEGRATION_VERSION: u32 = 1;
const CODEX_HOOK_INSTALL_NAME: &str = "herdr-agent-state.sh";
const CODEX_HOOK_ASSET: &str = include_str!("assets/codex/herdr-agent-state.sh");
const CODEX_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 = 1;
const INTEGRATION_VERSION_MARKER: &str = "HERDR_INTEGRATION_VERSION=";
#[derive(Debug)]
pub(crate) struct ClaudeInstallPaths {
@@ -35,6 +42,22 @@ pub(crate) struct OpenCodeInstallPaths {
pub plugin_path: PathBuf,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct IntegrationStatus {
pub target: crate::api::schema::IntegrationTarget,
pub path: PathBuf,
pub state: IntegrationStatusKind,
pub installed_version: Option<u32>,
pub expected_version: u32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum IntegrationStatusKind {
NotInstalled,
Current,
Outdated,
}
#[derive(Debug)]
pub(crate) struct PiUninstallResult {
pub extension_path: PathBuf,
@@ -213,7 +236,9 @@ pub(crate) fn uninstall_target(
Ok(messages)
}
fn integration_target_label(target: crate::api::schema::IntegrationTarget) -> &'static str {
pub(crate) fn integration_target_label(
target: crate::api::schema::IntegrationTarget,
) -> &'static str {
match target {
crate::api::schema::IntegrationTarget::Pi => "pi",
crate::api::schema::IntegrationTarget::Claude => "claude",
@@ -222,6 +247,136 @@ fn integration_target_label(target: crate::api::schema::IntegrationTarget) -> &'
}
}
pub(crate) fn installed_integration_statuses() -> Vec<IntegrationStatus> {
integration_specs()
.into_iter()
.filter_map(|(target, path, expected_version)| {
Some(integration_status_at(target, path.ok()?, expected_version))
})
.collect()
}
fn outdated_installed_integrations() -> Vec<IntegrationStatus> {
installed_integration_statuses()
.into_iter()
.filter(|status| status.state == IntegrationStatusKind::Outdated)
.collect()
}
fn integration_specs() -> [(
crate::api::schema::IntegrationTarget,
io::Result<PathBuf>,
u32,
); 4] {
[
(
crate::api::schema::IntegrationTarget::Pi,
pi_extension_dir().map(|dir| dir.join(PI_EXTENSION_INSTALL_NAME)),
PI_INTEGRATION_VERSION,
),
(
crate::api::schema::IntegrationTarget::Claude,
claude_dir().map(|dir| dir.join("hooks").join(CLAUDE_HOOK_INSTALL_NAME)),
CLAUDE_INTEGRATION_VERSION,
),
(
crate::api::schema::IntegrationTarget::Codex,
codex_dir().map(|dir| dir.join(CODEX_HOOK_INSTALL_NAME)),
CODEX_INTEGRATION_VERSION,
),
(
crate::api::schema::IntegrationTarget::Opencode,
opencode_dir().map(|dir| dir.join("plugins").join(OPENCODE_PLUGIN_INSTALL_NAME)),
OPENCODE_INTEGRATION_VERSION,
),
]
}
pub(crate) fn integration_update_instructions(
targets: &[crate::api::schema::IntegrationTarget],
) -> String {
let commands: Vec<String> = targets
.iter()
.map(|target| {
format!(
"`herdr integration install {}`",
integration_target_label(*target)
)
})
.collect();
match commands.as_slice() {
[] => String::new(),
[command] => format!("run {command}"),
[rest @ .., last] => format!("run {} and {last}", rest.join(", ")),
}
}
pub(crate) fn print_outdated_update_notice() -> bool {
let outdated = outdated_installed_integrations();
if outdated.is_empty() {
return false;
}
let targets = outdated
.iter()
.map(|integration| integration.target)
.collect::<Vec<_>>();
eprintln!(
"installed herdr integrations need updating; {}.",
integration_update_instructions(&targets).replace('`', "")
);
true
}
fn integration_status_at(
target: crate::api::schema::IntegrationTarget,
path: PathBuf,
expected_version: u32,
) -> IntegrationStatus {
if !path.is_file() {
return IntegrationStatus {
target,
path,
state: IntegrationStatusKind::NotInstalled,
installed_version: None,
expected_version,
};
}
let installed_version = fs::read_to_string(&path)
.ok()
.and_then(|content| parse_integration_version(&content));
let state = if installed_version.is_some_and(|version| version >= expected_version) {
IntegrationStatusKind::Current
} else {
IntegrationStatusKind::Outdated
};
IntegrationStatus {
target,
path,
state,
installed_version,
expected_version,
}
}
fn parse_integration_version(content: &str) -> Option<u32> {
content.lines().find_map(|line| {
let marker_line = line
.trim()
.trim_start_matches('/')
.trim_start_matches('#')
.trim();
marker_line
.strip_prefix(INTEGRATION_VERSION_MARKER)?
.trim()
.parse()
.ok()
})
}
pub(crate) fn install_pi() -> io::Result<PathBuf> {
let dir = pi_extension_dir()?;
if !dir.is_dir() {
@@ -815,10 +970,15 @@ fn home_dir() -> io::Result<PathBuf> {
.map_err(|_| io::Error::other("HOME is not set; cannot locate home directory"))
}
#[cfg(test)]
pub(crate) fn integration_env_lock() -> MutexGuard<'static, ()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(())).lock().unwrap()
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::{Mutex, MutexGuard, OnceLock};
fn unique_base() -> PathBuf {
std::env::temp_dir().join(format!(
@@ -831,14 +991,9 @@ mod tests {
))
}
fn env_lock() -> MutexGuard<'static, ()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(())).lock().unwrap()
}
#[test]
fn install_pi_writes_embedded_asset_to_pi_extensions_dir() {
let _lock = env_lock();
let _lock = integration_env_lock();
let base = unique_base();
let home = base.join("home");
let ext_dir = home.join(".pi/agent/extensions");
@@ -857,7 +1012,7 @@ mod tests {
#[test]
fn uninstall_pi_removes_embedded_extension_when_present() {
let _lock = env_lock();
let _lock = integration_env_lock();
let base = unique_base();
let home = base.join("home");
let ext_dir = home.join(".pi/agent/extensions");
@@ -878,9 +1033,51 @@ mod tests {
let _ = fs::remove_dir_all(base);
}
#[test]
fn outdated_integrations_treat_missing_version_marker_as_legacy() {
let _lock = integration_env_lock();
let base = unique_base();
let home = base.join("home");
let ext_dir = home.join(".pi/agent/extensions");
fs::create_dir_all(&ext_dir).unwrap();
let extension_path = ext_dir.join(PI_EXTENSION_INSTALL_NAME);
fs::write(&extension_path, "// installed by herdr\n").unwrap();
std::env::set_var("HOME", &home);
let outdated = outdated_installed_integrations();
assert_eq!(outdated.len(), 1);
assert_eq!(
outdated[0].target,
crate::api::schema::IntegrationTarget::Pi
);
assert_eq!(outdated[0].path, extension_path);
assert_eq!(outdated[0].installed_version, None);
assert_eq!(outdated[0].expected_version, PI_INTEGRATION_VERSION);
std::env::remove_var("HOME");
let _ = fs::remove_dir_all(base);
}
#[test]
fn outdated_integrations_accept_current_version_marker() {
let _lock = integration_env_lock();
let base = unique_base();
let home = base.join("home");
let ext_dir = home.join(".pi/agent/extensions");
fs::create_dir_all(&ext_dir).unwrap();
fs::write(ext_dir.join(PI_EXTENSION_INSTALL_NAME), PI_EXTENSION_ASSET).unwrap();
std::env::set_var("HOME", &home);
assert!(outdated_installed_integrations().is_empty());
std::env::remove_var("HOME");
let _ = fs::remove_dir_all(base);
}
#[test]
fn install_pi_errors_when_extension_dir_missing() {
let _lock = env_lock();
let _lock = integration_env_lock();
let base = unique_base();
let home = base.join("home");
fs::create_dir_all(&home).unwrap();
@@ -896,7 +1093,7 @@ mod tests {
#[test]
fn install_claude_writes_hook_and_updates_settings() {
let _lock = env_lock();
let _lock = integration_env_lock();
let base = unique_base();
let home = base.join("home");
let claude_dir = home.join(".claude");
@@ -965,7 +1162,7 @@ mod tests {
#[test]
fn install_claude_is_idempotent_for_hook_entries() {
let _lock = env_lock();
let _lock = integration_env_lock();
let base = unique_base();
let home = base.join("home");
let claude_dir = home.join(".claude");
@@ -1017,7 +1214,7 @@ mod tests {
#[test]
fn uninstall_claude_removes_herdr_hooks_and_preserves_others() {
let _lock = env_lock();
let _lock = integration_env_lock();
let base = unique_base();
let home = base.join("home");
let claude_dir = home.join(".claude");
@@ -1073,7 +1270,7 @@ mod tests {
#[test]
fn install_claude_errors_when_claude_dir_missing() {
let _lock = env_lock();
let _lock = integration_env_lock();
let base = unique_base();
let home = base.join("home");
fs::create_dir_all(&home).unwrap();
@@ -1089,7 +1286,7 @@ mod tests {
#[test]
fn install_codex_writes_hook_and_updates_hooks_and_config() {
let _lock = env_lock();
let _lock = integration_env_lock();
let base = unique_base();
let home = base.join("home");
let codex_dir = home.join(".codex");
@@ -1133,7 +1330,7 @@ mod tests {
#[test]
fn install_codex_is_idempotent_for_hook_entries_and_feature_flag() {
let _lock = env_lock();
let _lock = integration_env_lock();
let base = unique_base();
let home = base.join("home");
let codex_dir = home.join(".codex");
@@ -1169,7 +1366,7 @@ mod tests {
#[test]
fn uninstall_codex_removes_herdr_hooks_and_leaves_config_alone() {
let _lock = env_lock();
let _lock = integration_env_lock();
let base = unique_base();
let home = base.join("home");
let codex_dir = home.join(".codex");
@@ -1224,7 +1421,7 @@ mod tests {
#[test]
fn install_codex_errors_when_config_dir_missing() {
let _lock = env_lock();
let _lock = integration_env_lock();
let base = unique_base();
let home = base.join("home");
fs::create_dir_all(&home).unwrap();
@@ -1240,7 +1437,7 @@ mod tests {
#[test]
fn install_opencode_writes_plugin_to_plugins_dir() {
let _lock = env_lock();
let _lock = integration_env_lock();
let base = unique_base();
let home = base.join("home");
let opencode_dir = home.join(".config/opencode");
@@ -1264,7 +1461,7 @@ mod tests {
#[test]
fn uninstall_opencode_removes_plugin_when_present() {
let _lock = env_lock();
let _lock = integration_env_lock();
let base = unique_base();
let home = base.join("home");
let opencode_dir = home.join(".config/opencode/plugins");
@@ -1287,7 +1484,7 @@ mod tests {
#[test]
fn install_opencode_errors_when_config_dir_missing() {
let _lock = env_lock();
let _lock = integration_env_lock();
let base = unique_base();
let home = base.join("home");
fs::create_dir_all(&home).unwrap();
+176 -5
View File
@@ -1,3 +1,5 @@
use std::collections::HashMap;
use crate::detect::{Agent, AgentState};
const CLAUDE_WORKING_HOLD: std::time::Duration = std::time::Duration::from_millis(1200);
@@ -26,6 +28,7 @@ pub struct PaneState {
pub detected_agent: Option<Agent>,
pub fallback_state: AgentState,
pub hook_authority: Option<HookAuthority>,
hook_report_sequences: HashMap<String, u64>,
pub state: AgentState,
/// Whether the user has seen this pane since its last state change to Idle.
/// False = "Done" (agent finished while user was in another workspace).
@@ -38,6 +41,7 @@ impl PaneState {
detected_agent: None,
fallback_state: AgentState::Unknown,
hook_authority: None,
hook_report_sequences: HashMap::new(),
state: AgentState::Unknown,
seen: true,
}
@@ -71,7 +75,12 @@ impl PaneState {
agent_label: String,
state: AgentState,
message: Option<String>,
seq: Option<u64>,
) -> Option<EffectiveStateChange> {
if !self.accept_hook_report(&source, seq) {
return None;
}
let previous_agent_label = self.effective_agent_label().map(str::to_string);
let previous_known_agent = self.effective_known_agent();
let previous_state = self.state;
@@ -84,7 +93,39 @@ impl PaneState {
self.recompute_effective_state(previous_agent_label, previous_known_agent, previous_state)
}
pub fn clear_hook_authority(&mut self, source: Option<&str>) -> Option<EffectiveStateChange> {
fn accept_hook_report(&mut self, source: &str, seq: Option<u64>) -> bool {
let Some(seq) = seq else {
return !self.hook_report_sequences.contains_key(source);
};
if self
.hook_report_sequences
.get(source)
.is_some_and(|last_seq| seq <= *last_seq)
{
return false;
}
self.hook_report_sequences.insert(source.to_string(), seq);
true
}
pub fn clear_hook_authority(
&mut self,
source: Option<&str>,
seq: Option<u64>,
) -> Option<EffectiveStateChange> {
let sequence_source = source.map(str::to_string).or_else(|| {
self.hook_authority
.as_ref()
.map(|authority| authority.source.clone())
});
if let Some(source) = sequence_source.as_deref() {
if !self.accept_hook_report(source, seq) {
return None;
}
}
let previous_agent_label = self.effective_agent_label().map(str::to_string);
let previous_known_agent = self.effective_known_agent();
let previous_state = self.state;
@@ -103,7 +144,12 @@ impl PaneState {
&mut self,
source: &str,
agent_label: &str,
seq: Option<u64>,
) -> Option<EffectiveStateChange> {
if !self.accept_hook_report(source, seq) {
return None;
}
let current_agent_label = self.effective_agent_label()?;
if current_agent_label != agent_label {
return None;
@@ -260,7 +306,13 @@ mod tests {
fn hook_authority_overrides_fallback_for_same_agent() {
let mut pane = PaneState::new();
pane.set_detected_state(Some(Agent::Pi), AgentState::Idle);
pane.set_hook_authority("herdr:pi".into(), "pi".into(), AgentState::Working, None);
pane.set_hook_authority(
"herdr:pi".into(),
"pi".into(),
AgentState::Working,
None,
None,
);
assert_eq!(pane.detected_agent, Some(Agent::Pi));
assert_eq!(pane.fallback_state, AgentState::Idle);
@@ -277,6 +329,7 @@ mod tests {
"hermes".into(),
AgentState::Working,
None,
None,
);
assert_eq!(pane.detected_agent, Some(Agent::Pi));
@@ -294,6 +347,7 @@ mod tests {
"hermes".into(),
AgentState::Working,
None,
None,
);
pane.set_detected_state(None, AgentState::Unknown);
@@ -313,6 +367,7 @@ mod tests {
"opencode".into(),
AgentState::Idle,
None,
None,
);
pane.set_detected_state(None, AgentState::Unknown);
@@ -328,7 +383,13 @@ mod tests {
fn detected_agent_change_clears_previous_matching_hook_authority() {
let mut pane = PaneState::new();
pane.set_detected_state(Some(Agent::Codex), AgentState::Idle);
pane.set_hook_authority("herdr:codex".into(), "codex".into(), AgentState::Idle, None);
pane.set_hook_authority(
"herdr:codex".into(),
"codex".into(),
AgentState::Idle,
None,
None,
);
pane.set_detected_state(Some(Agent::OpenCode), AgentState::Working);
@@ -342,13 +403,123 @@ mod tests {
fn release_agent_clears_identity_immediately() {
let mut pane = PaneState::new();
pane.set_detected_state(Some(Agent::Pi), AgentState::Idle);
pane.set_hook_authority("herdr:pi".into(), "pi".into(), AgentState::Working, None);
pane.set_hook_authority(
"herdr:pi".into(),
"pi".into(),
AgentState::Working,
None,
None,
);
pane.release_agent("herdr:pi", "pi");
pane.release_agent("herdr:pi", "pi", None);
assert!(pane.hook_authority.is_none());
assert_eq!(pane.detected_agent, None);
assert_eq!(pane.fallback_state, AgentState::Unknown);
assert_eq!(pane.state, AgentState::Unknown);
}
#[test]
fn stale_hook_report_sequence_is_ignored_for_same_source() {
let mut pane = PaneState::new();
pane.set_hook_authority(
"herdr:pi".into(),
"pi".into(),
AgentState::Working,
None,
Some(20),
);
let change = pane.set_hook_authority(
"herdr:pi".into(),
"pi".into(),
AgentState::Idle,
None,
Some(19),
);
assert!(change.is_none());
assert_eq!(pane.state, AgentState::Working);
assert_eq!(
pane.hook_authority.as_ref().unwrap().state,
AgentState::Working
);
}
#[test]
fn unsequenced_hook_report_is_ignored_after_source_uses_sequence() {
let mut pane = PaneState::new();
pane.set_hook_authority(
"herdr:pi".into(),
"pi".into(),
AgentState::Working,
None,
Some(20),
);
let change =
pane.set_hook_authority("herdr:pi".into(), "pi".into(), AgentState::Idle, None, None);
assert!(change.is_none());
assert_eq!(pane.state, AgentState::Working);
}
#[test]
fn stale_release_sequence_is_ignored_for_same_source() {
let mut pane = PaneState::new();
pane.set_hook_authority(
"herdr:pi".into(),
"pi".into(),
AgentState::Working,
None,
Some(20),
);
let change = pane.release_agent("herdr:pi", "pi", Some(19));
assert!(change.is_none());
assert_eq!(pane.state, AgentState::Working);
assert!(pane.hook_authority.is_some());
}
#[test]
fn stale_clear_all_sequence_is_checked_against_current_authority_source() {
let mut pane = PaneState::new();
pane.set_hook_authority(
"herdr:pi".into(),
"pi".into(),
AgentState::Working,
None,
Some(20),
);
let change = pane.clear_hook_authority(None, Some(19));
assert!(change.is_none());
assert_eq!(pane.state, AgentState::Working);
assert!(pane.hook_authority.is_some());
}
#[test]
fn same_sequence_from_different_sources_is_independent() {
let mut pane = PaneState::new();
pane.set_hook_authority(
"herdr:pi".into(),
"pi".into(),
AgentState::Working,
None,
Some(20),
);
pane.set_hook_authority(
"custom:pi".into(),
"pi".into(),
AgentState::Idle,
None,
Some(19),
);
assert_eq!(pane.state, AgentState::Idle);
assert_eq!(pane.hook_authority.as_ref().unwrap().source, "custom:pi");
}
}
+189 -177
View File
@@ -689,6 +689,19 @@ impl HeadlessServer {
changed
}
fn pane_effective_state(&self, pane_id: crate::layout::PaneId) -> crate::detect::AgentState {
self.app
.state
.workspaces
.iter()
.find_map(|ws| {
ws.tabs
.iter()
.find_map(|tab| tab.panes.get(&pane_id).map(|pane| pane.state))
})
.unwrap_or(crate::detect::AgentState::Unknown)
}
/// Handles a single internal event with forwarding logic for clipboard,
/// sound, and toast notifications to connected clients.
///
@@ -710,31 +723,16 @@ impl HeadlessServer {
// ClipboardWrite doesn't change visual state — no render needed.
false
}
AppEvent::StateChanged {
pane_id,
agent,
state,
} => {
AppEvent::StateChanged { pane_id, agent, .. } => {
// Capture toast before handling.
let toast_before = self.app.state.toast.clone();
let pane_id_val = *pane_id;
let agent_val = *agent;
let state_val = *state;
// Find the previous state of this pane before the event
// is processed. We need this to determine if a sound
// notification would be triggered.
let prev_state = self
.app
.state
.workspaces
.iter()
.find_map(|ws| {
ws.tabs
.iter()
.find_map(|tab| tab.panes.get(&pane_id_val).map(|p| p.state))
})
.unwrap_or(crate::detect::AgentState::Unknown);
// Find the previous effective state of this pane before the event
// is processed. Notifications must follow effective state changes,
// not raw fallback reports that may be masked by hook authority.
let prev_state = self.pane_effective_state(pane_id_val);
// Handle the state change (updates pane state, sets toast on AppState).
// Headless mode disables local sound playback separately from the
@@ -756,11 +754,13 @@ impl HeadlessServer {
let suppress_active_tab_notifications =
self.active_tab_suppresses_notifications(is_active_tab);
let next_state = self.pane_effective_state(pane_id_val);
if self.app.state.sound.allows(agent_val) {
if let Some(sound) = crate::app::actions::notification_sound_for_state_change(
suppress_active_tab_notifications,
prev_state,
state_val,
next_state,
) {
let msg = match sound {
crate::sound::Sound::Done => "agent done",
@@ -787,7 +787,7 @@ impl HeadlessServer {
pane_id_val,
suppress_active_tab_notifications,
prev_state,
state_val,
next_state,
)
}
} else {
@@ -806,43 +806,24 @@ impl HeadlessServer {
AppEvent::HookStateReported {
pane_id,
agent_label,
state,
..
} => {
// The hook authority may not change the effective pane state if
// the detected agent doesn't match. We forward based on the
// hook-reported state transition regardless.
// Hook reports can be stale or no-op after sequence rejection.
// Forward only effective state changes observed after handling.
let toast_before = self.app.state.toast.clone();
let pane_id_val = *pane_id;
let agent_val = crate::detect::parse_agent_label(agent_label);
let hook_state_val = *state;
// Capture the previous hook authority state for this pane.
// If no hook authority exists, use the effective state.
let prev_hook_state = self
.app
.state
.workspaces
.iter()
.find_map(|ws| {
ws.tabs.iter().find_map(|tab| {
tab.panes.get(&pane_id_val).map(|p| {
p.hook_authority
.as_ref()
.map(|h| h.state)
.unwrap_or(p.state)
})
})
})
.unwrap_or(crate::detect::AgentState::Unknown);
// Capture the previous effective state for this pane. Hook reports
// are already folded into pane.state; raw hook transitions must not
// produce a second notification path.
let prev_state = self.pane_effective_state(pane_id_val);
self.sync_foreground_client_state();
self.app.handle_internal_event(ev);
// Forward sound notification based on hook state transition when
// server-side sound policy allows it. This ensures API-reported state
// changes (pane.report_agent) produce notifications even before
// fallback detection confirms.
// Forward sound notification based on the effective transition when
// server-side sound policy allows it.
let is_active_tab = self
.app
.state
@@ -856,11 +837,13 @@ impl HeadlessServer {
let suppress_active_tab_notifications =
self.active_tab_suppresses_notifications(is_active_tab);
let next_state = self.pane_effective_state(pane_id_val);
if self.app.state.sound.allows(agent_val) {
if let Some(sound) = crate::app::actions::notification_sound_for_state_change(
suppress_active_tab_notifications,
prev_hook_state,
hook_state_val,
prev_state,
next_state,
) {
let msg = match sound {
crate::sound::Sound::Done => "agent done",
@@ -886,8 +869,8 @@ impl HeadlessServer {
&self.app.state,
pane_id_val,
suppress_active_tab_notifications,
prev_hook_state,
hook_state_val,
prev_state,
next_state,
)
}
} else {
@@ -1228,42 +1211,26 @@ impl HeadlessServer {
let changed = api::request_changes_ui(&msg.request);
// Capture toast and pane agent states before the API call so we can
// forward any resulting notifications to connected clients.
// API requests like pane.report_agent trigger handle_internal_event
// internally, which bypasses drain_internal_events_with_forwarding.
// Headless mode disables local sound playback, so sound notifications
// need to be forwarded to clients here; toasts may be set but not forwarded.
//
// Note: pane.report_agent sets hook_authority on the pane, but the
// effective state may not change until the fallback detector confirms
// the agent (detected_agent must match). So we capture both the
// effective state AND the hook authority state for comparison.
// Capture toast and effective pane states before the API call so we can
// forward resulting client-local notifications. API requests like
// pane.report_agent trigger handle_internal_event internally, which
// bypasses drain_internal_events_with_forwarding. Headless mode disables
// local sound playback, so sound notifications need to be forwarded here.
let toast_before = self.app.state.toast.clone();
let pane_states_before: Vec<(
usize,
crate::layout::PaneId,
crate::detect::AgentState,
Option<crate::detect::AgentState>,
)> = self
.app
.state
.workspaces
.iter()
.enumerate()
.flat_map(|(ws_idx, ws)| {
ws.tabs.iter().flat_map(move |tab| {
tab.panes.iter().map(move |(&pane_id, pane)| {
(
ws_idx,
pane_id,
pane.state,
pane.hook_authority.as_ref().map(|h| h.state),
)
let pane_states_before: Vec<(usize, crate::layout::PaneId, crate::detect::AgentState)> =
self.app
.state
.workspaces
.iter()
.enumerate()
.flat_map(|(ws_idx, ws)| {
ws.tabs.iter().flat_map(move |tab| {
tab.panes
.iter()
.map(move |(&pane_id, pane)| (ws_idx, pane_id, pane.state))
})
})
})
.collect();
.collect();
self.sync_foreground_client_state();
let response = self.app.handle_api_request(msg.request);
@@ -1293,16 +1260,10 @@ impl HeadlessServer {
false
};
// Forward sound notifications for any pane state changes that occurred
// during the API request. Compare before/after pane states (including
// hook authority state) to find transitions that would trigger a sound.
//
// pane.report_agent sets hook_authority on the pane, but the effective
// state may not change until the fallback detector confirms the agent
// (detected_agent must match hook_authority.agent). We check BOTH the
// effective state AND the hook authority state so that API-reported
// state changes trigger notifications even before fallback confirmation.
for (ws_idx, pane_id, prev_effective_state, prev_hook_state) in &pane_states_before {
// Forward notifications for effective pane state changes that occurred
// during the API request. Hook authority is already folded into
// pane.state, so raw hook transitions must not produce separate sounds.
for (ws_idx, pane_id, prev_state) in &pane_states_before {
let pane_after = self
.app
.state
@@ -1314,97 +1275,77 @@ impl HeadlessServer {
continue;
};
let new_effective_state = pane_after.state;
let new_hook_state = pane_after.hook_authority.as_ref().map(|h| h.state);
let new_state = pane_after.state;
if new_state == *prev_state {
continue;
}
// Check effective state change first.
let effective_changed = new_effective_state != *prev_effective_state;
// Check hook authority state change — this catches API-reported
// state changes that haven't been confirmed by fallback detection.
let hook_changed = new_hook_state != *prev_hook_state;
let is_active_tab = self.app.state.pane_is_in_active_tab(*ws_idx, *pane_id);
let suppress_active_tab_notifications =
self.active_tab_suppresses_notifications(is_active_tab);
if effective_changed || hook_changed {
// Use the hook state if available (it reflects the API-reported
// state), otherwise use the effective state.
let prev_state = prev_hook_state.unwrap_or(*prev_effective_state);
let new_state = new_hook_state.unwrap_or(new_effective_state);
let agent = pane_after.effective_known_agent();
// Skip if the derived transition is a no-op.
if prev_state == new_state {
continue;
}
debug!(
ws_idx,
pane_id = pane_id.raw(),
prev_state = ?prev_state,
new_state = ?new_state,
agent = ?agent,
"pane effective state changed during API request, checking notification"
);
let is_active_tab = self.app.state.pane_is_in_active_tab(*ws_idx, *pane_id);
let suppress_active_tab_notifications =
self.active_tab_suppresses_notifications(is_active_tab);
// Get the known agent for sound settings. Unknown custom labels
// fall back to None so clients use the generic sound behavior.
let agent = pane_after.effective_known_agent();
debug!(
ws_idx,
pane_id = pane_id.raw(),
prev_state = ?prev_state,
new_state = ?new_state,
agent = ?agent,
effective_changed,
hook_changed,
"pane state changed during API request, checking sound notification"
);
if !forwarded_toast_from_state
&& should_forward_toast_to_clients(self.app.state.toast_config.delivery)
{
if let Some(kind) = crate::app::actions::notification_toast_for_state_change(
suppress_active_tab_notifications,
prev_state,
new_state,
) {
if let Some(agent_label) = pane_after.effective_agent_label() {
let event_text = match kind {
crate::app::state::ToastKind::NeedsAttention => "needs attention",
crate::app::state::ToastKind::Finished => "finished",
crate::app::state::ToastKind::UpdateInstalled => "updated",
};
let msg_text = format!(
"{} {}: {}",
agent_label,
event_text,
crate::app::actions::notification_context(
&self.app.state.workspaces[*ws_idx],
*ws_idx,
*pane_id,
)
);
self.send_to_foreground_client(ServerMessage::Notify {
kind: protocol::NotifyKind::Toast,
message: msg_text,
});
}
}
}
// Forward sound notification when server-side sound policy allows it.
// Clients still decide locally whether they can execute the side effect.
if self.app.state.sound.allows(agent) {
if let Some(sound) = crate::app::actions::notification_sound_for_state_change(
suppress_active_tab_notifications,
prev_state,
new_state,
) {
let msg_text = match sound {
crate::sound::Sound::Done => "agent done",
crate::sound::Sound::Request => "agent attention",
if !forwarded_toast_from_state
&& should_forward_toast_to_clients(self.app.state.toast_config.delivery)
{
if let Some(kind) = crate::app::actions::notification_toast_for_state_change(
suppress_active_tab_notifications,
*prev_state,
new_state,
) {
if let Some(agent_label) = pane_after.effective_agent_label() {
let event_text = match kind {
crate::app::state::ToastKind::NeedsAttention => "needs attention",
crate::app::state::ToastKind::Finished => "finished",
crate::app::state::ToastKind::UpdateInstalled => "updated",
};
debug!(sound = ?sound, "forwarding sound notification from API request");
let msg_text = format!(
"{} {}: {}",
agent_label,
event_text,
crate::app::actions::notification_context(
&self.app.state.workspaces[*ws_idx],
*ws_idx,
*pane_id,
)
);
self.send_to_foreground_client(ServerMessage::Notify {
kind: protocol::NotifyKind::Sound,
message: msg_text.to_owned(),
kind: protocol::NotifyKind::Toast,
message: msg_text,
});
}
}
}
// Forward sound notification when server-side sound policy allows it.
// Clients still decide locally whether they can execute the side effect.
if self.app.state.sound.allows(agent) {
if let Some(sound) = crate::app::actions::notification_sound_for_state_change(
suppress_active_tab_notifications,
*prev_state,
new_state,
) {
let msg_text = match sound {
crate::sound::Sound::Done => "agent done",
crate::sound::Sound::Request => "agent attention",
};
debug!(sound = ?sound, "forwarding sound notification from API request");
self.send_to_foreground_client(ServerMessage::Notify {
kind: protocol::NotifyKind::Sound,
message: msg_text.to_owned(),
});
}
}
}
changed
@@ -2646,6 +2587,77 @@ mod tests {
);
}
#[test]
fn stale_api_agent_report_does_not_forward_done_sound() {
let mut server = test_headless_server();
let mut background = crate::workspace::Workspace::test_new("background");
let pane_id = background.tabs[0].root_pane;
let public_pane_id = format!("{}-1", background.id);
background.tabs[0]
.panes
.get_mut(&pane_id)
.unwrap()
.set_hook_authority(
"herdr:pi".into(),
"pi".into(),
crate::detect::AgentState::Working,
None,
Some(20),
);
let foreground = crate::workspace::Workspace::test_new("foreground");
server.app.state.workspaces = vec![background, foreground];
server.app.state.active = Some(1);
server.app.state.selected = 1;
server.app.state.mode = crate::app::Mode::Terminal;
let (client_tx, client_control_rx, _client_rx) = test_client_writer();
server.clients.insert(
1,
ClientConnection::new(
(80, 24),
crate::terminal_theme::TerminalTheme::default(),
None,
1,
RenderEncoding::SemanticFrame,
Some(client_tx),
),
);
server.foreground_client_id = Some(1);
server.sync_foreground_client_state();
let (respond_to, response_rx) = std::sync::mpsc::channel();
let changed = server.handle_api_request_with_shutdown_check(api::ApiRequestMessage {
request: api::schema::Request {
id: "stale".into(),
method: api::schema::Method::PaneReportAgent(api::schema::PaneReportAgentParams {
pane_id: public_pane_id,
source: "herdr:pi".into(),
agent: "pi".into(),
state: api::schema::PaneAgentState::Idle,
message: None,
seq: Some(19),
}),
},
respond_to,
});
assert!(changed);
assert!(response_rx.recv_timeout(Duration::from_millis(100)).is_ok());
assert_eq!(
server.app.state.workspaces[0]
.pane_state(pane_id)
.unwrap()
.state,
crate::detect::AgentState::Working
);
assert!(
client_control_rx
.recv_timeout(Duration::from_millis(50))
.is_err(),
"stale idle report must not forward a done sound"
);
}
/// Verify that no direct calls to `self.app.handle_internal_event`
/// exist outside of `handle_internal_event_with_forwarding` in this
/// module. This ensures the forwarding bypass cannot be reintroduced.
+12
View File
@@ -563,9 +563,11 @@ pub fn self_update() -> Result<Version, String> {
tracing::warn!("failed to save pending release notes: {e}");
}
let downloaded_update = download_update(&release)?;
let updated_exe = downloaded_update.current_exe.clone();
let stopped_server = stop_running_server_for_update(running_server_plan.as_ref(), &release)?;
install_downloaded_update(downloaded_update)?;
eprintln!("updated to v{}", release.version);
print_outdated_integration_notice_with_updated_binary(&updated_exe);
if stopped_server {
eprintln!("run herdr again to start the updated server.");
@@ -578,6 +580,16 @@ pub fn self_update() -> Result<Version, String> {
Ok(release.version)
}
fn print_outdated_integration_notice_with_updated_binary(updated_exe: &Path) {
let status = Command::new(updated_exe)
.args(["integration", "status", "--outdated-only"])
.status();
if !status.is_ok_and(|status| status.success()) {
crate::integration::print_outdated_update_notice();
}
}
/// Background update check: only surface availability and release notes.
/// Runs in a background thread at startup.
pub fn auto_update(events: tokio::sync::mpsc::Sender<crate::events::AppEvent>) {
+1
View File
@@ -183,6 +183,7 @@ mod tests {
"hermes".into(),
AgentState::Working,
None,
None,
);
let details = ws.pane_details();
+67
View File
@@ -454,6 +454,7 @@ fn claude_hook_reports_subagent_working_and_blocked() {
run_claude_hook("working", subagent_input).expect("subagent working should report working");
assert_eq!(working["method"], "pane.report_agent");
assert_eq!(working["params"]["state"], "working");
assert!(working["params"]["seq"].as_u64().is_some());
let blocked =
run_claude_hook("blocked", subagent_input).expect("subagent blocked should report blocked");
@@ -833,6 +834,17 @@ fn integration_commands_run_locally_when_server_is_missing() {
"integration install should write local files without a server"
);
let integration_status = Command::new(env!("CARGO_BIN_EXE_herdr"))
.args(["integration", "status"])
.env("HERDR_SOCKET_PATH", &missing_socket)
.env("HOME", &home_dir)
.output()
.unwrap();
assert_eq!(integration_status.status.code(), Some(0));
let status_stdout = String::from_utf8_lossy(&integration_status.stdout);
assert!(status_stdout.contains("pi: current (v1)"));
assert!(status_stdout.contains("claude: not installed"));
let integration_uninstall = Command::new(env!("CARGO_BIN_EXE_herdr"))
.args(["integration", "uninstall", "pi"])
.env("HERDR_SOCKET_PATH", &missing_socket)
@@ -848,6 +860,61 @@ fn integration_commands_run_locally_when_server_is_missing() {
cleanup_test_base(&base);
}
#[test]
fn integration_status_outdated_only_prints_action_for_legacy_install() {
let base = unique_test_dir();
let home_dir = base.join("home");
let extensions_dir = home_dir.join(".pi/agent/extensions");
fs::create_dir_all(&extensions_dir).unwrap();
fs::write(
extensions_dir.join("herdr-agent-state.ts"),
"// legacy herdr integration\n",
)
.unwrap();
let runtime_dir = base.join("runtime");
fs::create_dir_all(&runtime_dir).unwrap();
register_runtime_dir(&runtime_dir);
let missing_socket = runtime_dir.join("missing.sock");
let output = Command::new(env!("CARGO_BIN_EXE_herdr"))
.args(["integration", "status", "--outdated-only"])
.env("HERDR_SOCKET_PATH", &missing_socket)
.env("HOME", &home_dir)
.output()
.unwrap();
assert_eq!(output.status.code(), Some(0));
assert!(output.stdout.is_empty());
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(stderr.contains("installed herdr integrations need updating"));
assert!(stderr.contains("herdr integration install pi"));
cleanup_test_base(&base);
}
#[test]
fn integration_status_rejects_unknown_flags() {
let base = unique_test_dir();
let home_dir = base.join("home");
fs::create_dir_all(&home_dir).unwrap();
let runtime_dir = base.join("runtime");
fs::create_dir_all(&runtime_dir).unwrap();
register_runtime_dir(&runtime_dir);
let missing_socket = runtime_dir.join("missing.sock");
let output = Command::new(env!("CARGO_BIN_EXE_herdr"))
.args(["integration", "status", "--wat"])
.env("HERDR_SOCKET_PATH", &missing_socket)
.env("HOME", &home_dir)
.output()
.unwrap();
assert_eq!(output.status.code(), Some(2));
cleanup_test_base(&base);
}
#[test]
fn status_commands_report_client_and_server_versions() {
let base = unique_test_dir();