parent
1bab1d2ce2
commit
db67e9d74f
|
|
@ -316,6 +316,19 @@ kitty_graphics = false
|
|||
|
||||
Leave this off unless you are testing terminal image behavior.
|
||||
|
||||
## Agent session restore
|
||||
|
||||
Herdr can restart supported agent panes in their native conversation sessions after a Herdr server restart.
|
||||
|
||||
```toml
|
||||
[session]
|
||||
resume_agents_on_restore = false
|
||||
```
|
||||
|
||||
When enabled, Herdr only resumes panes that reported a native session reference through an official Herdr integration. Supported resume targets are Claude Code, Codex, Pi, Hermes Agent, and OpenCode. Unsupported, missing, invalid, duplicated, or stale session references restore as a normal shell in the saved pane directory.
|
||||
|
||||
Session references are stored in the local Herdr session snapshot. They are not shown in normal pane, agent, status, or event output.
|
||||
|
||||
## IME cursor tracking
|
||||
|
||||
When the focused pane hides its cursor and paints its own — common in AI-agent TUIs like Claude Code, pi, and codex — macOS native input methods stop tracking the candidate window position because the outer terminal stops reporting the cursor.
|
||||
|
|
|
|||
|
|
@ -43,6 +43,8 @@ Herdr combines three signals:
|
|||
|
||||
Integrations enrich state reporting. They do not replace process detection.
|
||||
|
||||
Some integrations also report native agent session references. If `[session] resume_agents_on_restore = true` is enabled, Herdr uses official session references to resume Claude Code, Codex, Pi, Hermes Agent, and OpenCode panes after a Herdr server restart.
|
||||
|
||||
## Pi
|
||||
|
||||
Install the Pi integration:
|
||||
|
|
@ -111,7 +113,7 @@ herdr integration install opencode
|
|||
|
||||
Herdr writes the plugin to `~/.config/opencode/plugins/herdr-agent-state.js`. The OpenCode config directory must already exist. Uninstall removes only that plugin file.
|
||||
|
||||
The plugin reports semantic state while OpenCode runs inside a Herdr pane.
|
||||
The plugin reports semantic state while OpenCode runs inside a Herdr pane. After OpenCode emits a session-bearing event, Herdr can use the reported session id to resume the pane with `opencode --session <id>`.
|
||||
|
||||
## Hermes Agent
|
||||
|
||||
|
|
@ -123,7 +125,7 @@ herdr integration install hermes
|
|||
|
||||
Herdr writes `~/.hermes/plugins/herdr-agent-state/` and enables `herdr-agent-state` in `~/.hermes/config.yaml`. The Hermes config directory must already exist. Restart Hermes after installing so the plugin loads. Uninstall removes the plugin directory and removes `herdr-agent-state` from `plugins.enabled`.
|
||||
|
||||
The plugin reports lifecycle, tool, and approval state while Hermes runs inside a Herdr pane. Native screen heuristics remain available when the plugin is not installed.
|
||||
The plugin reports lifecycle, tool, approval state, and session id while Hermes runs inside a Herdr pane. Herdr can use the reported session id to resume the pane with `hermes --resume <id>`. Native screen heuristics remain available when the plugin is not installed.
|
||||
|
||||
## Custom status labels
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,302 @@
|
|||
use std::path::Path;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const MAX_SESSION_ID_LEN: usize = 512;
|
||||
const MAX_SESSION_PATH_LEN: usize = 4096;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AgentSessionRef {
|
||||
pub kind: AgentSessionRefKind,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AgentSessionRefKind {
|
||||
Id,
|
||||
Path,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct AgentResumePlan {
|
||||
pub agent: String,
|
||||
pub argv: Vec<String>,
|
||||
pub dedupe_key: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PersistedAgentSession {
|
||||
pub source: String,
|
||||
pub agent: String,
|
||||
pub session_ref: AgentSessionRef,
|
||||
}
|
||||
|
||||
impl AgentSessionRef {
|
||||
pub fn id(value: impl Into<String>) -> Option<Self> {
|
||||
let value = value.into();
|
||||
valid_session_id(&value).then_some(Self {
|
||||
kind: AgentSessionRefKind::Id,
|
||||
value,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn path(value: impl Into<String>) -> Option<Self> {
|
||||
let value = value.into();
|
||||
valid_session_path(&value).then_some(Self {
|
||||
kind: AgentSessionRefKind::Path,
|
||||
value,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub fn session_ref_from_report(
|
||||
source: &str,
|
||||
agent: &str,
|
||||
agent_session_id: Option<String>,
|
||||
_agent_session_path: Option<String>,
|
||||
) -> Option<AgentSessionRef> {
|
||||
if !is_official_agent_source(source, agent) {
|
||||
return None;
|
||||
}
|
||||
|
||||
if agent == "pi" {
|
||||
return _agent_session_path
|
||||
.and_then(AgentSessionRef::path)
|
||||
.or_else(|| agent_session_id.and_then(AgentSessionRef::id));
|
||||
}
|
||||
|
||||
agent_session_id.and_then(AgentSessionRef::id)
|
||||
}
|
||||
|
||||
pub fn session_ref_from_snapshot(
|
||||
source: &str,
|
||||
agent: &str,
|
||||
kind: AgentSessionRefKind,
|
||||
value: &str,
|
||||
) -> Option<PersistedAgentSession> {
|
||||
if !is_official_agent_source(source, agent) {
|
||||
return None;
|
||||
}
|
||||
let session_ref = match (agent, kind) {
|
||||
("pi", AgentSessionRefKind::Path) => AgentSessionRef::path(value)?,
|
||||
(_, AgentSessionRefKind::Id) => AgentSessionRef::id(value)?,
|
||||
_ => return None,
|
||||
};
|
||||
Some(PersistedAgentSession {
|
||||
source: source.to_string(),
|
||||
agent: agent.to_string(),
|
||||
session_ref,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn plan(source: &str, agent: &str, session_ref: &AgentSessionRef) -> Option<AgentResumePlan> {
|
||||
if !is_official_agent_source(source, agent) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let argv = match (source, agent, session_ref.kind) {
|
||||
("herdr:claude", "claude", AgentSessionRefKind::Id) => {
|
||||
vec![
|
||||
"claude".into(),
|
||||
"--resume".into(),
|
||||
session_ref.value.clone(),
|
||||
]
|
||||
}
|
||||
("herdr:codex", "codex", AgentSessionRefKind::Id) => {
|
||||
vec!["codex".into(), "resume".into(), session_ref.value.clone()]
|
||||
}
|
||||
("herdr:pi", "pi", AgentSessionRefKind::Path | AgentSessionRefKind::Id) => {
|
||||
vec!["pi".into(), "--session".into(), session_ref.value.clone()]
|
||||
}
|
||||
("herdr:hermes", "hermes", AgentSessionRefKind::Id) => {
|
||||
vec![
|
||||
"hermes".into(),
|
||||
"--resume".into(),
|
||||
session_ref.value.clone(),
|
||||
]
|
||||
}
|
||||
("herdr:opencode", "opencode", AgentSessionRefKind::Id) => {
|
||||
vec![
|
||||
"opencode".into(),
|
||||
"--session".into(),
|
||||
session_ref.value.clone(),
|
||||
]
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
Some(AgentResumePlan {
|
||||
agent: agent.to_string(),
|
||||
argv,
|
||||
dedupe_key: dedupe_key(source, agent, session_ref),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn dedupe_key(source: &str, agent: &str, session_ref: &AgentSessionRef) -> String {
|
||||
format!(
|
||||
"{source}\u{0}{agent}\u{0}{:?}\u{0}{}",
|
||||
session_ref.kind, session_ref.value
|
||||
)
|
||||
}
|
||||
|
||||
fn is_official_agent_source(source: &str, agent: &str) -> bool {
|
||||
matches!(
|
||||
(source, agent),
|
||||
("herdr:claude", "claude")
|
||||
| ("herdr:codex", "codex")
|
||||
| ("herdr:pi", "pi")
|
||||
| ("herdr:hermes", "hermes")
|
||||
| ("herdr:opencode", "opencode")
|
||||
)
|
||||
}
|
||||
|
||||
fn valid_session_id(value: &str) -> bool {
|
||||
!value.is_empty() && value.len() <= MAX_SESSION_ID_LEN && !value.chars().any(char::is_control)
|
||||
}
|
||||
|
||||
fn valid_session_path(value: &str) -> bool {
|
||||
!value.is_empty()
|
||||
&& value.len() <= MAX_SESSION_PATH_LEN
|
||||
&& !value.chars().any(char::is_control)
|
||||
&& Path::new(value).is_absolute()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn planner_allows_supported_agents() {
|
||||
assert_eq!(
|
||||
plan(
|
||||
"herdr:claude",
|
||||
"claude",
|
||||
&AgentSessionRef::id("claude-session").unwrap()
|
||||
)
|
||||
.unwrap()
|
||||
.argv,
|
||||
vec!["claude", "--resume", "claude-session"]
|
||||
);
|
||||
assert_eq!(
|
||||
plan(
|
||||
"herdr:codex",
|
||||
"codex",
|
||||
&AgentSessionRef::id("codex-session").unwrap()
|
||||
)
|
||||
.unwrap()
|
||||
.argv,
|
||||
vec!["codex", "resume", "codex-session"]
|
||||
);
|
||||
assert_eq!(
|
||||
plan(
|
||||
"herdr:pi",
|
||||
"pi",
|
||||
&AgentSessionRef::path("/tmp/pi-session.jsonl").unwrap()
|
||||
)
|
||||
.unwrap()
|
||||
.argv,
|
||||
vec!["pi", "--session", "/tmp/pi-session.jsonl"]
|
||||
);
|
||||
assert_eq!(
|
||||
plan(
|
||||
"herdr:hermes",
|
||||
"hermes",
|
||||
&AgentSessionRef::id("hermes-session").unwrap()
|
||||
)
|
||||
.unwrap()
|
||||
.argv,
|
||||
vec!["hermes", "--resume", "hermes-session"]
|
||||
);
|
||||
assert_eq!(
|
||||
plan(
|
||||
"herdr:opencode",
|
||||
"opencode",
|
||||
&AgentSessionRef::id("opencode-session").unwrap()
|
||||
)
|
||||
.unwrap()
|
||||
.argv,
|
||||
vec!["opencode", "--session", "opencode-session"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn planner_rejects_custom_and_unsupported_path_refs() {
|
||||
assert!(plan(
|
||||
"custom:claude",
|
||||
"claude",
|
||||
&AgentSessionRef::id("session").unwrap()
|
||||
)
|
||||
.is_none());
|
||||
assert!(plan(
|
||||
"herdr:claude",
|
||||
"claude",
|
||||
&AgentSessionRef::path("/tmp/claude-session").unwrap()
|
||||
)
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn report_ref_prefers_pi_path_and_validates_values() {
|
||||
let session_ref = session_ref_from_report(
|
||||
"herdr:pi",
|
||||
"pi",
|
||||
Some("pi-id".into()),
|
||||
Some("/tmp/pi-session.jsonl".into()),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(session_ref.kind, AgentSessionRefKind::Path);
|
||||
assert_eq!(session_ref.value, "/tmp/pi-session.jsonl");
|
||||
|
||||
assert!(session_ref_from_report("herdr:pi", "pi", Some("bad\nid".into()), None).is_none());
|
||||
assert!(
|
||||
session_ref_from_report("herdr:pi", "pi", None, Some("relative.jsonl".into()))
|
||||
.is_none()
|
||||
);
|
||||
assert!(session_ref_from_report("custom:pi", "pi", Some("pi-id".into()), None).is_none());
|
||||
assert!(session_ref_from_report(
|
||||
"herdr:claude",
|
||||
"claude",
|
||||
None,
|
||||
Some("/tmp/claude-session".into())
|
||||
)
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ids_are_data_not_shell_text() {
|
||||
let id = "abc; rm -rf /";
|
||||
let plan = plan("herdr:codex", "codex", &AgentSessionRef::id(id).unwrap()).unwrap();
|
||||
assert_eq!(plan.argv, vec!["codex", "resume", id]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn planner_rejects_path_refs_for_id_only_agents() {
|
||||
assert!(plan(
|
||||
"herdr:hermes",
|
||||
"hermes",
|
||||
&AgentSessionRef::path("/tmp/hermes-session").unwrap()
|
||||
)
|
||||
.is_none());
|
||||
assert!(plan(
|
||||
"herdr:opencode",
|
||||
"opencode",
|
||||
&AgentSessionRef::path("/tmp/opencode-session").unwrap()
|
||||
)
|
||||
.is_none());
|
||||
assert!(session_ref_from_snapshot(
|
||||
"herdr:hermes",
|
||||
"hermes",
|
||||
AgentSessionRefKind::Id,
|
||||
"hermes-session"
|
||||
)
|
||||
.is_some());
|
||||
assert!(session_ref_from_snapshot(
|
||||
"herdr:opencode",
|
||||
"opencode",
|
||||
AgentSessionRefKind::Id,
|
||||
"opencode-session"
|
||||
)
|
||||
.is_some());
|
||||
}
|
||||
}
|
||||
|
|
@ -331,6 +331,10 @@ pub struct PaneReportAgentParams {
|
|||
pub custom_status: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub seq: Option<u64>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub agent_session_id: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub agent_session_path: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
|
|
@ -959,6 +963,8 @@ mod tests {
|
|||
message: Some("thinking".into()),
|
||||
custom_status: Some("indexing".into()),
|
||||
seq: Some(42),
|
||||
agent_session_id: Some("pi-session".into()),
|
||||
agent_session_path: Some("/tmp/pi-session.jsonl".into()),
|
||||
}),
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ use tracing::{info, warn};
|
|||
use crate::detect::{Agent, AgentState};
|
||||
use crate::events::AppEvent;
|
||||
use crate::layout::{find_in_direction, NavDirection, PaneId};
|
||||
use crate::terminal::EffectiveStateChange;
|
||||
use crate::terminal::{EffectiveStateChange, TerminalStateMutation};
|
||||
use crate::workspace::WorkspaceGitStatus;
|
||||
|
||||
use super::state::{AppState, Mode, ToastKind, ToastNotification, ToastTarget, ViewLayout};
|
||||
|
|
@ -883,7 +883,7 @@ impl AppState {
|
|||
observed_at,
|
||||
} => self
|
||||
.update_terminal_state(pane_id, |terminal| {
|
||||
terminal.set_detected_state_with_screen_signals_at(
|
||||
Some(terminal.set_detected_state_with_screen_signals_at(
|
||||
agent,
|
||||
state,
|
||||
visible_blocker,
|
||||
|
|
@ -891,7 +891,7 @@ impl AppState {
|
|||
visible_working,
|
||||
process_exited,
|
||||
observed_at,
|
||||
)
|
||||
))
|
||||
})
|
||||
.into_iter()
|
||||
.collect(),
|
||||
|
|
@ -903,14 +903,16 @@ impl AppState {
|
|||
message,
|
||||
custom_status,
|
||||
seq,
|
||||
session_ref,
|
||||
} => self
|
||||
.update_terminal_state(pane_id, |terminal| {
|
||||
terminal.set_hook_authority_with_custom_status(
|
||||
terminal.set_hook_authority_with_session_ref(
|
||||
source,
|
||||
agent_label,
|
||||
state,
|
||||
message,
|
||||
custom_status,
|
||||
session_ref,
|
||||
seq,
|
||||
)
|
||||
})
|
||||
|
|
@ -922,7 +924,7 @@ impl AppState {
|
|||
seq,
|
||||
} => self
|
||||
.update_terminal_state(pane_id, |terminal| {
|
||||
terminal.clear_hook_authority(source.as_deref(), seq)
|
||||
terminal.clear_hook_authority_with_mutation(source.as_deref(), seq)
|
||||
})
|
||||
.into_iter()
|
||||
.collect(),
|
||||
|
|
@ -934,7 +936,7 @@ impl AppState {
|
|||
..
|
||||
} => self
|
||||
.update_terminal_state(pane_id, |terminal| {
|
||||
terminal.release_agent(&source, &agent_label, seq)
|
||||
terminal.release_agent_with_mutation(&source, &agent_label, seq)
|
||||
})
|
||||
.into_iter()
|
||||
.collect(),
|
||||
|
|
@ -952,7 +954,7 @@ impl AppState {
|
|||
|
||||
fn update_terminal_state<F>(&mut self, pane_id: PaneId, update: F) -> Option<PaneStateUpdate>
|
||||
where
|
||||
F: FnOnce(&mut crate::terminal::TerminalState) -> Option<EffectiveStateChange>,
|
||||
F: FnOnce(&mut crate::terminal::TerminalState) -> Option<TerminalStateMutation>,
|
||||
{
|
||||
let ws_idx = self
|
||||
.workspaces
|
||||
|
|
@ -962,10 +964,14 @@ impl AppState {
|
|||
.pane_state(pane_id)?
|
||||
.attached_terminal_id
|
||||
.clone();
|
||||
let change = {
|
||||
let mutation = {
|
||||
let terminal = self.terminals.get_mut(&terminal_id)?;
|
||||
update(terminal)?
|
||||
};
|
||||
if mutation.session_ref_changed {
|
||||
self.mark_session_dirty();
|
||||
}
|
||||
let change = mutation.effective_state_change?;
|
||||
let update = PaneStateUpdate {
|
||||
pane_id,
|
||||
ws_idx,
|
||||
|
|
@ -1788,6 +1794,7 @@ mod tests {
|
|||
message: None,
|
||||
custom_status: None,
|
||||
seq: None,
|
||||
session_ref: None,
|
||||
});
|
||||
|
||||
let toast = state.toast.as_ref().unwrap();
|
||||
|
|
@ -1827,6 +1834,7 @@ mod tests {
|
|||
message: None,
|
||||
custom_status: None,
|
||||
seq: Some(1),
|
||||
session_ref: None,
|
||||
});
|
||||
state.handle_app_event(AppEvent::StateChanged {
|
||||
pane_id: bg_pane_id,
|
||||
|
|
@ -1877,6 +1885,7 @@ mod tests {
|
|||
message: None,
|
||||
custom_status: None,
|
||||
seq: Some(1),
|
||||
session_ref: None,
|
||||
});
|
||||
state.handle_app_event(AppEvent::StateChanged {
|
||||
pane_id: bg_pane_id,
|
||||
|
|
@ -1894,6 +1903,39 @@ mod tests {
|
|||
assert!(state.toast.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hidden_session_ref_only_update_marks_session_dirty_without_visible_update() {
|
||||
let mut state = app_with_workspaces(&["active"]);
|
||||
let pane_id = *state.workspaces[0].panes.keys().next().unwrap();
|
||||
|
||||
let first_updates = state.handle_app_event(AppEvent::HookStateReported {
|
||||
pane_id,
|
||||
source: "herdr:pi".into(),
|
||||
agent_label: "pi".into(),
|
||||
state: AgentState::Working,
|
||||
message: None,
|
||||
custom_status: None,
|
||||
seq: Some(20),
|
||||
session_ref: crate::agent_resume::AgentSessionRef::path("/tmp/one.jsonl"),
|
||||
});
|
||||
assert_eq!(first_updates.len(), 1);
|
||||
state.session_dirty = false;
|
||||
|
||||
let second_updates = state.handle_app_event(AppEvent::HookStateReported {
|
||||
pane_id,
|
||||
source: "herdr:pi".into(),
|
||||
agent_label: "pi".into(),
|
||||
state: AgentState::Working,
|
||||
message: None,
|
||||
custom_status: None,
|
||||
seq: Some(21),
|
||||
session_ref: crate::agent_resume::AgentSessionRef::path("/tmp/two.jsonl"),
|
||||
});
|
||||
|
||||
assert!(second_updates.is_empty());
|
||||
assert!(state.session_dirty);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn background_idle_sets_finished_toast() {
|
||||
let mut state = app_with_workspaces(&["active", "background"]);
|
||||
|
|
|
|||
|
|
@ -179,6 +179,12 @@ impl App {
|
|||
};
|
||||
self.handle_internal_event(crate::events::AppEvent::HookStateReported {
|
||||
pane_id,
|
||||
session_ref: crate::agent_resume::session_ref_from_report(
|
||||
¶ms.source,
|
||||
&agent_label,
|
||||
params.agent_session_id,
|
||||
params.agent_session_path,
|
||||
),
|
||||
source: params.source,
|
||||
agent_label,
|
||||
state: detect_state_from_api(params.state),
|
||||
|
|
|
|||
|
|
@ -255,6 +255,7 @@ impl App {
|
|||
80,
|
||||
config.advanced.scrollback_limit_bytes,
|
||||
&config.terminal.default_shell,
|
||||
config.session.resume_agents_on_restore,
|
||||
event_tx.clone(),
|
||||
render_notify.clone(),
|
||||
render_dirty.clone(),
|
||||
|
|
|
|||
|
|
@ -299,7 +299,7 @@ fn pane_run(args: &[String]) -> std::io::Result<i32> {
|
|||
|
||||
fn pane_report_agent(args: &[String]) -> std::io::Result<i32> {
|
||||
let Some(raw_pane_id) = args.first() else {
|
||||
eprintln!("usage: herdr pane report-agent <pane_id> --source ID --agent LABEL --state idle|working|blocked|unknown [--message TEXT] [--custom-status TEXT] [--seq N]");
|
||||
eprintln!("usage: herdr pane report-agent <pane_id> --source ID --agent LABEL --state idle|working|blocked|unknown [--message TEXT] [--custom-status TEXT] [--seq N] [--agent-session-id ID] [--agent-session-path PATH]");
|
||||
return Ok(2);
|
||||
};
|
||||
|
||||
|
|
@ -310,6 +310,8 @@ fn pane_report_agent(args: &[String]) -> std::io::Result<i32> {
|
|||
let mut message = None;
|
||||
let mut custom_status = None;
|
||||
let mut seq = None;
|
||||
let mut agent_session_id = None;
|
||||
let mut agent_session_path = None;
|
||||
|
||||
let mut index = 1;
|
||||
while index < args.len() {
|
||||
|
|
@ -362,6 +364,22 @@ fn pane_report_agent(args: &[String]) -> std::io::Result<i32> {
|
|||
seq = Some(super::parse_u64_flag("--seq", value)?);
|
||||
index += 2;
|
||||
}
|
||||
"--agent-session-id" => {
|
||||
let Some(value) = args.get(index + 1) else {
|
||||
eprintln!("missing value for --agent-session-id");
|
||||
return Ok(2);
|
||||
};
|
||||
agent_session_id = Some(value.clone());
|
||||
index += 2;
|
||||
}
|
||||
"--agent-session-path" => {
|
||||
let Some(value) = args.get(index + 1) else {
|
||||
eprintln!("missing value for --agent-session-path");
|
||||
return Ok(2);
|
||||
};
|
||||
agent_session_path = Some(value.clone());
|
||||
index += 2;
|
||||
}
|
||||
other => {
|
||||
eprintln!("unknown option: {other}");
|
||||
return Ok(2);
|
||||
|
|
@ -390,6 +408,8 @@ fn pane_report_agent(args: &[String]) -> std::io::Result<i32> {
|
|||
message,
|
||||
custom_status,
|
||||
seq,
|
||||
agent_session_id,
|
||||
agent_session_path,
|
||||
}))
|
||||
}
|
||||
|
||||
|
|
@ -405,6 +425,6 @@ fn print_pane_help() {
|
|||
eprintln!(" herdr pane close <pane_id>");
|
||||
eprintln!(" herdr pane send-text <pane_id> <text>");
|
||||
eprintln!(" herdr pane send-keys <pane_id> <key> [key ...]");
|
||||
eprintln!(" herdr pane report-agent <pane_id> --source ID --agent LABEL --state idle|working|blocked|unknown [--message TEXT] [--custom-status TEXT] [--seq N]");
|
||||
eprintln!(" herdr pane report-agent <pane_id> --source ID --agent LABEL --state idle|working|blocked|unknown [--message TEXT] [--custom-status TEXT] [--seq N] [--agent-session-id ID] [--agent-session-path PATH]");
|
||||
eprintln!(" herdr pane run <pane_id> <command>");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -173,6 +173,14 @@ fn load_live_config_from_str(content: &str) -> Result<LoadedConfig, Vec<String>>
|
|||
&mut invalid_sections,
|
||||
|section| config.terminal = section,
|
||||
);
|
||||
load_live_section(
|
||||
table,
|
||||
"session",
|
||||
"session config",
|
||||
&mut diagnostics,
|
||||
&mut invalid_sections,
|
||||
|section| config.session = section,
|
||||
);
|
||||
load_live_section(
|
||||
table,
|
||||
"ui",
|
||||
|
|
@ -463,6 +471,21 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_live_config_parses_session_section() {
|
||||
let loaded = load_live_config_from_str(
|
||||
r#"
|
||||
[session]
|
||||
resume_agents_on_restore = true
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(loaded.config.session.resume_agents_on_restore);
|
||||
assert!(loaded.diagnostics.is_empty());
|
||||
assert!(loaded.invalid_sections.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_keybinding_config_sections_removes_keys_tables_only() {
|
||||
let content = r#"onboarding = false
|
||||
|
|
|
|||
|
|
@ -72,6 +72,14 @@ pub struct TerminalConfig {
|
|||
pub new_cwd: NewTerminalCwdConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct SessionConfig {
|
||||
/// Resume supported AI-agent panes into their native conversation sessions
|
||||
/// when restoring a Herdr session. Default: false.
|
||||
pub resume_agents_on_restore: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ConfigReloadStatus {
|
||||
|
|
@ -105,6 +113,7 @@ pub struct Config {
|
|||
pub onboarding: Option<bool>,
|
||||
pub theme: ThemeConfig,
|
||||
pub terminal: TerminalConfig,
|
||||
pub session: SessionConfig,
|
||||
pub keys: KeysConfig,
|
||||
pub ui: UiConfig,
|
||||
pub worktrees: WorktreesConfig,
|
||||
|
|
@ -489,6 +498,19 @@ new_cwd = "~/Projects"
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resume_agents_on_restore_defaults_off_and_parses() {
|
||||
let default_config = Config::default();
|
||||
assert!(!default_config.session.resume_agents_on_restore);
|
||||
|
||||
let toml = r#"
|
||||
[session]
|
||||
resume_agents_on_restore = true
|
||||
"#;
|
||||
let config: Config = toml::from_str(toml).unwrap();
|
||||
assert!(config.session.resume_agents_on_restore);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_panel_scope_config_parses() {
|
||||
let toml = r#"
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ pub enum AppEvent {
|
|||
message: Option<String>,
|
||||
custom_status: Option<String>,
|
||||
seq: Option<u64>,
|
||||
session_ref: Option<crate::agent_resume::AgentSessionRef>,
|
||||
},
|
||||
/// Hook authority was explicitly cleared for a pane.
|
||||
HookAuthorityCleared {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
# installed by herdr
|
||||
# safe to edit. this hook only activates inside herdr-managed panes.
|
||||
# HERDR_INTEGRATION_ID=claude
|
||||
# HERDR_INTEGRATION_VERSION=3
|
||||
# HERDR_INTEGRATION_VERSION=4
|
||||
|
||||
set -eu
|
||||
|
||||
|
|
@ -60,6 +60,8 @@ if is_subagent and action in ("idle", "release"):
|
|||
|
||||
request_id = f"{source}:{int(time.time() * 1000)}:{random.randrange(1_000_000):06d}"
|
||||
report_seq = time.time_ns()
|
||||
session_id = hook_input.get("session_id")
|
||||
agent_session_id = session_id if isinstance(session_id, str) and session_id else None
|
||||
if action == "release":
|
||||
request = {
|
||||
"id": request_id,
|
||||
|
|
@ -83,6 +85,8 @@ else:
|
|||
"seq": report_seq,
|
||||
},
|
||||
}
|
||||
if agent_session_id:
|
||||
request["params"]["agent_session_id"] = agent_session_id
|
||||
|
||||
try:
|
||||
client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
|
|
|
|||
|
|
@ -2,12 +2,14 @@
|
|||
# installed by herdr
|
||||
# safe to edit. this hook only activates inside herdr-managed panes.
|
||||
# HERDR_INTEGRATION_ID=codex
|
||||
# HERDR_INTEGRATION_VERSION=3
|
||||
# HERDR_INTEGRATION_VERSION=4
|
||||
|
||||
set -eu
|
||||
|
||||
action="${1:-}"
|
||||
cat >/dev/null 2>/dev/null || true
|
||||
hook_input_file="$(mktemp "${TMPDIR:-/tmp}/herdr-codex-hook.XXXXXX")" || exit 0
|
||||
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) ;;
|
||||
|
|
@ -19,7 +21,7 @@ esac
|
|||
[ -n "${HERDR_PANE_ID:-}" ] || exit 0
|
||||
command -v python3 >/dev/null 2>&1 || exit 0
|
||||
|
||||
HERDR_ACTION="$action" python3 - <<'PY'
|
||||
HERDR_ACTION="$action" HERDR_HOOK_INPUT_FILE="$hook_input_file" python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
|
|
@ -30,12 +32,25 @@ source = "herdr:codex"
|
|||
action = os.environ.get("HERDR_ACTION", "")
|
||||
pane_id = os.environ.get("HERDR_PANE_ID")
|
||||
socket_path = os.environ.get("HERDR_SOCKET_PATH")
|
||||
hook_input_file = os.environ.get("HERDR_HOOK_INPUT_FILE")
|
||||
|
||||
if not pane_id or not socket_path:
|
||||
raise SystemExit(0)
|
||||
|
||||
hook_input = {}
|
||||
if hook_input_file:
|
||||
try:
|
||||
with open(hook_input_file, encoding="utf-8") as handle:
|
||||
content = handle.read()
|
||||
if content.strip():
|
||||
hook_input = json.loads(content)
|
||||
except Exception:
|
||||
hook_input = {}
|
||||
|
||||
request_id = f"{source}:{int(time.time() * 1000)}:{random.randrange(1_000_000):06d}"
|
||||
report_seq = time.time_ns()
|
||||
session_id = hook_input.get("session_id")
|
||||
agent_session_id = session_id if isinstance(session_id, str) and session_id else None
|
||||
if action == "release":
|
||||
request = {
|
||||
"id": request_id,
|
||||
|
|
@ -59,6 +74,8 @@ else:
|
|||
"seq": report_seq,
|
||||
},
|
||||
}
|
||||
if agent_session_id:
|
||||
request["params"]["agent_session_id"] = agent_session_id
|
||||
|
||||
try:
|
||||
client = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
"""Hermes plugin installed by Herdr to report agent lifecycle state."""
|
||||
|
||||
# HERDR_INTEGRATION_ID=hermes
|
||||
# HERDR_INTEGRATION_VERSION=1
|
||||
# HERDR_INTEGRATION_VERSION=2
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
|
|
@ -56,8 +56,19 @@ def _send(method: str, params: dict) -> None:
|
|||
pass
|
||||
|
||||
|
||||
def _report(state: str) -> None:
|
||||
_send("pane.report_agent", {"state": state})
|
||||
def _session_id(kwargs: dict) -> str | None:
|
||||
value = kwargs.get("session_id")
|
||||
if isinstance(value, str) and value:
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def _report(state: str, **kwargs) -> None:
|
||||
params = {"state": state}
|
||||
session_id = _session_id(kwargs)
|
||||
if session_id:
|
||||
params["agent_session_id"] = session_id
|
||||
_send("pane.report_agent", params)
|
||||
|
||||
|
||||
def _release() -> None:
|
||||
|
|
@ -65,18 +76,15 @@ def _release() -> None:
|
|||
|
||||
|
||||
def _working(**kwargs) -> None:
|
||||
del kwargs
|
||||
_report("working")
|
||||
_report("working", **kwargs)
|
||||
|
||||
|
||||
def _blocked(**kwargs) -> None:
|
||||
del kwargs
|
||||
_report("blocked")
|
||||
_report("blocked", **kwargs)
|
||||
|
||||
|
||||
def _idle(**kwargs) -> None:
|
||||
del kwargs
|
||||
_report("idle")
|
||||
_report("idle", **kwargs)
|
||||
|
||||
|
||||
def _finalize(**kwargs) -> None:
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
// installed by herdr
|
||||
// safe to edit. this plugin only activates inside herdr-managed panes.
|
||||
// HERDR_INTEGRATION_ID=opencode
|
||||
// HERDR_INTEGRATION_VERSION=1
|
||||
// HERDR_INTEGRATION_VERSION=2
|
||||
|
||||
import net from "node:net";
|
||||
|
||||
|
|
@ -13,7 +13,13 @@ function nextReportSeq() {
|
|||
return reportSeq;
|
||||
}
|
||||
|
||||
function reportState(action) {
|
||||
function sessionIDFromProperties(properties) {
|
||||
return typeof properties?.sessionID === "string" && properties.sessionID
|
||||
? properties.sessionID
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function reportState(action, sessionID) {
|
||||
const paneId = process.env.HERDR_PANE_ID;
|
||||
const socketPath = process.env.HERDR_SOCKET_PATH;
|
||||
|
||||
|
|
@ -24,24 +30,26 @@ function reportState(action) {
|
|||
const requestId = `${SOURCE}:${Date.now()}:${Math.floor(Math.random() * 1_000_000)
|
||||
.toString()
|
||||
.padStart(6, "0")}`;
|
||||
const params =
|
||||
action === "release"
|
||||
? {
|
||||
pane_id: paneId,
|
||||
source: SOURCE,
|
||||
agent: "opencode",
|
||||
seq: nextReportSeq(),
|
||||
}
|
||||
: {
|
||||
pane_id: paneId,
|
||||
source: SOURCE,
|
||||
agent: "opencode",
|
||||
state: action,
|
||||
seq: nextReportSeq(),
|
||||
...(sessionID ? { agent_session_id: sessionID } : {}),
|
||||
};
|
||||
const request = {
|
||||
id: requestId,
|
||||
method: action === "release" ? "pane.release_agent" : "pane.report_agent",
|
||||
params:
|
||||
action === "release"
|
||||
? {
|
||||
pane_id: paneId,
|
||||
source: SOURCE,
|
||||
agent: "opencode",
|
||||
seq: nextReportSeq(),
|
||||
}
|
||||
: {
|
||||
pane_id: paneId,
|
||||
source: SOURCE,
|
||||
agent: "opencode",
|
||||
state: action,
|
||||
seq: nextReportSeq(),
|
||||
},
|
||||
params,
|
||||
};
|
||||
|
||||
return new Promise((resolve) => {
|
||||
|
|
@ -75,26 +83,33 @@ export const HerdrAgentStatePlugin = async () => {
|
|||
event: async ({ event }) => {
|
||||
const type = event?.type;
|
||||
const properties = event?.properties ?? {};
|
||||
const sessionID = sessionIDFromProperties(properties);
|
||||
|
||||
switch (type) {
|
||||
case "permission.asked":
|
||||
case "question.asked":
|
||||
await reportState("blocked");
|
||||
await reportState("blocked", sessionID);
|
||||
break;
|
||||
case "permission.replied": {
|
||||
const reply = properties.reply ?? properties.response;
|
||||
if (reply === "reject") {
|
||||
await reportState("idle");
|
||||
await reportState("idle", sessionID);
|
||||
} else if (reply === "once" || reply === "always") {
|
||||
await reportState("working");
|
||||
await reportState("working", sessionID);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "question.replied":
|
||||
await reportState("working");
|
||||
await reportState("working", sessionID);
|
||||
break;
|
||||
case "question.rejected":
|
||||
await reportState("idle");
|
||||
await reportState("idle", sessionID);
|
||||
break;
|
||||
case "session.created":
|
||||
case "session.updated":
|
||||
if (sessionID) {
|
||||
await reportState("idle", sessionID);
|
||||
}
|
||||
break;
|
||||
case "session.status": {
|
||||
const status =
|
||||
|
|
@ -102,14 +117,14 @@ export const HerdrAgentStatePlugin = async () => {
|
|||
? properties.status
|
||||
: properties.status?.type;
|
||||
if (status === "busy" || status === "retry") {
|
||||
await reportState("working");
|
||||
await reportState("working", sessionID);
|
||||
} else if (status === "idle") {
|
||||
await reportState("idle");
|
||||
await reportState("idle", sessionID);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "session.idle":
|
||||
await reportState("idle");
|
||||
await reportState("idle", sessionID);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
// installed by herdr
|
||||
// safe to edit. this integration only activates inside herdr-managed panes.
|
||||
// HERDR_INTEGRATION_ID=pi
|
||||
// HERDR_INTEGRATION_VERSION=1
|
||||
// HERDR_INTEGRATION_VERSION=2
|
||||
// @ts-nocheck
|
||||
|
||||
import { createConnection } from "node:net";
|
||||
|
|
@ -52,6 +52,8 @@ 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;
|
||||
let currentAgentSessionId: string | undefined;
|
||||
let currentAgentSessionPath: string | undefined;
|
||||
|
||||
function nextReportSeq(): number {
|
||||
reportSeq += 1;
|
||||
|
|
@ -70,18 +72,45 @@ function parseDurationEnv(name: string, fallback: number): number {
|
|||
return parsed;
|
||||
}
|
||||
|
||||
function updateSessionRef(ctx: any): void {
|
||||
try {
|
||||
const file = ctx?.sessionManager?.getSessionFile?.();
|
||||
currentAgentSessionPath =
|
||||
typeof file === "string" && file.startsWith("/") ? file : undefined;
|
||||
} catch {
|
||||
currentAgentSessionPath = undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const id = ctx?.sessionManager?.getSessionId?.();
|
||||
currentAgentSessionId = typeof id === "string" && id.length > 0 ? id : undefined;
|
||||
} catch {
|
||||
currentAgentSessionId = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function withSessionRef(params: Record<string, unknown>): Record<string, unknown> {
|
||||
if (currentAgentSessionPath) {
|
||||
return { ...params, agent_session_path: currentAgentSessionPath };
|
||||
}
|
||||
if (currentAgentSessionId) {
|
||||
return { ...params, agent_session_id: currentAgentSessionId };
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
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",
|
||||
params: {
|
||||
params: withSessionRef({
|
||||
pane_id: paneId,
|
||||
source,
|
||||
agent: "pi",
|
||||
state,
|
||||
message,
|
||||
seq,
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -200,9 +229,9 @@ export default function (pi) {
|
|||
return { state: "idle" as const, message: undefined };
|
||||
}
|
||||
|
||||
function publishState() {
|
||||
function publishState(force = false) {
|
||||
const next = desiredState();
|
||||
if (next.state === lastState && next.message === lastMessage) {
|
||||
if (!force && next.state === lastState && next.message === lastMessage) {
|
||||
return;
|
||||
}
|
||||
lastState = next.state;
|
||||
|
|
@ -220,6 +249,11 @@ export default function (pi) {
|
|||
idleTimer.unref?.();
|
||||
}
|
||||
|
||||
pi.on("session_start", (_event, ctx) => {
|
||||
updateSessionRef(ctx);
|
||||
publishState(true);
|
||||
});
|
||||
|
||||
function holdForRetry(message: string) {
|
||||
clearPendingTimers();
|
||||
retryHoldActive = true;
|
||||
|
|
|
|||
|
|
@ -12,28 +12,28 @@ 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 PI_INTEGRATION_VERSION: u32 = 2;
|
||||
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 = 1;
|
||||
const PI_CODING_AGENT_DIR_ENV_VAR: &str = "PI_CODING_AGENT_DIR";
|
||||
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 = 3;
|
||||
const CLAUDE_INTEGRATION_VERSION: u32 = 4;
|
||||
const CLAUDE_CONFIG_DIR_ENV_VAR: &str = "CLAUDE_CONFIG_DIR";
|
||||
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 = 3;
|
||||
const CODEX_INTEGRATION_VERSION: u32 = 4;
|
||||
const CODEX_HOME_ENV_VAR: &str = "CODEX_HOME";
|
||||
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 OPENCODE_INTEGRATION_VERSION: u32 = 2;
|
||||
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 = 1;
|
||||
const HERMES_INTEGRATION_VERSION: u32 = 2;
|
||||
const INTEGRATION_VERSION_MARKER: &str = "HERDR_INTEGRATION_VERSION=";
|
||||
|
||||
#[derive(Debug)]
|
||||
|
|
@ -683,6 +683,13 @@ pub(crate) fn install_claude() -> io::Result<ClaudeInstallPaths> {
|
|||
"SubagentStop",
|
||||
&format!("bash {quoted_hook_path} working"),
|
||||
)?;
|
||||
ensure_command_hook(
|
||||
hooks,
|
||||
"SessionStart",
|
||||
format!("bash {quoted_hook_path} idle"),
|
||||
10,
|
||||
Some("*"),
|
||||
)?;
|
||||
ensure_command_hook(
|
||||
hooks,
|
||||
"UserPromptSubmit",
|
||||
|
|
@ -908,6 +915,11 @@ pub(crate) fn uninstall_claude() -> io::Result<ClaudeUninstallResult> {
|
|||
"claude settings hooks",
|
||||
)? {
|
||||
let quoted_hook_path = shell_single_quote(&hook_path.display().to_string());
|
||||
updated_settings |= remove_command_hook(
|
||||
hooks,
|
||||
"SessionStart",
|
||||
&format!("bash {quoted_hook_path} idle"),
|
||||
)?;
|
||||
updated_settings |= remove_command_hook(
|
||||
hooks,
|
||||
"UserPromptSubmit",
|
||||
|
|
@ -1901,6 +1913,11 @@ mod tests {
|
|||
);
|
||||
assert_eq!(hook_content, CLAUDE_HOOK_ASSET);
|
||||
assert!(settings["permissions"]["allow"].is_array());
|
||||
assert_eq!(settings["hooks"]["SessionStart"][0]["matcher"], "*");
|
||||
assert!(settings["hooks"]["SessionStart"][0]["hooks"][0]["command"]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains(" idle"));
|
||||
assert_eq!(settings["hooks"]["UserPromptSubmit"][0]["matcher"], "*");
|
||||
assert!(
|
||||
settings["hooks"]["UserPromptSubmit"][0]["hooks"][0]["command"]
|
||||
|
|
@ -1984,6 +2001,10 @@ mod tests {
|
|||
.len(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
settings["hooks"]["SessionStart"].as_array().unwrap().len(),
|
||||
1
|
||||
);
|
||||
assert!(settings["hooks"].get("PostToolUse").is_none());
|
||||
assert!(settings["hooks"].get("PostToolUseFailure").is_none());
|
||||
assert!(settings["hooks"].get("SubagentStop").is_none());
|
||||
|
|
@ -2069,7 +2090,7 @@ mod tests {
|
|||
|
||||
assert_eq!(claude.path, hook_path);
|
||||
assert_eq!(claude.installed_version, Some(1));
|
||||
assert_eq!(claude.expected_version, 3);
|
||||
assert_eq!(claude.expected_version, 4);
|
||||
assert_eq!(claude.state, IntegrationStatusKind::Outdated);
|
||||
|
||||
std::env::remove_var("HOME");
|
||||
|
|
@ -2099,7 +2120,7 @@ mod tests {
|
|||
|
||||
assert_eq!(claude.path, hook_path);
|
||||
assert_eq!(claude.installed_version, Some(2));
|
||||
assert_eq!(claude.expected_version, 3);
|
||||
assert_eq!(claude.expected_version, 4);
|
||||
assert_eq!(claude.state, IntegrationStatusKind::Outdated);
|
||||
|
||||
std::env::remove_var("HOME");
|
||||
|
|
@ -2119,7 +2140,8 @@ mod tests {
|
|||
fs::write(
|
||||
claude_dir.join("settings.json"),
|
||||
format!(
|
||||
r#"{{"hooks":{{"UserPromptSubmit":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' working","timeout":10}},{{"type":"command","command":"echo keep","timeout":10}}]}}],"PermissionRequest":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' blocked","timeout":10}}]}}],"PostToolUse":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' working","timeout":10}}]}}],"PostToolUseFailure":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' working","timeout":10}}]}}],"SubagentStop":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' working","timeout":10}}]}}],"Stop":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' idle","timeout":10}}]}}],"SessionEnd":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' release","timeout":10}}]}}]}}}}"#,
|
||||
r#"{{"hooks":{{"SessionStart":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' idle","timeout":10}}]}}],"UserPromptSubmit":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' working","timeout":10}},{{"type":"command","command":"echo keep","timeout":10}}]}}],"PermissionRequest":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' blocked","timeout":10}}]}}],"PostToolUse":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' working","timeout":10}}]}}],"PostToolUseFailure":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' working","timeout":10}}]}}],"SubagentStop":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' working","timeout":10}}]}}],"Stop":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' idle","timeout":10}}]}}],"SessionEnd":[{{"matcher":"*","hooks":[{{"type":"command","command":"bash '{}' release","timeout":10}}]}}]}}}}"#,
|
||||
hook_path.display(),
|
||||
hook_path.display(),
|
||||
hook_path.display(),
|
||||
hook_path.display(),
|
||||
|
|
@ -2152,6 +2174,7 @@ mod tests {
|
|||
"echo keep"
|
||||
);
|
||||
assert!(settings["hooks"].get("PermissionRequest").is_none());
|
||||
assert!(settings["hooks"].get("SessionStart").is_none());
|
||||
assert!(settings["hooks"].get("PostToolUse").is_none());
|
||||
assert!(settings["hooks"].get("PostToolUseFailure").is_none());
|
||||
assert!(settings["hooks"].get("SubagentStop").is_none());
|
||||
|
|
@ -2201,7 +2224,7 @@ mod tests {
|
|||
|
||||
assert_eq!(codex.path, hook_path);
|
||||
assert_eq!(codex.installed_version, Some(2));
|
||||
assert_eq!(codex.expected_version, 3);
|
||||
assert_eq!(codex.expected_version, 4);
|
||||
assert_eq!(codex.state, IntegrationStatusKind::Outdated);
|
||||
|
||||
std::env::remove_var("HOME");
|
||||
|
|
@ -2590,4 +2613,18 @@ mod tests {
|
|||
std::env::remove_var("HOME");
|
||||
let _ = fs::remove_dir_all(base);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundled_integration_assets_report_session_refs() {
|
||||
assert!(PI_EXTENSION_ASSET.contains("agent_session_path: currentAgentSessionPath"));
|
||||
assert!(PI_EXTENSION_ASSET.contains("agent_session_id: currentAgentSessionId"));
|
||||
assert!(PI_EXTENSION_ASSET.contains("publishState(true)"));
|
||||
assert!(CLAUDE_HOOK_ASSET.contains("agent_session_id"));
|
||||
assert!(CODEX_HOOK_ASSET.contains("HERDR_HOOK_INPUT_FILE"));
|
||||
assert!(CODEX_HOOK_ASSET.contains("agent_session_id"));
|
||||
assert!(OPENCODE_PLUGIN_ASSET.contains("properties?.sessionID"));
|
||||
assert!(OPENCODE_PLUGIN_ASSET.contains("agent_session_id: sessionID"));
|
||||
assert!(HERMES_PLUGIN_INIT_ASSET.contains("session_id = _session_id(kwargs)"));
|
||||
assert!(HERMES_PLUGIN_INIT_ASSET.contains("agent_session_id"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ const NESTED_HERDR_MESSAGES: [&str; 6] = [
|
|||
"recursion detected. base case not found. aborting.",
|
||||
];
|
||||
|
||||
mod agent_resume;
|
||||
mod api;
|
||||
mod app;
|
||||
mod cli;
|
||||
|
|
@ -212,6 +213,11 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration
|
|||
# [ui.sound.agents]
|
||||
# droid = "off"
|
||||
|
||||
[session]
|
||||
# Resume supported AI-agent panes into their native conversation sessions after
|
||||
# a Herdr server restart. Requires official integrations that report session refs.
|
||||
# resume_agents_on_restore = false
|
||||
|
||||
[experimental]
|
||||
# Allow launching herdr from inside a herdr-managed pane.
|
||||
# allow_nested = false
|
||||
|
|
|
|||
179
src/pane.rs
179
src/pane.rs
|
|
@ -326,6 +326,35 @@ fn pane_shell_from(configured_shell: &str, env_shell: Option<String>) -> String
|
|||
.unwrap_or_else(|| "/bin/sh".into())
|
||||
}
|
||||
|
||||
fn restore_command_builder(agent: &str, fallback_shell: &str, argv: &[String]) -> CommandBuilder {
|
||||
let mut cmd = CommandBuilder::new("/bin/sh");
|
||||
cmd.arg("-c");
|
||||
cmd.arg(
|
||||
r#"agent="$1"
|
||||
fallback_shell="$2"
|
||||
early_window="$3"
|
||||
shift 3
|
||||
start="$(date +%s 2>/dev/null || printf 0)"
|
||||
"$@"
|
||||
status="$?"
|
||||
end="$(date +%s 2>/dev/null || printf 999999)"
|
||||
elapsed="$((end - start))"
|
||||
if [ "$status" -ne 0 ] && [ "$elapsed" -le "$early_window" ]; then
|
||||
printf 'herdr: %s session restore failed; started a shell instead\n' "$agent"
|
||||
fi
|
||||
exec "$fallback_shell"
|
||||
"#,
|
||||
);
|
||||
cmd.arg("herdr-agent-restore");
|
||||
cmd.arg(agent);
|
||||
cmd.arg(fallback_shell);
|
||||
cmd.arg("30");
|
||||
for arg in argv {
|
||||
cmd.arg(arg);
|
||||
}
|
||||
cmd
|
||||
}
|
||||
|
||||
impl PaneRuntime {
|
||||
pub fn shutdown(self) {
|
||||
self.detect_handle.abort();
|
||||
|
|
@ -365,6 +394,7 @@ impl PaneRuntime {
|
|||
render_dirty,
|
||||
cmd,
|
||||
"failed to spawn shell",
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -402,6 +432,7 @@ impl PaneRuntime {
|
|||
render_dirty,
|
||||
cmd,
|
||||
"failed to spawn command pane",
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -442,6 +473,48 @@ impl PaneRuntime {
|
|||
render_dirty,
|
||||
cmd,
|
||||
"failed to spawn argv command pane",
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn spawn_agent_restore(
|
||||
pane_id: PaneId,
|
||||
rows: u16,
|
||||
cols: u16,
|
||||
cwd: std::path::PathBuf,
|
||||
restore_plan: &crate::agent_resume::AgentResumePlan,
|
||||
scrollback_limit_bytes: usize,
|
||||
host_terminal_theme: crate::terminal_theme::TerminalTheme,
|
||||
default_shell: &str,
|
||||
events: mpsc::Sender<AppEvent>,
|
||||
render_notify: Arc<Notify>,
|
||||
render_dirty: Arc<AtomicBool>,
|
||||
) -> std::io::Result<Self> {
|
||||
if restore_plan.argv.is_empty() {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidInput,
|
||||
"restore argv must not be empty",
|
||||
));
|
||||
}
|
||||
|
||||
let shell = pane_shell(default_shell);
|
||||
let mut cmd = restore_command_builder(&restore_plan.agent, &shell, &restore_plan.argv);
|
||||
cmd.cwd(cwd);
|
||||
cmd.env(crate::HERDR_ENV_VAR, crate::HERDR_ENV_VALUE);
|
||||
apply_pane_terminal_env(&mut cmd);
|
||||
crate::integration::apply_pane_env(&mut cmd, pane_id);
|
||||
Self::spawn_command_builder(
|
||||
pane_id,
|
||||
rows,
|
||||
cols,
|
||||
scrollback_limit_bytes,
|
||||
host_terminal_theme,
|
||||
events,
|
||||
render_notify,
|
||||
render_dirty,
|
||||
cmd,
|
||||
"failed to spawn agent restore pane",
|
||||
crate::detect::parse_agent_label(&restore_plan.agent),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -456,6 +529,7 @@ impl PaneRuntime {
|
|||
render_dirty: Arc<AtomicBool>,
|
||||
cmd: CommandBuilder,
|
||||
spawn_error_message: &'static str,
|
||||
initial_detected_agent: Option<Agent>,
|
||||
) -> std::io::Result<Self> {
|
||||
let pty_system = native_pty_system();
|
||||
let pair = pty_system
|
||||
|
|
@ -605,12 +679,17 @@ impl PaneRuntime {
|
|||
let pending_release_for_task = pending_release.clone();
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
let mut agent_presence = AgentDetectionPresence::from_agent(None);
|
||||
let mut state = AgentState::Unknown;
|
||||
let mut agent_presence = AgentDetectionPresence::from_agent(initial_detected_agent);
|
||||
let mut state = if initial_detected_agent.is_some() {
|
||||
AgentState::Idle
|
||||
} else {
|
||||
AgentState::Unknown
|
||||
};
|
||||
let mut last_process_check = Instant::now();
|
||||
let mut last_foreground_pgid = None;
|
||||
let mut pending_foreground_shell_clear = false;
|
||||
let mut foreground_shell_exit_reported = false;
|
||||
let mut pending_restore_probe = initial_detected_agent.is_some();
|
||||
let mut last_claude_working_at = None;
|
||||
let mut last_visible_blocker = false;
|
||||
let mut last_visible_idle = false;
|
||||
|
|
@ -637,6 +716,7 @@ impl PaneRuntime {
|
|||
last_foreground_pgid = None;
|
||||
pending_foreground_shell_clear = false;
|
||||
foreground_shell_exit_reported = false;
|
||||
pending_restore_probe = false;
|
||||
last_claude_working_at = None;
|
||||
last_visible_blocker = false;
|
||||
last_visible_idle = false;
|
||||
|
|
@ -657,6 +737,7 @@ impl PaneRuntime {
|
|||
|| agent_presence.current_agent().is_none()
|
||||
|| foreground_group_changed
|
||||
|| pending_foreground_shell_clear
|
||||
|| pending_restore_probe
|
||||
|| now.duration_since(last_process_check) >= PROCESS_RECHECK;
|
||||
|
||||
let mut agent_changed = false;
|
||||
|
|
@ -718,8 +799,10 @@ impl PaneRuntime {
|
|||
};
|
||||
if new_agent.is_some() {
|
||||
last_foreground_pgid = process_group_id;
|
||||
pending_restore_probe = false;
|
||||
} else if agent_presence.current_agent().is_none() {
|
||||
last_foreground_pgid = None;
|
||||
pending_restore_probe = false;
|
||||
}
|
||||
if changed {
|
||||
agent = agent_presence.current_agent();
|
||||
|
|
@ -1234,6 +1317,26 @@ mod tests {
|
|||
output
|
||||
}
|
||||
|
||||
fn capture_command_output(cmd: CommandBuilder) -> (bool, String) {
|
||||
let pair = native_pty_system()
|
||||
.openpty(PtySize {
|
||||
rows: 24,
|
||||
cols: 80,
|
||||
pixel_width: 0,
|
||||
pixel_height: 0,
|
||||
})
|
||||
.unwrap();
|
||||
let mut reader = pair.master.try_clone_reader().unwrap();
|
||||
let slave = pair.slave;
|
||||
let mut child = slave.spawn_command(cmd).unwrap();
|
||||
drop(slave);
|
||||
let status = child.wait().unwrap();
|
||||
|
||||
let mut output = String::new();
|
||||
reader.read_to_string(&mut output).unwrap();
|
||||
(status.success(), output)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pane_shell_prefers_configured_shell() {
|
||||
assert_eq!(
|
||||
|
|
@ -1271,6 +1374,78 @@ mod tests {
|
|||
assert_eq!(output, "vt100\n24bit\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_wrapper_falls_back_after_early_resume_failure() {
|
||||
let argv = vec!["/bin/sh".into(), "-c".into(), "exit 7".into()];
|
||||
let cmd = restore_command_builder("codex", "/bin/true", &argv);
|
||||
let (success, output) = capture_command_output(cmd);
|
||||
|
||||
assert!(success, "fallback command should own the final exit status");
|
||||
assert!(output.contains("herdr: codex session restore failed; started a shell instead"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn spawn_agent_restore_keeps_pane_alive_after_early_failure() {
|
||||
let (events, mut event_rx) = mpsc::channel(4);
|
||||
let runtime = PaneRuntime::spawn_agent_restore(
|
||||
PaneId::from_raw(7),
|
||||
24,
|
||||
80,
|
||||
std::env::current_dir().unwrap(),
|
||||
&crate::agent_resume::AgentResumePlan {
|
||||
agent: "codex".into(),
|
||||
argv: vec!["/bin/sh".into(), "-c".into(), "exit 7".into()],
|
||||
dedupe_key: "test".into(),
|
||||
},
|
||||
0,
|
||||
crate::terminal_theme::TerminalTheme::default(),
|
||||
"/bin/sh",
|
||||
events,
|
||||
Arc::new(Notify::new()),
|
||||
Arc::new(AtomicBool::new(false)),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(250)).await;
|
||||
|
||||
assert!(runtime
|
||||
.visible_text()
|
||||
.contains("herdr: codex session restore failed; started a shell instead"));
|
||||
assert!(
|
||||
tokio::time::timeout(std::time::Duration::from_millis(50), event_rx.recv())
|
||||
.await
|
||||
.is_err(),
|
||||
"fallback shell should keep the pane runtime alive"
|
||||
);
|
||||
let deadline = tokio::time::Instant::now() + std::time::Duration::from_millis(2500);
|
||||
let mut cleared = false;
|
||||
while tokio::time::Instant::now() < deadline {
|
||||
let Some(event) = tokio::time::timeout(
|
||||
deadline.saturating_duration_since(tokio::time::Instant::now()),
|
||||
event_rx.recv(),
|
||||
)
|
||||
.await
|
||||
.expect("fallback shell should clear the seeded restored agent") else {
|
||||
break;
|
||||
};
|
||||
if matches!(
|
||||
event,
|
||||
AppEvent::StateChanged {
|
||||
pane_id,
|
||||
agent: None,
|
||||
state: AgentState::Unknown,
|
||||
..
|
||||
} if pane_id == PaneId::from_raw(7)
|
||||
) {
|
||||
cleared = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(cleared);
|
||||
|
||||
runtime.shutdown();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn focus_events_are_forwarded_when_enabled() {
|
||||
let (tx, mut rx) = mpsc::channel(4);
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ use ratatui::layout::Direction;
|
|||
use tokio::sync::{mpsc, Notify};
|
||||
use tracing::{error, warn};
|
||||
|
||||
use crate::detect::AgentState;
|
||||
use crate::events::AppEvent;
|
||||
use crate::layout::{Node, PaneId, TileLayout};
|
||||
use crate::pane::PaneState;
|
||||
|
|
@ -22,6 +23,7 @@ pub fn restore(
|
|||
cols: u16,
|
||||
scrollback_limit_bytes: usize,
|
||||
default_shell: &str,
|
||||
resume_agents_on_restore: bool,
|
||||
events: mpsc::Sender<AppEvent>,
|
||||
render_notify: Arc<Notify>,
|
||||
render_dirty: Arc<AtomicBool>,
|
||||
|
|
@ -33,6 +35,7 @@ pub fn restore(
|
|||
let mut workspaces = Vec::new();
|
||||
let mut terminals = HashMap::new();
|
||||
let mut terminal_runtimes = HashMap::new();
|
||||
let mut resumed_agent_sessions = HashSet::new();
|
||||
for ws_snap in &snapshot.workspaces {
|
||||
if let Some((workspace, restored_terminals, restored_runtimes)) = restore_workspace(
|
||||
ws_snap,
|
||||
|
|
@ -40,6 +43,8 @@ pub fn restore(
|
|||
cols,
|
||||
scrollback_limit_bytes,
|
||||
default_shell,
|
||||
resume_agents_on_restore,
|
||||
&mut resumed_agent_sessions,
|
||||
events.clone(),
|
||||
render_notify.clone(),
|
||||
render_dirty.clone(),
|
||||
|
|
@ -60,6 +65,8 @@ fn restore_workspace(
|
|||
cols: u16,
|
||||
scrollback_limit_bytes: usize,
|
||||
default_shell: &str,
|
||||
resume_agents_on_restore: bool,
|
||||
resumed_agent_sessions: &mut HashSet<String>,
|
||||
events: mpsc::Sender<AppEvent>,
|
||||
render_notify: Arc<Notify>,
|
||||
render_dirty: Arc<AtomicBool>,
|
||||
|
|
@ -82,6 +89,8 @@ fn restore_workspace(
|
|||
cols,
|
||||
scrollback_limit_bytes,
|
||||
default_shell,
|
||||
resume_agents_on_restore,
|
||||
resumed_agent_sessions,
|
||||
events.clone(),
|
||||
render_notify.clone(),
|
||||
render_dirty.clone(),
|
||||
|
|
@ -142,6 +151,8 @@ fn restore_tab(
|
|||
cols: u16,
|
||||
scrollback_limit_bytes: usize,
|
||||
default_shell: &str,
|
||||
resume_agents_on_restore: bool,
|
||||
resumed_agent_sessions: &mut HashSet<String>,
|
||||
events: mpsc::Sender<AppEvent>,
|
||||
render_notify: Arc<Notify>,
|
||||
render_dirty: Arc<AtomicBool>,
|
||||
|
|
@ -192,19 +203,55 @@ fn restore_tab(
|
|||
.get(id)
|
||||
.and_then(|old_id| snap.panes.get(old_id))
|
||||
.and_then(|p| p.agent_name.clone());
|
||||
let saved_agent_session = reverse_id_map
|
||||
.get(id)
|
||||
.and_then(|old_id| snap.panes.get(old_id))
|
||||
.and_then(|p| p.agent_session.as_ref());
|
||||
let mut restore_plan = reverse_id_map
|
||||
.get(id)
|
||||
.and_then(|old_id| snap.panes.get(old_id))
|
||||
.and_then(|p| p.agent_session.as_ref())
|
||||
.and_then(|session| restore_plan_for_snapshot(session, resume_agents_on_restore));
|
||||
let duplicate_agent_session = restore_plan
|
||||
.as_ref()
|
||||
.is_some_and(|plan| !resumed_agent_sessions.insert(plan.dedupe_key.clone()));
|
||||
if duplicate_agent_session {
|
||||
restore_plan = None;
|
||||
}
|
||||
let initial_restore_agent = restore_plan
|
||||
.as_ref()
|
||||
.and_then(|plan| crate::detect::parse_agent_label(&plan.agent));
|
||||
|
||||
match TerminalRuntime::spawn(
|
||||
*id,
|
||||
rows,
|
||||
cols,
|
||||
cwd.clone(),
|
||||
scrollback_limit_bytes,
|
||||
crate::terminal_theme::TerminalTheme::default(),
|
||||
default_shell,
|
||||
events.clone(),
|
||||
render_notify.clone(),
|
||||
render_dirty.clone(),
|
||||
) {
|
||||
let runtime_result = if let Some(plan) = restore_plan {
|
||||
TerminalRuntime::spawn_agent_restore(
|
||||
*id,
|
||||
rows,
|
||||
cols,
|
||||
cwd.clone(),
|
||||
&plan,
|
||||
scrollback_limit_bytes,
|
||||
crate::terminal_theme::TerminalTheme::default(),
|
||||
default_shell,
|
||||
events.clone(),
|
||||
render_notify.clone(),
|
||||
render_dirty.clone(),
|
||||
)
|
||||
} else {
|
||||
TerminalRuntime::spawn(
|
||||
*id,
|
||||
rows,
|
||||
cols,
|
||||
cwd.clone(),
|
||||
scrollback_limit_bytes,
|
||||
crate::terminal_theme::TerminalTheme::default(),
|
||||
default_shell,
|
||||
events.clone(),
|
||||
render_notify.clone(),
|
||||
render_dirty.clone(),
|
||||
)
|
||||
};
|
||||
|
||||
match runtime_result {
|
||||
Ok(runtime) => {
|
||||
let terminal_id = TerminalId::alloc();
|
||||
let mut terminal = TerminalState::new(terminal_id.clone(), cwd.clone());
|
||||
|
|
@ -214,6 +261,22 @@ fn restore_tab(
|
|||
if let Some(agent_name) = saved_agent_name {
|
||||
terminal.set_agent_name(agent_name);
|
||||
}
|
||||
if let Some(agent) = initial_restore_agent {
|
||||
let _ = terminal.set_detected_state_with_screen_signals_at(
|
||||
Some(agent),
|
||||
AgentState::Idle,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
std::time::Instant::now(),
|
||||
);
|
||||
}
|
||||
if let Some(session) =
|
||||
restored_terminal_agent_session(saved_agent_session, duplicate_agent_session)
|
||||
{
|
||||
terminal.set_persisted_agent_session(session);
|
||||
}
|
||||
panes.insert(*id, PaneState::new(terminal_id.clone()));
|
||||
terminal_runtimes.insert(terminal_id, runtime);
|
||||
terminals.push(terminal);
|
||||
|
|
@ -269,6 +332,48 @@ fn restore_tab(
|
|||
))
|
||||
}
|
||||
|
||||
fn restore_plan_for_snapshot(
|
||||
session: &super::snapshot::PaneAgentSessionSnapshot,
|
||||
resume_agents_on_restore: bool,
|
||||
) -> Option<crate::agent_resume::AgentResumePlan> {
|
||||
if !resume_agents_on_restore {
|
||||
return None;
|
||||
}
|
||||
let persisted = persisted_agent_session_from_snapshot(session)?;
|
||||
crate::agent_resume::plan(&session.source, &session.agent, &persisted.session_ref)
|
||||
}
|
||||
|
||||
fn persisted_agent_session_from_snapshot(
|
||||
session: &super::snapshot::PaneAgentSessionSnapshot,
|
||||
) -> Option<crate::agent_resume::PersistedAgentSession> {
|
||||
crate::agent_resume::session_ref_from_snapshot(
|
||||
&session.source,
|
||||
&session.agent,
|
||||
session.kind,
|
||||
&session.value,
|
||||
)
|
||||
}
|
||||
|
||||
fn restored_terminal_agent_session(
|
||||
session: Option<&super::snapshot::PaneAgentSessionSnapshot>,
|
||||
duplicate_agent_session: bool,
|
||||
) -> Option<crate::agent_resume::PersistedAgentSession> {
|
||||
if duplicate_agent_session {
|
||||
return None;
|
||||
}
|
||||
session.and_then(persisted_agent_session_from_snapshot)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn take_restore_plan_for_snapshot(
|
||||
session: &super::snapshot::PaneAgentSessionSnapshot,
|
||||
resume_agents_on_restore: bool,
|
||||
resumed_agent_sessions: &mut HashSet<String>,
|
||||
) -> Option<crate::agent_resume::AgentResumePlan> {
|
||||
restore_plan_for_snapshot(session, resume_agents_on_restore)
|
||||
.filter(|plan| resumed_agent_sessions.insert(plan.dedupe_key.clone()))
|
||||
}
|
||||
|
||||
pub(super) fn prune_restored_node(node: Node, surviving: &HashSet<PaneId>) -> Option<Node> {
|
||||
match node {
|
||||
Node::Pane(id) => surviving.contains(&id).then_some(Node::Pane(id)),
|
||||
|
|
@ -436,4 +541,142 @@ mod tests {
|
|||
|
||||
assert_eq!(restored_worktree_space_membership(Some(membership)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_plan_respects_opt_in_and_allowlist() {
|
||||
let session = super::super::snapshot::PaneAgentSessionSnapshot {
|
||||
source: "herdr:pi".into(),
|
||||
agent: "pi".into(),
|
||||
kind: crate::agent_resume::AgentSessionRefKind::Path,
|
||||
value: "/tmp/pi-session.jsonl".into(),
|
||||
};
|
||||
|
||||
assert!(restore_plan_for_snapshot(&session, false).is_none());
|
||||
assert_eq!(
|
||||
restore_plan_for_snapshot(&session, true).unwrap().argv,
|
||||
vec!["pi", "--session", "/tmp/pi-session.jsonl"]
|
||||
);
|
||||
|
||||
let unsupported_path = super::super::snapshot::PaneAgentSessionSnapshot {
|
||||
source: "herdr:claude".into(),
|
||||
agent: "claude".into(),
|
||||
kind: crate::agent_resume::AgentSessionRefKind::Path,
|
||||
value: "/tmp/claude-session".into(),
|
||||
};
|
||||
assert!(restore_plan_for_snapshot(&unsupported_path, true).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_plan_selection_suppresses_duplicates() {
|
||||
let session = super::super::snapshot::PaneAgentSessionSnapshot {
|
||||
source: "herdr:pi".into(),
|
||||
agent: "pi".into(),
|
||||
kind: crate::agent_resume::AgentSessionRefKind::Path,
|
||||
value: "/tmp/pi-session.jsonl".into(),
|
||||
};
|
||||
let mut resumed = HashSet::new();
|
||||
|
||||
assert!(take_restore_plan_for_snapshot(&session, false, &mut resumed).is_none());
|
||||
assert!(resumed.is_empty());
|
||||
|
||||
let first = take_restore_plan_for_snapshot(&session, true, &mut resumed)
|
||||
.expect("first restore should get a plan");
|
||||
assert_eq!(first.argv, vec!["pi", "--session", "/tmp/pi-session.jsonl"]);
|
||||
assert!(take_restore_plan_for_snapshot(&session, true, &mut resumed).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_rehydrates_agent_session_metadata() {
|
||||
let session = super::super::snapshot::PaneAgentSessionSnapshot {
|
||||
source: "herdr:hermes".into(),
|
||||
agent: "hermes".into(),
|
||||
kind: crate::agent_resume::AgentSessionRefKind::Id,
|
||||
value: "hermes-session".into(),
|
||||
};
|
||||
|
||||
let preserved = restored_terminal_agent_session(Some(&session), false)
|
||||
.expect("restore should preserve metadata");
|
||||
assert_eq!(preserved.source, "herdr:hermes");
|
||||
assert_eq!(preserved.agent, "hermes");
|
||||
assert_eq!(preserved.session_ref.value, "hermes-session");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_does_not_rehydrate_duplicate_agent_session_metadata() {
|
||||
let session = super::super::snapshot::PaneAgentSessionSnapshot {
|
||||
source: "herdr:pi".into(),
|
||||
agent: "pi".into(),
|
||||
kind: crate::agent_resume::AgentSessionRefKind::Path,
|
||||
value: "/tmp/pi-session.jsonl".into(),
|
||||
};
|
||||
let mut resumed = HashSet::new();
|
||||
assert!(take_restore_plan_for_snapshot(&session, true, &mut resumed).is_some());
|
||||
assert!(take_restore_plan_for_snapshot(&session, true, &mut resumed).is_none());
|
||||
|
||||
assert!(restored_terminal_agent_session(Some(&session), true).is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn restore_carries_persisted_agent_session_metadata() {
|
||||
let cwd = std::env::current_dir().unwrap();
|
||||
let snapshot = SessionSnapshot {
|
||||
version: super::super::snapshot::SNAPSHOT_VERSION,
|
||||
workspaces: vec![WorkspaceSnapshot {
|
||||
id: Some("workspace".into()),
|
||||
custom_name: None,
|
||||
identity_cwd: cwd.clone(),
|
||||
worktree_space: None,
|
||||
tabs: vec![TabSnapshot {
|
||||
custom_name: None,
|
||||
layout: LayoutSnapshot::Pane(0),
|
||||
panes: HashMap::from([(
|
||||
0,
|
||||
super::super::snapshot::PaneSnapshot {
|
||||
cwd,
|
||||
label: None,
|
||||
agent_name: None,
|
||||
agent_session: Some(super::super::snapshot::PaneAgentSessionSnapshot {
|
||||
source: "herdr:opencode".into(),
|
||||
agent: "opencode".into(),
|
||||
kind: crate::agent_resume::AgentSessionRefKind::Id,
|
||||
value: "opencode-session".into(),
|
||||
}),
|
||||
},
|
||||
)]),
|
||||
zoomed: false,
|
||||
focused: Some(0),
|
||||
root_pane: Some(0),
|
||||
}],
|
||||
active_tab: 0,
|
||||
}],
|
||||
active: Some(0),
|
||||
selected: 0,
|
||||
agent_panel_scope: Default::default(),
|
||||
sidebar_width: None,
|
||||
sidebar_section_split: None,
|
||||
collapsed_space_keys: Default::default(),
|
||||
};
|
||||
let (events, _event_rx) = mpsc::channel(4);
|
||||
|
||||
let (_workspaces, terminals, _runtimes) = restore(
|
||||
&snapshot,
|
||||
24,
|
||||
80,
|
||||
0,
|
||||
"/bin/true",
|
||||
false,
|
||||
events,
|
||||
Arc::new(Notify::new()),
|
||||
Arc::new(AtomicBool::new(false)),
|
||||
);
|
||||
|
||||
let session = terminals
|
||||
.values()
|
||||
.next()
|
||||
.and_then(|terminal| terminal.persisted_agent_session.as_ref())
|
||||
.expect("persisted agent session should survive restore");
|
||||
assert_eq!(session.source, "herdr:opencode");
|
||||
assert_eq!(session.agent, "opencode");
|
||||
assert_eq!(session.session_ref.value, "opencode-session");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -77,6 +77,16 @@ pub struct PaneSnapshot {
|
|||
pub label: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub agent_name: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub agent_session: Option<PaneAgentSessionSnapshot>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PaneAgentSessionSnapshot {
|
||||
pub source: String,
|
||||
pub agent: String,
|
||||
pub kind: crate::agent_resume::AgentSessionRefKind,
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
/// Serializable BSP tree.
|
||||
|
|
@ -280,12 +290,37 @@ fn capture_tab(
|
|||
.get(id)
|
||||
.and_then(|pane| terminals.get(&pane.attached_terminal_id))
|
||||
.and_then(|terminal| terminal.agent_name.clone());
|
||||
let agent_session =
|
||||
tab.panes
|
||||
.get(id)
|
||||
.and_then(|pane| terminals.get(&pane.attached_terminal_id))
|
||||
.and_then(|terminal| {
|
||||
if let Some(authority) = terminal.hook_authority.as_ref() {
|
||||
if let Some(session_ref) = authority.session_ref.as_ref() {
|
||||
return Some(PaneAgentSessionSnapshot {
|
||||
source: authority.source.clone(),
|
||||
agent: authority.agent_label.clone(),
|
||||
kind: session_ref.kind,
|
||||
value: session_ref.value.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
terminal.persisted_agent_session.as_ref().map(|session| {
|
||||
PaneAgentSessionSnapshot {
|
||||
source: session.source.clone(),
|
||||
agent: session.agent.clone(),
|
||||
kind: session.session_ref.kind,
|
||||
value: session.session_ref.value.clone(),
|
||||
}
|
||||
})
|
||||
});
|
||||
panes.insert(
|
||||
id.raw(),
|
||||
PaneSnapshot {
|
||||
cwd,
|
||||
label,
|
||||
agent_name,
|
||||
agent_session,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -448,6 +483,7 @@ mod tests {
|
|||
cwd: PathBuf::from("/home/can/Projects/herdr"),
|
||||
label: None,
|
||||
agent_name: None,
|
||||
agent_session: None,
|
||||
},
|
||||
);
|
||||
panes.insert(
|
||||
|
|
@ -456,6 +492,7 @@ mod tests {
|
|||
cwd: PathBuf::from("/home/can/Projects/website"),
|
||||
label: Some("website".into()),
|
||||
agent_name: None,
|
||||
agent_session: None,
|
||||
},
|
||||
);
|
||||
|
||||
|
|
@ -764,6 +801,76 @@ mod tests {
|
|||
assert_eq!(tab.panes[&second.raw()].cwd, PathBuf::from("/tmp/herdr"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capture_contract_tracks_hook_authority_agent_session() {
|
||||
let mut state = state_with_workspaces(&["one"]);
|
||||
let root = state.workspaces[0].tabs[0].root_pane;
|
||||
state.ensure_test_terminals();
|
||||
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,
|
||||
None,
|
||||
crate::agent_resume::AgentSessionRef::path("/tmp/pi-session.jsonl"),
|
||||
Some(20),
|
||||
);
|
||||
|
||||
let snapshot = capture_from_state(&state);
|
||||
let agent_session = snapshot.workspaces[0].tabs[0].panes[&root.raw()]
|
||||
.agent_session
|
||||
.as_ref()
|
||||
.expect("agent session should be captured");
|
||||
|
||||
assert_eq!(agent_session.source, "herdr:pi");
|
||||
assert_eq!(agent_session.agent, "pi");
|
||||
assert_eq!(
|
||||
agent_session.kind,
|
||||
crate::agent_resume::AgentSessionRefKind::Path
|
||||
);
|
||||
assert_eq!(agent_session.value, "/tmp/pi-session.jsonl");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capture_contract_preserves_restored_agent_session() {
|
||||
let mut state = state_with_workspaces(&["one"]);
|
||||
let root = state.workspaces[0].tabs[0].root_pane;
|
||||
state.ensure_test_terminals();
|
||||
let terminal_id = state.workspaces[0].tabs[0].panes[&root]
|
||||
.attached_terminal_id
|
||||
.clone();
|
||||
state
|
||||
.terminals
|
||||
.get_mut(&terminal_id)
|
||||
.unwrap()
|
||||
.set_persisted_agent_session(crate::agent_resume::PersistedAgentSession {
|
||||
source: "herdr:opencode".into(),
|
||||
agent: "opencode".into(),
|
||||
session_ref: crate::agent_resume::AgentSessionRef::id("opencode-session").unwrap(),
|
||||
});
|
||||
|
||||
let snapshot = capture_from_state(&state);
|
||||
let agent_session = snapshot.workspaces[0].tabs[0].panes[&root.raw()]
|
||||
.agent_session
|
||||
.as_ref()
|
||||
.expect("persisted agent session should be captured");
|
||||
|
||||
assert_eq!(agent_session.source, "herdr:opencode");
|
||||
assert_eq!(agent_session.agent, "opencode");
|
||||
assert_eq!(
|
||||
agent_session.kind,
|
||||
crate::agent_resume::AgentSessionRefKind::Id
|
||||
);
|
||||
assert_eq!(agent_session.value, "opencode-session");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_unversioned_snapshot_loads_as_version_0() {
|
||||
let json = r#"{"workspaces":[],"active":null,"selected":0}"#;
|
||||
|
|
@ -793,6 +900,7 @@ mod tests {
|
|||
cwd: PathBuf::from("/tmp/this-directory-does-not-exist-for-herdr-test"),
|
||||
label: None,
|
||||
agent_name: None,
|
||||
agent_session: None,
|
||||
},
|
||||
);
|
||||
panes.insert(
|
||||
|
|
@ -803,6 +911,7 @@ mod tests {
|
|||
.unwrap_or_else(|_| PathBuf::from("/tmp")),
|
||||
label: None,
|
||||
agent_name: None,
|
||||
agent_session: None,
|
||||
},
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -3746,6 +3746,8 @@ next_tab = ""
|
|||
message: None,
|
||||
custom_status: None,
|
||||
seq: Some(19),
|
||||
agent_session_id: None,
|
||||
agent_session_path: None,
|
||||
}),
|
||||
},
|
||||
respond_to,
|
||||
|
|
|
|||
|
|
@ -6,4 +6,4 @@ pub mod state;
|
|||
pub use id::TerminalId;
|
||||
pub use runtime::TerminalRuntime;
|
||||
pub(crate) use runtime_registry::TerminalRuntimeRegistry;
|
||||
pub use state::{EffectiveStateChange, TerminalState};
|
||||
pub use state::{EffectiveStateChange, TerminalState, TerminalStateMutation};
|
||||
|
|
|
|||
|
|
@ -102,6 +102,35 @@ impl TerminalRuntime {
|
|||
.map(Self)
|
||||
}
|
||||
|
||||
pub fn spawn_agent_restore(
|
||||
pane_id: PaneId,
|
||||
rows: u16,
|
||||
cols: u16,
|
||||
cwd: std::path::PathBuf,
|
||||
restore_plan: &crate::agent_resume::AgentResumePlan,
|
||||
scrollback_limit_bytes: usize,
|
||||
host_terminal_theme: crate::terminal_theme::TerminalTheme,
|
||||
default_shell: &str,
|
||||
events: mpsc::Sender<AppEvent>,
|
||||
render_notify: Arc<Notify>,
|
||||
render_dirty: Arc<AtomicBool>,
|
||||
) -> std::io::Result<Self> {
|
||||
crate::pane::PaneRuntime::spawn_agent_restore(
|
||||
pane_id,
|
||||
rows,
|
||||
cols,
|
||||
cwd,
|
||||
restore_plan,
|
||||
scrollback_limit_bytes,
|
||||
host_terminal_theme,
|
||||
default_shell,
|
||||
events,
|
||||
render_notify,
|
||||
render_dirty,
|
||||
)
|
||||
.map(Self)
|
||||
}
|
||||
|
||||
pub fn apply_host_terminal_theme(&self, theme: crate::terminal_theme::TerminalTheme) {
|
||||
self.0.apply_host_terminal_theme(theme);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ pub struct HookAuthority {
|
|||
pub message: Option<String>,
|
||||
pub custom_status: Option<String>,
|
||||
pub reported_at: Instant,
|
||||
pub session_ref: Option<crate::agent_resume::AgentSessionRef>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
|
|
@ -35,6 +36,12 @@ pub struct EffectiveStateChange {
|
|||
pub custom_status: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct TerminalStateMutation {
|
||||
pub effective_state_change: Option<EffectiveStateChange>,
|
||||
pub session_ref_changed: bool,
|
||||
}
|
||||
|
||||
/// Pure state for a server-owned terminal.
|
||||
///
|
||||
/// During the migration this is still one-to-one with a pane-backed PTY, but
|
||||
|
|
@ -51,6 +58,7 @@ pub struct TerminalState {
|
|||
fallback_observed_at: Option<Instant>,
|
||||
stale_hook_idle_since: Option<Instant>,
|
||||
pub hook_authority: Option<HookAuthority>,
|
||||
pub persisted_agent_session: Option<crate::agent_resume::PersistedAgentSession>,
|
||||
pub manual_label: Option<String>,
|
||||
pub agent_name: Option<String>,
|
||||
hook_report_sequences: HashMap<String, u64>,
|
||||
|
|
@ -72,6 +80,7 @@ impl TerminalState {
|
|||
fallback_observed_at: None,
|
||||
stale_hook_idle_since: None,
|
||||
hook_authority: None,
|
||||
persisted_agent_session: None,
|
||||
manual_label: None,
|
||||
agent_name: None,
|
||||
hook_report_sequences: HashMap::new(),
|
||||
|
|
@ -95,6 +104,23 @@ impl TerminalState {
|
|||
self.set_detected_state_with_visible_blocker(agent, fallback_state, false, false, false)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn set_detected_state_with_mutation(
|
||||
&mut self,
|
||||
agent: Option<Agent>,
|
||||
fallback_state: AgentState,
|
||||
) -> TerminalStateMutation {
|
||||
self.set_detected_state_with_screen_signals_at(
|
||||
agent,
|
||||
fallback_state,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
Instant::now(),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn set_detected_state_with_visible_blocker(
|
||||
&mut self,
|
||||
|
|
@ -113,6 +139,7 @@ impl TerminalState {
|
|||
process_exited,
|
||||
Instant::now(),
|
||||
)
|
||||
.effective_state_change
|
||||
}
|
||||
|
||||
pub fn set_detected_state_with_screen_signals_at(
|
||||
|
|
@ -124,11 +151,12 @@ impl TerminalState {
|
|||
visible_working: bool,
|
||||
process_exited: bool,
|
||||
now: Instant,
|
||||
) -> Option<EffectiveStateChange> {
|
||||
) -> TerminalStateMutation {
|
||||
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;
|
||||
let previous_detected_agent = self.detected_agent;
|
||||
let previous_session = self.current_session_identity_for_persistence();
|
||||
self.detected_agent = agent;
|
||||
self.fallback_state = fallback_state;
|
||||
self.fallback_visible_blocker = visible_blocker && fallback_state == AgentState::Blocked;
|
||||
|
|
@ -156,13 +184,26 @@ impl TerminalState {
|
|||
self.hook_authority = None;
|
||||
self.stale_hook_idle_since = None;
|
||||
}
|
||||
let detected_agent_changed_or_disappeared =
|
||||
previous_detected_agent.is_some() && agent != previous_detected_agent;
|
||||
let persisted_agent_was_previously_detected =
|
||||
self.persisted_agent_session_belongs_to_detected_agent(previous_detected_agent);
|
||||
if self.persisted_agent_session_conflicts_with_detected_agent(agent)
|
||||
|| detected_agent_changed_or_disappeared && persisted_agent_was_previously_detected
|
||||
{
|
||||
self.persisted_agent_session = None;
|
||||
}
|
||||
self.update_stale_hook_idle_window(now);
|
||||
self.recompute_effective_state(
|
||||
previous_agent_label,
|
||||
previous_known_agent,
|
||||
previous_state,
|
||||
now,
|
||||
)
|
||||
TerminalStateMutation {
|
||||
effective_state_change: self.recompute_effective_state(
|
||||
previous_agent_label,
|
||||
previous_known_agent,
|
||||
previous_state,
|
||||
now,
|
||||
),
|
||||
session_ref_changed: previous_session
|
||||
!= self.current_session_identity_for_persistence(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -177,6 +218,7 @@ impl TerminalState {
|
|||
self.set_hook_authority_with_custom_status(source, agent_label, state, message, None, seq)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn set_hook_authority_with_custom_status(
|
||||
&mut self,
|
||||
source: String,
|
||||
|
|
@ -192,6 +234,30 @@ impl TerminalState {
|
|||
state,
|
||||
message,
|
||||
custom_status,
|
||||
None,
|
||||
seq,
|
||||
Instant::now(),
|
||||
)
|
||||
.and_then(|mutation| mutation.effective_state_change)
|
||||
}
|
||||
|
||||
pub fn set_hook_authority_with_session_ref(
|
||||
&mut self,
|
||||
source: String,
|
||||
agent_label: String,
|
||||
state: AgentState,
|
||||
message: Option<String>,
|
||||
custom_status: Option<String>,
|
||||
session_ref: Option<crate::agent_resume::AgentSessionRef>,
|
||||
seq: Option<u64>,
|
||||
) -> Option<TerminalStateMutation> {
|
||||
self.set_hook_authority_with_custom_status_at(
|
||||
source,
|
||||
agent_label,
|
||||
state,
|
||||
message,
|
||||
custom_status,
|
||||
session_ref,
|
||||
seq,
|
||||
Instant::now(),
|
||||
)
|
||||
|
|
@ -204,9 +270,10 @@ impl TerminalState {
|
|||
state: AgentState,
|
||||
message: Option<String>,
|
||||
custom_status: Option<String>,
|
||||
session_ref: Option<crate::agent_resume::AgentSessionRef>,
|
||||
seq: Option<u64>,
|
||||
now: Instant,
|
||||
) -> Option<EffectiveStateChange> {
|
||||
) -> Option<TerminalStateMutation> {
|
||||
if !self.accept_hook_report(&source, seq) {
|
||||
return None;
|
||||
}
|
||||
|
|
@ -214,9 +281,11 @@ impl TerminalState {
|
|||
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;
|
||||
let previous_session = self.current_session_identity_for_persistence();
|
||||
if self.known_agent_label_conflicts_with_detected_agent(&agent_label) {
|
||||
return None;
|
||||
}
|
||||
self.persisted_agent_session = None;
|
||||
self.hook_authority = Some(HookAuthority {
|
||||
source,
|
||||
agent_label,
|
||||
|
|
@ -224,14 +293,19 @@ impl TerminalState {
|
|||
message,
|
||||
custom_status,
|
||||
reported_at: now,
|
||||
session_ref,
|
||||
});
|
||||
self.stale_hook_idle_since = None;
|
||||
self.recompute_effective_state(
|
||||
previous_agent_label,
|
||||
previous_known_agent,
|
||||
previous_state,
|
||||
now,
|
||||
)
|
||||
let current_session = self.current_session_identity_for_persistence();
|
||||
Some(TerminalStateMutation {
|
||||
effective_state_change: self.recompute_effective_state(
|
||||
previous_agent_label,
|
||||
previous_known_agent,
|
||||
previous_state,
|
||||
now,
|
||||
),
|
||||
session_ref_changed: previous_session != current_session,
|
||||
})
|
||||
}
|
||||
|
||||
fn hook_authority_not_newer_than(&self, observed_at: Instant) -> bool {
|
||||
|
|
@ -257,6 +331,73 @@ impl TerminalState {
|
|||
})
|
||||
}
|
||||
|
||||
fn persisted_agent_session_conflicts_with_detected_agent(
|
||||
&self,
|
||||
detected_agent: Option<Agent>,
|
||||
) -> bool {
|
||||
let Some(detected_agent) = detected_agent else {
|
||||
return false;
|
||||
};
|
||||
self.persisted_agent_session
|
||||
.as_ref()
|
||||
.and_then(|session| crate::detect::parse_agent_label(&session.agent))
|
||||
.is_some_and(|agent| agent != detected_agent)
|
||||
}
|
||||
|
||||
fn persisted_agent_session_belongs_to_detected_agent(
|
||||
&self,
|
||||
detected_agent: Option<Agent>,
|
||||
) -> bool {
|
||||
let Some(detected_agent) = detected_agent else {
|
||||
return false;
|
||||
};
|
||||
self.persisted_agent_session
|
||||
.as_ref()
|
||||
.and_then(|session| crate::detect::parse_agent_label(&session.agent))
|
||||
.is_some_and(|agent| agent == detected_agent)
|
||||
}
|
||||
|
||||
fn persisted_agent_session_matches(&self, source: &str, agent: &str) -> bool {
|
||||
self.persisted_agent_session
|
||||
.as_ref()
|
||||
.is_some_and(|session| session.source == source && session.agent == agent)
|
||||
}
|
||||
|
||||
fn current_session_identity_for_persistence(
|
||||
&self,
|
||||
) -> Option<(
|
||||
String,
|
||||
String,
|
||||
crate::agent_resume::AgentSessionRefKind,
|
||||
String,
|
||||
)> {
|
||||
if let Some(authority) = self.hook_authority.as_ref() {
|
||||
if let Some(session_ref) = authority.session_ref.as_ref() {
|
||||
return Some((
|
||||
authority.source.clone(),
|
||||
authority.agent_label.clone(),
|
||||
session_ref.kind,
|
||||
session_ref.value.clone(),
|
||||
));
|
||||
}
|
||||
}
|
||||
self.persisted_agent_session.as_ref().map(|session| {
|
||||
(
|
||||
session.source.clone(),
|
||||
session.agent.clone(),
|
||||
session.session_ref.kind,
|
||||
session.session_ref.value.clone(),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_persisted_agent_session(
|
||||
&mut self,
|
||||
session: crate::agent_resume::PersistedAgentSession,
|
||||
) {
|
||||
self.persisted_agent_session = Some(session);
|
||||
}
|
||||
|
||||
fn known_agent_label_conflicts_with_detected_agent(&self, agent_label: &str) -> bool {
|
||||
let Some(detected_agent) = self.detected_agent else {
|
||||
return false;
|
||||
|
|
@ -282,11 +423,21 @@ impl TerminalState {
|
|||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn clear_hook_authority(
|
||||
&mut self,
|
||||
source: Option<&str>,
|
||||
seq: Option<u64>,
|
||||
) -> Option<EffectiveStateChange> {
|
||||
self.clear_hook_authority_with_mutation(source, seq)
|
||||
.and_then(|mutation| mutation.effective_state_change)
|
||||
}
|
||||
|
||||
pub fn clear_hook_authority_with_mutation(
|
||||
&mut self,
|
||||
source: Option<&str>,
|
||||
seq: Option<u64>,
|
||||
) -> Option<TerminalStateMutation> {
|
||||
let sequence_source = source.map(str::to_string).or_else(|| {
|
||||
self.hook_authority
|
||||
.as_ref()
|
||||
|
|
@ -301,6 +452,7 @@ impl TerminalState {
|
|||
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;
|
||||
let previous_session = self.current_session_identity_for_persistence();
|
||||
let should_clear = self
|
||||
.hook_authority
|
||||
.as_ref()
|
||||
|
|
@ -310,26 +462,37 @@ impl TerminalState {
|
|||
}
|
||||
self.hook_authority = None;
|
||||
self.stale_hook_idle_since = None;
|
||||
self.recompute_effective_state(
|
||||
previous_agent_label,
|
||||
previous_known_agent,
|
||||
previous_state,
|
||||
Instant::now(),
|
||||
)
|
||||
self.persisted_agent_session = None;
|
||||
let now = Instant::now();
|
||||
Some(TerminalStateMutation {
|
||||
effective_state_change: self.recompute_effective_state(
|
||||
previous_agent_label,
|
||||
previous_known_agent,
|
||||
previous_state,
|
||||
now,
|
||||
),
|
||||
session_ref_changed: previous_session.is_some(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn release_agent(
|
||||
&mut self,
|
||||
source: &str,
|
||||
agent_label: &str,
|
||||
seq: Option<u64>,
|
||||
) -> Option<EffectiveStateChange> {
|
||||
if !self.accept_hook_report(source, seq) {
|
||||
return None;
|
||||
}
|
||||
self.release_agent_with_mutation(source, agent_label, seq)
|
||||
.and_then(|mutation| mutation.effective_state_change)
|
||||
}
|
||||
|
||||
let current_agent_label = self.effective_agent_label()?;
|
||||
if current_agent_label != agent_label {
|
||||
pub fn release_agent_with_mutation(
|
||||
&mut self,
|
||||
source: &str,
|
||||
agent_label: &str,
|
||||
seq: Option<u64>,
|
||||
) -> Option<TerminalStateMutation> {
|
||||
if !self.accept_hook_report(source, seq) {
|
||||
return None;
|
||||
}
|
||||
|
||||
|
|
@ -339,9 +502,16 @@ impl TerminalState {
|
|||
return None;
|
||||
}
|
||||
|
||||
let matches_current_agent = self.effective_agent_label() == Some(agent_label);
|
||||
let matches_persisted_session = self.persisted_agent_session_matches(source, agent_label);
|
||||
if !matches_current_agent && !matches_persisted_session {
|
||||
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;
|
||||
let previous_session = self.current_session_identity_for_persistence();
|
||||
self.detected_agent = None;
|
||||
self.fallback_state = AgentState::Unknown;
|
||||
self.fallback_visible_blocker = false;
|
||||
|
|
@ -350,12 +520,17 @@ impl TerminalState {
|
|||
self.fallback_observed_at = None;
|
||||
self.hook_authority = None;
|
||||
self.stale_hook_idle_since = None;
|
||||
self.recompute_effective_state(
|
||||
previous_agent_label,
|
||||
previous_known_agent,
|
||||
previous_state,
|
||||
Instant::now(),
|
||||
)
|
||||
self.persisted_agent_session = None;
|
||||
let now = Instant::now();
|
||||
Some(TerminalStateMutation {
|
||||
effective_state_change: self.recompute_effective_state(
|
||||
previous_agent_label,
|
||||
previous_known_agent,
|
||||
previous_state,
|
||||
now,
|
||||
),
|
||||
session_ref_changed: previous_session.is_some(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn effective_agent_label(&self) -> Option<&str> {
|
||||
|
|
@ -860,6 +1035,7 @@ mod tests {
|
|||
None,
|
||||
Some("thinking".into()),
|
||||
None,
|
||||
None,
|
||||
now,
|
||||
);
|
||||
|
||||
|
|
@ -873,7 +1049,7 @@ mod tests {
|
|||
now + Duration::from_millis(500),
|
||||
);
|
||||
|
||||
assert!(waiting.is_none());
|
||||
assert!(waiting.effective_state_change.is_none());
|
||||
assert_eq!(terminal.fallback_state, AgentState::Idle);
|
||||
assert_eq!(terminal.state, AgentState::Working);
|
||||
assert_eq!(terminal.effective_custom_status(), Some("thinking"));
|
||||
|
|
@ -890,7 +1066,10 @@ mod tests {
|
|||
|
||||
assert_eq!(terminal.state, AgentState::Idle);
|
||||
assert_eq!(terminal.effective_custom_status(), None);
|
||||
assert_eq!(change.unwrap().previous_state, AgentState::Working);
|
||||
assert_eq!(
|
||||
change.effective_state_change.unwrap().previous_state,
|
||||
AgentState::Working
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -905,6 +1084,7 @@ mod tests {
|
|||
None,
|
||||
Some("thinking".into()),
|
||||
None,
|
||||
None,
|
||||
now,
|
||||
);
|
||||
terminal.set_detected_state_with_screen_signals_at(
|
||||
|
|
@ -923,6 +1103,7 @@ mod tests {
|
|||
AgentState::Working,
|
||||
None,
|
||||
Some("thinking".into()),
|
||||
None,
|
||||
Some(1),
|
||||
now + Duration::from_millis(800),
|
||||
);
|
||||
|
|
@ -936,7 +1117,7 @@ mod tests {
|
|||
now + STALE_HOOK_IDLE_GRACE + Duration::from_millis(1),
|
||||
);
|
||||
|
||||
assert!(change.is_none());
|
||||
assert!(change.effective_state_change.is_none());
|
||||
assert_eq!(terminal.state, AgentState::Working);
|
||||
}
|
||||
|
||||
|
|
@ -952,6 +1133,7 @@ mod tests {
|
|||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
now,
|
||||
);
|
||||
|
||||
|
|
@ -966,7 +1148,10 @@ mod tests {
|
|||
);
|
||||
|
||||
assert_eq!(terminal.state, AgentState::Working);
|
||||
assert_eq!(change.unwrap().previous_state, AgentState::Idle);
|
||||
assert_eq!(
|
||||
change.effective_state_change.unwrap().previous_state,
|
||||
AgentState::Idle
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -1183,6 +1368,7 @@ mod tests {
|
|||
AgentState::Working,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some(1),
|
||||
observed + Duration::from_secs(1),
|
||||
);
|
||||
|
|
@ -1220,6 +1406,7 @@ mod tests {
|
|||
AgentState::Working,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some(1),
|
||||
observed,
|
||||
);
|
||||
|
|
@ -1229,6 +1416,7 @@ mod tests {
|
|||
AgentState::Working,
|
||||
None,
|
||||
Some("new turn".into()),
|
||||
None,
|
||||
Some(2),
|
||||
observed + Duration::from_secs(1),
|
||||
);
|
||||
|
|
@ -1316,6 +1504,263 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepted_hook_report_stores_session_ref() {
|
||||
let mut terminal = test_terminal();
|
||||
let mutation = terminal
|
||||
.set_hook_authority_with_session_ref(
|
||||
"herdr:pi".into(),
|
||||
"pi".into(),
|
||||
AgentState::Working,
|
||||
None,
|
||||
None,
|
||||
crate::agent_resume::AgentSessionRef::path("/tmp/pi.jsonl"),
|
||||
Some(20),
|
||||
)
|
||||
.expect("accepted report");
|
||||
|
||||
assert!(mutation.session_ref_changed);
|
||||
assert_eq!(
|
||||
terminal
|
||||
.hook_authority
|
||||
.as_ref()
|
||||
.and_then(|authority| authority.session_ref.as_ref())
|
||||
.map(|session_ref| (&session_ref.kind, session_ref.value.as_str())),
|
||||
Some((
|
||||
&crate::agent_resume::AgentSessionRefKind::Path,
|
||||
"/tmp/pi.jsonl"
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_hook_report_cannot_overwrite_session_ref() {
|
||||
let mut terminal = test_terminal();
|
||||
terminal.set_hook_authority_with_session_ref(
|
||||
"herdr:pi".into(),
|
||||
"pi".into(),
|
||||
AgentState::Working,
|
||||
None,
|
||||
None,
|
||||
crate::agent_resume::AgentSessionRef::path("/tmp/pi.jsonl"),
|
||||
Some(20),
|
||||
);
|
||||
|
||||
let mutation = terminal.set_hook_authority_with_session_ref(
|
||||
"herdr:pi".into(),
|
||||
"pi".into(),
|
||||
AgentState::Working,
|
||||
None,
|
||||
None,
|
||||
crate::agent_resume::AgentSessionRef::path("/tmp/new.jsonl"),
|
||||
Some(19),
|
||||
);
|
||||
|
||||
assert!(mutation.is_none());
|
||||
assert_eq!(
|
||||
terminal
|
||||
.hook_authority
|
||||
.as_ref()
|
||||
.and_then(|authority| authority.session_ref.as_ref())
|
||||
.map(|session_ref| session_ref.value.as_str()),
|
||||
Some("/tmp/pi.jsonl")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepted_hook_report_without_session_ref_clears_previous_ref() {
|
||||
let mut terminal = test_terminal();
|
||||
terminal.set_hook_authority_with_session_ref(
|
||||
"herdr:pi".into(),
|
||||
"pi".into(),
|
||||
AgentState::Working,
|
||||
None,
|
||||
None,
|
||||
crate::agent_resume::AgentSessionRef::path("/tmp/pi.jsonl"),
|
||||
Some(20),
|
||||
);
|
||||
|
||||
let mutation = terminal
|
||||
.set_hook_authority_with_session_ref(
|
||||
"herdr:pi".into(),
|
||||
"pi".into(),
|
||||
AgentState::Working,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some(21),
|
||||
)
|
||||
.expect("accepted report");
|
||||
|
||||
assert!(mutation.session_ref_changed);
|
||||
assert!(mutation.effective_state_change.is_none());
|
||||
assert!(terminal
|
||||
.hook_authority
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.session_ref
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepted_hook_report_marks_changed_when_session_identity_changes() {
|
||||
let mut terminal = test_terminal();
|
||||
terminal.set_persisted_agent_session(crate::agent_resume::PersistedAgentSession {
|
||||
source: "herdr:opencode".into(),
|
||||
agent: "opencode".into(),
|
||||
session_ref: crate::agent_resume::AgentSessionRef::id("same-session").unwrap(),
|
||||
});
|
||||
|
||||
let mutation = terminal
|
||||
.set_hook_authority_with_session_ref(
|
||||
"herdr:hermes".into(),
|
||||
"hermes".into(),
|
||||
AgentState::Working,
|
||||
None,
|
||||
None,
|
||||
crate::agent_resume::AgentSessionRef::id("same-session"),
|
||||
Some(20),
|
||||
)
|
||||
.expect("accepted report");
|
||||
|
||||
assert!(mutation.session_ref_changed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clearing_hook_authority_clears_session_ref() {
|
||||
let mut terminal = test_terminal();
|
||||
terminal.set_hook_authority_with_session_ref(
|
||||
"herdr:pi".into(),
|
||||
"pi".into(),
|
||||
AgentState::Working,
|
||||
None,
|
||||
None,
|
||||
crate::agent_resume::AgentSessionRef::path("/tmp/pi.jsonl"),
|
||||
Some(20),
|
||||
);
|
||||
|
||||
let mutation = terminal
|
||||
.clear_hook_authority_with_mutation(Some("herdr:pi"), Some(21))
|
||||
.expect("accepted clear");
|
||||
|
||||
assert!(mutation.session_ref_changed);
|
||||
assert!(terminal.hook_authority.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_agent_clears_session_ref() {
|
||||
let mut terminal = test_terminal();
|
||||
terminal.set_hook_authority_with_session_ref(
|
||||
"herdr:pi".into(),
|
||||
"pi".into(),
|
||||
AgentState::Working,
|
||||
None,
|
||||
None,
|
||||
crate::agent_resume::AgentSessionRef::path("/tmp/pi.jsonl"),
|
||||
Some(20),
|
||||
);
|
||||
|
||||
let mutation = terminal
|
||||
.release_agent_with_mutation("herdr:pi", "pi", Some(21))
|
||||
.expect("accepted release");
|
||||
|
||||
assert!(mutation.session_ref_changed);
|
||||
assert!(terminal.hook_authority.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn release_agent_clears_matching_restored_session_ref_before_detection() {
|
||||
let mut terminal = test_terminal();
|
||||
terminal.set_persisted_agent_session(crate::agent_resume::PersistedAgentSession {
|
||||
source: "herdr:hermes".into(),
|
||||
agent: "hermes".into(),
|
||||
session_ref: crate::agent_resume::AgentSessionRef::id("hermes-session").unwrap(),
|
||||
});
|
||||
|
||||
let mutation = terminal
|
||||
.release_agent_with_mutation("herdr:hermes", "hermes", Some(21))
|
||||
.expect("accepted release");
|
||||
|
||||
assert!(mutation.session_ref_changed);
|
||||
assert!(mutation.effective_state_change.is_none());
|
||||
assert!(terminal.persisted_agent_session.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detected_conflict_clears_session_ref() {
|
||||
let mut terminal = test_terminal();
|
||||
terminal.set_hook_authority_with_session_ref(
|
||||
"herdr:claude".into(),
|
||||
"claude".into(),
|
||||
AgentState::Working,
|
||||
None,
|
||||
None,
|
||||
crate::agent_resume::AgentSessionRef::id("claude-session"),
|
||||
Some(20),
|
||||
);
|
||||
|
||||
let mutation =
|
||||
terminal.set_detected_state_with_mutation(Some(Agent::Grok), AgentState::Idle);
|
||||
|
||||
assert!(mutation.session_ref_changed);
|
||||
assert!(terminal.hook_authority.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detected_agent_disappearance_clears_matching_hook_session_ref() {
|
||||
let mut terminal = test_terminal();
|
||||
terminal.set_detected_state(Some(Agent::Hermes), AgentState::Idle);
|
||||
terminal.set_hook_authority_with_session_ref(
|
||||
"herdr:hermes".into(),
|
||||
"hermes".into(),
|
||||
AgentState::Working,
|
||||
None,
|
||||
None,
|
||||
crate::agent_resume::AgentSessionRef::id("hermes-session"),
|
||||
Some(20),
|
||||
);
|
||||
|
||||
let mutation = terminal.set_detected_state_with_mutation(None, AgentState::Unknown);
|
||||
|
||||
assert!(mutation.session_ref_changed);
|
||||
assert!(terminal.hook_authority.is_none());
|
||||
assert!(terminal.persisted_agent_session.is_none());
|
||||
assert_eq!(terminal.effective_agent_label(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detected_agent_disappearance_clears_matching_persisted_session_ref() {
|
||||
let mut terminal = test_terminal();
|
||||
terminal.set_persisted_agent_session(crate::agent_resume::PersistedAgentSession {
|
||||
source: "herdr:opencode".into(),
|
||||
agent: "opencode".into(),
|
||||
session_ref: crate::agent_resume::AgentSessionRef::id("opencode-session").unwrap(),
|
||||
});
|
||||
|
||||
let first =
|
||||
terminal.set_detected_state_with_mutation(Some(Agent::OpenCode), AgentState::Idle);
|
||||
assert!(!first.session_ref_changed);
|
||||
assert!(terminal.persisted_agent_session.is_some());
|
||||
|
||||
let second = terminal.set_detected_state_with_mutation(None, AgentState::Unknown);
|
||||
assert!(second.session_ref_changed);
|
||||
assert!(terminal.persisted_agent_session.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn initial_unknown_detection_preserves_restored_session_ref() {
|
||||
let mut terminal = test_terminal();
|
||||
terminal.set_persisted_agent_session(crate::agent_resume::PersistedAgentSession {
|
||||
source: "herdr:hermes".into(),
|
||||
agent: "hermes".into(),
|
||||
session_ref: crate::agent_resume::AgentSessionRef::id("hermes-session").unwrap(),
|
||||
});
|
||||
|
||||
let mutation = terminal.set_detected_state_with_mutation(None, AgentState::Unknown);
|
||||
assert!(!mutation.session_ref_changed);
|
||||
assert!(terminal.persisted_agent_session.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsequenced_hook_report_is_ignored_after_source_uses_sequence() {
|
||||
let mut terminal = test_terminal();
|
||||
|
|
|
|||
|
|
@ -427,6 +427,22 @@ fn send_request(socket_path: &Path, json: &str) -> serde_json::Value {
|
|||
}
|
||||
|
||||
fn run_claude_hook(action: &str, hook_input: &str) -> Option<serde_json::Value> {
|
||||
run_shell_hook(
|
||||
"src/integration/assets/claude/herdr-agent-state.sh",
|
||||
&[action],
|
||||
hook_input,
|
||||
)
|
||||
}
|
||||
|
||||
fn run_codex_hook(action: &str, hook_input: &str) -> Option<serde_json::Value> {
|
||||
run_shell_hook(
|
||||
"src/integration/assets/codex/herdr-agent-state.sh",
|
||||
&[action],
|
||||
hook_input,
|
||||
)
|
||||
}
|
||||
|
||||
fn run_shell_hook(asset_path: &str, args: &[&str], hook_input: &str) -> Option<serde_json::Value> {
|
||||
let base = unique_test_dir();
|
||||
fs::create_dir_all(&base).unwrap();
|
||||
let socket_path = base.join("herdr.sock");
|
||||
|
|
@ -455,11 +471,10 @@ fn run_claude_hook(action: &str, hook_input: &str) -> Option<serde_json::Value>
|
|||
None
|
||||
});
|
||||
|
||||
let hook_path = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("src/integration/assets/claude/herdr-agent-state.sh");
|
||||
let hook_path = Path::new(env!("CARGO_MANIFEST_DIR")).join(asset_path);
|
||||
let mut child = Command::new("bash")
|
||||
.arg(hook_path)
|
||||
.arg(action)
|
||||
.args(args)
|
||||
.env("HERDR_ENV", "1")
|
||||
.env("HERDR_SOCKET_PATH", &socket_path)
|
||||
.env("HERDR_PANE_ID", "p_test")
|
||||
|
|
@ -524,6 +539,31 @@ fn claude_hook_keeps_parent_agent_type_only_blocked() {
|
|||
assert_eq!(request["params"]["state"], "blocked");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_hook_reports_session_id_from_stdin() {
|
||||
let request = run_claude_hook(
|
||||
"idle",
|
||||
r#"{"hook_event_name":"SessionStart","session_id":"claude-session"}"#,
|
||||
)
|
||||
.expect("session start should report idle");
|
||||
|
||||
assert_eq!(request["method"], "pane.report_agent");
|
||||
assert_eq!(request["params"]["agent_session_id"], "claude-session");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_hook_reports_session_id_from_stdin() {
|
||||
let request = run_codex_hook(
|
||||
"working",
|
||||
r#"{"hook_event_name":"SessionStart","session_id":"codex-session"}"#,
|
||||
)
|
||||
.expect("codex hook should report working");
|
||||
|
||||
assert_eq!(request["method"], "pane.report_agent");
|
||||
assert_eq!(request["params"]["state"], "working");
|
||||
assert_eq!(request["params"]["agent_session_id"], "codex-session");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pane_run_sends_one_send_input_request_with_enter_key() {
|
||||
let base = unique_test_dir();
|
||||
|
|
@ -915,7 +955,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 (v1)"));
|
||||
assert!(status_stdout.contains("pi: current (v2)"));
|
||||
assert!(status_stdout.contains("claude: not installed"));
|
||||
|
||||
let integration_uninstall = Command::new(env!("CARGO_BIN_EXE_herdr"))
|
||||
|
|
|
|||
Loading…
Reference in New Issue