Files
herdr/src/detect/mod.rs
T
JJ Liebig 8c8cb49c23 fix(windows): read agent command lines without PROCESS_VM_READ (#4708)
* 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.
2026-09-28 13:16:10 +02:00

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;
}
}