mirror of
https://github.com/herdrdev/herdr.git
synced 2026-09-22 00:01:06 +00:00
fix: stabilize integration agent state reports
This commit is contained in:
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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>) {
|
||||
|
||||
@@ -183,6 +183,7 @@ mod tests {
|
||||
"hermes".into(),
|
||||
AgentState::Working,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
let details = ws.pane_details();
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user