mirror of
https://github.com/herdrdev/herdr.git
synced 2026-09-21 16:01:04 +00:00
@@ -8,6 +8,7 @@
|
||||
### Fixed
|
||||
- Pane applications that query OSC 4 palette colors now inherit the host terminal palette. (#1752)
|
||||
- Ctrl-clicking a pane URL no longer forwards an unmatched mouse release to alternate-screen applications, preventing duplicate browser tabs. (#1761)
|
||||
- Known-agent integrations now leave pane ownership to confirmed process exit, so restarting Pi with the same saved session restores lifecycle state even with custom working UI. (#1792)
|
||||
- OMP integration install, status, and uninstall now respect `PI_CONFIG_DIR` when `PI_CODING_AGENT_DIR` is not set, and installation refuses extension-directory collisions with Pi. (#1696)
|
||||
- Physical Escape key records on native Windows now bypass raw VT report framing, so pane applications receive Escape immediately and reliably. (#1736)
|
||||
|
||||
|
||||
+1
-1
@@ -203,7 +203,7 @@ pub fn dedupe_key(source: &str, agent: &str, session_ref: &AgentSessionRef) -> S
|
||||
)
|
||||
}
|
||||
|
||||
fn is_official_agent_source(source: &str, agent: &str) -> bool {
|
||||
pub(crate) fn is_official_agent_source(source: &str, agent: &str) -> bool {
|
||||
matches!(
|
||||
(source, agent),
|
||||
("herdr:claude", "claude")
|
||||
|
||||
+77
-27
@@ -2793,7 +2793,7 @@ impl AppState {
|
||||
seq,
|
||||
..
|
||||
} => {
|
||||
if crate::agent_resume::is_reserved_native_state_source(&source, &agent_label) {
|
||||
if crate::agent_resume::is_official_agent_source(&source, &agent_label) {
|
||||
Vec::new()
|
||||
} else {
|
||||
self.update_terminal_state(pane_id, |terminal| {
|
||||
@@ -2890,8 +2890,16 @@ impl AppState {
|
||||
previous_state: change.previous_state,
|
||||
previous_seen,
|
||||
previous_presentation: change.previous_presentation.clone(),
|
||||
agent_label: change.agent_label.clone(),
|
||||
known_agent: change.known_agent,
|
||||
agent_label: if agent_released {
|
||||
change.previous_agent_label.clone()
|
||||
} else {
|
||||
change.agent_label.clone()
|
||||
},
|
||||
known_agent: if agent_released {
|
||||
change.previous_known_agent
|
||||
} else {
|
||||
change.known_agent
|
||||
},
|
||||
state: change.state,
|
||||
seen,
|
||||
presentation: change.presentation.clone(),
|
||||
@@ -3011,7 +3019,11 @@ impl AppState {
|
||||
return None;
|
||||
}
|
||||
|
||||
let agent_label = change.agent_label.clone()?;
|
||||
let agent_label = change
|
||||
.agent_label
|
||||
.clone()
|
||||
.or_else(|| change.previous_agent_label.clone())?;
|
||||
let known_agent = change.known_agent.or(change.previous_known_agent);
|
||||
let kind = client_notification_kind.unwrap_or(match sound {
|
||||
Some(crate::sound::Sound::Request) => ToastKind::NeedsAttention,
|
||||
Some(crate::sound::Sound::Done) | None => ToastKind::Finished,
|
||||
@@ -3024,7 +3036,7 @@ impl AppState {
|
||||
pane_id,
|
||||
workspace_id,
|
||||
agent_label,
|
||||
change.known_agent,
|
||||
known_agent,
|
||||
kind,
|
||||
change.state,
|
||||
);
|
||||
@@ -3036,7 +3048,7 @@ impl AppState {
|
||||
pane_id,
|
||||
workspace_id,
|
||||
agent_label,
|
||||
known_agent: change.known_agent,
|
||||
known_agent,
|
||||
kind,
|
||||
state: change.state,
|
||||
deadline: {
|
||||
@@ -3071,7 +3083,10 @@ impl AppState {
|
||||
if terminal_state.state != expected_state {
|
||||
return None;
|
||||
}
|
||||
if terminal_state.effective_agent_label() != Some(agent_label.as_str()) {
|
||||
if terminal_state
|
||||
.effective_agent_label()
|
||||
.is_some_and(|current| current != agent_label)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
@@ -4978,7 +4993,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reserved_native_release_report_does_not_clear_screen_state() {
|
||||
fn official_release_preserves_process_owned_agent_identity() {
|
||||
let mut state = app_with_workspaces(&["active"]);
|
||||
let pane_id = *state.workspaces[0].panes.keys().next().unwrap();
|
||||
let terminal_id = state.workspaces[0]
|
||||
@@ -4990,24 +5005,51 @@ mod tests {
|
||||
|
||||
state.handle_app_event(AppEvent::StateChanged {
|
||||
pane_id,
|
||||
agent: Some(Agent::Claude),
|
||||
agent: Some(Agent::Pi),
|
||||
state: AgentState::Working,
|
||||
visible_blocker: false,
|
||||
visible_working: true,
|
||||
process_exited: false,
|
||||
observed_at: std::time::Instant::now(),
|
||||
});
|
||||
state.handle_app_event(AppEvent::HookAgentReleased {
|
||||
let terminal = state.terminals.get_mut(&terminal_id).unwrap();
|
||||
terminal.set_persisted_agent_session(crate::agent_resume::PersistedAgentSession {
|
||||
source: "herdr:pi".into(),
|
||||
agent: "pi".into(),
|
||||
session_ref: crate::agent_resume::AgentSessionRef::path(
|
||||
std::env::current_dir()
|
||||
.unwrap()
|
||||
.join("release-session.jsonl")
|
||||
.display()
|
||||
.to_string(),
|
||||
)
|
||||
.unwrap(),
|
||||
});
|
||||
terminal.set_hook_authority(
|
||||
"herdr:pi".into(),
|
||||
"pi".into(),
|
||||
AgentState::Working,
|
||||
None,
|
||||
Some(1),
|
||||
);
|
||||
terminal.set_agent_name("reviewer".into());
|
||||
state.session_dirty = false;
|
||||
|
||||
let updates = state.handle_app_event(AppEvent::HookAgentReleased {
|
||||
pane_id,
|
||||
source: "herdr:claude".into(),
|
||||
agent_label: "claude".into(),
|
||||
known_agent: Some(Agent::Claude),
|
||||
seq: Some(1),
|
||||
source: "herdr:pi".into(),
|
||||
agent_label: "pi".into(),
|
||||
known_agent: Some(Agent::Pi),
|
||||
seq: Some(2),
|
||||
});
|
||||
|
||||
let terminal = state.terminals.get(&terminal_id).unwrap();
|
||||
assert!(updates.is_empty());
|
||||
let terminal = &state.terminals[&terminal_id];
|
||||
assert_eq!(terminal.state, AgentState::Working);
|
||||
assert_eq!(terminal.detected_agent, Some(Agent::Claude));
|
||||
assert_eq!(terminal.detected_agent, Some(Agent::Pi));
|
||||
assert_eq!(terminal.agent_name.as_deref(), Some("reviewer"));
|
||||
assert!(terminal.full_lifecycle_hook_authority_active());
|
||||
assert!(!state.session_dirty);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -5081,7 +5123,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn releasing_an_agent_alias_marks_the_session_dirty() {
|
||||
fn custom_release_clears_report_owned_agent() {
|
||||
let mut state = app_with_workspaces(&["active"]);
|
||||
let pane_id = *state.workspaces[0].panes.keys().next().unwrap();
|
||||
let terminal_id = state.workspaces[0]
|
||||
@@ -5089,21 +5131,29 @@ mod tests {
|
||||
.unwrap()
|
||||
.attached_terminal_id
|
||||
.clone();
|
||||
let terminal = state.terminals.get_mut(&terminal_id).unwrap();
|
||||
terminal.set_detected_state(Some(Agent::Pi), AgentState::Working);
|
||||
terminal.set_agent_name("reviewer".into());
|
||||
state.session_dirty = false;
|
||||
state
|
||||
.terminals
|
||||
.get_mut(&terminal_id)
|
||||
.unwrap()
|
||||
.set_hook_authority(
|
||||
"custom:agent".into(),
|
||||
"custom-agent".into(),
|
||||
AgentState::Working,
|
||||
None,
|
||||
Some(1),
|
||||
);
|
||||
|
||||
state.handle_app_event(AppEvent::HookAgentReleased {
|
||||
pane_id,
|
||||
source: "herdr:pi".into(),
|
||||
agent_label: "pi".into(),
|
||||
known_agent: Some(Agent::Pi),
|
||||
seq: Some(1),
|
||||
source: "custom:agent".into(),
|
||||
agent_label: "custom-agent".into(),
|
||||
known_agent: None,
|
||||
seq: Some(2),
|
||||
});
|
||||
|
||||
assert!(state.terminals[&terminal_id].agent_name.is_none());
|
||||
assert!(state.session_dirty);
|
||||
let terminal = &state.terminals[&terminal_id];
|
||||
assert!(terminal.hook_authority.is_none());
|
||||
assert_eq!(terminal.state, AgentState::Unknown);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+6
-16
@@ -1529,7 +1529,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn agent_explain_reports_hook_only_full_lifecycle_authority() {
|
||||
async fn agent_explain_rejects_hook_only_full_lifecycle_authority() {
|
||||
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let mut app = App::new(
|
||||
&crate::config::Config::default(),
|
||||
@@ -1567,17 +1567,7 @@ mod tests {
|
||||
});
|
||||
let response: serde_json::Value = serde_json::from_str(&response).unwrap();
|
||||
|
||||
assert_eq!(response["result"]["type"], "agent_explain");
|
||||
assert_eq!(response["result"]["explain"]["agent"], "omp");
|
||||
assert_eq!(response["result"]["explain"]["state"], "working");
|
||||
assert_eq!(
|
||||
response["result"]["explain"]["screen_detection_skip_reason"],
|
||||
"full_lifecycle_hook_authority"
|
||||
);
|
||||
assert_eq!(
|
||||
response["result"]["explain"]["matched_rule"],
|
||||
serde_json::Value::Null
|
||||
);
|
||||
assert_eq!(response["error"]["code"], "agent_not_found");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1941,7 +1931,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_detector_exit_does_not_release_a_newer_hook_owned_agent() {
|
||||
fn process_exit_releases_a_newer_hook_owned_agent() {
|
||||
let event_hub = crate::api::EventHub::default();
|
||||
let (_api_tx, api_rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let mut app = App::new(
|
||||
@@ -1983,9 +1973,9 @@ mod tests {
|
||||
});
|
||||
|
||||
let terminal = &app.state.terminals[&terminal_id];
|
||||
assert_eq!(terminal.state, AgentState::Working);
|
||||
assert_eq!(terminal.agent_name.as_deref(), Some("reviewer"));
|
||||
assert!(!event_hub.events_after(0).iter().any(|(_, event)| matches!(
|
||||
assert_eq!(terminal.state, AgentState::Idle);
|
||||
assert!(terminal.agent_name.is_none());
|
||||
assert!(event_hub.events_after(0).iter().any(|(_, event)| matches!(
|
||||
event.data,
|
||||
crate::api::schema::EventData::PaneAgentDetected { released: true, .. }
|
||||
)));
|
||||
|
||||
+141
-1
@@ -1357,9 +1357,21 @@ impl App {
|
||||
let Some(terminal) = self.state.terminals.get_mut(&terminal_id) else {
|
||||
return pane_not_found(id, ¶ms.pane_id);
|
||||
};
|
||||
if terminal.metadata_report_blocked_by_process_exit(
|
||||
&source,
|
||||
agent_label.as_deref(),
|
||||
applies_to_source.as_deref(),
|
||||
) {
|
||||
return encode_success(id, ResponseResult::Ok {});
|
||||
}
|
||||
if !terminal.metadata_report_sequence_is_fresh(&source, params.seq) {
|
||||
return encode_success(id, ResponseResult::Ok {});
|
||||
}
|
||||
let metadata_agent = crate::terminal::TerminalState::metadata_report_agent(
|
||||
&source,
|
||||
agent_label.as_deref(),
|
||||
applies_to_source.as_deref(),
|
||||
);
|
||||
if let Some(tokens) = tokens.as_ref() {
|
||||
if terminal.metadata_tokens.key_count_after_patch(tokens)
|
||||
> MAX_METADATA_TOKEN_KEYS_PER_RESOURCE
|
||||
@@ -1373,7 +1385,8 @@ impl App {
|
||||
);
|
||||
}
|
||||
}
|
||||
match terminal.accept_metadata_report(&source, params.seq, tokens.is_some()) {
|
||||
match terminal.accept_metadata_report(&source, params.seq, tokens.is_some(), metadata_agent)
|
||||
{
|
||||
Ok(true) => {}
|
||||
Ok(false) => return encode_success(id, ResponseResult::Ok {}),
|
||||
Err(()) => {
|
||||
@@ -3818,6 +3831,133 @@ mod tests {
|
||||
.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pane_metadata_ignored_after_process_exit_does_not_poison_sequence() {
|
||||
let (mut app, pane_id) = app_with_test_workspace();
|
||||
let (_, internal_pane_id) = app.parse_pane_id(&pane_id).unwrap();
|
||||
let terminal_id = app.state.workspaces[0]
|
||||
.pane_state(internal_pane_id)
|
||||
.unwrap()
|
||||
.attached_terminal_id
|
||||
.clone();
|
||||
app.state
|
||||
.terminals
|
||||
.get_mut(&terminal_id)
|
||||
.unwrap()
|
||||
.set_detected_state(Some(Agent::Pi), AgentState::Idle);
|
||||
|
||||
let mut initial = metadata_params(pane_id.clone());
|
||||
initial.source = "custom:pi-metadata".into();
|
||||
initial.agent = Some("pi".into());
|
||||
initial.seq = Some(100);
|
||||
let response = app.handle_pane_report_metadata("initial".into(), initial);
|
||||
let _: SuccessResponse = serde_json::from_str(&response).unwrap();
|
||||
|
||||
let mut initial_tokens = metadata_params(pane_id.clone());
|
||||
initial_tokens.source = "custom:pi-tokens".into();
|
||||
initial_tokens.agent = Some("pi".into());
|
||||
initial_tokens.title = None;
|
||||
initial_tokens.tokens =
|
||||
std::collections::HashMap::from([("generation".into(), Some("old".into()))]);
|
||||
initial_tokens.seq = Some(100);
|
||||
let response = app.handle_pane_report_metadata("initial-tokens".into(), initial_tokens);
|
||||
let _: SuccessResponse = serde_json::from_str(&response).unwrap();
|
||||
|
||||
let exit_at = std::time::Instant::now() + std::time::Duration::from_millis(1);
|
||||
app.state
|
||||
.terminals
|
||||
.get_mut(&terminal_id)
|
||||
.unwrap()
|
||||
.set_detected_state_with_screen_signals_at(
|
||||
Some(Agent::Pi),
|
||||
AgentState::Idle,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
exit_at,
|
||||
);
|
||||
app.state
|
||||
.terminals
|
||||
.get_mut(&terminal_id)
|
||||
.unwrap()
|
||||
.set_detected_state_with_screen_signals_at(
|
||||
None,
|
||||
AgentState::Unknown,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
exit_at + std::time::Duration::from_millis(1),
|
||||
);
|
||||
|
||||
let mut stale = metadata_params(pane_id.clone());
|
||||
stale.source = "custom:pi-metadata".into();
|
||||
stale.agent = Some("pi".into());
|
||||
stale.title = Some("stale".into());
|
||||
stale.seq = Some(200);
|
||||
let response = app.handle_pane_report_metadata("stale".into(), stale);
|
||||
let _: SuccessResponse = serde_json::from_str(&response).unwrap();
|
||||
|
||||
let mut official = metadata_params(pane_id.clone());
|
||||
official.source = "herdr:pi".into();
|
||||
official.seq = Some(200);
|
||||
let response = app.handle_pane_report_metadata("official".into(), official);
|
||||
let _: SuccessResponse = serde_json::from_str(&response).unwrap();
|
||||
|
||||
let terminal = &app.state.terminals[&terminal_id];
|
||||
assert!(terminal.metadata_report_sequence_is_fresh("custom:pi-metadata", Some(1)));
|
||||
assert!(terminal.metadata_report_sequence_is_fresh("custom:pi-tokens", Some(1)));
|
||||
assert!(terminal.metadata_report_sequence_is_fresh("herdr:pi", Some(1)));
|
||||
|
||||
app.state
|
||||
.terminals
|
||||
.get_mut(&terminal_id)
|
||||
.unwrap()
|
||||
.set_detected_state_with_screen_signals_at(
|
||||
Some(Agent::Pi),
|
||||
AgentState::Idle,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
exit_at + std::time::Duration::from_millis(2),
|
||||
);
|
||||
let mut fresh = metadata_params(pane_id.clone());
|
||||
fresh.source = "custom:pi-metadata".into();
|
||||
fresh.agent = Some("pi".into());
|
||||
fresh.title = Some("fresh".into());
|
||||
fresh.seq = Some(1);
|
||||
let response = app.handle_pane_report_metadata("fresh".into(), fresh);
|
||||
let _: SuccessResponse = serde_json::from_str(&response).unwrap();
|
||||
|
||||
let mut fresh_tokens = metadata_params(pane_id);
|
||||
fresh_tokens.source = "custom:pi-tokens".into();
|
||||
fresh_tokens.agent = Some("pi".into());
|
||||
fresh_tokens.title = None;
|
||||
fresh_tokens.tokens =
|
||||
std::collections::HashMap::from([("generation".into(), Some("new".into()))]);
|
||||
fresh_tokens.seq = Some(1);
|
||||
let response = app.handle_pane_report_metadata("fresh-tokens".into(), fresh_tokens);
|
||||
let _: SuccessResponse = serde_json::from_str(&response).unwrap();
|
||||
|
||||
let terminal = &app.state.terminals[&terminal_id];
|
||||
assert_eq!(
|
||||
terminal.agent_metadata["custom:pi-metadata"]
|
||||
.title
|
||||
.as_deref(),
|
||||
Some("fresh")
|
||||
);
|
||||
assert_eq!(
|
||||
terminal
|
||||
.metadata_tokens
|
||||
.values()
|
||||
.get("generation")
|
||||
.map(String::as_str),
|
||||
Some("new")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pane_report_metadata_accepts_documented_source_chars_and_max_ttl() {
|
||||
let (mut app, pane_id) = app_with_test_workspace();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Hermes plugin installed by Herdr to report agent lifecycle state."""
|
||||
|
||||
# HERDR_INTEGRATION_ID=hermes
|
||||
# HERDR_INTEGRATION_VERSION=3
|
||||
# HERDR_INTEGRATION_VERSION=4
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -71,6 +71,19 @@ def _report(state: str, **kwargs) -> None:
|
||||
_send("pane.report_agent", params)
|
||||
|
||||
|
||||
def _session_started(**kwargs) -> None:
|
||||
session_id = _session_id(kwargs)
|
||||
if session_id:
|
||||
_send(
|
||||
"pane.report_agent_session",
|
||||
{
|
||||
"agent_session_id": session_id,
|
||||
"session_start_source": "startup",
|
||||
},
|
||||
)
|
||||
_idle(**kwargs)
|
||||
|
||||
|
||||
def _working(**kwargs) -> None:
|
||||
_report("working", **kwargs)
|
||||
|
||||
@@ -84,7 +97,7 @@ def _idle(**kwargs) -> None:
|
||||
|
||||
|
||||
def register(ctx):
|
||||
ctx.register_hook("on_session_start", _idle)
|
||||
ctx.register_hook("on_session_start", _session_started)
|
||||
ctx.register_hook("pre_llm_call", _working)
|
||||
ctx.register_hook("pre_api_request", _working)
|
||||
ctx.register_hook("pre_tool_call", _working)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// managed by herdr; reinstalling or updating the integration overwrites this file.
|
||||
// add custom hooks/plugins beside this file instead of editing it.
|
||||
// HERDR_INTEGRATION_ID=kilo
|
||||
// HERDR_INTEGRATION_VERSION=3
|
||||
// HERDR_INTEGRATION_VERSION=4
|
||||
|
||||
import net from "node:net";
|
||||
|
||||
@@ -88,7 +88,10 @@ function reportSession(sessionID) {
|
||||
if (!sessionID) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return request("pane.report_agent_session", { agent_session_id: sessionID });
|
||||
return request("pane.report_agent_session", {
|
||||
agent_session_id: sessionID,
|
||||
session_start_source: "startup",
|
||||
});
|
||||
}
|
||||
|
||||
function reportState(state, sessionID) {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# managed by herdr; reinstalling or updating the integration overwrites this file.
|
||||
# add custom hooks beside this file instead of editing it.
|
||||
# HERDR_INTEGRATION_ID=kimi
|
||||
# HERDR_INTEGRATION_VERSION=5
|
||||
# HERDR_INTEGRATION_VERSION=6
|
||||
|
||||
param([string]$Action = "")
|
||||
|
||||
@@ -23,7 +23,7 @@ $sessionId = if ($null -ne $payload -and -not [string]::IsNullOrWhiteSpace($payl
|
||||
try {
|
||||
if ($Action -eq "session") {
|
||||
if ([string]::IsNullOrWhiteSpace($sessionId)) { exit 0 }
|
||||
& herdr pane report-agent-session $env:HERDR_PANE_ID --source herdr:kimi --agent kimi --agent-session-id $sessionId --seq $seq 2>$null | Out-Null
|
||||
& herdr pane report-agent-session $env:HERDR_PANE_ID --source herdr:kimi --agent kimi --agent-session-id $sessionId --session-start-source startup --seq $seq 2>$null | Out-Null
|
||||
} else {
|
||||
if ([string]::IsNullOrWhiteSpace($sessionId)) {
|
||||
& herdr pane report-agent $env:HERDR_PANE_ID --source herdr:kimi --agent kimi --state $Action --seq $seq 2>$null | Out-Null
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/bin/sh
|
||||
# managed by herdr; reinstalling the integration replaces this file.
|
||||
# HERDR_INTEGRATION_ID=kimi
|
||||
# HERDR_INTEGRATION_VERSION=5
|
||||
# HERDR_INTEGRATION_VERSION=6
|
||||
|
||||
action="${1:-}"
|
||||
case "$action" in
|
||||
@@ -42,6 +42,7 @@ if action == "session":
|
||||
if session_id is None:
|
||||
raise SystemExit(0)
|
||||
method = "pane.report_agent_session"
|
||||
params["session_start_source"] = "startup"
|
||||
else:
|
||||
method = "pane.report_agent"
|
||||
params["state"] = action
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# managed by herdr; reinstalling or updating the integration overwrites this file.
|
||||
# add custom hooks beside this file instead of editing it.
|
||||
# HERDR_INTEGRATION_ID=mastracode
|
||||
# HERDR_INTEGRATION_VERSION=1
|
||||
# HERDR_INTEGRATION_VERSION=2
|
||||
|
||||
set -eu
|
||||
|
||||
@@ -13,7 +13,7 @@ trap 'rm -f "$hook_input_file"' EXIT HUP INT TERM
|
||||
cat >"$hook_input_file" 2>/dev/null || true
|
||||
|
||||
case "$action" in
|
||||
working|idle|blocked|release) ;;
|
||||
session|working|idle|blocked) ;;
|
||||
*) exit 0 ;;
|
||||
esac
|
||||
|
||||
@@ -55,14 +55,18 @@ if isinstance(session_id, str) and session_id:
|
||||
agent_session_id = session_id
|
||||
else:
|
||||
agent_session_id = None
|
||||
if action == "release":
|
||||
if action == "session":
|
||||
if not agent_session_id:
|
||||
raise SystemExit(0)
|
||||
request = {
|
||||
"id": request_id,
|
||||
"method": "pane.release_agent",
|
||||
"method": "pane.report_agent_session",
|
||||
"params": {
|
||||
"pane_id": pane_id,
|
||||
"source": source,
|
||||
"agent": "mastracode",
|
||||
"agent_session_id": agent_session_id,
|
||||
"session_start_source": "startup",
|
||||
"seq": report_seq,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// managed by herdr; reinstalling or updating the integration overwrites this file.
|
||||
// add custom hooks/plugins beside this file instead of editing it.
|
||||
// HERDR_INTEGRATION_ID=omp
|
||||
// HERDR_INTEGRATION_VERSION=6
|
||||
// HERDR_INTEGRATION_VERSION=7
|
||||
// @ts-nocheck
|
||||
|
||||
import net from "node:net";
|
||||
@@ -168,29 +168,6 @@ function sendState(state: AgentState, message?: string, seq = nextReportSeq()):
|
||||
});
|
||||
}
|
||||
|
||||
function releaseAgent(): Promise<void> {
|
||||
return sendRequest({
|
||||
id: `${source}:release:${Date.now()}:${Math.random().toString(36).slice(2)}`,
|
||||
method: "pane.release_agent",
|
||||
params: {
|
||||
pane_id: paneId,
|
||||
source,
|
||||
agent: "omp",
|
||||
seq: nextReportSeq(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function shouldReleaseOnSessionShutdown(event: any): boolean {
|
||||
// OMP tears down and rebinds extension runtimes for internal lifecycle actions
|
||||
// such as /reload, /new, /resume, and /fork. Those do not mean the pane's
|
||||
// agent process has exited, and releasing hook authority there can suppress
|
||||
// legitimate reports from the replacement runtime. Only a user/process quit
|
||||
// should release Herdr's full-lifecycle authority.
|
||||
const reason = event?.reason;
|
||||
return reason === "quit";
|
||||
}
|
||||
|
||||
let sendInFlight = false;
|
||||
let queuedState: QueuedState | undefined;
|
||||
|
||||
@@ -470,13 +447,9 @@ export default function (pi) {
|
||||
scheduleIdle();
|
||||
});
|
||||
|
||||
pi.on("session_shutdown", async (event) => {
|
||||
if (!rootSession) {
|
||||
return;
|
||||
}
|
||||
clearPendingTimers();
|
||||
if (shouldReleaseOnSessionShutdown(event)) {
|
||||
await releaseAgent();
|
||||
pi.on("session_shutdown", () => {
|
||||
if (rootSession) {
|
||||
clearPendingTimers();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// managed by herdr; reinstalling or updating the integration overwrites this file.
|
||||
// add custom hooks/plugins beside this file instead of editing it.
|
||||
// HERDR_INTEGRATION_ID=pi
|
||||
// HERDR_INTEGRATION_VERSION=6
|
||||
// HERDR_INTEGRATION_VERSION=7
|
||||
// @ts-nocheck
|
||||
|
||||
import net from "node:net";
|
||||
@@ -142,29 +142,6 @@ function sendState(state: AgentState, message?: string, seq = nextReportSeq()):
|
||||
});
|
||||
}
|
||||
|
||||
function releaseAgent(): Promise<void> {
|
||||
return sendRequest({
|
||||
id: `${source}:release:${Date.now()}:${Math.random().toString(36).slice(2)}`,
|
||||
method: "pane.release_agent",
|
||||
params: {
|
||||
pane_id: paneId,
|
||||
source,
|
||||
agent: "pi",
|
||||
seq: nextReportSeq(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function shouldReleaseOnSessionShutdown(event: any): boolean {
|
||||
// Pi tears down and rebinds extension runtimes for internal lifecycle actions
|
||||
// such as /reload, /new, /resume, and /fork. Those do not mean the pane's
|
||||
// agent process has exited, and releasing hook authority there can suppress
|
||||
// legitimate reports from the replacement runtime. Only a user/process quit
|
||||
// should release Herdr's full-lifecycle authority.
|
||||
const reason = event?.reason;
|
||||
return reason === "quit";
|
||||
}
|
||||
|
||||
let sendInFlight = false;
|
||||
let queuedState: QueuedState | undefined;
|
||||
|
||||
@@ -275,13 +252,4 @@ export default function (pi) {
|
||||
agentActive = false;
|
||||
publishState();
|
||||
});
|
||||
|
||||
pi.on("session_shutdown", async (event) => {
|
||||
if (!rootSession) {
|
||||
return;
|
||||
}
|
||||
if (shouldReleaseOnSessionShutdown(event)) {
|
||||
await releaseAgent();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
+10
-9
@@ -22,10 +22,10 @@ pub(crate) use types::{IntegrationRecommendation, IntegrationStatus, Integration
|
||||
|
||||
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 = 6;
|
||||
const PI_INTEGRATION_VERSION: u32 = 7;
|
||||
const OMP_EXTENSION_INSTALL_NAME: &str = "herdr-omp-agent-state.ts";
|
||||
const OMP_EXTENSION_ASSET: &str = include_str!("assets/omp/herdr-agent-state.ts");
|
||||
const OMP_INTEGRATION_VERSION: u32 = 6;
|
||||
const OMP_INTEGRATION_VERSION: u32 = 7;
|
||||
const CLAUDE_HOOK_INSTALL_NAME: &str = if cfg!(windows) {
|
||||
"herdr-agent-state.ps1"
|
||||
} else {
|
||||
@@ -58,7 +58,7 @@ const KIMI_HOOK_ASSET: &str = if cfg!(windows) {
|
||||
} else {
|
||||
include_str!("assets/kimi/herdr-agent-state.sh")
|
||||
};
|
||||
const KIMI_INTEGRATION_VERSION: u32 = 5;
|
||||
const KIMI_INTEGRATION_VERSION: u32 = 6;
|
||||
const KIMI_CONFIG_BLOCK_BEGIN: &str = "# >>> herdr kimi integration";
|
||||
const KIMI_CONFIG_BLOCK_END: &str = "# <<< herdr kimi integration";
|
||||
const KIMI_MIN_VERSION: &str = "0.14.0";
|
||||
@@ -160,13 +160,13 @@ const OPENCODE_PLUGIN_ASSET: &str = include_str!("assets/opencode/herdr-agent-st
|
||||
const OPENCODE_INTEGRATION_VERSION: u32 = 9;
|
||||
const KILO_PLUGIN_INSTALL_NAME: &str = "herdr-agent-state.js";
|
||||
const KILO_PLUGIN_ASSET: &str = include_str!("assets/kilo/herdr-agent-state.js");
|
||||
const KILO_INTEGRATION_VERSION: u32 = 3;
|
||||
const KILO_INTEGRATION_VERSION: u32 = 4;
|
||||
const HERMES_PLUGIN_INSTALL_NAME: &str = "herdr-agent-state";
|
||||
const HERMES_PLUGIN_MANIFEST_INSTALL_NAME: &str = "plugin.yaml";
|
||||
const HERMES_PLUGIN_INIT_INSTALL_NAME: &str = "__init__.py";
|
||||
const HERMES_PLUGIN_MANIFEST_ASSET: &str = include_str!("assets/hermes/plugin.yaml");
|
||||
const HERMES_PLUGIN_INIT_ASSET: &str = include_str!("assets/hermes/__init__.py");
|
||||
const HERMES_INTEGRATION_VERSION: u32 = 3;
|
||||
const HERMES_INTEGRATION_VERSION: u32 = 4;
|
||||
const QODERCLI_HOOK_INSTALL_NAME: &str = if cfg!(windows) {
|
||||
"herdr-agent-state.ps1"
|
||||
} else {
|
||||
@@ -198,10 +198,12 @@ const CURSOR_HOOK_ASSET: &str = include_str!("assets/cursor/herdr-agent-state.sh
|
||||
const CURSOR_INTEGRATION_VERSION: u32 = 1;
|
||||
const MASTRACODE_HOOK_INSTALL_NAME: &str = "herdr-agent-state.sh";
|
||||
const MASTRACODE_HOOK_ASSET: &str = include_str!("assets/mastracode/herdr-agent-state.sh");
|
||||
const MASTRACODE_INTEGRATION_VERSION: u32 = 1;
|
||||
const MASTRACODE_INTEGRATION_VERSION: u32 = 2;
|
||||
const MASTRACODE_HOOK_TIMEOUT_MS: u64 = 10_000;
|
||||
const MASTRACODE_HOOK_EVENTS: [(&str, &str); 12] = [
|
||||
("SessionStart", "idle"),
|
||||
const MASTRACODE_REMOVED_HOOK_EVENTS: [(&str, &str); 2] =
|
||||
[("SessionStart", "idle"), ("SessionEnd", "release")];
|
||||
const MASTRACODE_HOOK_EVENTS: [(&str, &str); 11] = [
|
||||
("SessionStart", "session"),
|
||||
("UserPromptSubmit", "working"),
|
||||
("AgentStart", "working"),
|
||||
("PreToolUse", "working"),
|
||||
@@ -212,7 +214,6 @@ const MASTRACODE_HOOK_EVENTS: [(&str, &str); 12] = [
|
||||
("Interrupt", "idle"),
|
||||
("AgentEnd", "idle"),
|
||||
("Stop", "idle"),
|
||||
("SessionEnd", "release"),
|
||||
];
|
||||
const INTEGRATION_VERSION_MARKER: &str = "HERDR_INTEGRATION_VERSION=";
|
||||
|
||||
|
||||
@@ -39,10 +39,11 @@ use super::{
|
||||
HERMES_PLUGIN_INIT_INSTALL_NAME, HERMES_PLUGIN_MANIFEST_ASSET,
|
||||
HERMES_PLUGIN_MANIFEST_INSTALL_NAME, KILO_PLUGIN_ASSET, KILO_PLUGIN_INSTALL_NAME,
|
||||
KIMI_HOOK_ASSET, KIMI_HOOK_INSTALL_NAME, MASTRACODE_HOOK_ASSET, MASTRACODE_HOOK_EVENTS,
|
||||
MASTRACODE_HOOK_INSTALL_NAME, MASTRACODE_HOOK_TIMEOUT_MS, OMP_EXTENSION_ASSET,
|
||||
OMP_EXTENSION_INSTALL_NAME, OPENCODE_PLUGIN_ASSET, OPENCODE_PLUGIN_INSTALL_NAME,
|
||||
PI_EXTENSION_ASSET, PI_EXTENSION_INSTALL_NAME, QODERCLI_HOOK_ASSET, QODERCLI_HOOK_EVENTS,
|
||||
QODERCLI_HOOK_INSTALL_NAME, QODERCLI_REMOVED_LIFECYCLE_HOOK_EVENTS,
|
||||
MASTRACODE_HOOK_INSTALL_NAME, MASTRACODE_HOOK_TIMEOUT_MS, MASTRACODE_REMOVED_HOOK_EVENTS,
|
||||
OMP_EXTENSION_ASSET, OMP_EXTENSION_INSTALL_NAME, OPENCODE_PLUGIN_ASSET,
|
||||
OPENCODE_PLUGIN_INSTALL_NAME, PI_EXTENSION_ASSET, PI_EXTENSION_INSTALL_NAME,
|
||||
QODERCLI_HOOK_ASSET, QODERCLI_HOOK_EVENTS, QODERCLI_HOOK_INSTALL_NAME,
|
||||
QODERCLI_REMOVED_LIFECYCLE_HOOK_EVENTS,
|
||||
};
|
||||
|
||||
fn ensure_extension_dir(dir: &Path, agent: &str) -> io::Result<()> {
|
||||
@@ -1133,6 +1134,9 @@ pub(crate) fn install_mastracode() -> io::Result<MastracodeInstallPaths> {
|
||||
})?;
|
||||
|
||||
let quoted_hook_path = shell_single_quote(&hook_path.display().to_string());
|
||||
for (event, action) in MASTRACODE_REMOVED_HOOK_EVENTS {
|
||||
remove_flat_command_hook(hooks, event, &format!("bash {quoted_hook_path} {action}"))?;
|
||||
}
|
||||
for (event, action) in MASTRACODE_HOOK_EVENTS {
|
||||
ensure_flat_command_hook(
|
||||
hooks,
|
||||
@@ -1171,7 +1175,10 @@ pub(crate) fn uninstall_mastracode() -> io::Result<MastracodeUninstallResult> {
|
||||
})?;
|
||||
|
||||
let quoted_hook_path = shell_single_quote(&hook_path.display().to_string());
|
||||
for (event, action) in MASTRACODE_HOOK_EVENTS {
|
||||
for (event, action) in MASTRACODE_HOOK_EVENTS
|
||||
.into_iter()
|
||||
.chain(MASTRACODE_REMOVED_HOOK_EVENTS)
|
||||
{
|
||||
updated_hooks |= remove_flat_command_hook(
|
||||
hooks,
|
||||
event,
|
||||
|
||||
+73
-44
@@ -2667,8 +2667,7 @@ fn bundled_integration_assets_report_session_refs() {
|
||||
assert!(PI_EXTENSION_ASSET.contains("pane.report_agent\""));
|
||||
assert!(PI_EXTENSION_ASSET.contains("pi.on(\"agent_start\""));
|
||||
assert!(PI_EXTENSION_ASSET.contains("pi.on(\"agent_settled\""));
|
||||
assert!(PI_EXTENSION_ASSET.contains("pane.release_agent"));
|
||||
assert!(PI_EXTENSION_ASSET.contains("pi.on(\"session_shutdown\""));
|
||||
assert!(!PI_EXTENSION_ASSET.contains("pi.on(\"session_shutdown\""));
|
||||
assert!(OMP_EXTENSION_ASSET.contains("agent_session_path"));
|
||||
assert!(OMP_EXTENSION_ASSET.contains("agent_session_id"));
|
||||
assert!(OMP_EXTENSION_ASSET.contains("ctx?.hasUI !== true"));
|
||||
@@ -2676,7 +2675,6 @@ fn bundled_integration_assets_report_session_refs() {
|
||||
assert!(OMP_EXTENSION_ASSET.contains("pane.report_agent\""));
|
||||
assert!(OMP_EXTENSION_ASSET.contains("pi.on(\"agent_start\""));
|
||||
assert!(OMP_EXTENSION_ASSET.contains("pi.on(\"agent_end\""));
|
||||
assert!(OMP_EXTENSION_ASSET.contains("pane.release_agent"));
|
||||
assert!(OMP_EXTENSION_ASSET.contains("pi.on(\"session_shutdown\""));
|
||||
assert!(
|
||||
CLAUDE_HOOK_ASSET.contains("agent_session_id")
|
||||
@@ -2718,6 +2716,7 @@ fn bundled_integration_assets_report_session_refs() {
|
||||
assert!(KIMI_HOOK_ASSET.contains("source\": \"herdr:kimi"));
|
||||
assert!(KIMI_HOOK_ASSET.contains("agent_session_id"));
|
||||
assert!(KIMI_HOOK_ASSET.contains("method = \"pane.report_agent_session\""));
|
||||
assert!(KIMI_HOOK_ASSET.contains("params[\"session_start_source\"] = \"startup\""));
|
||||
assert!(KIMI_HOOK_ASSET.contains("method = \"pane.report_agent\""));
|
||||
assert!(KIMI_HOOK_ASSET.contains("params[\"state\"] = action"));
|
||||
assert!(!KIMI_HOOK_ASSET.contains("pane.release_agent"));
|
||||
@@ -2743,10 +2742,13 @@ fn bundled_integration_assets_report_session_refs() {
|
||||
assert!(KILO_PLUGIN_ASSET.contains("SOURCE = \"herdr:kilo\""));
|
||||
assert!(KILO_PLUGIN_ASSET.contains("AGENT = \"kilo\""));
|
||||
assert!(KILO_PLUGIN_ASSET.contains("pane.report_agent_session"));
|
||||
assert!(KILO_PLUGIN_ASSET.contains("session_start_source: \"startup\""));
|
||||
assert!(KILO_PLUGIN_ASSET.contains("reportState"));
|
||||
assert!(!KILO_PLUGIN_ASSET.contains("pane.release_agent"));
|
||||
assert!(HERMES_PLUGIN_INIT_ASSET.contains("session_id = _session_id(kwargs)"));
|
||||
assert!(HERMES_PLUGIN_INIT_ASSET.contains("agent_session_id"));
|
||||
assert!(HERMES_PLUGIN_INIT_ASSET.contains("pane.report_agent_session\","));
|
||||
assert!(HERMES_PLUGIN_INIT_ASSET.contains("\"session_start_source\": \"startup\""));
|
||||
assert!(HERMES_PLUGIN_INIT_ASSET.contains("pane.report_agent\","));
|
||||
assert!(HERMES_PLUGIN_INIT_ASSET.contains("on_session_end"));
|
||||
assert!(!HERMES_PLUGIN_INIT_ASSET.contains("on_session_finalize"));
|
||||
@@ -2768,33 +2770,30 @@ fn bundled_integration_assets_report_session_refs() {
|
||||
assert!(!CURSOR_HOOK_ASSET.contains("\"state\":"));
|
||||
assert!(!CURSOR_HOOK_ASSET.contains("pane.release_agent"));
|
||||
assert!(MASTRACODE_HOOK_ASSET.contains("HERDR_INTEGRATION_ID=mastracode"));
|
||||
assert!(MASTRACODE_HOOK_ASSET.contains("HERDR_INTEGRATION_VERSION=1"));
|
||||
assert!(MASTRACODE_HOOK_ASSET.contains("HERDR_INTEGRATION_VERSION=2"));
|
||||
assert!(MASTRACODE_HOOK_ASSET.contains("session_id"));
|
||||
assert!(!MASTRACODE_HOOK_ASSET.contains("run_id"));
|
||||
assert!(MASTRACODE_HOOK_ASSET.contains("agent_session_id"));
|
||||
assert!(MASTRACODE_HOOK_ASSET.contains("pane.report_agent_session"));
|
||||
assert!(MASTRACODE_HOOK_ASSET.contains("session_start_source"));
|
||||
assert!(MASTRACODE_HOOK_ASSET.contains("pane.report_agent"));
|
||||
assert!(MASTRACODE_HOOK_ASSET.contains("pane.release_agent"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pi_extension_releases_only_for_quit_session_shutdown() {
|
||||
let release_policy = PI_EXTENSION_ASSET
|
||||
.find("function shouldReleaseOnSessionShutdown")
|
||||
.expect("pi extension should centralize session shutdown release policy");
|
||||
let quit_check = PI_EXTENSION_ASSET
|
||||
.find("reason === \"quit\"")
|
||||
.expect("pi extension should release only for true quit shutdowns");
|
||||
let shutdown_handler = PI_EXTENSION_ASSET
|
||||
.find("pi.on(\"session_shutdown\", async (event)")
|
||||
.expect("pi extension should inspect the session_shutdown event");
|
||||
let guarded_release = PI_EXTENSION_ASSET[shutdown_handler..]
|
||||
.find("if (shouldReleaseOnSessionShutdown(event))")
|
||||
.expect("pi extension should guard releaseAgent by shutdown reason");
|
||||
|
||||
assert!(release_policy < shutdown_handler);
|
||||
assert!(release_policy < quit_check);
|
||||
assert!(quit_check < shutdown_handler);
|
||||
assert!(guarded_release > 0);
|
||||
fn process_owned_integration_assets_do_not_report_release() {
|
||||
for (name, asset) in [
|
||||
("pi", PI_EXTENSION_ASSET),
|
||||
("omp", OMP_EXTENSION_ASSET),
|
||||
("mastracode", MASTRACODE_HOOK_ASSET),
|
||||
("kimi", KIMI_HOOK_ASSET),
|
||||
("kilo", KILO_PLUGIN_ASSET),
|
||||
("hermes", HERMES_PLUGIN_INIT_ASSET),
|
||||
] {
|
||||
assert!(
|
||||
!asset.contains("pane.release_agent"),
|
||||
"{name} process exit should own lifecycle release"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2817,27 +2816,6 @@ fn pi_extension_refreshes_session_ref_before_agent_start_state() {
|
||||
assert!(report_session < publish_state);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn omp_extension_releases_only_for_quit_session_shutdown() {
|
||||
let release_policy = OMP_EXTENSION_ASSET
|
||||
.find("function shouldReleaseOnSessionShutdown")
|
||||
.expect("omp extension should centralize session shutdown release policy");
|
||||
let quit_check = OMP_EXTENSION_ASSET
|
||||
.find("reason === \"quit\"")
|
||||
.expect("omp extension should release only for true quit shutdowns");
|
||||
let shutdown_handler = OMP_EXTENSION_ASSET
|
||||
.find("pi.on(\"session_shutdown\", async (event)")
|
||||
.expect("omp extension should inspect the session_shutdown event");
|
||||
let guarded_release = OMP_EXTENSION_ASSET[shutdown_handler..]
|
||||
.find("if (shouldReleaseOnSessionShutdown(event))")
|
||||
.expect("omp extension should guard releaseAgent by shutdown reason");
|
||||
|
||||
assert!(release_policy < shutdown_handler);
|
||||
assert!(release_policy < quit_check);
|
||||
assert!(quit_check < shutdown_handler);
|
||||
assert!(guarded_release > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn omp_extension_refreshes_session_ref_before_agent_start_state() {
|
||||
let agent_start = OMP_EXTENSION_ASSET
|
||||
@@ -3353,6 +3331,57 @@ fn install_mastracode_writes_hook_and_updates_hooks_json() {
|
||||
let _ = fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_mastracode_removes_v1_lifecycle_hooks() {
|
||||
let _lock = integration_env_lock();
|
||||
let base = unique_base();
|
||||
let original_home = std::env::var_os("HOME");
|
||||
let mastracode_dir = base.join(".mastracode");
|
||||
let hook_path = mastracode_dir
|
||||
.join("hooks")
|
||||
.join(MASTRACODE_HOOK_INSTALL_NAME);
|
||||
fs::create_dir_all(&mastracode_dir).unwrap();
|
||||
fs::write(
|
||||
mastracode_dir.join("hooks.json"),
|
||||
serde_json::to_string(&json!({
|
||||
"SessionStart": [{
|
||||
"type": "command",
|
||||
"command": format!("bash '{}' idle", hook_path.display()),
|
||||
"timeout": MASTRACODE_HOOK_TIMEOUT_MS
|
||||
}],
|
||||
"SessionEnd": [{
|
||||
"type": "command",
|
||||
"command": format!("bash '{}' release", hook_path.display()),
|
||||
"timeout": MASTRACODE_HOOK_TIMEOUT_MS
|
||||
}]
|
||||
}))
|
||||
.unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
std::env::set_var("HOME", &base);
|
||||
|
||||
install_mastracode().unwrap();
|
||||
|
||||
let hooks_file: Value =
|
||||
serde_json::from_str(&fs::read_to_string(mastracode_dir.join("hooks.json")).unwrap())
|
||||
.unwrap();
|
||||
let hooks = hooks_file.as_object().unwrap();
|
||||
assert!(!hooks.contains_key("SessionEnd"));
|
||||
let session_start = hooks["SessionStart"].as_array().unwrap();
|
||||
assert_eq!(session_start.len(), 1);
|
||||
assert!(session_start[0]["command"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.ends_with("session"));
|
||||
|
||||
if let Some(home) = original_home {
|
||||
std::env::set_var("HOME", home);
|
||||
} else {
|
||||
std::env::remove_var("HOME");
|
||||
}
|
||||
let _ = fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_mastracode_is_idempotent_for_hook_entries() {
|
||||
let _lock = integration_env_lock();
|
||||
|
||||
+81
-41
@@ -290,6 +290,7 @@ fn foreground_member_cwd_different_from_shell(
|
||||
enum ForegroundShellAgentAction {
|
||||
ObserveProbe,
|
||||
ReportProcessExit,
|
||||
ReportReplacementProcess,
|
||||
ClearAgent,
|
||||
}
|
||||
|
||||
@@ -299,12 +300,20 @@ fn foreground_shell_agent_action(
|
||||
foreground_is_pane_shell: bool,
|
||||
process_exit_reported: bool,
|
||||
) -> ForegroundShellAgentAction {
|
||||
if previous_agent.is_none() || new_agent.is_some() {
|
||||
let Some(previous_agent) = previous_agent else {
|
||||
return ForegroundShellAgentAction::ObserveProbe;
|
||||
}
|
||||
|
||||
};
|
||||
if process_exit_reported {
|
||||
return ForegroundShellAgentAction::ClearAgent;
|
||||
return if new_agent == Some(previous_agent) {
|
||||
ForegroundShellAgentAction::ReportReplacementProcess
|
||||
} else if new_agent.is_none() {
|
||||
ForegroundShellAgentAction::ClearAgent
|
||||
} else {
|
||||
ForegroundShellAgentAction::ObserveProbe
|
||||
};
|
||||
}
|
||||
if new_agent.is_some() {
|
||||
return ForegroundShellAgentAction::ObserveProbe;
|
||||
}
|
||||
|
||||
if foreground_is_pane_shell {
|
||||
@@ -317,6 +326,38 @@ fn foreground_shell_agent_action(
|
||||
ForegroundShellAgentAction::ObserveProbe
|
||||
}
|
||||
|
||||
fn apply_foreground_shell_agent_action(
|
||||
agent_presence: &mut AgentDetectionPresence,
|
||||
action: ForegroundShellAgentAction,
|
||||
previous_agent: Option<Agent>,
|
||||
new_agent: Option<Agent>,
|
||||
pending_foreground_shell_clear: &mut bool,
|
||||
foreground_shell_exit_reported: &mut bool,
|
||||
) -> bool {
|
||||
match action {
|
||||
ForegroundShellAgentAction::ReportReplacementProcess => {
|
||||
*pending_foreground_shell_clear = false;
|
||||
*foreground_shell_exit_reported = false;
|
||||
agent_presence.observe_process_probe(previous_agent);
|
||||
true
|
||||
}
|
||||
ForegroundShellAgentAction::ReportProcessExit => {
|
||||
*pending_foreground_shell_clear = true;
|
||||
false
|
||||
}
|
||||
ForegroundShellAgentAction::ClearAgent => {
|
||||
*pending_foreground_shell_clear = false;
|
||||
*foreground_shell_exit_reported = false;
|
||||
agent_presence.clear_current_agent()
|
||||
}
|
||||
ForegroundShellAgentAction::ObserveProbe => {
|
||||
*pending_foreground_shell_clear = false;
|
||||
*foreground_shell_exit_reported = false;
|
||||
agent_presence.observe_process_probe(new_agent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct ProcessProbeInput {
|
||||
current_agent: Option<Agent>,
|
||||
@@ -680,27 +721,20 @@ fn spawn_basic_detection_task(
|
||||
}
|
||||
}
|
||||
let previous_agent = agent_presence.current_agent();
|
||||
let changed = match foreground_shell_agent_action(
|
||||
let foreground_action = foreground_shell_agent_action(
|
||||
previous_agent,
|
||||
new_agent,
|
||||
foreground_is_pane_shell,
|
||||
foreground_shell_exit_reported,
|
||||
) {
|
||||
ForegroundShellAgentAction::ReportProcessExit => {
|
||||
pending_foreground_shell_clear = true;
|
||||
false
|
||||
}
|
||||
ForegroundShellAgentAction::ClearAgent => {
|
||||
pending_foreground_shell_clear = false;
|
||||
foreground_shell_exit_reported = false;
|
||||
agent_presence.clear_current_agent()
|
||||
}
|
||||
ForegroundShellAgentAction::ObserveProbe => {
|
||||
pending_foreground_shell_clear = false;
|
||||
foreground_shell_exit_reported = false;
|
||||
agent_presence.observe_process_probe(new_agent)
|
||||
}
|
||||
};
|
||||
);
|
||||
let changed = apply_foreground_shell_agent_action(
|
||||
&mut agent_presence,
|
||||
foreground_action,
|
||||
previous_agent,
|
||||
new_agent,
|
||||
&mut pending_foreground_shell_clear,
|
||||
&mut foreground_shell_exit_reported,
|
||||
);
|
||||
if new_agent.is_some() {
|
||||
last_foreground_pgid = process_group_id.or(foreground_pgid);
|
||||
acquisition_started_at = None;
|
||||
@@ -715,7 +749,9 @@ fn spawn_basic_detection_task(
|
||||
}
|
||||
if changed {
|
||||
agent = agent_presence.current_agent();
|
||||
agent_changed = previous_agent != agent;
|
||||
agent_changed = previous_agent != agent
|
||||
|| foreground_action
|
||||
== ForegroundShellAgentAction::ReportReplacementProcess;
|
||||
if agent_changed {
|
||||
pending_idle.clear();
|
||||
last_screen_scan_detection_content_seq = None;
|
||||
@@ -2105,27 +2141,20 @@ impl PaneRuntime {
|
||||
}
|
||||
|
||||
let previous_agent = agent_presence.current_agent();
|
||||
let changed = match foreground_shell_agent_action(
|
||||
let foreground_action = foreground_shell_agent_action(
|
||||
previous_agent,
|
||||
new_agent,
|
||||
foreground_is_pane_shell,
|
||||
foreground_shell_exit_reported,
|
||||
) {
|
||||
ForegroundShellAgentAction::ReportProcessExit => {
|
||||
pending_foreground_shell_clear = true;
|
||||
false
|
||||
}
|
||||
ForegroundShellAgentAction::ClearAgent => {
|
||||
pending_foreground_shell_clear = false;
|
||||
foreground_shell_exit_reported = false;
|
||||
agent_presence.clear_current_agent()
|
||||
}
|
||||
ForegroundShellAgentAction::ObserveProbe => {
|
||||
pending_foreground_shell_clear = false;
|
||||
foreground_shell_exit_reported = false;
|
||||
agent_presence.observe_process_probe(new_agent)
|
||||
}
|
||||
};
|
||||
);
|
||||
let changed = apply_foreground_shell_agent_action(
|
||||
&mut agent_presence,
|
||||
foreground_action,
|
||||
previous_agent,
|
||||
new_agent,
|
||||
&mut pending_foreground_shell_clear,
|
||||
&mut foreground_shell_exit_reported,
|
||||
);
|
||||
if new_agent.is_some() {
|
||||
last_foreground_pgid = process_group_id;
|
||||
acquisition_started_at = None;
|
||||
@@ -2142,7 +2171,10 @@ impl PaneRuntime {
|
||||
}
|
||||
if changed {
|
||||
agent = agent_presence.current_agent();
|
||||
if agent != previous_agent {
|
||||
if agent != previous_agent
|
||||
|| foreground_action
|
||||
== ForegroundShellAgentAction::ReportReplacementProcess
|
||||
{
|
||||
pending_idle.clear();
|
||||
last_screen_scan_detection_content_seq = None;
|
||||
// A new foreground agent must not inherit OSC
|
||||
@@ -3369,6 +3401,14 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_agent_after_reported_exit_is_a_replacement_process() {
|
||||
assert_eq!(
|
||||
foreground_shell_agent_action(Some(Agent::Pi), Some(Agent::Pi), false, true),
|
||||
ForegroundShellAgentAction::ReportReplacementProcess
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_non_shell_foreground_job_is_not_immediate_clear_signal() {
|
||||
assert_eq!(
|
||||
@@ -3388,7 +3428,7 @@ mod tests {
|
||||
#[test]
|
||||
fn foreground_agent_job_is_not_clear_signal() {
|
||||
assert_eq!(
|
||||
foreground_shell_agent_action(Some(Agent::Claude), Some(Agent::OpenCode), true, false),
|
||||
foreground_shell_agent_action(Some(Agent::Claude), Some(Agent::OpenCode), true, false,),
|
||||
ForegroundShellAgentAction::ObserveProbe
|
||||
);
|
||||
}
|
||||
|
||||
+18
-12
@@ -1108,18 +1108,24 @@ mod tests {
|
||||
let terminal_id = state.workspaces[0].tabs[0].panes[&root]
|
||||
.attached_terminal_id
|
||||
.clone();
|
||||
state
|
||||
.terminals
|
||||
.get_mut(&terminal_id)
|
||||
.unwrap()
|
||||
.set_hook_authority_with_session_ref(
|
||||
"herdr:pi".into(),
|
||||
"pi".into(),
|
||||
crate::detect::AgentState::Working,
|
||||
None,
|
||||
crate::agent_resume::AgentSessionRef::path(session_path.clone()),
|
||||
Some(20),
|
||||
);
|
||||
let terminal = state.terminals.get_mut(&terminal_id).unwrap();
|
||||
terminal.set_detected_state(
|
||||
Some(crate::detect::Agent::Pi),
|
||||
crate::detect::AgentState::Idle,
|
||||
);
|
||||
terminal.set_persisted_agent_session(crate::agent_resume::PersistedAgentSession {
|
||||
source: "herdr:pi".into(),
|
||||
agent: "pi".into(),
|
||||
session_ref: crate::agent_resume::AgentSessionRef::path(session_path.clone()).unwrap(),
|
||||
});
|
||||
terminal.set_hook_authority_with_session_ref(
|
||||
"herdr:pi".into(),
|
||||
"pi".into(),
|
||||
crate::detect::AgentState::Working,
|
||||
None,
|
||||
crate::agent_resume::AgentSessionRef::path(session_path.clone()),
|
||||
Some(20),
|
||||
);
|
||||
|
||||
let snapshot = capture_from_state(&state);
|
||||
let agent_session = snapshot.workspaces[0].tabs[0].panes[&root.raw()]
|
||||
|
||||
+30
-2
@@ -5840,7 +5840,7 @@ next_tab = ""
|
||||
assert!(
|
||||
server.handle_internal_event_with_forwarding(AppEvent::HookStateReported {
|
||||
pane_id,
|
||||
source: "herdr:pi".into(),
|
||||
source: "custom:pi".into(),
|
||||
agent_label: "pi".into(),
|
||||
state: crate::detect::AgentState::Working,
|
||||
message: None,
|
||||
@@ -5853,7 +5853,7 @@ next_tab = ""
|
||||
pane_id,
|
||||
source: "user:pi-display".into(),
|
||||
agent_label: Some("pi".into()),
|
||||
applies_to_source: Some("herdr:pi".into()),
|
||||
applies_to_source: Some("custom:pi".into()),
|
||||
title: Some("short lived".into()),
|
||||
display_agent: None,
|
||||
state_labels: HashMap::new(),
|
||||
@@ -9522,6 +9522,34 @@ next_tab = ""
|
||||
.unwrap()
|
||||
.attached_terminal_id
|
||||
.clone();
|
||||
server
|
||||
.app
|
||||
.state
|
||||
.terminals
|
||||
.get_mut(&terminal_id)
|
||||
.unwrap()
|
||||
.set_detected_state(
|
||||
Some(crate::detect::Agent::Pi),
|
||||
crate::detect::AgentState::Idle,
|
||||
);
|
||||
server
|
||||
.app
|
||||
.state
|
||||
.terminals
|
||||
.get_mut(&terminal_id)
|
||||
.unwrap()
|
||||
.set_persisted_agent_session(crate::agent_resume::PersistedAgentSession {
|
||||
source: "herdr:pi".into(),
|
||||
agent: "pi".into(),
|
||||
session_ref: crate::agent_resume::AgentSessionRef::path(
|
||||
std::env::current_dir()
|
||||
.unwrap()
|
||||
.join("headless-pi-session.jsonl")
|
||||
.display()
|
||||
.to_string(),
|
||||
)
|
||||
.unwrap(),
|
||||
});
|
||||
server
|
||||
.app
|
||||
.state
|
||||
|
||||
+112
-3
@@ -58,11 +58,47 @@ impl TerminalState {
|
||||
crate::metadata_tokens::sequence_is_fresh(&self.metadata_report_sequences, source, seq)
|
||||
}
|
||||
|
||||
pub(crate) fn metadata_report_agent(
|
||||
source: &str,
|
||||
agent_label: Option<&str>,
|
||||
applies_to_source: Option<&str>,
|
||||
) -> Option<crate::detect::Agent> {
|
||||
agent_label
|
||||
.and_then(crate::detect::parse_agent_label)
|
||||
.or_else(|| {
|
||||
crate::detect::Agent::ALL.iter().copied().find(|agent| {
|
||||
let agent_label = crate::detect::agent_label(*agent);
|
||||
crate::agent_resume::is_official_agent_source(source, agent_label)
|
||||
|| applies_to_source.is_some_and(|source| {
|
||||
crate::agent_resume::is_official_agent_source(source, agent_label)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn metadata_report_blocked_by_process_exit(
|
||||
&self,
|
||||
source: &str,
|
||||
agent_label: Option<&str>,
|
||||
applies_to_source: Option<&str>,
|
||||
) -> bool {
|
||||
let Some(exit) = self.recent_agent_process_exit else {
|
||||
return false;
|
||||
};
|
||||
let exited_agent_label = crate::detect::agent_label(exit.agent);
|
||||
agent_label.and_then(crate::detect::parse_agent_label) == Some(exit.agent)
|
||||
|| crate::agent_resume::is_official_agent_source(source, exited_agent_label)
|
||||
|| applies_to_source.is_some_and(|source| {
|
||||
crate::agent_resume::is_official_agent_source(source, exited_agent_label)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn accept_metadata_report(
|
||||
&mut self,
|
||||
source: &str,
|
||||
seq: Option<u64>,
|
||||
includes_tokens: bool,
|
||||
agent: Option<crate::detect::Agent>,
|
||||
) -> Result<bool, ()> {
|
||||
let Some(seq) = seq else {
|
||||
return Ok(true);
|
||||
@@ -79,6 +115,10 @@ impl TerminalState {
|
||||
}
|
||||
self.metadata_report_sequences
|
||||
.insert(source.to_string(), seq);
|
||||
if let Some(agent) = agent {
|
||||
self.metadata_report_agents
|
||||
.insert(source.to_string(), agent);
|
||||
}
|
||||
if includes_tokens {
|
||||
self.metadata_token_sequence_sources
|
||||
.insert(source.to_string());
|
||||
@@ -105,8 +145,20 @@ impl TerminalState {
|
||||
&mut self,
|
||||
report: AgentMetadataReport,
|
||||
) -> Option<TerminalStateMutation> {
|
||||
if self.metadata_report_blocked_by_process_exit(
|
||||
&report.source,
|
||||
report.agent_label.as_deref(),
|
||||
report.applies_to_source.as_deref(),
|
||||
) {
|
||||
return None;
|
||||
}
|
||||
let report_agent = Self::metadata_report_agent(
|
||||
&report.source,
|
||||
report.agent_label.as_deref(),
|
||||
report.applies_to_source.as_deref(),
|
||||
);
|
||||
if !matches!(
|
||||
self.accept_metadata_report(&report.source, report.seq, false),
|
||||
self.accept_metadata_report(&report.source, report.seq, false, report_agent),
|
||||
Ok(true)
|
||||
) {
|
||||
return None;
|
||||
@@ -482,13 +534,13 @@ mod tests {
|
||||
let mut terminal = test_terminal();
|
||||
for index in 0..=crate::metadata_tokens::MAX_SEQUENCE_SOURCES {
|
||||
assert_eq!(
|
||||
terminal.accept_metadata_report(&format!("source-{index}"), Some(1), false),
|
||||
terminal.accept_metadata_report(&format!("source-{index}"), Some(1), false, None,),
|
||||
Ok(true)
|
||||
);
|
||||
}
|
||||
for index in 0..crate::metadata_tokens::MAX_SEQUENCE_SOURCES {
|
||||
assert_eq!(
|
||||
terminal.accept_metadata_report(&format!("source-{index}"), Some(2), true),
|
||||
terminal.accept_metadata_report(&format!("source-{index}"), Some(2), true, None,),
|
||||
Ok(true)
|
||||
);
|
||||
}
|
||||
@@ -497,11 +549,68 @@ mod tests {
|
||||
&format!("source-{}", crate::metadata_tokens::MAX_SEQUENCE_SOURCES),
|
||||
Some(2),
|
||||
true,
|
||||
None,
|
||||
),
|
||||
Err(())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_metadata_reanchors_sequence_after_process_restart() {
|
||||
let mut terminal = test_terminal();
|
||||
terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle);
|
||||
let report = |seq, ttl| AgentMetadataReport {
|
||||
source: "custom:pi-metadata".into(),
|
||||
agent_label: Some("pi".into()),
|
||||
applies_to_source: None,
|
||||
title: Some("Pi task".into()),
|
||||
display_agent: None,
|
||||
state_labels: HashMap::new(),
|
||||
clear_title: false,
|
||||
clear_display_agent: false,
|
||||
clear_state_labels: false,
|
||||
ttl,
|
||||
seq: Some(seq),
|
||||
};
|
||||
assert!(terminal
|
||||
.set_agent_metadata(report(100, Some(Duration::ZERO)))
|
||||
.is_some());
|
||||
let deadline = terminal.next_agent_metadata_expiry().unwrap();
|
||||
terminal.expire_agent_metadata_at(deadline, deadline);
|
||||
assert!(terminal.agent_metadata.is_empty());
|
||||
let exit_at = Instant::now() + Duration::from_millis(1);
|
||||
terminal.set_detected_state_with_screen_signals_at(
|
||||
Some(Agent::Pi),
|
||||
AgentState::Idle,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
true,
|
||||
exit_at,
|
||||
);
|
||||
terminal.set_detected_state_with_screen_signals_at(
|
||||
None,
|
||||
AgentState::Unknown,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
exit_at + Duration::from_millis(1),
|
||||
);
|
||||
assert!(terminal.set_agent_metadata(report(1, None)).is_none());
|
||||
|
||||
terminal.set_detected_state_with_screen_signals_at(
|
||||
Some(Agent::Pi),
|
||||
AgentState::Idle,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
exit_at + Duration::from_millis(2),
|
||||
);
|
||||
assert!(terminal.set_agent_metadata(report(1, None)).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_agent_metadata_overrides_presentation_fields_only() {
|
||||
let mut terminal = test_terminal();
|
||||
|
||||
+1200
-857
File diff suppressed because it is too large
Load Diff
+34
-14
@@ -1699,10 +1699,19 @@ fn pane_report_agent_updates_effective_state() {
|
||||
}
|
||||
|
||||
let session_path = base.join("pi-session.jsonl");
|
||||
let session = send_request(
|
||||
&socket_path,
|
||||
&format!(
|
||||
r#"{{"id":"req_hook_session","method":"pane.report_agent_session","params":{{"pane_id":"{}","source":"herdr:pi","agent":"pi","agent_session_path":"{}","session_start_source":"startup","seq":1}}}}"#,
|
||||
pane_id,
|
||||
session_path.display()
|
||||
),
|
||||
);
|
||||
assert_eq!(session["result"]["type"], "ok");
|
||||
let hook = send_request(
|
||||
&socket_path,
|
||||
&format!(
|
||||
r#"{{"id":"req_hook_5","method":"pane.report_agent","params":{{"pane_id":"{}","source":"herdr:pi","agent":"pi","state":"working","message":"thinking","agent_session_path":"{}"}}}}"#,
|
||||
r#"{{"id":"req_hook_5","method":"pane.report_agent","params":{{"pane_id":"{}","source":"herdr:pi","agent":"pi","state":"working","message":"thinking","agent_session_path":"{}","seq":2}}}}"#,
|
||||
pane_id,
|
||||
session_path.display()
|
||||
),
|
||||
@@ -1875,7 +1884,7 @@ fn pane_report_agent_accepts_unknown_agent_labels() {
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
#[test]
|
||||
fn pane_release_agent_suppresses_reacquire_during_graceful_exit() {
|
||||
fn official_release_waits_for_confirmed_process_exit() {
|
||||
let _lock = test_lock();
|
||||
let base = unique_test_dir();
|
||||
let config_home = base.join("config");
|
||||
@@ -1960,11 +1969,22 @@ fn pane_release_agent_suppresses_reacquire_during_graceful_exit() {
|
||||
thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
|
||||
let session_path = base.join("release-session.jsonl");
|
||||
let session = send_request(
|
||||
&socket_path,
|
||||
&format!(
|
||||
r#"{{"id":"req_release_session","method":"pane.report_agent_session","params":{{"pane_id":"{}","source":"herdr:pi","agent":"pi","agent_session_path":"{}","session_start_source":"startup","seq":1}}}}"#,
|
||||
pane_id,
|
||||
session_path.display()
|
||||
),
|
||||
);
|
||||
assert_eq!(session["result"]["type"], "ok");
|
||||
let hook = send_request(
|
||||
&socket_path,
|
||||
&format!(
|
||||
r#"{{"id":"req_release_4","method":"pane.report_agent","params":{{"pane_id":"{}","source":"herdr:pi","agent":"pi","state":"working"}}}}"#,
|
||||
pane_id
|
||||
r#"{{"id":"req_release_4","method":"pane.report_agent","params":{{"pane_id":"{}","source":"herdr:pi","agent":"pi","state":"working","agent_session_path":"{}","seq":2}}}}"#,
|
||||
pane_id,
|
||||
session_path.display()
|
||||
),
|
||||
);
|
||||
assert_eq!(hook["result"]["type"], "ok");
|
||||
@@ -1978,8 +1998,8 @@ fn pane_release_agent_suppresses_reacquire_during_graceful_exit() {
|
||||
);
|
||||
assert_eq!(released["result"]["type"], "ok");
|
||||
|
||||
let suppression_deadline = Instant::now() + Duration::from_millis(300);
|
||||
while Instant::now() < suppression_deadline {
|
||||
let release_observation_deadline = Instant::now() + Duration::from_millis(300);
|
||||
while Instant::now() < release_observation_deadline {
|
||||
let pane = send_request(
|
||||
&socket_path,
|
||||
&format!(
|
||||
@@ -1987,11 +2007,11 @@ fn pane_release_agent_suppresses_reacquire_during_graceful_exit() {
|
||||
pane_id
|
||||
),
|
||||
);
|
||||
assert!(
|
||||
pane["result"]["pane"]["agent"].is_null(),
|
||||
"pane reacquired pi during graceful release: {pane}"
|
||||
assert_eq!(
|
||||
pane["result"]["pane"]["agent"], "pi",
|
||||
"official release hid the live Pi process: {pane}"
|
||||
);
|
||||
assert_eq!(pane["result"]["pane"]["agent_status"], "unknown");
|
||||
assert_eq!(pane["result"]["pane"]["agent_status"], "working");
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
|
||||
@@ -2013,7 +2033,7 @@ fn pane_release_agent_suppresses_reacquire_during_graceful_exit() {
|
||||
}
|
||||
assert!(
|
||||
Instant::now() < cleared_deadline,
|
||||
"pi agent was not cleared promptly after release: {pane}"
|
||||
"pi agent was not cleared promptly after process exit: {pane}"
|
||||
);
|
||||
thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
@@ -2443,7 +2463,7 @@ fn metadata_status_subscription_filter_and_ttl_expiry_are_observable() {
|
||||
let report_agent = send_request(
|
||||
&socket_path,
|
||||
&format!(
|
||||
r#"{{"id":"req_meta_sub_2","method":"pane.report_agent","params":{{"pane_id":"{}","source":"herdr:pi","agent":"pi","state":"working"}}}}"#,
|
||||
r#"{{"id":"req_meta_sub_2","method":"pane.report_agent","params":{{"pane_id":"{}","source":"custom:pi","agent":"pi","state":"working"}}}}"#,
|
||||
pane_id
|
||||
),
|
||||
);
|
||||
@@ -2463,7 +2483,7 @@ fn metadata_status_subscription_filter_and_ttl_expiry_are_observable() {
|
||||
let metadata = send_request(
|
||||
&socket_path,
|
||||
&format!(
|
||||
r#"{{"id":"req_meta_sub_3","method":"pane.report_metadata","params":{{"pane_id":"{}","source":"user:pi-display","agent":"pi","applies_to_source":"herdr:pi","title":"filtered out"}}}}"#,
|
||||
r#"{{"id":"req_meta_sub_3","method":"pane.report_metadata","params":{{"pane_id":"{}","source":"user:pi-display","agent":"pi","applies_to_source":"custom:pi","title":"filtered out"}}}}"#,
|
||||
pane_id
|
||||
),
|
||||
);
|
||||
@@ -2489,7 +2509,7 @@ fn metadata_status_subscription_filter_and_ttl_expiry_are_observable() {
|
||||
let metadata = send_request(
|
||||
&socket_path,
|
||||
&format!(
|
||||
r#"{{"id":"req_meta_sub_4","method":"pane.report_metadata","params":{{"pane_id":"{}","source":"user:pi-display","agent":"pi","applies_to_source":"herdr:pi","title":"short lived","ttl_ms":100}}}}"#,
|
||||
r#"{{"id":"req_meta_sub_4","method":"pane.report_metadata","params":{{"pane_id":"{}","source":"user:pi-display","agent":"pi","applies_to_source":"custom:pi","title":"short lived","ttl_ms":100}}}}"#,
|
||||
pane_id
|
||||
),
|
||||
);
|
||||
|
||||
@@ -26,7 +26,7 @@ fn agent_wait_exits_immediately_when_status_already_matches() {
|
||||
let reported = send_request(
|
||||
&socket_path,
|
||||
&format!(
|
||||
r#"{{"id":"req_cli_immediate_2","method":"pane.report_agent","params":{{"pane_id":"{}","source":"herdr:pi","agent":"pi","state":"idle"}}}}"#,
|
||||
r#"{{"id":"req_cli_immediate_2","method":"pane.report_agent","params":{{"pane_id":"{}","source":"custom:test","agent":"pi","state":"idle"}}}}"#,
|
||||
pane_id
|
||||
),
|
||||
);
|
||||
@@ -78,7 +78,7 @@ fn agent_wait_times_out_when_status_does_not_match() {
|
||||
let reported = send_request(
|
||||
&socket_path,
|
||||
&format!(
|
||||
r#"{{"id":"req_cli_timeout_2","method":"pane.report_agent","params":{{"pane_id":"{}","source":"herdr:pi","agent":"pi","state":"working"}}}}"#,
|
||||
r#"{{"id":"req_cli_timeout_2","method":"pane.report_agent","params":{{"pane_id":"{}","source":"custom:test","agent":"pi","state":"working"}}}}"#,
|
||||
pane_id
|
||||
),
|
||||
);
|
||||
|
||||
@@ -230,7 +230,7 @@ fn integration_commands_run_locally_when_server_is_missing() {
|
||||
.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 (v6)"));
|
||||
assert!(status_stdout.contains("pi: current (v7)"));
|
||||
assert!(status_stdout.contains("claude: not installed"));
|
||||
|
||||
let integration_uninstall = Command::new(env!("CARGO_BIN_EXE_herdr"))
|
||||
|
||||
+78
-11
@@ -1213,6 +1213,8 @@ fn live_handoff_accepts_canonical_pane_id_from_child_env() {
|
||||
|
||||
#[test]
|
||||
fn live_handoff_keeps_unmanaged_agent_name_bound_to_saved_session() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let _lock = test_lock();
|
||||
let base = unique_test_dir();
|
||||
let config_home = base.join("config");
|
||||
@@ -1220,6 +1222,18 @@ fn live_handoff_keeps_unmanaged_agent_name_bound_to_saved_session() {
|
||||
let api_socket = runtime_dir.join("herdr.sock");
|
||||
let old_session = base.join("old-session.jsonl");
|
||||
let new_session = base.join("new-session.jsonl");
|
||||
let started_marker = base.join("agent-started");
|
||||
let fake_pi = base.join("pi");
|
||||
fs::create_dir_all(&base).unwrap();
|
||||
fs::write(
|
||||
&fake_pi,
|
||||
format!(
|
||||
"#!/bin/sh\nexport HERDR_AGENT=pi\necho started > {}\nexec /bin/sleep 30\n",
|
||||
started_marker.display()
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
fs::set_permissions(&fake_pi, fs::Permissions::from_mode(0o755)).unwrap();
|
||||
|
||||
let spawned = spawn_server(&config_home, &runtime_dir, &api_socket);
|
||||
wait_for_socket(&api_socket, Duration::from_secs(10));
|
||||
@@ -1236,6 +1250,30 @@ fn live_handoff_keeps_unmanaged_agent_name_bound_to_saved_session() {
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
assert_ok(request(
|
||||
&api_socket,
|
||||
serde_json::json!({
|
||||
"id": "test:pane:start-agent",
|
||||
"method": "pane.send_input",
|
||||
"params": {"pane_id": pane_id, "text": fake_pi, "keys": ["Enter"]}
|
||||
}),
|
||||
));
|
||||
support::wait_for_file(&started_marker, Duration::from_secs(5));
|
||||
assert_ok(request(
|
||||
&api_socket,
|
||||
serde_json::json!({
|
||||
"id": "test:agent:session",
|
||||
"method": "pane.report_agent_session",
|
||||
"params": {
|
||||
"pane_id": pane_id,
|
||||
"source": "herdr:pi",
|
||||
"agent": "pi",
|
||||
"seq": 1,
|
||||
"agent_session_path": old_session,
|
||||
"session_start_source": "startup"
|
||||
}
|
||||
}),
|
||||
));
|
||||
assert_ok(request(
|
||||
&api_socket,
|
||||
serde_json::json!({
|
||||
@@ -1246,11 +1284,30 @@ fn live_handoff_keeps_unmanaged_agent_name_bound_to_saved_session() {
|
||||
"source": "herdr:pi",
|
||||
"agent": "pi",
|
||||
"state": "idle",
|
||||
"seq": 1,
|
||||
"seq": 2,
|
||||
"agent_session_path": old_session
|
||||
}
|
||||
}),
|
||||
));
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
loop {
|
||||
let response = request(
|
||||
&api_socket,
|
||||
serde_json::json!({
|
||||
"id": "test:agent:wait-for-process",
|
||||
"method": "agent.get",
|
||||
"params": {"target": pane_id}
|
||||
}),
|
||||
);
|
||||
if response.get("result").is_some() {
|
||||
break;
|
||||
}
|
||||
assert!(
|
||||
Instant::now() < deadline,
|
||||
"agent process was not detected: {response}"
|
||||
);
|
||||
thread::sleep(Duration::from_millis(25));
|
||||
}
|
||||
assert_ok(request(
|
||||
&api_socket,
|
||||
serde_json::json!({
|
||||
@@ -1276,21 +1333,31 @@ fn live_handoff_keeps_unmanaged_agent_name_bound_to_saved_session() {
|
||||
"pane_id": pane_id,
|
||||
"source": "herdr:pi",
|
||||
"agent": "pi",
|
||||
"seq": 2,
|
||||
"seq": 3,
|
||||
"agent_session_path": new_session,
|
||||
"session_start_source": "new"
|
||||
}
|
||||
}),
|
||||
));
|
||||
let old_name = request(
|
||||
&api_socket,
|
||||
serde_json::json!({
|
||||
"id": "test:agent:get-old-name",
|
||||
"method": "agent.get",
|
||||
"params": {"target": "reviewer"}
|
||||
}),
|
||||
);
|
||||
assert_eq!(old_name["error"]["code"], "agent_not_found", "{old_name}");
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
loop {
|
||||
let old_name = request(
|
||||
&api_socket,
|
||||
serde_json::json!({
|
||||
"id": "test:agent:get-old-name",
|
||||
"method": "agent.get",
|
||||
"params": {"target": "reviewer"}
|
||||
}),
|
||||
);
|
||||
if old_name["error"]["code"] == "agent_not_found" {
|
||||
break;
|
||||
}
|
||||
assert!(
|
||||
Instant::now() < deadline,
|
||||
"old session alias was not cleared: {old_name}"
|
||||
);
|
||||
thread::sleep(Duration::from_millis(25));
|
||||
}
|
||||
|
||||
let _ = request(
|
||||
&api_socket,
|
||||
|
||||
Reference in New Issue
Block a user