Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 76 additions & 4 deletions src/collector/codex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -553,12 +553,18 @@ impl CodexCollector {
// so task_complete alone does NOT mean the session is finished when PID is alive.
// However, for exec (one-shot) sessions, task_complete means truly done.
let pid_alive = proc.is_some();
// The rollout's originator names the client ("codex_exec" vs
// "codex-tui"); argv is only a guess for rollouts without one.
let is_exec = match result.originator.as_str() {
"" => process_ctx.is_exec,
originator => originator == "codex_exec",
};
// Mirrors Claude: trust the trailing-event-is-user signal alone.
// Codex tool outputs flow through response_item, not user_message,
// so model_generating only flips on real prompts.
let status = if process_ctx.unknown_process_owner {
SessionStatus::Unknown
} else if !pid_alive || (process_ctx.is_exec && result.task_complete) {
} else if !pid_alive || (is_exec && result.task_complete) {
SessionStatus::Done
} else {
let has_active_child = process_ctx.owns_process_tree
Expand Down Expand Up @@ -692,9 +698,12 @@ impl CodexCollector {
continue;
}
let cmd = &info.command;
let is_exec = cmd.contains(" exec");
// Whole tokens only: `-c` values and prompts can contain " exec"
// or "executable", and an interactive TUI marked as exec is hidden
// as Done after its first turn.
let is_exec = cmd.split_whitespace().skip(1).any(|tok| tok == "exec");
let is_codex = process::cmd_has_binary(cmd, "codex");
if is_codex && !cmd.contains(" app-server") && !cmd.contains("grep") {
if is_codex && !cmd.contains(" app-server") && !process::cmd_is_grep(cmd) {
pids.push((*pid, is_exec));
}
}
Expand Down Expand Up @@ -734,7 +743,7 @@ impl CodexCollector {
let cmd = &info.command;
if process::cmd_has_binary(cmd, "codex")
&& cmd.contains(" app-server")
&& !cmd.contains("grep")
&& !process::cmd_is_grep(cmd)
{
pids.push(*pid);
}
Expand Down Expand Up @@ -1768,6 +1777,31 @@ mod tests {
assert_eq!(pids, vec![(10, false)]);
}

#[test]
fn find_codex_pids_ignores_grep_and_exec_inside_args() {
let mut process_info = HashMap::new();
// ps joins argv with spaces, so a long `-c` value reads as words.
process_info.insert(
10,
proc_info(
10,
1,
"codex -c developer_instructions=Use grep first, then run executables --resume abc",
),
);
process_info.insert(20, proc_info(20, 1, "grep codex"));
process_info.insert(30, proc_info(30, 1, "/usr/bin/pgrep codex"));
process_info.insert(
40,
proc_info(40, 1, "codex --profile ci exec fix the build"),
);

let mut pids = CodexCollector::find_codex_pids_from_shared(&process_info, &HashSet::new());
pids.sort_unstable();

assert_eq!(pids, vec![(10, false), (40, true)]);
}

#[test]
fn find_codex_desktop_pids_detects_app_servers() {
let mut process_info = HashMap::new();
Expand Down Expand Up @@ -2620,6 +2654,44 @@ mod tests {
assert_eq!(session.current_tasks, vec!["finished".to_string()]);
}

#[test]
fn test_codex_tui_originator_overrides_exec_guess_from_argv() {
let mut file = tempfile::NamedTempFile::new().unwrap();
write_lines(
&mut file,
&[
r#"{"type":"session_meta","timestamp":"2026-03-28T15:00:00Z","payload":{"id":"sess-123","cwd":"/home/user/project","originator":"codex-tui","cli_version":"0.163.0","timestamp":"2026-03-28T15:00:00Z"}}"#,
r#"{"type":"event_msg","timestamp":"2026-03-28T15:01:00Z","payload":{"type":"task_started"}}"#,
r#"{"type":"event_msg","timestamp":"2026-03-28T15:02:01Z","payload":{"type":"task_complete"}}"#,
],
);

let collector = CodexCollector::new();
let mut process_info = HashMap::new();
process_info.insert(
42,
proc_info(42, 1, "codex -c developer_instructions=never exec blindly"),
);
let process_ctx = CodexProcessContext {
pid: Some(42),
is_exec: true,
owns_process_tree: true,
unknown_process_owner: false,
};

let (session, _) = collector
.load_session_with_rate_limit(
process_ctx,
file.path(),
&process_info,
&HashMap::new(),
&HashMap::new(),
)
.unwrap();

assert_eq!(session.status, SessionStatus::Waiting);
}

#[test]
fn test_codex_exec_command_end_closes_task_and_records_duration() {
let mut file = tempfile::NamedTempFile::new().unwrap();
Expand Down
2 changes: 1 addition & 1 deletion src/collector/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ pub fn detect(process_info: &HashMap<u32, ProcInfo>) -> McpDetection {
fn is_codex_mcp_server(cmd: &str) -> bool {
process::cmd_has_binary(cmd, "codex")
&& cmd.contains("mcp-server")
&& !cmd.contains("grep")
&& !process::cmd_is_grep(cmd)
&& !cmd.contains("app-server")
}

Expand Down
3 changes: 2 additions & 1 deletion src/collector/opencode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,8 @@ impl OpenCodeCollector {
process_info
.iter()
.filter(|(_, info)| {
process::cmd_has_binary(&info.command, "opencode") && !info.command.contains("grep")
process::cmd_has_binary(&info.command, "opencode")
&& !process::cmd_is_grep(&info.command)
})
.map(|(pid, _)| *pid)
.collect()
Expand Down
27 changes: 27 additions & 0 deletions src/collector/process.rs
Original file line number Diff line number Diff line change
Expand Up @@ -416,6 +416,22 @@ pub fn cmd_first_token_has_binary(cmd: &str, name: &str) -> bool {
.is_some_and(|tok| unix_token_has_binary(tok, name))
}

/// True when the process itself is a grep (`grep`, `pgrep`, `egrep`, ...),
/// e.g. a `ps aux | grep codex` that `cmd_has_binary` would match on its
/// second token. Only the executable counts: an agent whose arguments
/// mention grep (prompts, `-c` instructions) is not a grep.
pub fn cmd_is_grep(cmd: &str) -> bool {
#[cfg(windows)]
let first = windows_command_tokens(cmd).into_iter().next();
#[cfg(not(windows))]
let first = cmd.split_whitespace().next().map(str::to_owned);
first.is_some_and(|tok| {
tok.rsplit(['/', '\\'])
.next()
.is_some_and(|base| base.contains("grep"))
})
}

#[cfg(not(windows))]
fn unix_token_has_binary(tok: &str, name: &str) -> bool {
let mut iter = tok.rsplit('/');
Expand Down Expand Up @@ -577,6 +593,17 @@ mod tests {
assert!(!cmd_has_binary("/some/versions/2.1.121", "claude"));
}

#[test]
fn cmd_is_grep_checks_only_the_executable() {
assert!(cmd_is_grep("grep codex"));
assert!(cmd_is_grep("/usr/bin/pgrep -f codex mcp-server"));
assert!(!cmd_is_grep(
"codex -c instructions=use-grep-first mcp-server"
));
assert!(!cmd_is_grep("opencode run 'grep the logs'"));
assert!(!cmd_is_grep(""));
}

#[cfg(windows)]
#[test]
fn cmd_has_binary_windows_detects_node_wrapped_codex() {
Expand Down