mirror of
https://github.com/herdrdev/herdr.git
synced 2026-10-08 16:00:45 +00:00
* fix(windows): read agent command lines without PROCESS_VM_READ Windows process command lines were read by opening each process with PROCESS_VM_READ and walking its PEB. Hardened runtimes (Electron/Node) and security products deny that access, so those processes resolved to a bare `node.exe`/`bun.exe` name with no argv, argv0, or cmdline. A runtime-wrapped agent launched from such a process was then indistinguishable from an unrelated runtime process, and the pane stayed on the shell with `agent_status: unknown`. Prefer `NtQueryInformationProcess` with `ProcessCommandLineInformation` (Windows 8.1+), which needs only `PROCESS_QUERY_LIMITED_INFORMATION`, and keep the existing PEB read as a fallback for processes that do not expose a stored command line. The fallback still requires `PROCESS_VM_READ`, so this only widens coverage. refs #4579 * fix(windows): handle growing command lines before failure test The retry added for a command line that grows between the size probe and the read was unreachable. NTSTATUS is signed, so STATUS_BUFFER_OVERFLOW, STATUS_BUFFER_TOO_SMALL, and STATUS_INFO_LENGTH_MISMATCH are all negative and were treated as failures by the earlier `status < 0` check. Check the growth statuses first and retry once with the reported size. Also read the returned UNICODE_STRING header unaligned, since a Vec<u8> buffer only guarantees byte alignment. * test(windows): keep command-line marker test process alive Passing the marker as a second argument to `ping` let `ping` exit as soon as it saw one target, so the command-line query could race the process exit. Move the marker into a `rem` after `&` so it stays in cmd.exe's own command line and `ping -n 11` keeps the process alive for the read.
2019 lines
67 KiB
Rust
2019 lines
67 KiB
Rust
//! Agent state detection via terminal tail pattern matching.
|
|
//!
|
|
//! Each pane's live bottom-of-buffer text is read periodically and matched
|
|
//! against known agent output patterns to determine state.
|
|
|
|
pub mod manifest;
|
|
pub mod manifest_update;
|
|
|
|
/// The detected state of a terminal pane.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
|
pub enum AgentState {
|
|
/// Agent finished, prompt visible, nothing happening.
|
|
Idle,
|
|
/// Agent is actively working/processing.
|
|
Working,
|
|
/// Agent needs human input and is blocked on a response.
|
|
Blocked,
|
|
/// Plain shell or unrecognized program.
|
|
Unknown,
|
|
}
|
|
|
|
/// Screen-derived agent state plus confidence metadata used for source arbitration.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub struct AgentDetection {
|
|
pub state: AgentState,
|
|
/// True when the current screen is an agent-owned viewer that shows
|
|
/// transcript/history instead of the live prompt state.
|
|
pub skip_state_update: bool,
|
|
/// True when the current screen visibly shows live idle chrome.
|
|
pub visible_idle: bool,
|
|
/// True when the current screen visibly shows live UI chrome that needs
|
|
/// human input. This is stronger than arbitrary prompt-like text in the
|
|
/// scrollback and may override a non-blocked integration state.
|
|
pub visible_blocker: bool,
|
|
/// True when the current screen visibly shows live working chrome. PTY
|
|
/// activity is the normal working authority; this remains diagnostic
|
|
/// metadata and for non-PTY fallback paths.
|
|
pub visible_working: bool,
|
|
}
|
|
|
|
/// Which agent we detected running in a pane.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum Agent {
|
|
Pi,
|
|
Claude,
|
|
Codex,
|
|
Gemini,
|
|
Cursor,
|
|
Devin,
|
|
Antigravity,
|
|
Cline,
|
|
Omp,
|
|
Mastracode,
|
|
OpenCode,
|
|
GithubCopilot,
|
|
Kimi,
|
|
Kiro,
|
|
Droid,
|
|
Amp,
|
|
Grok,
|
|
Hermes,
|
|
Kilo,
|
|
Qodercli,
|
|
Qwen,
|
|
Letta,
|
|
Maki,
|
|
Muse,
|
|
}
|
|
|
|
impl Agent {
|
|
pub const ALL: [Self; 24] = [
|
|
Self::Pi,
|
|
Self::Claude,
|
|
Self::Codex,
|
|
Self::Gemini,
|
|
Self::Cursor,
|
|
Self::Devin,
|
|
Self::Antigravity,
|
|
Self::Cline,
|
|
Self::Omp,
|
|
Self::Mastracode,
|
|
Self::OpenCode,
|
|
Self::GithubCopilot,
|
|
Self::Kimi,
|
|
Self::Kiro,
|
|
Self::Droid,
|
|
Self::Amp,
|
|
Self::Grok,
|
|
Self::Hermes,
|
|
Self::Kilo,
|
|
Self::Qodercli,
|
|
Self::Qwen,
|
|
Self::Letta,
|
|
Self::Maki,
|
|
Self::Muse,
|
|
];
|
|
|
|
pub const SCREEN_MANIFEST_AGENTS: [Self; 22] = [
|
|
Self::Pi,
|
|
Self::Claude,
|
|
Self::Codex,
|
|
Self::Gemini,
|
|
Self::Cursor,
|
|
Self::Devin,
|
|
Self::Antigravity,
|
|
Self::Cline,
|
|
Self::OpenCode,
|
|
Self::GithubCopilot,
|
|
Self::Kimi,
|
|
Self::Kiro,
|
|
Self::Droid,
|
|
Self::Amp,
|
|
Self::Grok,
|
|
Self::Hermes,
|
|
Self::Kilo,
|
|
Self::Qodercli,
|
|
Self::Qwen,
|
|
Self::Letta,
|
|
Self::Maki,
|
|
Self::Muse,
|
|
];
|
|
}
|
|
|
|
pub fn agent_label(agent: Agent) -> &'static str {
|
|
match agent {
|
|
Agent::Pi => "pi",
|
|
Agent::Claude => "claude",
|
|
Agent::Codex => "codex",
|
|
Agent::Gemini => "gemini",
|
|
Agent::Cursor => "cursor",
|
|
Agent::Devin => "devin",
|
|
Agent::Antigravity => "agy",
|
|
Agent::Cline => "cline",
|
|
Agent::Omp => "omp",
|
|
Agent::Mastracode => "mastracode",
|
|
Agent::OpenCode => "opencode",
|
|
Agent::GithubCopilot => "copilot",
|
|
Agent::Kimi => "kimi",
|
|
Agent::Kiro => "kiro",
|
|
Agent::Droid => "droid",
|
|
Agent::Amp => "amp",
|
|
Agent::Grok => "grok",
|
|
Agent::Hermes => "hermes",
|
|
Agent::Kilo => "kilo",
|
|
Agent::Qodercli => "qodercli",
|
|
Agent::Qwen => "qwen",
|
|
Agent::Letta => "letta",
|
|
Agent::Maki => "maki",
|
|
Agent::Muse => "muse",
|
|
}
|
|
}
|
|
|
|
pub fn interactive_agent_executable(agent: Agent) -> &'static str {
|
|
match agent {
|
|
Agent::Pi => "pi",
|
|
Agent::Claude => "claude",
|
|
Agent::Codex => "codex",
|
|
Agent::Gemini => "gemini",
|
|
Agent::Cursor => {
|
|
if cfg!(windows) {
|
|
"cursor-agent.cmd"
|
|
} else {
|
|
"cursor-agent"
|
|
}
|
|
}
|
|
Agent::Devin => "devin",
|
|
Agent::Antigravity => "agy",
|
|
Agent::Cline => "cline",
|
|
Agent::Omp => "omp",
|
|
Agent::Mastracode => "mastracode",
|
|
Agent::OpenCode => "opencode",
|
|
Agent::GithubCopilot => "copilot",
|
|
Agent::Kimi => "kimi",
|
|
Agent::Kiro => "kiro-cli",
|
|
Agent::Droid => "droid",
|
|
Agent::Amp => "amp",
|
|
Agent::Grok => "grok",
|
|
Agent::Hermes => "hermes",
|
|
Agent::Kilo => "kilo",
|
|
Agent::Qodercli => "qodercli",
|
|
Agent::Qwen => "qwen",
|
|
Agent::Letta => "letta",
|
|
Agent::Maki => "maki",
|
|
Agent::Muse => "muse",
|
|
}
|
|
}
|
|
|
|
pub fn parse_agent_label(agent: &str) -> Option<Agent> {
|
|
let name = normalized_agent_lookup_name(agent);
|
|
parse_canonical_agent_label(&name).or_else(|| lookup_agent(&name))
|
|
}
|
|
|
|
pub(crate) fn parse_canonical_agent_label(label: &str) -> Option<Agent> {
|
|
let agent = lookup_agent(label)?;
|
|
(agent_label(agent) == label).then_some(agent)
|
|
}
|
|
|
|
fn lookup_agent(name: &str) -> Option<Agent> {
|
|
let name = path_basename(name);
|
|
match name {
|
|
"pi" => Some(Agent::Pi),
|
|
"claude" | "claude-code" => Some(Agent::Claude),
|
|
"codex" => Some(Agent::Codex),
|
|
"gemini" => Some(Agent::Gemini),
|
|
"cursor" | "cursor-agent" => Some(Agent::Cursor),
|
|
"devin" | "devin-cli" | "devin cli" => Some(Agent::Devin),
|
|
"agy" | "antigravity" | "antigravity-cli" => Some(Agent::Antigravity),
|
|
"cline" | ".cline" => Some(Agent::Cline),
|
|
"omp" => Some(Agent::Omp),
|
|
"mastracode" | "mastra-code" | "mastra code" => Some(Agent::Mastracode),
|
|
"opencode" | "opencode2" | "open-code" => Some(Agent::OpenCode),
|
|
"copilot" | "github-copilot" | "ghcs" => Some(Agent::GithubCopilot),
|
|
"kimi" | "kimi-code" | "kimi code" => Some(Agent::Kimi),
|
|
"kiro" | "kiro-cli" => Some(Agent::Kiro),
|
|
"droid" => Some(Agent::Droid),
|
|
"amp" | "amp-local" => Some(Agent::Amp),
|
|
"grok" | "grok-build" => Some(Agent::Grok),
|
|
"hermes" | "hermes-agent" => Some(Agent::Hermes),
|
|
"kilo" | "kilo-code" | "kilo code" => Some(Agent::Kilo),
|
|
"qodercli" | "qoderclicn" | "qoder" | "qodercn" => Some(Agent::Qodercli),
|
|
"qwen" | "qwen-code" | "qwen code" => Some(Agent::Qwen),
|
|
"letta" | "letta-code" | "letta code" => Some(Agent::Letta),
|
|
"maki" => Some(Agent::Maki),
|
|
"muse" | "muse-code" | "muse-cli" => Some(Agent::Muse),
|
|
_ if is_muse_versioned_binary(name) => Some(Agent::Muse),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
/// Muse's install-dir launcher script resolves the active release and execs
|
|
/// `muse-bin-<version>` (e.g. `muse-bin-0.1.0-R708.1`), so the running
|
|
/// process never carries a bare `muse`/`muse-bin` alias. Require a digit
|
|
/// immediately after the `muse-bin-` prefix so unrelated binaries such as
|
|
/// `muse-binary` or a bare `muse-bin` stay unmatched.
|
|
/// Accepts path-qualified `argv0` values by checking only the basename, since
|
|
/// the launcher may `exec` with an absolute install-dir path.
|
|
fn is_muse_versioned_binary(name: &str) -> bool {
|
|
path_basename(name)
|
|
.strip_prefix("muse-bin-")
|
|
.is_some_and(|rest| rest.starts_with(|c: char| c.is_ascii_digit()))
|
|
}
|
|
|
|
/// Identify which agent is running from the process name.
|
|
/// Returns `None` for plain shells or unrecognized programs.
|
|
pub fn identify_agent(process_name: &str) -> Option<Agent> {
|
|
parse_agent_label(process_name)
|
|
}
|
|
|
|
pub fn identify_agent_in_job(job: &crate::platform::ForegroundJob) -> Option<(Agent, String)> {
|
|
if let Some(process) = job
|
|
.processes
|
|
.iter()
|
|
.find(|process| process.pid == job.process_group_id)
|
|
{
|
|
let candidate = normalized_process_name(process);
|
|
if let Some(agent) = identify_agent(&candidate) {
|
|
if agent != Agent::Letta || is_interactive_letta_process(process) {
|
|
return Some((agent, candidate));
|
|
}
|
|
}
|
|
}
|
|
|
|
let mut best: Option<(u8, Agent, String)> = None;
|
|
|
|
for process in &job.processes {
|
|
let candidate = normalized_process_name(process);
|
|
let Some(agent) = identify_agent(&candidate) else {
|
|
continue;
|
|
};
|
|
if agent == Agent::Letta && !is_interactive_letta_process(process) {
|
|
continue;
|
|
}
|
|
let score = process_priority(process, &candidate);
|
|
|
|
match &best {
|
|
Some((best_score, _, _)) if *best_score >= score => {}
|
|
_ => best = Some((score, agent, candidate)),
|
|
}
|
|
}
|
|
|
|
best.map(|(_, agent, name)| (agent, name))
|
|
}
|
|
|
|
/// Detect the state of an agent from the live terminal tail snapshot.
|
|
/// If `agent` is `None`, returns `Unknown`.
|
|
#[cfg(test)]
|
|
pub fn detect_state(agent: Option<Agent>, screen_content: &str) -> AgentState {
|
|
detect_agent(agent, screen_content).state
|
|
}
|
|
|
|
/// Detect state and whether a visible blocker is present on the current screen.
|
|
#[allow(dead_code)] // shim for existing callers; detect_agent_with_osc is the real path
|
|
pub fn detect_agent(agent: Option<Agent>, screen_content: &str) -> AgentDetection {
|
|
detect_agent_with_osc(agent, screen_content, "", "")
|
|
}
|
|
|
|
/// Detect state using screen content plus OSC title/progress strings.
|
|
pub fn detect_agent_with_osc(
|
|
agent: Option<Agent>,
|
|
screen_content: &str,
|
|
osc_title: &str,
|
|
osc_progress: &str,
|
|
) -> AgentDetection {
|
|
let Some(agent) = agent else {
|
|
return AgentDetection {
|
|
state: AgentState::Unknown,
|
|
skip_state_update: false,
|
|
visible_idle: false,
|
|
visible_blocker: false,
|
|
visible_working: false,
|
|
};
|
|
};
|
|
manifest::detect_with_osc(
|
|
agent,
|
|
manifest::DetectionInput {
|
|
screen: screen_content,
|
|
osc_title,
|
|
osc_progress,
|
|
},
|
|
)
|
|
}
|
|
|
|
pub(crate) fn full_lifecycle_hook_authority(source: &str, agent_label: &str) -> bool {
|
|
matches!(
|
|
(source, agent_label),
|
|
("herdr:pi", "pi")
|
|
| ("herdr:omp", "omp")
|
|
| ("herdr:mastracode", "mastracode")
|
|
| ("herdr:opencode", "opencode")
|
|
| ("herdr:kilo", "kilo")
|
|
| ("herdr:kimi", "kimi")
|
|
)
|
|
}
|
|
|
|
pub(crate) fn session_identity_only_integration(source: &str, agent_label: &str) -> bool {
|
|
matches!(
|
|
(source, agent_label),
|
|
("herdr:hermes", "hermes")
|
|
| ("herdr:qwen", "qwen")
|
|
| ("herdr:letta", "letta")
|
|
| ("herdr:antigravity_cli", "agy")
|
|
)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Process identification (platform-specific)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/// Get the foreground job for a given child PID.
|
|
/// Delegates to platform-specific implementation.
|
|
pub fn foreground_job(child_pid: u32) -> Option<crate::platform::ForegroundJob> {
|
|
crate::platform::foreground_job(child_pid)
|
|
}
|
|
|
|
/// Get the foreground process group leader as a one-process job.
|
|
/// This is cheaper than collecting every process in the foreground job.
|
|
pub fn foreground_group_leader_job(
|
|
process_group_id: u32,
|
|
) -> Option<crate::platform::ForegroundJob> {
|
|
crate::platform::foreground_group_leader_job(process_group_id)
|
|
}
|
|
|
|
/// Get the foreground process group for a pane shell PID.
|
|
/// This is cheaper than collecting every process in the foreground job.
|
|
pub fn foreground_process_group_id(child_pid: u32) -> Option<u32> {
|
|
crate::platform::foreground_process_group_id(child_pid)
|
|
}
|
|
|
|
/// True when the pane's own shell is at its prompt with nothing running in it.
|
|
pub fn pane_shell_is_idle(child_pid: u32) -> bool {
|
|
crate::platform::available_pane_shell(child_pid).is_some()
|
|
}
|
|
|
|
fn normalized_process_name(process: &crate::platform::ForegroundProcess) -> String {
|
|
let effective = process.argv0.as_deref().unwrap_or(&process.name);
|
|
let lower_effective = effective.to_lowercase();
|
|
|
|
if is_generic_runtime_or_shell(&lower_effective) {
|
|
if let Some(wrapped_agent) =
|
|
wrapped_agent_name_from_runtime_argv(&lower_effective, process.argv.as_deref())
|
|
{
|
|
return wrapped_agent;
|
|
}
|
|
}
|
|
|
|
if identify_agent(effective).is_some() {
|
|
return effective.to_string();
|
|
}
|
|
|
|
if let Some(runtime) = process.argv.as_deref().and_then(|argv| argv.first()) {
|
|
let runtime_name = normalized_agent_lookup_name(path_basename(runtime));
|
|
if matches!(runtime_name.as_str(), "node" | "bun") {
|
|
if let Some(wrapped_agent) =
|
|
wrapped_agent_name_from_runtime_argv(runtime, process.argv.as_deref())
|
|
{
|
|
if matches!(
|
|
identify_agent(&wrapped_agent),
|
|
Some(Agent::Qwen | Agent::Cline | Agent::Letta)
|
|
) {
|
|
return wrapped_agent;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if let Some(wrapped_agent) = argv0_agent_name(process.argv.as_deref())
|
|
.or_else(|| cmdline_argv0_agent_name(process.cmdline.as_deref().unwrap_or_default()))
|
|
{
|
|
return wrapped_agent;
|
|
}
|
|
|
|
effective.to_string()
|
|
}
|
|
|
|
fn wrapped_agent_name_from_runtime_argv(runtime: &str, argv: Option<&[String]>) -> Option<String> {
|
|
let argv = argv?;
|
|
let runtime_name = normalized_agent_lookup_name(path_basename(runtime));
|
|
|
|
match runtime_name.as_str() {
|
|
"node" => cursor_agent_name_from_bundled_node_argv(argv)
|
|
.or_else(|| script_arg_agent_name(argv, &["-e", "--eval", "-p", "--print"], &[])),
|
|
"bun" => script_arg_agent_name(argv, &["-e", "--eval", "-p", "--print"], &[]),
|
|
name if is_python_runtime(name) => script_arg_agent_name(argv, &["-c"], &["-m"]),
|
|
"sh" | "bash" | "zsh" | "fish" => script_arg_agent_name(argv, &["-c"], &[]),
|
|
"cmd" => windows_cmd_arg_agent_name(argv),
|
|
"powershell" | "pwsh" => powershell_arg_agent_name(argv),
|
|
"tmux" => None,
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn cursor_agent_name_from_bundled_node_argv(argv: &[String]) -> Option<String> {
|
|
let (runtime_parent, runtime_name) = path_parent_and_basename(argv.first()?)?;
|
|
let (script_parent, script_name) = path_parent_and_basename(argv.get(1)?)?;
|
|
if !runtime_name.eq_ignore_ascii_case("node.exe")
|
|
|| !script_name.eq_ignore_ascii_case("index.js")
|
|
|| !runtime_parent.eq_ignore_ascii_case(script_parent)
|
|
{
|
|
return None;
|
|
}
|
|
|
|
let mut tail = runtime_parent
|
|
.rsplit(['/', '\\'])
|
|
.filter(|component| !component.is_empty());
|
|
let (Some(version), Some(versions), Some(package)) = (tail.next(), tail.next(), tail.next())
|
|
else {
|
|
return None;
|
|
};
|
|
(package.eq_ignore_ascii_case("cursor-agent")
|
|
&& versions.eq_ignore_ascii_case("versions")
|
|
&& !version.trim().is_empty())
|
|
.then(|| agent_label(Agent::Cursor).to_string())
|
|
}
|
|
|
|
fn path_parent_and_basename(path: &str) -> Option<(&str, &str)> {
|
|
let split = path.rfind(['/', '\\'])?;
|
|
let parent = path[..split].trim_end_matches(['/', '\\']);
|
|
let basename = &path[split + 1..];
|
|
(!parent.is_empty() && !basename.is_empty()).then_some((parent, basename))
|
|
}
|
|
|
|
fn windows_cmd_arg_agent_name(argv: &[String]) -> Option<String> {
|
|
let mut args = argv.iter().skip(1);
|
|
while let Some(arg) = args.next() {
|
|
let flag = arg.trim_matches('"').to_lowercase();
|
|
match flag.as_str() {
|
|
"/c" | "/k" => {
|
|
return args
|
|
.next()
|
|
.and_then(|command| command_text_agent_name(command))
|
|
}
|
|
"/d" | "/s" | "/q" | "/a" | "/u" | "/e:on" | "/e:off" | "/f:on" | "/f:off"
|
|
| "/v:on" | "/v:off" => continue,
|
|
_ => {}
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
fn powershell_arg_agent_name(argv: &[String]) -> Option<String> {
|
|
let mut args = argv.iter().skip(1);
|
|
while let Some(arg) = args.next() {
|
|
let flag = arg.trim_matches('"').to_lowercase();
|
|
match flag.as_str() {
|
|
"-file" | "-f" | "/file" => {
|
|
return args
|
|
.next()
|
|
.and_then(|path| agent_name_from_path_token(path));
|
|
}
|
|
"-command" | "-c" | "/command" | "/c" => {
|
|
return args
|
|
.next()
|
|
.and_then(|command| command_text_agent_name(command));
|
|
}
|
|
"-encodedcommand" | "-enc" | "/encodedcommand" | "/enc" => return None,
|
|
"-configurationname" | "-executionpolicy" | "-outputformat" | "-psconsolefile"
|
|
| "-version" | "-windowstyle" | "-workingdirectory" => {
|
|
let _ = args.next();
|
|
}
|
|
_ if flag.starts_with('-') || flag.starts_with('/') => {}
|
|
_ => return agent_name_from_path_token(arg),
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
fn command_text_agent_name(command: &str) -> Option<String> {
|
|
let mut rest = command;
|
|
while let Some((token, next)) = command_text_token(rest) {
|
|
let token = token.trim();
|
|
if token.eq_ignore_ascii_case("&")
|
|
|| token.eq_ignore_ascii_case(".")
|
|
|| token.eq_ignore_ascii_case("call")
|
|
{
|
|
rest = next;
|
|
continue;
|
|
}
|
|
return agent_name_from_path_token(token);
|
|
}
|
|
None
|
|
}
|
|
|
|
fn command_text_token(input: &str) -> Option<(&str, &str)> {
|
|
let input = input.trim_start();
|
|
let first = input.chars().next()?;
|
|
if first == '"' || first == '\'' {
|
|
let start = first.len_utf8();
|
|
if let Some(end) = input[start..].find(first) {
|
|
let end = start + end;
|
|
return Some((&input[start..end], &input[end + first.len_utf8()..]));
|
|
}
|
|
return Some((&input[start..], ""));
|
|
}
|
|
|
|
let end = input.find(char::is_whitespace).unwrap_or(input.len());
|
|
Some((&input[..end], &input[end..]))
|
|
}
|
|
|
|
fn script_arg_agent_name(
|
|
argv: &[String],
|
|
eval_flags: &[&str],
|
|
module_flags: &[&str],
|
|
) -> Option<String> {
|
|
let mut args = argv.iter().skip(1);
|
|
while let Some(arg) = args.next() {
|
|
if arg == "--" {
|
|
return args
|
|
.next()
|
|
.and_then(|token| agent_name_from_path_token(token));
|
|
}
|
|
|
|
if flag_matches(arg, eval_flags) || flag_matches(arg, module_flags) {
|
|
return None;
|
|
}
|
|
|
|
if arg.starts_with('-') {
|
|
if option_takes_value(arg) {
|
|
let _ = args.next();
|
|
}
|
|
continue;
|
|
}
|
|
|
|
return agent_name_from_path_token(arg);
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
fn flag_matches(arg: &str, flags: &[&str]) -> bool {
|
|
flags
|
|
.iter()
|
|
.any(|flag| arg == *flag || short_flag_payload(arg, flag) || long_flag_value(arg, flag))
|
|
}
|
|
|
|
fn short_flag_payload(arg: &str, flag: &str) -> bool {
|
|
flag.starts_with('-')
|
|
&& !flag.starts_with("--")
|
|
&& arg.starts_with(flag)
|
|
&& arg.len() > flag.len()
|
|
}
|
|
|
|
fn long_flag_value(arg: &str, flag: &str) -> bool {
|
|
flag.starts_with("--")
|
|
&& arg
|
|
.strip_prefix(flag)
|
|
.is_some_and(|rest| rest.starts_with('='))
|
|
}
|
|
|
|
fn option_takes_value(arg: &str) -> bool {
|
|
matches!(
|
|
arg,
|
|
"-r" | "--require"
|
|
| "--loader"
|
|
| "--import"
|
|
| "--experimental-loader"
|
|
| "--inspect-port"
|
|
| "-W"
|
|
| "-X"
|
|
| "-S"
|
|
| "-L"
|
|
| "-o"
|
|
)
|
|
}
|
|
|
|
fn argv0_agent_name(argv: Option<&[String]>) -> Option<String> {
|
|
agent_name_from_path_token(argv?.first()?)
|
|
}
|
|
|
|
fn cmdline_argv0_agent_name(cmdline: &str) -> Option<String> {
|
|
agent_name_from_path_token(cmdline.split_whitespace().next()?)
|
|
}
|
|
|
|
fn agent_name_from_path_token(token: &str) -> Option<String> {
|
|
let trimmed = token.trim_matches(|c| matches!(c, '"' | '\''));
|
|
if trimmed.is_empty() || trimmed.starts_with('-') {
|
|
return None;
|
|
}
|
|
|
|
agent_name_from_basename(path_basename(trimmed))
|
|
.or_else(|| agent_name_from_known_package_path(trimmed))
|
|
.or_else(|| resolved_agent_name_from_path_token(trimmed))
|
|
}
|
|
|
|
fn agent_name_from_known_package_path(path: &str) -> Option<String> {
|
|
let raw_components: Vec<&str> = path
|
|
.split(['/', '\\'])
|
|
.filter(|component| !component.is_empty())
|
|
.collect();
|
|
let ends_with = |suffix: &[&str]| {
|
|
raw_components.len() >= suffix.len()
|
|
&& raw_components[raw_components.len() - suffix.len()..]
|
|
.iter()
|
|
.zip(suffix)
|
|
.all(|(actual, expected)| actual.eq_ignore_ascii_case(expected))
|
|
};
|
|
if ends_with(&[
|
|
"node_modules",
|
|
"@earendil-works",
|
|
"pi-coding-agent",
|
|
"dist",
|
|
"cli.js",
|
|
]) || ends_with(&[
|
|
"node_modules",
|
|
"@earendil-works",
|
|
"pi-coding-agent",
|
|
"dist",
|
|
"bundle",
|
|
"cli.js",
|
|
]) {
|
|
return Some(agent_label(Agent::Pi).to_string());
|
|
}
|
|
if ends_with(&[
|
|
"node_modules",
|
|
"@oh-my-pi",
|
|
"pi-coding-agent",
|
|
"dist",
|
|
"cli.js",
|
|
]) {
|
|
return Some(agent_label(Agent::Omp).to_string());
|
|
}
|
|
if ends_with(&[
|
|
"node_modules",
|
|
"@moonshot-ai",
|
|
"kimi-code",
|
|
"dist",
|
|
"main.mjs",
|
|
]) {
|
|
return Some(agent_label(Agent::Kimi).to_string());
|
|
}
|
|
|
|
let components: Vec<String> = raw_components
|
|
.into_iter()
|
|
.map(normalized_agent_lookup_name)
|
|
.collect();
|
|
for window in components.windows(5) {
|
|
if window == ["node_modules", "@qwen-code", "qwen-code", "dist", "index"] {
|
|
return Some(agent_label(Agent::Qwen).to_string());
|
|
}
|
|
}
|
|
for window in components.windows(4) {
|
|
if window == ["node_modules", "mastracode", "dist", "cli"] {
|
|
return Some(agent_label(Agent::Mastracode).to_string());
|
|
}
|
|
if window == ["node_modules", "@letta-ai", "letta-code", "letta"] {
|
|
return Some(agent_label(Agent::Letta).to_string());
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
fn letta_entrypoint_index(argv: &[String]) -> Option<usize> {
|
|
let is_letta =
|
|
|arg: &str| agent_name_from_path_token(arg).as_deref() == Some(agent_label(Agent::Letta));
|
|
if argv.first().is_some_and(|arg| is_letta(arg)) {
|
|
return Some(0);
|
|
}
|
|
|
|
let runtime = argv
|
|
.first()
|
|
.map(|arg| normalized_agent_lookup_name(path_basename(arg)))?;
|
|
if !matches!(runtime.as_str(), "node" | "bun") {
|
|
return None;
|
|
}
|
|
|
|
let mut index = 1;
|
|
while let Some(arg) = argv.get(index) {
|
|
if arg == "--" {
|
|
return argv
|
|
.get(index + 1)
|
|
.is_some_and(|arg| is_letta(arg))
|
|
.then_some(index + 1);
|
|
}
|
|
if flag_matches(arg, &["-e", "--eval", "-p", "--print"]) {
|
|
return None;
|
|
}
|
|
if arg.starts_with('-') {
|
|
index += if option_takes_value(arg) { 2 } else { 1 };
|
|
continue;
|
|
}
|
|
return is_letta(arg).then_some(index);
|
|
}
|
|
None
|
|
}
|
|
|
|
fn letta_first_arg_after_backend_selection(args: &[String]) -> Option<&str> {
|
|
let mut args = args.iter();
|
|
while let Some(arg) = args.next() {
|
|
if arg == "--backend" {
|
|
let _ = args.next();
|
|
continue;
|
|
}
|
|
if arg.starts_with("--backend=") {
|
|
continue;
|
|
}
|
|
return Some(arg);
|
|
}
|
|
None
|
|
}
|
|
|
|
fn is_interactive_letta_process(process: &crate::platform::ForegroundProcess) -> bool {
|
|
let parsed_cmdline;
|
|
let argv = if let Some(argv) = process.argv.as_deref() {
|
|
argv
|
|
} else {
|
|
parsed_cmdline = process
|
|
.cmdline
|
|
.as_deref()
|
|
.unwrap_or_default()
|
|
.split_whitespace()
|
|
.map(|arg| arg.trim_matches(|ch| matches!(ch, '\'' | '"')).to_string())
|
|
.collect::<Vec<_>>();
|
|
if parsed_cmdline.is_empty() {
|
|
return true;
|
|
}
|
|
&parsed_cmdline
|
|
};
|
|
|
|
let cli_args = letta_entrypoint_index(argv)
|
|
.map(|index| &argv[index + 1..])
|
|
.unwrap_or(argv);
|
|
|
|
if cli_args.iter().any(|arg| {
|
|
let option = arg.split_once('=').map_or(arg.as_str(), |(name, _)| name);
|
|
matches!(
|
|
option,
|
|
"-p" | "--print"
|
|
| "--prompt"
|
|
| "--json"
|
|
| "--stream-json"
|
|
| "--run"
|
|
| "--disable-memory-guard"
|
|
| "--output-format"
|
|
| "--input-format"
|
|
| "--include-partial-messages"
|
|
| "--from-agent"
|
|
| "--environment"
|
|
| "--env"
|
|
| "--pre-load-skills"
|
|
| "--tags"
|
|
| "--ephemeral"
|
|
| "--stateless"
|
|
| "--max-turns"
|
|
| "--memfs-startup"
|
|
| "-h"
|
|
| "--help"
|
|
| "-v"
|
|
| "--version"
|
|
| "--info"
|
|
| "--update"
|
|
| "--upgrade"
|
|
)
|
|
}) {
|
|
return false;
|
|
}
|
|
|
|
letta_first_arg_after_backend_selection(cli_args).is_none_or(|arg| arg.starts_with('-'))
|
|
}
|
|
|
|
fn resolved_agent_name_from_path_token(token: &str) -> Option<String> {
|
|
let path = std::path::Path::new(token);
|
|
if path.components().count() < 2 {
|
|
return None;
|
|
}
|
|
|
|
let resolved = std::fs::canonicalize(path).ok()?;
|
|
let basename = resolved.file_name()?.to_str()?;
|
|
agent_name_from_basename(basename)
|
|
}
|
|
|
|
fn agent_name_from_basename(basename: &str) -> Option<String> {
|
|
let agent = parse_agent_label(basename)?;
|
|
Some(agent_label(agent).to_string())
|
|
}
|
|
|
|
fn normalized_agent_lookup_name(name: &str) -> String {
|
|
let mut name = name.trim().to_lowercase();
|
|
for suffix in [".exe", ".cmd", ".bat", ".ps1", ".js"] {
|
|
if name.ends_with(suffix) {
|
|
name.truncate(name.len() - suffix.len());
|
|
break;
|
|
}
|
|
}
|
|
name
|
|
}
|
|
|
|
fn path_basename(path: &str) -> &str {
|
|
path.rsplit(['/', '\\'])
|
|
.find(|component| !component.is_empty())
|
|
.unwrap_or(path)
|
|
}
|
|
|
|
fn process_priority(process: &crate::platform::ForegroundProcess, normalized_name: &str) -> u8 {
|
|
let lower_name = normalized_name.to_lowercase();
|
|
if lower_name != process.name.to_lowercase() {
|
|
return 3;
|
|
}
|
|
if !is_generic_runtime_or_shell(&lower_name) {
|
|
return 2;
|
|
}
|
|
1
|
|
}
|
|
|
|
fn is_generic_runtime_or_shell(name: &str) -> bool {
|
|
let name = normalized_agent_lookup_name(path_basename(name));
|
|
is_python_runtime(&name)
|
|
|| matches!(
|
|
name.as_str(),
|
|
"sh" | "bash"
|
|
| "zsh"
|
|
| "fish"
|
|
| "tmux"
|
|
| "node"
|
|
| "bun"
|
|
| "cmd"
|
|
| "powershell"
|
|
| "pwsh"
|
|
)
|
|
}
|
|
|
|
fn is_python_runtime(name: &str) -> bool {
|
|
name == "python"
|
|
|| name.strip_prefix("python").is_some_and(|version| {
|
|
!version.is_empty()
|
|
&& version
|
|
.split('.')
|
|
.all(|part| !part.is_empty() && part.chars().all(|ch| ch.is_ascii_digit()))
|
|
})
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Tests
|
|
// ---------------------------------------------------------------------------
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn foreground_process(
|
|
pid: u32,
|
|
name: &str,
|
|
argv: &[&str],
|
|
) -> crate::platform::ForegroundProcess {
|
|
crate::platform::ForegroundProcess {
|
|
pid,
|
|
name: name.to_string(),
|
|
argv0: None,
|
|
argv: Some(argv.iter().map(|arg| (*arg).to_string()).collect()),
|
|
cmdline: Some(argv.join(" ")),
|
|
}
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
fn temp_detection_path(name: &str) -> std::path::PathBuf {
|
|
let unique = format!(
|
|
"herdr-detect-tests-{}-{}-{}",
|
|
name,
|
|
std::process::id(),
|
|
std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.expect("system time should be after unix epoch")
|
|
.as_nanos()
|
|
);
|
|
std::env::temp_dir().join(unique)
|
|
}
|
|
|
|
// ---- Agent identification ----
|
|
|
|
#[test]
|
|
fn identify_known_agents() {
|
|
assert_eq!(identify_agent("pi"), Some(Agent::Pi));
|
|
assert_eq!(identify_agent("claude"), Some(Agent::Claude));
|
|
assert_eq!(identify_agent("claude-code"), Some(Agent::Claude));
|
|
assert_eq!(identify_agent("codex"), Some(Agent::Codex));
|
|
assert_eq!(identify_agent("gemini"), Some(Agent::Gemini));
|
|
assert_eq!(identify_agent("cursor"), Some(Agent::Cursor));
|
|
assert_eq!(identify_agent("cursor-agent"), Some(Agent::Cursor));
|
|
assert_eq!(identify_agent("devin"), Some(Agent::Devin));
|
|
assert_eq!(identify_agent("devin-cli"), Some(Agent::Devin));
|
|
assert_eq!(identify_agent("agy"), Some(Agent::Antigravity));
|
|
assert_eq!(identify_agent("antigravity-cli"), Some(Agent::Antigravity));
|
|
assert_eq!(identify_agent("cline"), Some(Agent::Cline));
|
|
assert_eq!(identify_agent("omp"), Some(Agent::Omp));
|
|
assert_eq!(identify_agent("mastracode"), Some(Agent::Mastracode));
|
|
assert_eq!(identify_agent("mastra-code"), Some(Agent::Mastracode));
|
|
assert_eq!(identify_agent("opencode"), Some(Agent::OpenCode));
|
|
assert_eq!(identify_agent("opencode.exe"), Some(Agent::OpenCode));
|
|
assert_eq!(identify_agent("opencode2"), Some(Agent::OpenCode));
|
|
assert_eq!(identify_agent("opencode2.exe"), Some(Agent::OpenCode));
|
|
assert_eq!(identify_agent("kimi"), Some(Agent::Kimi));
|
|
assert_eq!(identify_agent("Kimi Code"), Some(Agent::Kimi));
|
|
assert_eq!(identify_agent("kiro"), Some(Agent::Kiro));
|
|
assert_eq!(identify_agent("kiro-cli"), Some(Agent::Kiro));
|
|
assert_eq!(identify_agent("copilot"), Some(Agent::GithubCopilot));
|
|
assert_eq!(identify_agent("ghcs"), Some(Agent::GithubCopilot));
|
|
assert_eq!(identify_agent("grok"), Some(Agent::Grok));
|
|
assert_eq!(identify_agent("grok-build"), Some(Agent::Grok));
|
|
assert_eq!(identify_agent("hermes"), Some(Agent::Hermes));
|
|
assert_eq!(identify_agent("hermes-agent"), Some(Agent::Hermes));
|
|
assert_eq!(identify_agent("kilo"), Some(Agent::Kilo));
|
|
assert_eq!(identify_agent("kilo-code"), Some(Agent::Kilo));
|
|
assert_eq!(identify_agent("qwen"), Some(Agent::Qwen));
|
|
assert_eq!(identify_agent("Qwen Code"), Some(Agent::Qwen));
|
|
assert_eq!(identify_agent("letta"), Some(Agent::Letta));
|
|
assert_eq!(identify_agent("Letta Code"), Some(Agent::Letta));
|
|
assert_eq!(identify_agent("maki"), Some(Agent::Maki));
|
|
assert_eq!(identify_agent("muse"), Some(Agent::Muse));
|
|
assert_eq!(identify_agent("muse-code"), Some(Agent::Muse));
|
|
assert_eq!(identify_agent("muse-cli"), Some(Agent::Muse));
|
|
assert_eq!(identify_agent("muse-bin-0.1.0-R708.1"), Some(Agent::Muse));
|
|
assert_eq!(identify_agent("muse-bin-1.2.3"), Some(Agent::Muse));
|
|
assert_eq!(
|
|
identify_agent("/home/user/.local/bin/muse-bin-0.2.1-R1215.1"),
|
|
Some(Agent::Muse)
|
|
);
|
|
assert_eq!(
|
|
identify_agent(r"C:\Users\user\muse-bin-0.2.1-R1215.1.exe"),
|
|
Some(Agent::Muse)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_known_agent_labels() {
|
|
assert_eq!(parse_agent_label("pi"), Some(Agent::Pi));
|
|
assert_eq!(parse_agent_label("claude"), Some(Agent::Claude));
|
|
assert_eq!(parse_agent_label("cursor-agent"), Some(Agent::Cursor));
|
|
assert_eq!(parse_agent_label("devin-cli"), Some(Agent::Devin));
|
|
assert_eq!(parse_agent_label("agy"), Some(Agent::Antigravity));
|
|
assert_eq!(parse_agent_label("antigravity"), Some(Agent::Antigravity));
|
|
assert_eq!(parse_agent_label("omp"), Some(Agent::Omp));
|
|
assert_eq!(parse_agent_label("mastracode"), Some(Agent::Mastracode));
|
|
assert_eq!(parse_agent_label("mastra code"), Some(Agent::Mastracode));
|
|
assert_eq!(parse_agent_label("opencode.exe"), Some(Agent::OpenCode));
|
|
assert_eq!(parse_agent_label("copilot"), Some(Agent::GithubCopilot));
|
|
assert_eq!(parse_agent_label("kimi-code"), Some(Agent::Kimi));
|
|
assert_eq!(
|
|
parse_agent_label("github-copilot"),
|
|
Some(Agent::GithubCopilot)
|
|
);
|
|
assert_eq!(parse_agent_label("amp-local"), Some(Agent::Amp));
|
|
assert_eq!(parse_agent_label("kiro-cli"), Some(Agent::Kiro));
|
|
assert_eq!(parse_agent_label("grok-build"), Some(Agent::Grok));
|
|
assert_eq!(parse_agent_label("hermes-agent"), Some(Agent::Hermes));
|
|
assert_eq!(parse_agent_label("qwen-code"), Some(Agent::Qwen));
|
|
assert_eq!(parse_agent_label("letta-code"), Some(Agent::Letta));
|
|
assert_eq!(parse_agent_label("maki"), Some(Agent::Maki));
|
|
assert_eq!(parse_agent_label("kilo-code"), Some(Agent::Kilo));
|
|
}
|
|
|
|
#[test]
|
|
fn every_agent_label_round_trips_through_canonical_and_alias_parsers() {
|
|
for agent in Agent::ALL {
|
|
let label = agent_label(agent);
|
|
assert_eq!(parse_canonical_agent_label(label), Some(agent));
|
|
assert_eq!(parse_agent_label(label), Some(agent));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn every_agent_has_a_canonical_interactive_executable() {
|
|
let expected = [
|
|
(Agent::Pi, "pi"),
|
|
(Agent::Claude, "claude"),
|
|
(Agent::Codex, "codex"),
|
|
(Agent::Gemini, "gemini"),
|
|
(
|
|
Agent::Cursor,
|
|
if cfg!(windows) {
|
|
"cursor-agent.cmd"
|
|
} else {
|
|
"cursor-agent"
|
|
},
|
|
),
|
|
(Agent::Devin, "devin"),
|
|
(Agent::Antigravity, "agy"),
|
|
(Agent::Cline, "cline"),
|
|
(Agent::Omp, "omp"),
|
|
(Agent::Mastracode, "mastracode"),
|
|
(Agent::OpenCode, "opencode"),
|
|
(Agent::GithubCopilot, "copilot"),
|
|
(Agent::Kimi, "kimi"),
|
|
(Agent::Kiro, "kiro-cli"),
|
|
(Agent::Droid, "droid"),
|
|
(Agent::Amp, "amp"),
|
|
(Agent::Grok, "grok"),
|
|
(Agent::Hermes, "hermes"),
|
|
(Agent::Kilo, "kilo"),
|
|
(Agent::Qodercli, "qodercli"),
|
|
(Agent::Qwen, "qwen"),
|
|
(Agent::Letta, "letta"),
|
|
(Agent::Maki, "maki"),
|
|
(Agent::Muse, "muse"),
|
|
];
|
|
assert_eq!(expected.len(), Agent::ALL.len());
|
|
for (agent, executable) in expected {
|
|
assert_eq!(interactive_agent_executable(agent), executable);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn canonical_agent_labels_are_strict() {
|
|
assert_eq!(parse_canonical_agent_label("claude-code"), None);
|
|
assert_eq!(parse_canonical_agent_label("Pi"), None);
|
|
assert_eq!(parse_canonical_agent_label(" pi "), None);
|
|
assert_eq!(parse_canonical_agent_label("opencode.exe"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn mastracode_is_hook_authority_without_screen_manifest() {
|
|
assert!(full_lifecycle_hook_authority(
|
|
"herdr:mastracode",
|
|
"mastracode"
|
|
));
|
|
assert!(!Agent::SCREEN_MANIFEST_AGENTS.contains(&Agent::Mastracode));
|
|
}
|
|
|
|
#[test]
|
|
fn session_identity_integrations_leave_state_to_screen_detection() {
|
|
for (source, label, agent) in [
|
|
("herdr:hermes", "hermes", Agent::Hermes),
|
|
("herdr:qwen", "qwen", Agent::Qwen),
|
|
("herdr:letta", "letta", Agent::Letta),
|
|
("herdr:antigravity_cli", "agy", Agent::Antigravity),
|
|
] {
|
|
assert!(!full_lifecycle_hook_authority(source, label));
|
|
assert!(session_identity_only_integration(source, label));
|
|
assert!(Agent::SCREEN_MANIFEST_AGENTS.contains(&agent));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn identify_unknown_processes() {
|
|
assert_eq!(identify_agent("bash"), None);
|
|
assert_eq!(identify_agent("zsh"), None);
|
|
assert_eq!(identify_agent("vim"), None);
|
|
assert_eq!(identify_agent("node"), None);
|
|
assert_eq!(identify_agent("museum"), None);
|
|
assert_eq!(identify_agent("muse-helper"), None);
|
|
assert_eq!(identify_agent("muser"), None);
|
|
assert_eq!(identify_agent("musescore"), None);
|
|
assert_eq!(identify_agent("muse-bin"), None);
|
|
assert_eq!(identify_agent("muse-bin-"), None);
|
|
assert_eq!(identify_agent("muse-binary"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn identify_case_insensitive() {
|
|
assert_eq!(identify_agent("Pi"), Some(Agent::Pi));
|
|
assert_eq!(identify_agent("CLAUDE"), Some(Agent::Claude));
|
|
assert_eq!(identify_agent("Codex"), Some(Agent::Codex));
|
|
assert_eq!(identify_agent("Devin"), Some(Agent::Devin));
|
|
}
|
|
|
|
#[test]
|
|
fn identify_agent_in_job_prefers_wrapped_codex() {
|
|
let job = crate::platform::ForegroundJob {
|
|
process_group_id: 123,
|
|
processes: vec![
|
|
foreground_process(1, "node", &["node", "/path/to/bin/codex"]),
|
|
foreground_process(2, "bash", &["bash"]),
|
|
],
|
|
};
|
|
|
|
assert_eq!(
|
|
identify_agent_in_job(&job),
|
|
Some((Agent::Codex, "codex".to_string()))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn identify_agent_in_job_detects_node_wrapped_qwen() {
|
|
for argv in [
|
|
vec!["node", "/home/user/.fnm/bin/qwen"],
|
|
vec![
|
|
"node.exe",
|
|
r"C:\Users\user\AppData\Roaming\npm\node_modules\@qwen-code\qwen-code\dist\index.js",
|
|
],
|
|
] {
|
|
let job = crate::platform::ForegroundJob {
|
|
process_group_id: 123,
|
|
processes: vec![foreground_process(123, "MainThread", &argv)],
|
|
};
|
|
|
|
assert_eq!(
|
|
identify_agent_in_job(&job),
|
|
Some((Agent::Qwen, "qwen".to_string()))
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn identify_agent_in_job_detects_cline_native_binaries() {
|
|
for (name, executable) in [
|
|
(
|
|
".cline",
|
|
"/home/user/.npm/lib/node_modules/cline/bin/.cline",
|
|
),
|
|
(
|
|
"cline",
|
|
"/usr/local/lib/node_modules/@cline/cli-darwin-arm64/bin/cline",
|
|
),
|
|
(
|
|
"cline.exe",
|
|
r"C:\Users\user\AppData\Roaming\npm\node_modules\@cline\cli-windows-x64\bin\cline.exe",
|
|
),
|
|
] {
|
|
let job = crate::platform::ForegroundJob {
|
|
process_group_id: 123,
|
|
processes: vec![foreground_process(123, name, &[executable, "--tui"])],
|
|
};
|
|
|
|
assert_eq!(
|
|
identify_agent_in_job(&job),
|
|
Some((Agent::Cline, name.to_string()))
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn identify_agent_in_job_detects_cline_node_wrapper() {
|
|
for (name, argv) in [
|
|
(
|
|
"MainThread",
|
|
vec!["node", "/home/user/.fnm/bin/cline", "--tui"],
|
|
),
|
|
(
|
|
"node",
|
|
vec!["node", "/usr/local/lib/node_modules/cline/bin/cline"],
|
|
),
|
|
(
|
|
"node.exe",
|
|
vec![
|
|
r"C:\Program Files\nodejs\node.exe",
|
|
r"C:\Users\user\AppData\Roaming\npm\node_modules\cline\bin\cline",
|
|
],
|
|
),
|
|
] {
|
|
let job = crate::platform::ForegroundJob {
|
|
process_group_id: 123,
|
|
processes: vec![foreground_process(123, name, &argv)],
|
|
};
|
|
|
|
assert_eq!(
|
|
identify_agent_in_job(&job),
|
|
Some((Agent::Cline, "cline".to_string()))
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn identify_agent_in_job_rejects_unrelated_cline_mentions() {
|
|
for argv in [
|
|
vec!["node"],
|
|
vec!["node", "/path/to/other.js", "cline"],
|
|
vec!["node", "-e", "cline"],
|
|
vec!["node", "/path/to/cline-helper"],
|
|
vec!["/path/to/.cline-helper"],
|
|
vec!["/path/to/other", "/path/to/cline"],
|
|
] {
|
|
let job = crate::platform::ForegroundJob {
|
|
process_group_id: 123,
|
|
processes: vec![foreground_process(123, "MainThread", &argv)],
|
|
};
|
|
|
|
assert_eq!(identify_agent_in_job(&job), None);
|
|
}
|
|
assert_eq!(identify_agent("MainThread"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn identify_agent_in_job_detects_interactive_letta_entrypoints() {
|
|
for argv in [
|
|
vec!["letta", "--backend", "local"],
|
|
vec![
|
|
"node",
|
|
"/home/user/project/node_modules/.bin/letta",
|
|
"--conversation",
|
|
"conversation-id",
|
|
],
|
|
vec![
|
|
"node.exe",
|
|
r"C:\Users\user\AppData\Roaming\npm\node_modules\@letta-ai\letta-code\letta.js",
|
|
"--agent",
|
|
"agent-id",
|
|
],
|
|
] {
|
|
let job = crate::platform::ForegroundJob {
|
|
process_group_id: 123,
|
|
processes: vec![foreground_process(123, "MainThread", &argv)],
|
|
};
|
|
|
|
assert_eq!(
|
|
identify_agent_in_job(&job),
|
|
Some((Agent::Letta, "letta".to_string()))
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn identify_agent_in_job_ignores_noninteractive_letta_processes() {
|
|
for args in [
|
|
vec!["--prompt", "hello"],
|
|
vec!["--output-format", "json"],
|
|
vec!["--input-format=stream-json"],
|
|
vec!["--ephemeral"],
|
|
vec!["--max-turns=1"],
|
|
vec!["server"],
|
|
vec!["--backend", "local", "server"],
|
|
vec!["fix this bug"],
|
|
vec!["agents", "list"],
|
|
vec!["version"],
|
|
] {
|
|
let mut argv = vec!["node", "/home/user/project/node_modules/.bin/letta"];
|
|
argv.extend(args);
|
|
let job = crate::platform::ForegroundJob {
|
|
process_group_id: 123,
|
|
processes: vec![foreground_process(123, "MainThread", &argv)],
|
|
};
|
|
|
|
assert_eq!(identify_agent_in_job(&job), None, "argv: {argv:?}");
|
|
}
|
|
|
|
let unrelated = crate::platform::ForegroundJob {
|
|
process_group_id: 123,
|
|
processes: vec![foreground_process(
|
|
123,
|
|
"node",
|
|
&["node", "/tmp/server.js", "letta"],
|
|
)],
|
|
};
|
|
assert_eq!(identify_agent_in_job(&unrelated), None);
|
|
|
|
let source_checkout = crate::platform::ForegroundJob {
|
|
process_group_id: 123,
|
|
processes: vec![foreground_process(
|
|
123,
|
|
"node",
|
|
&["node", "/home/user/src/letta-code/letta/build.js"],
|
|
)],
|
|
};
|
|
assert_eq!(identify_agent_in_job(&source_checkout), None);
|
|
}
|
|
|
|
#[test]
|
|
fn identify_agent_in_job_detects_windows_cursor_install() {
|
|
let job = crate::platform::ForegroundJob {
|
|
process_group_id: 123,
|
|
processes: vec![foreground_process(
|
|
123,
|
|
"node.exe",
|
|
&[
|
|
r"C:\Users\user\AppData\Local\cursor-agent\versions\2026.08.11-e8db854\node.exe",
|
|
r"C:\Users\user\AppData\Local\cursor-agent\versions\2026.08.11-e8db854\index.js",
|
|
],
|
|
)],
|
|
};
|
|
|
|
assert_eq!(
|
|
identify_agent_in_job(&job),
|
|
Some((Agent::Cursor, "cursor".to_string()))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn identify_agent_in_job_ignores_invalid_windows_cursor_install_paths() {
|
|
for script in [
|
|
r"C:\Users\user\AppData\Local\cursor-agent\versions\2026.08.11-e8db854\scripts\postinstall.js",
|
|
r"C:\Users\user\AppData\Local\cursor-agent\versions\2026.08.11-e8db854\index",
|
|
r"C:\Users\user\AppData\Local\cursor-agent\versions\2026.08.11-e8db854\index.exe",
|
|
] {
|
|
let job = crate::platform::ForegroundJob {
|
|
process_group_id: 123,
|
|
processes: vec![foreground_process(
|
|
123,
|
|
"node.exe",
|
|
&[
|
|
r"C:\Users\user\AppData\Local\cursor-agent\versions\2026.08.11-e8db854\node.exe",
|
|
script,
|
|
],
|
|
)],
|
|
};
|
|
|
|
assert_eq!(identify_agent_in_job(&job), None, "script: {script}");
|
|
}
|
|
|
|
let lookalike = crate::platform::ForegroundJob {
|
|
process_group_id: 123,
|
|
processes: vec![foreground_process(
|
|
123,
|
|
"node.exe",
|
|
&[
|
|
r"C:\Program Files\nodejs\node.exe",
|
|
r"C:\workspace\cursor-agent\versions\test\index.js",
|
|
],
|
|
)],
|
|
};
|
|
assert_eq!(identify_agent_in_job(&lookalike), None);
|
|
}
|
|
|
|
#[test]
|
|
fn identify_agent_in_job_prefers_recognized_process_group_leader() {
|
|
let job = crate::platform::ForegroundJob {
|
|
process_group_id: 42,
|
|
processes: vec![
|
|
foreground_process(42, "claude", &["claude"]),
|
|
foreground_process(43, "node", &["node", "/tmp/mcp/bin/codex"]),
|
|
],
|
|
};
|
|
|
|
assert_eq!(
|
|
identify_agent_in_job(&job),
|
|
Some((Agent::Claude, "claude".to_string()))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn identify_agent_in_job_falls_back_when_process_group_leader_is_unrecognized() {
|
|
let job = crate::platform::ForegroundJob {
|
|
process_group_id: 42,
|
|
processes: vec![
|
|
foreground_process(42, "bash", &["bash"]),
|
|
foreground_process(43, "node", &["node", "/tmp/mcp/bin/codex"]),
|
|
],
|
|
};
|
|
|
|
assert_eq!(
|
|
identify_agent_in_job(&job),
|
|
Some((Agent::Codex, "codex".to_string()))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn identify_agent_in_job_detects_python_version_wrapped_hermes() {
|
|
let job = crate::platform::ForegroundJob {
|
|
process_group_id: 123,
|
|
processes: vec![foreground_process(
|
|
123,
|
|
"python3.12",
|
|
&[
|
|
"/nix/store/example/bin/python3.12",
|
|
"/nix/store/example/bin/hermes",
|
|
"--resume",
|
|
"session-id",
|
|
],
|
|
)],
|
|
};
|
|
|
|
assert_eq!(
|
|
identify_agent_in_job(&job),
|
|
Some((Agent::Hermes, "hermes".to_string()))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn identify_agent_in_job_detects_nix_wrapped_codex_from_cmdline_argv0() {
|
|
let job = crate::platform::ForegroundJob {
|
|
process_group_id: 123,
|
|
processes: vec![foreground_process(
|
|
1,
|
|
".codex-wrapped",
|
|
&["/etc/profiles/per-user/user/bin/codex", "--model", "gpt-5"],
|
|
)],
|
|
};
|
|
|
|
assert_eq!(
|
|
identify_agent_in_job(&job),
|
|
Some((Agent::Codex, "codex".to_string()))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn identify_agent_in_job_canonicalizes_nix_wrapped_aliases_from_cmdline_argv0() {
|
|
let job = crate::platform::ForegroundJob {
|
|
process_group_id: 123,
|
|
processes: vec![foreground_process(
|
|
1,
|
|
".claude-code-wrapped",
|
|
&["/nix/store/example/bin/claude-code"],
|
|
)],
|
|
};
|
|
|
|
assert_eq!(
|
|
identify_agent_in_job(&job),
|
|
Some((Agent::Claude, "claude".to_string()))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn identify_agent_in_job_detects_shell_wrapped_pi() {
|
|
let job = crate::platform::ForegroundJob {
|
|
process_group_id: 123,
|
|
processes: vec![foreground_process(
|
|
1,
|
|
"sh",
|
|
&["/bin/sh", "/tmp/test-bin/pi"],
|
|
)],
|
|
};
|
|
|
|
assert_eq!(
|
|
identify_agent_in_job(&job),
|
|
Some((Agent::Pi, "pi".to_string()))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn identify_agent_in_job_detects_bun_wrapped_omp() {
|
|
for (runtime, script) in [
|
|
("bun", "/home/can/.bun/bin/omp"),
|
|
(
|
|
"bun.exe",
|
|
r"C:\Users\herdr\AppData\Roaming\npm\node_modules\@oh-my-pi\pi-coding-agent\dist\cli.js",
|
|
),
|
|
] {
|
|
let job = crate::platform::ForegroundJob {
|
|
process_group_id: 123,
|
|
processes: vec![foreground_process(123, runtime, &[runtime, script])],
|
|
};
|
|
assert_eq!(
|
|
identify_agent_in_job(&job),
|
|
Some((Agent::Omp, "omp".to_string())),
|
|
"script: {script}"
|
|
);
|
|
}
|
|
|
|
let other_script = crate::platform::ForegroundJob {
|
|
process_group_id: 123,
|
|
processes: vec![foreground_process(
|
|
123,
|
|
"bun.exe",
|
|
&[
|
|
"bun.exe",
|
|
r"C:\Users\herdr\AppData\Roaming\npm\node_modules\@oh-my-pi\pi-coding-agent\dist\setup.js",
|
|
],
|
|
)],
|
|
};
|
|
assert_eq!(identify_agent_in_job(&other_script), None);
|
|
}
|
|
|
|
#[test]
|
|
fn identify_agent_in_job_detects_node_wrapped_pi_package_cli() {
|
|
let job = crate::platform::ForegroundJob {
|
|
process_group_id: 123,
|
|
processes: vec![foreground_process(
|
|
123,
|
|
"node.exe",
|
|
&[
|
|
"node.exe",
|
|
"C:\\Users\\herdr\\AppData\\Roaming\\npm\\node_modules\\@earendil-works\\pi-coding-agent\\dist\\cli.js",
|
|
],
|
|
)],
|
|
};
|
|
|
|
assert_eq!(
|
|
identify_agent_in_job(&job),
|
|
Some((Agent::Pi, "pi".to_string()))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn identify_agent_in_job_detects_node_wrapped_pi_bundled_cli() {
|
|
// Hardened runtimes deny `PROCESS_VM_READ`, so the command line can come
|
|
// from a LimitedInformation query with the launcher path intact. The
|
|
// detection path must not depend on how that command line was obtained.
|
|
let job = crate::platform::ForegroundJob {
|
|
process_group_id: 123,
|
|
processes: vec![foreground_process(
|
|
123,
|
|
"node.exe",
|
|
&[
|
|
r"C:\Users\herdr\AppData\Local\pi-node\current\node.exe",
|
|
r"C:\Users\herdr\AppData\Local\pi-node\current/node_modules/@earendil-works/pi-coding-agent/dist/bundle/cli.js",
|
|
],
|
|
)],
|
|
};
|
|
|
|
assert_eq!(
|
|
identify_agent_in_job(&job),
|
|
Some((Agent::Pi, "pi".to_string()))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn identify_agent_in_job_detects_node_wrapped_mastracode_package_cli() {
|
|
let job = crate::platform::ForegroundJob {
|
|
process_group_id: 123,
|
|
processes: vec![foreground_process(
|
|
123,
|
|
"node.exe",
|
|
&[
|
|
"node.exe",
|
|
"C:\\Users\\herdr\\AppData\\Roaming\\npm\\node_modules\\mastracode\\dist\\cli.js",
|
|
],
|
|
)],
|
|
};
|
|
|
|
assert_eq!(
|
|
identify_agent_in_job(&job),
|
|
Some((Agent::Mastracode, "mastracode".to_string()))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn identify_agent_in_job_detects_node_wrapped_kimi_package_cli() {
|
|
let job = crate::platform::ForegroundJob {
|
|
process_group_id: 123,
|
|
processes: vec![foreground_process(
|
|
123,
|
|
"node.exe",
|
|
&[
|
|
r"C:\Program Files\nodejs\node.exe",
|
|
r"C:\repro-3317-kimi-prefix\node_modules\@moonshot-ai\kimi-code\dist\main.mjs",
|
|
],
|
|
)],
|
|
};
|
|
|
|
assert_eq!(
|
|
identify_agent_in_job(&job),
|
|
Some((Agent::Kimi, "kimi".to_string()))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn identify_agent_in_job_ignores_non_cli_pi_package_scripts() {
|
|
for script in [
|
|
r"C:\Users\herdr\AppData\Roaming\npm\node_modules\@earendil-works\pi-coding-agent\scripts\build.js",
|
|
r"C:\Users\herdr\AppData\Local\pi-node\current\node_modules\@earendil-works\pi-coding-agent\dist\bundle\update.js",
|
|
r"C:\workspace\dist\bundle\cli.js",
|
|
r"C:\workspace\node_modules\other-package\dist\bundle\cli.js",
|
|
r"C:\workspace\node_modules\@earendil-works\pi-coding-agent\dist\cli.exe",
|
|
r"C:\workspace\node_modules\@earendil-works\pi-coding-agent\dist\cli.js\other.js",
|
|
r"C:\workspace\node_modules\@earendil-works\pi-coding-agent\dist\bundle\cli.exe",
|
|
r"C:\workspace\node_modules\@earendil-works\pi-coding-agent\dist\bundle\cli.js\other.js",
|
|
] {
|
|
let job = crate::platform::ForegroundJob {
|
|
process_group_id: 123,
|
|
processes: vec![foreground_process(123, "node.exe", &["node.exe", script])],
|
|
};
|
|
|
|
assert_eq!(identify_agent_in_job(&job), None, "script: {script}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn identify_agent_in_job_detects_windows_cmd_wrapped_codex() {
|
|
let job = crate::platform::ForegroundJob {
|
|
process_group_id: 123,
|
|
processes: vec![foreground_process(
|
|
1,
|
|
"cmd.exe",
|
|
&[
|
|
"cmd.exe",
|
|
"/D",
|
|
"/S",
|
|
"/C",
|
|
"C:\\Users\\herdr\\AppData\\Roaming\\npm\\codex.cmd --model gpt-5",
|
|
],
|
|
)],
|
|
};
|
|
|
|
assert_eq!(
|
|
identify_agent_in_job(&job),
|
|
Some((Agent::Codex, "codex".to_string()))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn identify_agent_in_job_detects_powershell_file_wrapped_claude() {
|
|
let job = crate::platform::ForegroundJob {
|
|
process_group_id: 123,
|
|
processes: vec![foreground_process(
|
|
1,
|
|
"powershell.exe",
|
|
&[
|
|
"powershell.exe",
|
|
"-NoProfile",
|
|
"-File",
|
|
"C:\\Users\\herdr\\Documents\\PowerShell\\Scripts\\claude.ps1",
|
|
],
|
|
)],
|
|
};
|
|
|
|
assert_eq!(
|
|
identify_agent_in_job(&job),
|
|
Some((Agent::Claude, "claude".to_string()))
|
|
);
|
|
}
|
|
|
|
// A plain shell pane launched with herdr's injected prompt integration
|
|
// must still classify as a shell, not an agent, even though its argv now
|
|
// carries a -Command payload.
|
|
#[test]
|
|
fn identify_agent_in_job_ignores_herdr_powershell_shell_integration_argv() {
|
|
let job = crate::platform::ForegroundJob {
|
|
process_group_id: 123,
|
|
processes: vec![foreground_process(
|
|
1,
|
|
"powershell.exe",
|
|
&[
|
|
"powershell.exe",
|
|
"-NoExit",
|
|
"-Command",
|
|
crate::pane::WINDOWS_POWERSHELL_SHELL_INTEGRATION_COMMAND,
|
|
],
|
|
)],
|
|
};
|
|
|
|
assert_eq!(identify_agent_in_job(&job), None);
|
|
}
|
|
|
|
#[test]
|
|
fn identify_agent_in_job_detects_opencode2_as_opencode() {
|
|
let job = crate::platform::ForegroundJob {
|
|
process_group_id: 123,
|
|
processes: vec![foreground_process(
|
|
123,
|
|
"opencode2",
|
|
&["opencode2", "--standalone"],
|
|
)],
|
|
};
|
|
|
|
assert_eq!(
|
|
identify_agent_in_job(&job),
|
|
Some((Agent::OpenCode, "opencode2".to_string()))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn identify_agent_in_job_detects_opencode_exe_from_pnpm_package() {
|
|
let job = crate::platform::ForegroundJob {
|
|
process_group_id: 123,
|
|
processes: vec![foreground_process(
|
|
123,
|
|
"opencode.exe",
|
|
&["/home/user/.local/share/pnpm/global/node_modules/opencode-ai/bin/opencode.exe"],
|
|
)],
|
|
};
|
|
|
|
assert_eq!(
|
|
identify_agent_in_job(&job),
|
|
Some((Agent::OpenCode, "opencode.exe".to_string()))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn identify_agent_in_job_detects_opencode_exe_from_argv0_path() {
|
|
let job = crate::platform::ForegroundJob {
|
|
process_group_id: 123,
|
|
processes: vec![foreground_process(
|
|
123,
|
|
"MainThread",
|
|
&["/home/user/.local/share/pnpm/global/node_modules/opencode-ai/bin/opencode.exe"],
|
|
)],
|
|
};
|
|
|
|
assert_eq!(
|
|
identify_agent_in_job(&job),
|
|
Some((Agent::OpenCode, "opencode".to_string()))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn wrapped_agent_name_from_runtime_argv_ignores_plain_shell_flags() {
|
|
assert_eq!(
|
|
wrapped_agent_name_from_runtime_argv("bash", Some(&["bash".into(), "-lc".into()])),
|
|
None
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn identify_agent_in_job_ignores_python_c_argument_named_codex() {
|
|
let job = crate::platform::ForegroundJob {
|
|
process_group_id: 123,
|
|
processes: vec![foreground_process(
|
|
1,
|
|
"python3",
|
|
&["python3", "-c", "import time; time.sleep(60)", "/tmp/codex"],
|
|
)],
|
|
};
|
|
|
|
assert_eq!(identify_agent_in_job(&job), None);
|
|
}
|
|
|
|
#[test]
|
|
fn identify_agent_in_job_ignores_node_eval_argument_named_codex() {
|
|
let job = crate::platform::ForegroundJob {
|
|
process_group_id: 123,
|
|
processes: vec![foreground_process(
|
|
1,
|
|
"node",
|
|
&["node", "-e", "setTimeout(() => {}, 60000)", "/tmp/codex"],
|
|
)],
|
|
};
|
|
|
|
assert_eq!(identify_agent_in_job(&job), None);
|
|
}
|
|
|
|
#[test]
|
|
fn identify_agent_in_job_ignores_shell_c_argument_named_codex() {
|
|
let job = crate::platform::ForegroundJob {
|
|
process_group_id: 123,
|
|
processes: vec![foreground_process(
|
|
1,
|
|
"bash",
|
|
&["bash", "-c", "sleep 60", "/tmp/codex"],
|
|
)],
|
|
};
|
|
|
|
assert_eq!(identify_agent_in_job(&job), None);
|
|
}
|
|
|
|
#[test]
|
|
fn identify_agent_in_job_detects_python_script_named_codex() {
|
|
let job = crate::platform::ForegroundJob {
|
|
process_group_id: 123,
|
|
processes: vec![foreground_process(
|
|
1,
|
|
"python3",
|
|
&["python3", "/tmp/codex", "--model", "gpt-5"],
|
|
)],
|
|
};
|
|
|
|
assert_eq!(
|
|
identify_agent_in_job(&job),
|
|
Some((Agent::Codex, "codex".to_string()))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn cmdline_argv0_agent_name_canonicalizes_known_aliases() {
|
|
assert_eq!(
|
|
cmdline_argv0_agent_name("/nix/store/example/bin/ghcs"),
|
|
Some("copilot".to_string())
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn cmdline_argv0_agent_name_requires_exact_agent_basename() {
|
|
assert_eq!(cmdline_argv0_agent_name("/tmp/my-codex-helper"), None);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn identify_agent_in_job_resolves_cursor_agent_symlink_argv0() {
|
|
let dir = temp_detection_path("cursor-agent-symlink");
|
|
std::fs::create_dir_all(&dir).expect("test directory should be created");
|
|
let target = dir.join("cursor-agent");
|
|
let link = dir.join("agent");
|
|
std::fs::write(&target, b"#!/bin/sh\n").expect("target should be written");
|
|
std::os::unix::fs::symlink(&target, &link).expect("symlink should be created");
|
|
|
|
let argv0 = link.to_string_lossy().into_owned();
|
|
let job = crate::platform::ForegroundJob {
|
|
process_group_id: 42,
|
|
processes: vec![foreground_process(
|
|
42,
|
|
"MainThread",
|
|
&[&argv0, "--use-system-ca", "/tmp/index.js"],
|
|
)],
|
|
};
|
|
|
|
assert_eq!(
|
|
identify_agent_in_job(&job),
|
|
Some((Agent::Cursor, "cursor".to_string()))
|
|
);
|
|
|
|
std::fs::remove_dir_all(&dir).ok();
|
|
}
|
|
|
|
// ---- Screen detection routing ----
|
|
|
|
#[test]
|
|
fn no_agent_returns_unknown() {
|
|
assert_eq!(detect_state(None, "anything"), AgentState::Unknown);
|
|
}
|
|
|
|
// ---- Process identification (real PTY) ----
|
|
|
|
#[cfg(target_os = "linux")]
|
|
fn open_test_pty() -> portable_pty::PtyPair {
|
|
portable_pty::native_pty_system()
|
|
.openpty(portable_pty::PtySize {
|
|
rows: 24,
|
|
cols: 80,
|
|
pixel_width: 0,
|
|
pixel_height: 0,
|
|
})
|
|
.expect("failed to open pty")
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
#[test]
|
|
fn foreground_job_detects_sleep() {
|
|
use portable_pty::CommandBuilder;
|
|
|
|
let pair = open_test_pty();
|
|
|
|
// Spawn "sleep 999" — a known, deterministic process
|
|
let mut cmd = CommandBuilder::new("sleep");
|
|
cmd.arg("999");
|
|
let mut child = pair.slave.spawn_command(cmd).expect("failed to spawn");
|
|
let pid = child.process_id().expect("no pid");
|
|
|
|
// Give the process a moment to become the foreground group
|
|
std::thread::sleep(std::time::Duration::from_millis(50));
|
|
|
|
let job = foreground_job(pid).expect("expected foreground job");
|
|
assert!(
|
|
job.processes.iter().any(|p| p.name == "sleep"),
|
|
"expected sleep in {job:?}"
|
|
);
|
|
assert_eq!(
|
|
identify_agent_in_job(&job),
|
|
None,
|
|
"sleep should not map to an agent"
|
|
);
|
|
|
|
// Clean up
|
|
child.kill().ok();
|
|
child.wait().ok();
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
#[test]
|
|
fn foreground_job_detects_shell_running_command() {
|
|
use portable_pty::CommandBuilder;
|
|
use std::io::Write;
|
|
|
|
let pair = open_test_pty();
|
|
|
|
// Spawn a shell, then run a command inside it
|
|
let cmd = CommandBuilder::new("sh");
|
|
let mut child = pair.slave.spawn_command(cmd).expect("failed to spawn");
|
|
let pid = child.process_id().expect("no pid");
|
|
|
|
// Write a command to the shell
|
|
let mut writer = pair.master.take_writer().expect("no writer");
|
|
// Use exec so sleep replaces sh as the foreground process
|
|
writer.write_all(b"exec sleep 999\n").ok();
|
|
drop(writer);
|
|
|
|
std::thread::sleep(std::time::Duration::from_millis(100));
|
|
|
|
let job = foreground_job(pid).expect("expected foreground job");
|
|
assert!(
|
|
job.processes.iter().any(|p| p.name == "sleep"),
|
|
"expected sleep in {job:?}"
|
|
);
|
|
assert_eq!(
|
|
identify_agent_in_job(&job),
|
|
None,
|
|
"sleep should not map to an agent"
|
|
);
|
|
|
|
child.kill().ok();
|
|
child.wait().ok();
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
#[test]
|
|
fn foreground_job_detects_agent_behind_shell_wrapper() {
|
|
use portable_pty::CommandBuilder;
|
|
|
|
let pair = open_test_pty();
|
|
|
|
let mut cmd = CommandBuilder::new("bash");
|
|
cmd.arg("-c");
|
|
cmd.arg("bash -c 'exec -a codex sleep 999' & wait");
|
|
let mut child = pair.slave.spawn_command(cmd).expect("failed to spawn");
|
|
let pid = child.process_id().expect("no pid");
|
|
std::thread::sleep(std::time::Duration::from_millis(100));
|
|
|
|
let job = foreground_job(pid);
|
|
let process_group_id = job.as_ref().map(|job| job.process_group_id).unwrap_or(pid);
|
|
unsafe {
|
|
libc::kill(-(process_group_id as i32), libc::SIGKILL);
|
|
}
|
|
child.wait().ok();
|
|
|
|
let job = job.expect("expected foreground job");
|
|
assert!(
|
|
job.processes.iter().any(|process| process.name == "bash")
|
|
&& job.processes.iter().any(|process| {
|
|
process.name == "sleep"
|
|
&& process
|
|
.argv
|
|
.as_deref()
|
|
.and_then(|argv| argv.first())
|
|
.is_some_and(|argv0| argv0 == "codex")
|
|
}),
|
|
"expected wrapper and agent child in {job:?}"
|
|
);
|
|
assert_eq!(
|
|
identify_agent_in_job(&job),
|
|
Some((Agent::Codex, "codex".to_string()))
|
|
);
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
#[test]
|
|
fn pane_shell_is_idle_tracks_foreground_command() {
|
|
use portable_pty::CommandBuilder;
|
|
use std::io::Write;
|
|
|
|
fn wait_for(expected: bool, pid: u32) -> bool {
|
|
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5);
|
|
while std::time::Instant::now() < deadline {
|
|
if pane_shell_is_idle(pid) == expected {
|
|
return true;
|
|
}
|
|
std::thread::sleep(std::time::Duration::from_millis(20));
|
|
}
|
|
false
|
|
}
|
|
|
|
let pair = open_test_pty();
|
|
let mut cmd = CommandBuilder::new("sh");
|
|
cmd.arg("-i");
|
|
cmd.env("ENV", "/dev/null");
|
|
let mut child = pair.slave.spawn_command(cmd).expect("failed to spawn");
|
|
let pid = child.process_id().expect("no pid");
|
|
let mut reader = pair.master.try_clone_reader().expect("no reader");
|
|
std::thread::spawn(move || {
|
|
let mut buf = [0u8; 1024];
|
|
while matches!(std::io::Read::read(&mut reader, &mut buf), Ok(n) if n > 0) {}
|
|
});
|
|
let mut writer = pair.master.take_writer().expect("no writer");
|
|
|
|
assert!(wait_for(true, pid), "interactive shell should start idle");
|
|
// End the foreground command with EOF rather than SIGINT. An interrupt
|
|
// sent while the shell is still moving the command into its own
|
|
// foreground process group can be delivered before the command resets
|
|
// its signal dispositions, leaving it running in a stale foreground
|
|
// group while the shell shows a prompt. `cat` blocks until stdin hits
|
|
// EOF, so it stays reliably busy until we end it.
|
|
writer.write_all(b"cat\n").unwrap();
|
|
assert!(wait_for(false, pid), "shell running a command is not idle");
|
|
writer.write_all(&[4]).unwrap();
|
|
assert!(
|
|
wait_for(true, pid),
|
|
"shell should be idle after the command ends"
|
|
);
|
|
|
|
child.kill().ok();
|
|
child.wait().ok();
|
|
}
|
|
|
|
#[cfg(target_os = "linux")]
|
|
#[test]
|
|
fn proc_stat_parsing_handles_spaces_in_comm() {
|
|
// Verify our /proc/pid/stat parser correctly extracts fields
|
|
// even when (comm) could contain spaces.
|
|
let pid = std::process::id();
|
|
let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).unwrap();
|
|
|
|
// Our parsing: find last ')' then split the rest
|
|
let close_paren = stat.rfind(')').expect("should have closing paren");
|
|
let rest = &stat[close_paren + 2..];
|
|
let fields: Vec<&str> = rest.split_whitespace().collect();
|
|
|
|
// We should have enough fields (at least 6 for tpgid)
|
|
assert!(
|
|
fields.len() >= 6,
|
|
"not enough fields in stat: {}",
|
|
fields.len()
|
|
);
|
|
|
|
// Field 0 should be a valid state char (S, R, D, etc.)
|
|
let state = fields[0];
|
|
assert!(
|
|
["S", "R", "D", "Z", "T", "t", "W", "X", "I"].contains(&state),
|
|
"unexpected state: {state}"
|
|
);
|
|
|
|
// Field 5 (tpgid) should parse as i32 (can be -1 if no controlling terminal)
|
|
let tpgid: i32 = fields[5].parse().expect("tpgid should be a number");
|
|
// In CI/test environments without a terminal, tpgid is typically -1
|
|
let _ = tpgid;
|
|
}
|
|
}
|