aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/api/schema.rs9
-rw-r--r--src/app/actions.rs19
-rw-r--r--src/app/api.rs3
-rw-r--r--src/cli.rs38
-rw-r--r--src/events.rs3
-rw-r--r--src/integration/assets/claude/herdr-agent-state.sh5
-rw-r--r--src/integration/assets/codex/herdr-agent-state.sh5
-rw-r--r--src/integration/assets/opencode/herdr-agent-state.js13
-rw-r--r--src/integration/assets/pi/herdr-agent-state.ts172
-rw-r--r--src/integration/mod.rs239
-rw-r--r--src/pane/state.rs181
-rw-r--r--src/server/headless.rs366
-rw-r--r--src/update.rs12
-rw-r--r--src/workspace/aggregate.rs1
14 files changed, 853 insertions, 213 deletions
diff --git a/src/api/schema.rs b/src/api/schema.rs
index e7534465..fb7177f7 100644
--- a/src/api/schema.rs
+++ b/src/api/schema.rs
@@ -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),
}),
};
diff --git a/src/app/actions.rs b/src/app/actions.rs
index b8777cdc..317bbff7 100644
--- a/src/app/actions.rs
+++ b/src/app/actions.rs
@@ -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();
diff --git a/src/app/api.rs b/src/app/api.rs
index b23787d2..c675aa6f 100644
--- a/src/app/api.rs
+++ b/src/app/api.rs
@@ -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,
diff --git a/src/cli.rs b/src/cli.rs
index a5ba8b1a..ce50b9b5 100644
--- a/src/cli.rs
+++ b/src/cli.rs
@@ -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() {
diff --git a/src/events.rs b/src/events.rs
index 74045d4c..8d1ff47a 100644
--- a/src/events.rs
+++ b/src/events.rs
@@ -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 },
diff --git a/src/integration/assets/claude/herdr-agent-state.sh b/src/integration/assets/claude/herdr-agent-state.sh
index 527fe9f8..18b042e8 100644
--- a/src/integration/assets/claude/herdr-agent-state.sh
+++ b/src/integration/assets/claude/herdr-agent-state.sh
@@ -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,
},
}
diff --git a/src/integration/assets/codex/herdr-agent-state.sh b/src/integration/assets/codex/herdr-agent-state.sh
index 01510d6d..c6ee287c 100644
--- a/src/integration/assets/codex/herdr-agent-state.sh
+++ b/src/integration/assets/codex/herdr-agent-state.sh
@@ -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,
},
}
diff --git a/src/integration/assets/opencode/herdr-agent-state.js b/src/integration/assets/opencode/herdr-agent-state.js
index 6d5e3c87..0fb96217 100644
--- a/src/integration/assets/opencode/herdr-agent-state.js
+++ b/src/integration/assets/opencode/herdr-agent-state.js
@@ -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(),
},
};
diff --git a/src/integration/assets/pi/herdr-agent-state.ts b/src/integration/assets/pi/herdr-agent-state.ts
index f51f05d1..26869f1c 100644
--- a/src/integration/assets/pi/herdr-agent-state.ts
+++ b/src/integration/assets/pi/herdr-agent-state.ts
@@ -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();
});
}
diff --git a/src/integration/mod.rs b/src/integration/mod.rs
index 402e29a7..1e5c2d68 100644
--- a/src/integration/mod.rs
+++ b/src/integration/mod.rs
@@ -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() {
@@ -816,9 +971,14 @@ fn home_dir() -> io::Result<PathBuf> {
}
#[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");
@@ -879,8 +1034,50 @@ mod tests {
}
#[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();
diff --git a/src/pane/state.rs b/src/pane/state.rs
index 590f2025..8b36826b 100644
--- a/src/pane/state.rs
+++ b/src/pane/state.rs
@@ -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");
+ }
}
diff --git a/src/server/headless.rs b/src/server/headless.rs
index 7bd8638a..bd1bf55e 100644
--- a/src/server/headless.rs
+++ b/src/server/headless.rs
@@ -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);
-
- // 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 new_state = pane_after.state;
+ if new_state == *prev_state {
+ continue;
+ }
- 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 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);
- // Skip if the derived transition is a no-op.
- if prev_state == new_state {
- continue;
- }
+ let agent = pane_after.effective_known_agent();
- 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,
- });
- }
- }
- }
+ 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"
+ );
- // 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.
diff --git a/src/update.rs b/src/update.rs
index c4c5b162..68528795 100644
--- a/src/update.rs
+++ b/src/update.rs
@@ -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>) {
diff --git a/src/workspace/aggregate.rs b/src/workspace/aggregate.rs
index 53e08920..bd476667 100644
--- a/src/workspace/aggregate.rs
+++ b/src/workspace/aggregate.rs
@@ -183,6 +183,7 @@ mod tests {
"hermes".into(),
AgentState::Working,
None,
+ None,
);
let details = ws.pane_details();