aboutsummaryrefslogtreecommitdiff
path: root/src/terminal
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-07-19 23:52:20 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-07-19 23:56:07 +0300
commit3f80947678a9723434aa7fddfe23750b5393e24c (patch)
tree6f463797132acfc51dfb6e135820a052954b4f13 /src/terminal
parentf76f895efe5b9f19aa9db909354c6c0f2195630b (diff)
feat: make agent automation a first-class cli primitive
Diffstat (limited to 'src/terminal')
-rw-r--r--src/terminal/metadata.rs2
-rw-r--r--src/terminal/state.rs341
2 files changed, 321 insertions, 22 deletions
diff --git a/src/terminal/metadata.rs b/src/terminal/metadata.rs
index 225d609a..27ac8528 100644
--- a/src/terminal/metadata.rs
+++ b/src/terminal/metadata.rs
@@ -237,6 +237,7 @@ impl TerminalState {
Some(TerminalStateMutation {
effective_state_change,
session_ref_changed: false,
+ agent_released: false,
})
}
pub fn effective_title(&self) -> Option<String> {
@@ -319,6 +320,7 @@ impl TerminalState {
now,
),
session_ref_changed: false,
+ agent_released: false,
})
}
diff --git a/src/terminal/state.rs b/src/terminal/state.rs
index 815a8b80..38e4739e 100644
--- a/src/terminal/state.rs
+++ b/src/terminal/state.rs
@@ -49,6 +49,7 @@ enum ManagedAgentPhase {
Pending {
ready_after: Option<Instant>,
deadline: Instant,
+ observed_expected: bool,
},
Active,
}
@@ -56,7 +57,6 @@ enum ManagedAgentPhase {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct ManagedAgent {
kind: Agent,
- observed_expected: bool,
phase: ManagedAgentPhase,
}
@@ -82,6 +82,13 @@ pub(crate) struct TerminalTitleChange {
pub struct TerminalStateMutation {
pub effective_state_change: Option<EffectiveStateChange>,
pub session_ref_changed: bool,
+ pub agent_released: bool,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+struct AgentNameOwner {
+ agent_label: String,
+ session_ref: Option<crate::agent_resume::AgentSessionRef>,
}
/// Pure state for a server-owned terminal.
@@ -103,6 +110,7 @@ pub struct TerminalState {
pub terminal_title: Option<String>,
pub manual_label: Option<String>,
pub agent_name: Option<String>,
+ agent_name_owner: Option<AgentNameOwner>,
managed_agent: Option<ManagedAgent>,
hook_report_sequences: HashMap<String, u64>,
suppressed_full_lifecycle_hook_reports: HashMap<String, SuppressedFullLifecycleHookReport>,
@@ -134,6 +142,7 @@ impl TerminalState {
terminal_title: None,
manual_label: None,
agent_name: None,
+ agent_name_owner: None,
managed_agent: None,
hook_report_sequences: HashMap::new(),
suppressed_full_lifecycle_hook_reports: HashMap::new(),
@@ -259,6 +268,9 @@ impl TerminalState {
let previous_presentation = self.effective_presentation_for_state_at(previous_state, now);
let previous_detected_agent = self.detected_agent;
let previous_session = self.current_session_identity_for_persistence();
+ let agent_released = process_exited
+ && self.hook_authority_not_newer_than(now)
+ && (previous_agent_label.is_some() || self.agent_name.is_some());
if self.should_ignore_detected_state_under_full_lifecycle_hook(agent, process_exited) {
if self
.hook_authority
@@ -278,6 +290,7 @@ impl TerminalState {
),
session_ref_changed: previous_session
!= self.current_session_identity_for_persistence(),
+ agent_released: false,
};
}
if !process_exited && self.detected_state_observed_before_release_suppression(agent, now) {
@@ -291,9 +304,14 @@ impl TerminalState {
),
session_ref_changed: previous_session
!= self.current_session_identity_for_persistence(),
+ agent_released: false,
};
}
self.detected_agent = agent;
+ if let Some(agent) = agent {
+ let agent_label = crate::detect::agent_label(agent);
+ self.reconcile_agent_name_owner(agent_label, None);
+ }
if !process_exited {
self.clear_full_lifecycle_hook_suppression_for_detected_agent(
previous_detected_agent,
@@ -358,6 +376,9 @@ impl TerminalState {
self.hook_authority = None;
self.persisted_agent_session = durable_session;
}
+ if agent_released {
+ self.clear_agent_name();
+ }
TerminalStateMutation {
effective_state_change: self.recompute_effective_state(
previous_agent_label,
@@ -368,6 +389,7 @@ impl TerminalState {
),
session_ref_changed: previous_session
!= self.current_session_identity_for_persistence(),
+ agent_released,
}
}
@@ -486,6 +508,7 @@ impl TerminalState {
let previous_state = self.state;
let previous_presentation = self.effective_presentation_for_state_at(previous_state, now);
let previous_session = self.current_session_identity_for_persistence();
+ self.reconcile_agent_name_owner(&agent_label, session_ref.as_ref());
if foreground_takeover_allowed {
self.suppress_current_full_lifecycle_hook_authority(
FullLifecycleHookSuppressionReason::HookClear,
@@ -521,6 +544,7 @@ impl TerminalState {
now,
),
session_ref_changed: previous_session != current_session,
+ agent_released: false,
})
}
@@ -1069,6 +1093,7 @@ impl TerminalState {
);
self.hook_authority = None;
}
+ self.reconcile_agent_name_owner(&agent_label, Some(&session_ref));
self.persisted_agent_session = Some(crate::agent_resume::PersistedAgentSession {
source,
agent: agent_label,
@@ -1084,6 +1109,7 @@ impl TerminalState {
now,
),
session_ref_changed: previous_session != current_session,
+ agent_released: false,
})
}
@@ -1200,6 +1226,7 @@ impl TerminalState {
now,
),
session_ref_changed: previous_session.is_some(),
+ agent_released: false,
})
}
@@ -1256,6 +1283,7 @@ impl TerminalState {
self.fallback_visible_blocker = false;
self.fallback_observed_at = None;
self.hook_authority = None;
+ self.clear_agent_name();
if !preserve_foreign_persisted_session {
self.persisted_agent_session = None;
}
@@ -1269,6 +1297,7 @@ impl TerminalState {
now,
),
session_ref_changed: previous_session != current_session,
+ agent_released: true,
})
}
@@ -1286,6 +1315,23 @@ impl TerminalState {
self.detected_agent
}
+ pub(crate) fn unchanged_effective_state_change_at(&self, now: Instant) -> EffectiveStateChange {
+ let agent_label = self.effective_agent_label().map(str::to_string);
+ let known_agent = self.effective_known_agent();
+ let state = self.state;
+ let presentation = self.effective_presentation_for_state_at(state, now);
+ EffectiveStateChange {
+ previous_agent_label: agent_label.clone(),
+ previous_known_agent: known_agent,
+ previous_state: state,
+ previous_presentation: presentation.clone(),
+ agent_label,
+ known_agent,
+ state,
+ presentation,
+ }
+ }
+
pub fn full_lifecycle_hook_authority_active(&self) -> bool {
self.live_full_lifecycle_hook_authority()
}
@@ -1319,8 +1365,30 @@ impl TerminalState {
}
pub fn set_agent_name(&mut self, name: String) {
- let name = name.trim().to_string();
self.agent_name = (!name.is_empty()).then_some(name);
+ self.agent_name_owner = self.agent_name.as_ref().and_then(|_| {
+ self.hook_authority
+ .as_ref()
+ .map(|authority| AgentNameOwner {
+ agent_label: authority.agent_label.clone(),
+ session_ref: authority.session_ref.clone(),
+ })
+ .or_else(|| {
+ self.persisted_agent_session
+ .as_ref()
+ .map(|session| AgentNameOwner {
+ agent_label: session.agent.clone(),
+ session_ref: Some(session.session_ref.clone()),
+ })
+ })
+ .or_else(|| {
+ self.effective_agent_label()
+ .map(|agent_label| AgentNameOwner {
+ agent_label: agent_label.to_string(),
+ session_ref: None,
+ })
+ })
+ });
}
pub fn begin_managed_agent(
@@ -1332,12 +1400,16 @@ impl TerminalState {
timeout: Duration,
) {
self.set_agent_name(name);
+ self.agent_name_owner = Some(AgentNameOwner {
+ agent_label: crate::detect::agent_label(kind).to_string(),
+ session_ref: None,
+ });
self.managed_agent = Some(ManagedAgent {
kind,
- observed_expected: false,
phase: ManagedAgentPhase::Pending {
ready_after: Some(now.checked_add(settle_delay).unwrap_or(now)),
deadline: now.checked_add(timeout).unwrap_or(now),
+ observed_expected: false,
},
});
}
@@ -1348,11 +1420,8 @@ impl TerminalState {
}
pub fn managed_agent_interactive_ready(&self) -> bool {
- self.managed_agent.is_some_and(|managed| {
- matches!(managed.phase, ManagedAgentPhase::Active)
- && self.effective_known_agent() == Some(managed.kind)
- && matches!(self.state, AgentState::Idle | AgentState::Blocked)
- })
+ self.managed_agent
+ .is_some_and(|managed| matches!(managed.phase, ManagedAgentPhase::Active))
}
pub fn managed_agent_kind(&self) -> Option<Agent> {
@@ -1363,6 +1432,7 @@ impl TerminalState {
let ManagedAgentPhase::Pending {
ready_after,
deadline,
+ ..
} = self.managed_agent?.phase
else {
return None;
@@ -1375,25 +1445,29 @@ impl TerminalState {
return false;
};
let known_agent = self.effective_known_agent();
- let observed_expected = managed.observed_expected || known_agent == Some(managed.kind);
+ let observed_expected = match managed.phase {
+ ManagedAgentPhase::Pending {
+ observed_expected, ..
+ } => observed_expected || known_agent == Some(managed.kind),
+ ManagedAgentPhase::Active => false,
+ };
let clear = process_exited
|| known_agent.is_some_and(|agent| agent != managed.kind)
|| matches!(managed.phase, ManagedAgentPhase::Pending { .. })
&& observed_expected
&& known_agent.is_none();
if clear {
- self.managed_agent = None;
- self.agent_name = None;
+ self.clear_agent_name();
return true;
}
if let ManagedAgentPhase::Pending {
ready_after,
deadline,
+ observed_expected: previous_observed_expected,
} = managed.phase
{
if now >= deadline {
- self.managed_agent = None;
- self.agent_name = None;
+ self.clear_agent_name();
return true;
}
if ready_after.is_none_or(|ready_after| now >= ready_after) {
@@ -1402,7 +1476,6 @@ impl TerminalState {
{
self.managed_agent = Some(ManagedAgent {
kind: managed.kind,
- observed_expected: true,
phase: ManagedAgentPhase::Active,
});
return true;
@@ -1410,19 +1483,23 @@ impl TerminalState {
if ready_after.is_some() {
self.managed_agent = Some(ManagedAgent {
kind: managed.kind,
- observed_expected,
phase: ManagedAgentPhase::Pending {
ready_after: None,
deadline,
+ observed_expected,
},
});
return true;
}
}
- if observed_expected != managed.observed_expected {
+ if observed_expected != previous_observed_expected {
self.managed_agent = Some(ManagedAgent {
- observed_expected,
- ..managed
+ kind: managed.kind,
+ phase: ManagedAgentPhase::Pending {
+ ready_after,
+ deadline,
+ observed_expected,
+ },
});
return true;
}
@@ -1432,15 +1509,19 @@ impl TerminalState {
pub fn restore_managed_agent(&mut self, name: String, kind: Agent) {
self.set_agent_name(name);
+ self.agent_name_owner = Some(AgentNameOwner {
+ agent_label: crate::detect::agent_label(kind).to_string(),
+ session_ref: None,
+ });
self.managed_agent = Some(ManagedAgent {
kind,
- observed_expected: true,
phase: ManagedAgentPhase::Active,
});
}
pub fn clear_agent_name(&mut self) {
self.agent_name = None;
+ self.agent_name_owner = None;
self.managed_agent = None;
}
@@ -1464,9 +1545,45 @@ impl TerminalState {
}
pub fn is_agent_terminal(&self) -> bool {
- self.agent_name.is_some()
- || self.effective_agent_label().is_some()
- || self.launch_argv.is_some()
+ self.agent_name.is_some() || self.effective_agent_label().is_some()
+ }
+
+ fn reconcile_agent_name_owner(
+ &mut self,
+ agent_label: &str,
+ session_ref: Option<&crate::agent_resume::AgentSessionRef>,
+ ) {
+ if self.agent_name.is_none() {
+ return;
+ }
+ if self.managed_agent.is_some_and(|managed| {
+ crate::detect::parse_agent_label(agent_label) == Some(managed.kind)
+ }) {
+ return;
+ }
+ match self.agent_name_owner.as_mut() {
+ Some(owner)
+ if owner.agent_label != agent_label
+ || owner
+ .session_ref
+ .as_ref()
+ .zip(session_ref)
+ .is_some_and(|(current, incoming)| current != incoming) =>
+ {
+ self.agent_name = None;
+ self.agent_name_owner = None;
+ }
+ Some(owner) if owner.session_ref.is_none() && session_ref.is_some() => {
+ owner.session_ref = session_ref.cloned();
+ }
+ None => {
+ self.agent_name_owner = Some(AgentNameOwner {
+ agent_label: agent_label.to_string(),
+ session_ref: session_ref.cloned(),
+ })
+ }
+ _ => {}
+ }
}
pub fn border_label(&self, show_agent_labels: bool) -> Option<String> {
@@ -1566,7 +1683,11 @@ mod tests {
assert!(terminal.managed_agent_interactive_ready());
assert_eq!(terminal.agent_name.as_deref(), Some("reviewer"));
+ terminal.set_detected_state(Some(Agent::Pi), AgentState::Working);
+ assert!(terminal.managed_agent_interactive_ready());
+
terminal.set_detected_state(None, AgentState::Unknown);
+ assert!(terminal.managed_agent_interactive_ready());
assert!(!terminal.reconcile_managed_agent_at(now + Duration::from_millis(101), false));
assert_eq!(terminal.agent_name.as_deref(), Some("reviewer"));
assert!(terminal.reconcile_managed_agent_at(now + Duration::from_millis(102), true));
@@ -3917,6 +4038,70 @@ mod tests {
}
#[test]
+ fn pi_session_replacement_clears_the_previous_sessions_alias() {
+ let mut terminal = test_terminal();
+ terminal.set_detected_state(Some(Agent::Pi), AgentState::Idle);
+ terminal
+ .set_agent_session_ref(
+ "herdr:pi".into(),
+ "pi".into(),
+ crate::agent_resume::AgentSessionRef::id("pi-old"),
+ Some(20),
+ )
+ .expect("initial session should be accepted");
+ terminal.set_agent_name("reviewer".into());
+
+ terminal
+ .set_agent_session_ref_for_session_start(
+ "herdr:pi".into(),
+ "pi".into(),
+ crate::agent_resume::AgentSessionRef::id("pi-new"),
+ Some(21),
+ Some("new".into()),
+ )
+ .expect("new should replace the session");
+
+ assert!(terminal.agent_name.is_none());
+ }
+
+ #[test]
+ fn managed_agent_name_survives_native_session_replacement() {
+ let mut terminal = test_terminal();
+ let now = Instant::now();
+ terminal.begin_managed_agent(
+ "reviewer".into(),
+ Agent::OpenCode,
+ now,
+ Duration::ZERO,
+ Duration::from_secs(1),
+ );
+ terminal.set_detected_state(Some(Agent::OpenCode), AgentState::Idle);
+ assert!(terminal.reconcile_managed_agent_at(now, false));
+
+ for (sequence, session) in [(20, "opencode-old"), (21, "opencode-new")] {
+ terminal
+ .set_agent_session_ref_for_session_start(
+ "herdr:opencode".into(),
+ "opencode".into(),
+ crate::agent_resume::AgentSessionRef::id(session),
+ Some(sequence),
+ Some("new".into()),
+ )
+ .expect("managed session should be accepted");
+ }
+
+ assert_eq!(terminal.agent_name.as_deref(), Some("reviewer"));
+ assert!(terminal.managed_agent_interactive_ready());
+ assert_eq!(
+ terminal
+ .persisted_agent_session
+ .as_ref()
+ .map(|session| session.session_ref.value.as_str()),
+ Some("opencode-new")
+ );
+ }
+
+ #[test]
fn opencode_session_ref_without_start_source_does_not_replace_existing() {
let mut terminal = test_terminal();
terminal
@@ -4352,6 +4537,118 @@ mod tests {
}
#[test]
+ fn agent_alias_survives_detection_uncertainty_but_not_replacement_or_release() {
+ let mut terminal = test_terminal();
+ terminal.set_detected_state(Some(Agent::Pi), AgentState::Working);
+ terminal.set_agent_name("reviewer".into());
+
+ terminal.set_detected_state(None, AgentState::Unknown);
+ assert_eq!(terminal.agent_name.as_deref(), Some("reviewer"));
+
+ terminal.set_detected_state(Some(Agent::Codex), AgentState::Idle);
+ assert!(terminal.agent_name.is_none());
+
+ terminal.set_agent_name("replacement".into());
+ terminal
+ .release_agent_with_mutation("herdr:codex", "codex", None)
+ .expect("detected agent release should be accepted");
+ assert!(terminal.agent_name.is_none());
+ }
+
+ #[test]
+ fn agent_replacement_clears_alias_owned_by_hook_identity() {
+ let mut terminal = test_terminal();
+ terminal.set_hook_authority(
+ "herdr:claude".into(),
+ "claude".into(),
+ AgentState::Working,
+ None,
+ Some(20),
+ );
+ terminal.set_agent_name("reviewer".into());
+
+ terminal.set_detected_state(Some(Agent::Grok), AgentState::Idle);
+
+ assert!(terminal.agent_name.is_none());
+ assert_eq!(terminal.effective_known_agent(), Some(Agent::Grok));
+ }
+
+ #[test]
+ fn accepted_hook_replacement_clears_the_previous_agents_alias() {
+ let mut terminal = test_terminal();
+ terminal
+ .set_hook_authority_at(
+ "custom:agent".into(),
+ "pi".into(),
+ AgentState::Working,
+ None,
+ None,
+ Some(20),
+ Instant::now(),
+ )
+ .expect("initial hook should be accepted");
+ terminal.set_agent_name("reviewer".into());
+
+ terminal
+ .set_hook_authority_at(
+ "custom:agent".into(),
+ "claude".into(),
+ AgentState::Idle,
+ None,
+ None,
+ Some(21),
+ Instant::now(),
+ )
+ .expect("replacement hook should be accepted");
+
+ assert!(terminal.agent_name.is_none());
+ assert_eq!(terminal.effective_known_agent(), Some(Agent::Claude));
+ }
+
+ #[test]
+ fn accepted_same_kind_hook_owner_replacement_clears_the_alias() {
+ let mut terminal = test_terminal();
+ terminal
+ .set_hook_authority_at(
+ "herdr:pi".into(),
+ "pi".into(),
+ AgentState::Working,
+ None,
+ crate::agent_resume::AgentSessionRef::path(test_session_path("first.jsonl")),
+ Some(20),
+ Instant::now(),
+ )
+ .expect("initial hook should be accepted");
+ terminal.set_agent_name("reviewer".into());
+ terminal
+ .clear_hook_authority_with_mutation(Some("herdr:pi"), Some(21))
+ .expect("hook clear should be accepted");
+ assert_eq!(terminal.agent_name.as_deref(), Some("reviewer"));
+
+ terminal
+ .set_hook_authority_at(
+ "herdr:pi".into(),
+ "pi".into(),
+ AgentState::Idle,
+ None,
+ crate::agent_resume::AgentSessionRef::path(test_session_path("second.jsonl")),
+ Some(22),
+ Instant::now(),
+ )
+ .expect("replacement hook should be accepted");
+
+ assert!(terminal.agent_name.is_none());
+ assert_eq!(terminal.effective_known_agent(), Some(Agent::Pi));
+ }
+
+ #[test]
+ fn launch_command_alone_does_not_make_a_terminal_an_agent() {
+ let terminal = test_terminal().with_launch_argv(vec!["just".into(), "dev".into()]);
+
+ assert!(!terminal.is_agent_terminal());
+ }
+
+ #[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 {