From 7d6eb47314f082dabc5f69c6676b40d8f81be543 Mon Sep 17 00:00:00 2001 From: can1357 Date: Sun, 2 Aug 2026 20:40:19 +0200 Subject: [PATCH] feat: implemented ps shell builtin with sorting and formatting options - Added the `ps` shell builtin with BSD and procps selection forms, custom formatting, and sorting capabilities. - Implemented the `sanitize_process_command` helper to clean process command names and arguments. - Updated the bash prompt template and package changelogs to document the new builtin. --- crates/pi-shell/src/shell.rs | 1140 ++++++++++++++++- packages/coding-agent/CHANGELOG.md | 2 + .../coding-agent/src/prompts/tools/bash.md | 2 +- packages/natives/CHANGELOG.md | 1 + 4 files changed, 1141 insertions(+), 4 deletions(-) diff --git a/crates/pi-shell/src/shell.rs b/crates/pi-shell/src/shell.rs index aef41c5b4..f752466a5 100644 --- a/crates/pi-shell/src/shell.rs +++ b/crates/pi-shell/src/shell.rs @@ -8,7 +8,7 @@ use std::{ path::{Path, PathBuf}, str, sync::Arc, - time::Duration, + time::{Duration, SystemTime, UNIX_EPOCH}, }; use anyhow::{Error, Result}; @@ -25,6 +25,7 @@ use brush_core::{ use bytes::Bytes; use clap::Parser; use flume::Sender; +use jiff::{Timestamp, fmt::strtime, tz::TimeZone}; #[cfg(not(unix))] use tokio::io::AsyncReadExt as _; use tokio::{sync::Mutex as TokioMutex, time}; @@ -606,6 +607,7 @@ async fn create_session_for_run( } shell.register_builtin("sleep", builtins::builtin::()); shell.register_builtin("timeout", builtins::builtin::()); + shell.register_builtin("ps", builtins::builtin::()); shell.register_builtin("top", builtins::builtin::()); shell.register_builtin("pgrep", builtins::builtin::()); shell.register_builtin("pkill", builtins::builtin::()); @@ -4353,6 +4355,1087 @@ impl builtins::Command for TimeoutCommand { } } +#[derive(Parser)] +#[command(disable_help_flag = true, disable_version_flag = true)] +struct PsCommand { + #[arg(num_args = 0.., trailing_var_arg = true, allow_hyphen_values = true)] + argv: Vec, +} + +#[derive(Default)] +struct PsOptions { + all: bool, + other_users: bool, + include_no_terminal: bool, + full_format: bool, + long_format: bool, + user_format: bool, + job_format: bool, + memory_format: bool, + bsd_syntax: bool, + command_only: bool, + running_only: bool, + no_headers: bool, + custom_format: bool, + pids: Vec, + parents: Vec, + groups: Vec, + sessions: Vec, + effective_users: Vec, + real_users: Vec, + real_groups: Vec, + terminals: Vec>, + columns: Vec, + sort: Vec, +} + +#[derive(Clone, Copy, PartialEq, Eq)] +enum PsField { + User, + Uid, + Pid, + Ppid, + Pgid, + Sid, + Tty, + State, + Start, + LongStart, + Elapsed, + ElapsedSeconds, + CpuTime, + CpuPercent, + CpuInteger, + MemPercent, + VirtualSize, + ResidentSize, + Nice, + Threads, + Command, + Args, +} + +impl PsField { + const fn header(self) -> &'static str { + match self { + Self::User => "USER", + Self::Uid => "UID", + Self::Pid => "PID", + Self::Ppid => "PPID", + Self::Pgid => "PGID", + Self::Sid => "SID", + Self::Tty => "TTY", + Self::State => "STAT", + Self::Start => "START", + Self::LongStart => "STARTED", + Self::Elapsed => "ELAPSED", + Self::ElapsedSeconds => "ELAPSED", + Self::CpuTime => "TIME", + Self::CpuPercent => "%CPU", + Self::CpuInteger => "C", + Self::MemPercent => "%MEM", + Self::VirtualSize => "VSZ", + Self::ResidentSize => "RSS", + Self::Nice => "NI", + Self::Threads => "NLWP", + Self::Command => "COMMAND", + Self::Args => "COMMAND", + } + } + + const fn right_aligned(self) -> bool { + matches!( + self, + Self::Uid + | Self::Pid + | Self::Ppid + | Self::Pgid + | Self::Sid + | Self::ElapsedSeconds + | Self::CpuPercent + | Self::CpuInteger + | Self::MemPercent + | Self::VirtualSize + | Self::ResidentSize + | Self::Nice + | Self::Threads + ) + } +} + +#[derive(Clone)] +struct PsColumn { + field: PsField, + header: String, + min_width: usize, +} + +impl PsColumn { + fn new(field: PsField) -> Self { + Self { field, header: field.header().to_string(), min_width: 0 } + } + + fn with_header(field: PsField, header: &str) -> Self { + Self { field, header: header.to_string(), min_width: 0 } + } +} + +#[derive(Clone, Copy)] +enum PsSortField { + Pid, + Ppid, + Cpu, + Mem, + Time, + Start, + Command, +} + +struct PsSort { + field: PsSortField, + descending: bool, +} + +enum ParsePsResult { + Options(Box), + Help, + Version, +} + +struct PsProcessRow { + pid: i32, + ppid: Option, + pgid: Option, + sid: Option, + user: Option, + terminal: Option, + state: char, + start_time: u64, + started_at: Option, + age: Option, + cpu_time: Option, + virtual_size: Option, + resident_size: Option, + threads: Option, + nice: Option, + command: String, + args: String, +} + +impl PsProcessRow { + fn from_process(process: proc_snapshot::ProcInfo, now: SystemTime, command_only: bool) -> Self { + let command = sanitize_process_command(process.command_name()); + let argv = process.args(); + let args = if command_only || argv.is_empty() { + command.clone() + } else { + sanitize_process_command(argv.join(" ")) + }; + let age = process.age(); + Self { + pid: process.pid(), + ppid: process.ppid(), + pgid: process.group_id(), + sid: process.session_id(), + user: process + .effective_user_id() + .or_else(|| process.real_user_id()), + terminal: process.terminal_id(), + state: process.state(), + start_time: process.start_time(), + started_at: age.and_then(|age| now.checked_sub(age)), + age, + cpu_time: process.cpu_time(), + virtual_size: process.virtual_bytes(), + resident_size: process.resident_bytes(), + threads: process.thread_count(), + nice: process.nice(), + command, + args, + } + } + + fn cpu_percent(&self) -> Option { + let age = self.age?.as_secs_f64(); + let cpu_time = self.cpu_time?.as_secs_f64(); + (age > 0.0).then_some(100.0 * cpu_time / age) + } + + fn memory_percent(&self, total_memory: Option) -> Option { + let total = total_memory.filter(|total| *total > 0)?; + Some(100.0 * self.resident_size? as f64 / total as f64) + } +} + +impl builtins::Command for PsCommand { + type Error = brush_core::Error; + + fn execute( + &self, + context: ExecutionContext<'_, SE>, + ) -> impl Future> + Send { + let argv = self.argv.clone(); + async move { + let options = match parse_ps_args(&argv) { + Ok(ParsePsResult::Options(options)) => *options, + Ok(ParsePsResult::Help) => { + write_ps_help(context.stdout())?; + return Ok(ExecutionResult::success()); + }, + Ok(ParsePsResult::Version) => { + writeln!(context.stdout(), "ps {}", env!("CARGO_PKG_VERSION"))?; + return Ok(ExecutionResult::success()); + }, + Err((code, message)) => { + writeln!(context.stderr(), "ps: {message}")?; + return Ok(ExecutionResult::new(code)); + }, + }; + if context.is_cancelled() { + return Ok(ExecutionExitCode::Interrupted.into()); + } + + let mut processes = proc_snapshot::ProcInfo::all(); + let current_pid = i32::try_from(std::process::id()).ok(); + let current = + current_pid.and_then(|pid| processes.iter().find(|process| process.pid() == pid)); + let current_user = current.and_then(|process| { + process + .effective_user_id() + .or_else(|| process.real_user_id()) + }); + let current_terminal = current.and_then(proc_snapshot::ProcInfo::terminal_id); + let current_session = current.and_then(proc_snapshot::ProcInfo::session_id); + processes.retain(|process| { + ps_process_selected( + process, + &options, + current_pid, + current_user, + current_terminal, + current_session, + ) + }); + + let now = SystemTime::now(); + let mut rows: Vec<_> = processes + .into_iter() + .map(|process| PsProcessRow::from_process(process, now, options.command_only)) + .collect(); + sort_ps_rows(&mut rows, &options.sort); + let columns = ps_columns(&options); + let output = render_ps_table(&rows, &columns, options.no_headers); + if let Err(err) = write!(context.stdout(), "{output}") { + if err.kind() == io::ErrorKind::BrokenPipe { + return Ok(ExecutionResult::success()); + } + return Err(err.into()); + } + Ok(if rows.is_empty() { + ExecutionResult::new(1) + } else { + ExecutionResult::success() + }) + } + } +} + +fn parse_ps_args(argv: &[String]) -> std::result::Result { + let mut options = PsOptions::default(); + let mut index = 0; + let mut options_done = false; + while index < argv.len() { + let arg = &argv[index]; + if !options_done && arg == "--" { + options_done = true; + index += 1; + continue; + } + if options_done { + parse_i32_list(arg, &mut options.pids)?; + index += 1; + continue; + } + match arg.as_str() { + "--help" => return Ok(ParsePsResult::Help), + "--version" => return Ok(ParsePsResult::Version), + "--all" | "--everyone" => options.all = true, + "--no-headers" => options.no_headers = true, + "--headers" => options.no_headers = false, + _ if arg == "--pid" || arg.starts_with("--pid=") => { + let value = take_ps_value(argv, &mut index, arg.strip_prefix("--pid="), "--pid")?; + parse_i32_list(&value, &mut options.pids)?; + }, + _ if arg == "--ppid" || arg.starts_with("--ppid=") => { + let value = take_ps_value(argv, &mut index, arg.strip_prefix("--ppid="), "--ppid")?; + parse_i32_list(&value, &mut options.parents)?; + }, + _ if arg == "--group" || arg.starts_with("--group=") => { + let value = take_ps_value(argv, &mut index, arg.strip_prefix("--group="), "--group")?; + parse_i32_list(&value, &mut options.groups)?; + }, + _ if arg == "--sid" || arg.starts_with("--sid=") => { + let value = take_ps_value(argv, &mut index, arg.strip_prefix("--sid="), "--sid")?; + parse_i32_list(&value, &mut options.sessions)?; + }, + _ if arg == "--user" || arg.starts_with("--user=") => { + let value = take_ps_value(argv, &mut index, arg.strip_prefix("--user="), "--user")?; + parse_user_list(&value, &mut options.effective_users)?; + }, + _ if arg == "--User" || arg.starts_with("--User=") => { + let value = take_ps_value(argv, &mut index, arg.strip_prefix("--User="), "--User")?; + parse_user_list(&value, &mut options.real_users)?; + }, + _ if arg == "--tty" || arg.starts_with("--tty=") => { + let value = take_ps_value(argv, &mut index, arg.strip_prefix("--tty="), "--tty")?; + parse_terminal_list(&value, &mut options.terminals)?; + }, + _ if arg == "--format" || arg.starts_with("--format=") => { + let value = take_ps_value(argv, &mut index, arg.strip_prefix("--format="), "--format")?; + parse_ps_format(&value, &mut options.columns)?; + options.custom_format = true; + }, + _ if arg == "--sort" || arg.starts_with("--sort=") => { + let value = take_ps_value(argv, &mut index, arg.strip_prefix("--sort="), "--sort")?; + parse_ps_sort(&value, &mut options.sort)?; + }, + _ if let Some(group) = arg.strip_prefix('-') => { + if group.is_empty() { + return Err((1, "invalid option '-'".to_string())); + } + let bsd = group.contains('x'); + parse_ps_flag_group(group, bsd, argv, &mut index, &mut options)?; + }, + _ if arg + .chars() + .all(|character| character.is_ascii_digit() || character == ',') => + { + parse_i32_list(arg, &mut options.pids)?; + }, + _ if arg.chars().all(|character| character.is_ascii_alphabetic()) => { + parse_ps_flag_group(arg, true, argv, &mut index, &mut options)?; + }, + _ => return Err((1, format!("unsupported operand '{arg}'"))), + } + index += 1; + } + Ok(ParsePsResult::Options(Box::new(options))) +} + +fn take_ps_value( + argv: &[String], + index: &mut usize, + inline: Option<&str>, + option: &str, +) -> std::result::Result { + if let Some(value) = inline { + if value.is_empty() { + return Err((1, format!("option '{option}' requires an argument"))); + } + return Ok(value.to_string()); + } + *index += 1; + argv + .get(*index) + .filter(|value| !value.is_empty()) + .cloned() + .ok_or_else(|| (1, format!("option '{option}' requires an argument"))) +} + +fn parse_ps_flag_group( + group: &str, + bsd: bool, + argv: &[String], + index: &mut usize, + options: &mut PsOptions, +) -> std::result::Result<(), (u8, String)> { + if bsd { + options.bsd_syntax = true; + } + let mut offset = 0; + while offset < group.len() { + let option = group.as_bytes()[offset] as char; + offset += 1; + let remainder = &group[offset..]; + match option { + 'A' => options.all = true, + 'e' if !bsd => options.all = true, + 'e' => {}, + 'a' => options.other_users = true, + 'x' => { + options.include_no_terminal = true; + options.bsd_syntax = true; + }, + 'f' => options.full_format = true, + 'l' => options.long_format = true, + 'j' => options.job_format = true, + 'v' => options.memory_format = true, + 'u' if bsd => options.user_format = true, + 'w' => {}, + 'c' => options.command_only = true, + 'r' => options.running_only = true, + 'h' => options.no_headers = true, + 'o' => { + let value = + take_ps_value(argv, index, (!remainder.is_empty()).then_some(remainder), "-o")?; + parse_ps_format(&value, &mut options.columns)?; + options.custom_format = true; + return Ok(()); + }, + 'p' | 'q' => { + let value = take_ps_value( + argv, + index, + (!remainder.is_empty()).then_some(remainder), + if option == 'p' { "-p" } else { "-q" }, + )?; + parse_i32_list(&value, &mut options.pids)?; + return Ok(()); + }, + 'P' => { + let value = + take_ps_value(argv, index, (!remainder.is_empty()).then_some(remainder), "-P")?; + parse_i32_list(&value, &mut options.parents)?; + return Ok(()); + }, + 'g' => { + let value = + take_ps_value(argv, index, (!remainder.is_empty()).then_some(remainder), "-g")?; + parse_i32_list(&value, &mut options.groups)?; + return Ok(()); + }, + 's' => { + let value = + take_ps_value(argv, index, (!remainder.is_empty()).then_some(remainder), "-s")?; + parse_i32_list(&value, &mut options.sessions)?; + return Ok(()); + }, + 't' => { + let value = + take_ps_value(argv, index, (!remainder.is_empty()).then_some(remainder), "-t")?; + parse_terminal_list(&value, &mut options.terminals)?; + return Ok(()); + }, + 'u' => { + let value = + take_ps_value(argv, index, (!remainder.is_empty()).then_some(remainder), "-u")?; + parse_user_list(&value, &mut options.effective_users)?; + return Ok(()); + }, + 'U' => { + let value = + take_ps_value(argv, index, (!remainder.is_empty()).then_some(remainder), "-U")?; + parse_user_list(&value, &mut options.real_users)?; + return Ok(()); + }, + 'G' => { + let value = + take_ps_value(argv, index, (!remainder.is_empty()).then_some(remainder), "-G")?; + parse_group_list(&value, &mut options.real_groups)?; + return Ok(()); + }, + _ => return Err((1, format!("unsupported option '-{option}'"))), + } + } + Ok(()) +} + +fn parse_ps_format( + value: &str, + columns: &mut Vec, +) -> std::result::Result<(), (u8, String)> { + let start_len = columns.len(); + for spec in value.split(',').flat_map(str::split_ascii_whitespace) { + let (field_spec, header) = spec + .split_once('=') + .map_or((spec, None), |(field, header)| (field, Some(header))); + let (name, min_width) = field_spec + .rsplit_once(':') + .and_then(|(name, width)| width.parse::().ok().map(|width| (name, width))) + .unwrap_or((field_spec, 0)); + let field = match name.to_ascii_lowercase().as_str() { + "user" | "uname" => PsField::User, + "uid" | "euid" => PsField::Uid, + "pid" | "lwp" => PsField::Pid, + "ppid" => PsField::Ppid, + "pgid" | "pgrp" => PsField::Pgid, + "sid" | "sess" => PsField::Sid, + "tty" | "tt" | "tname" => PsField::Tty, + "stat" | "state" | "s" => PsField::State, + "start" | "stime" | "bsdstart" => PsField::Start, + "lstart" | "start_time" => PsField::LongStart, + "etime" | "elapsed" => PsField::Elapsed, + "etimes" => PsField::ElapsedSeconds, + "time" | "cputime" => PsField::CpuTime, + "pcpu" | "%cpu" => PsField::CpuPercent, + "c" => PsField::CpuInteger, + "pmem" | "%mem" => PsField::MemPercent, + "vsz" | "vsize" => PsField::VirtualSize, + "rss" | "rssize" => PsField::ResidentSize, + "ni" | "nice" => PsField::Nice, + "nlwp" | "thcount" => PsField::Threads, + "comm" | "ucomm" | "fname" => PsField::Command, + "args" | "command" | "cmd" => PsField::Args, + _ => return Err((1, format!("unknown output format specifier '{name}'"))), + }; + let mut column = PsColumn::new(field); + if let Some(header) = header { + column.header = header.to_string(); + } + column.min_width = min_width; + columns.push(column); + } + if columns.len() == start_len { + return Err((1, "output format must name at least one column".to_string())); + } + Ok(()) +} + +fn parse_ps_sort(value: &str, sort: &mut Vec) -> std::result::Result<(), (u8, String)> { + for spec in value.split(',').flat_map(str::split_ascii_whitespace) { + let (descending, name) = if let Some(name) = spec.strip_prefix('-') { + (true, name) + } else { + (false, spec.strip_prefix('+').unwrap_or(spec)) + }; + let field = match name.to_ascii_lowercase().as_str() { + "pid" => PsSortField::Pid, + "ppid" => PsSortField::Ppid, + "pcpu" | "%cpu" | "cpu" => PsSortField::Cpu, + "pmem" | "%mem" | "mem" | "rss" => PsSortField::Mem, + "time" | "cputime" => PsSortField::Time, + "start" | "lstart" => PsSortField::Start, + "comm" | "command" | "cmd" => PsSortField::Command, + _ => return Err((1, format!("unknown sort specifier '{name}'"))), + }; + sort.push(PsSort { field, descending }); + } + if sort.is_empty() { + return Err((1, "sort must name at least one column".to_string())); + } + Ok(()) +} + +fn ps_process_selected( + process: &proc_snapshot::ProcInfo, + options: &PsOptions, + current_pid: Option, + current_user: Option, + current_terminal: Option, + current_session: Option, +) -> bool { + if options.running_only && process.state() != 'R' { + return false; + } + let has_selectors = !options.pids.is_empty() + || !options.parents.is_empty() + || !options.groups.is_empty() + || !options.sessions.is_empty() + || !options.effective_users.is_empty() + || !options.real_users.is_empty() + || !options.real_groups.is_empty() + || !options.terminals.is_empty(); + if options.all { + return true; + } + if has_selectors { + return options.pids.contains(&process.pid()) + || process + .ppid() + .is_some_and(|value| options.parents.contains(&value)) + || process + .group_id() + .is_some_and(|value| options.groups.contains(&value)) + || process + .session_id() + .is_some_and(|value| options.sessions.contains(&value)) + || process + .effective_user_id() + .is_some_and(|value| options.effective_users.contains(&value)) + || process + .real_user_id() + .is_some_and(|value| options.real_users.contains(&value)) + || process + .real_group_id() + .is_some_and(|value| options.real_groups.contains(&value)) + || options.terminals.contains(&process.terminal_id()); + } + if options.other_users { + return options.include_no_terminal || process.terminal_id().is_some(); + } + if current_user.is_some_and(|user| { + process.effective_user_id() != Some(user) && process.real_user_id() != Some(user) + }) { + return false; + } + if options.include_no_terminal { + return true; + } + if cfg!(target_os = "macos") { + return process.terminal_id().is_some(); + } + if let Some(terminal) = current_terminal { + return process.terminal_id() == Some(terminal); + } + if let Some(session) = current_session { + return process.session_id() == Some(session); + } + current_pid.is_none_or(|pid| process.pid() == pid) +} + +fn ps_columns(options: &PsOptions) -> Vec { + if options.custom_format { + return options.columns.clone(); + } + let columns = if options.user_format { + vec![ + (PsField::User, "USER"), + (PsField::Pid, "PID"), + (PsField::CpuPercent, "%CPU"), + (PsField::MemPercent, "%MEM"), + (PsField::VirtualSize, "VSZ"), + (PsField::ResidentSize, "RSS"), + (PsField::Tty, "TTY"), + (PsField::State, "STAT"), + (PsField::Start, "START"), + (PsField::CpuTime, "TIME"), + (PsField::Args, "COMMAND"), + ] + } else if options.long_format { + vec![ + (PsField::State, "S"), + (PsField::Uid, "UID"), + (PsField::Pid, "PID"), + (PsField::Ppid, "PPID"), + (PsField::Pgid, "PGID"), + (PsField::Sid, "SID"), + (PsField::Nice, "NI"), + (PsField::VirtualSize, "VSZ"), + (PsField::ResidentSize, "RSS"), + (PsField::Tty, "TTY"), + (PsField::CpuTime, "TIME"), + (PsField::Args, "CMD"), + ] + } else if options.job_format { + vec![ + (PsField::User, "USER"), + (PsField::Pid, "PID"), + (PsField::Ppid, "PPID"), + (PsField::Pgid, "PGID"), + (PsField::Sid, "SID"), + (PsField::State, "STAT"), + (PsField::Tty, "TTY"), + (PsField::CpuTime, "TIME"), + (PsField::Args, "COMMAND"), + ] + } else if options.memory_format { + vec![ + (PsField::Pid, "PID"), + (PsField::MemPercent, "%MEM"), + (PsField::VirtualSize, "VSZ"), + (PsField::ResidentSize, "RSS"), + (PsField::Tty, "TTY"), + (PsField::State, "STAT"), + (PsField::Start, "START"), + (PsField::CpuTime, "TIME"), + (PsField::Args, "COMMAND"), + ] + } else if options.full_format { + vec![ + (PsField::Uid, "UID"), + (PsField::Pid, "PID"), + (PsField::Ppid, "PPID"), + (PsField::CpuInteger, "C"), + (PsField::Start, "STIME"), + (PsField::Tty, "TTY"), + (PsField::CpuTime, "TIME"), + (PsField::Args, "CMD"), + ] + } else if options.bsd_syntax { + vec![ + (PsField::Pid, "PID"), + (PsField::Tty, "TTY"), + (PsField::State, "STAT"), + (PsField::CpuTime, "TIME"), + (PsField::Args, "COMMAND"), + ] + } else { + vec![ + (PsField::Pid, "PID"), + (PsField::Tty, "TTY"), + (PsField::CpuTime, "TIME"), + (PsField::Args, "CMD"), + ] + }; + columns + .into_iter() + .map(|(field, header)| PsColumn::with_header(field, header)) + .collect() +} + +fn sort_ps_rows(rows: &mut [PsProcessRow], sort: &[PsSort]) { + rows.sort_by(|left, right| { + for key in sort { + let ordering = match key.field { + PsSortField::Pid => left.pid.cmp(&right.pid), + PsSortField::Ppid => left.ppid.cmp(&right.ppid), + PsSortField::Cpu => match (left.cpu_percent(), right.cpu_percent()) { + (Some(left), Some(right)) => left.total_cmp(&right), + (left, right) => left.is_some().cmp(&right.is_some()), + }, + PsSortField::Mem => left.resident_size.cmp(&right.resident_size), + PsSortField::Time => left.cpu_time.cmp(&right.cpu_time), + PsSortField::Start => left.start_time.cmp(&right.start_time), + PsSortField::Command => left.command.cmp(&right.command), + }; + let ordering = if key.descending { + ordering.reverse() + } else { + ordering + }; + if ordering != std::cmp::Ordering::Equal { + return ordering; + } + } + left.pid.cmp(&right.pid) + }); +} + +fn render_ps_table(rows: &[PsProcessRow], columns: &[PsColumn], no_headers: bool) -> String { + let has_field = |wanted| columns.iter().any(|column| column.field == wanted); + let total_memory = has_field(PsField::MemPercent) + .then(ps_total_memory_bytes) + .flatten(); + let timezone = (has_field(PsField::Start) || has_field(PsField::LongStart)) + .then(|| TimeZone::try_system().unwrap_or(TimeZone::UTC)); + let terminal_names = if has_field(PsField::Tty) { + ps_terminal_names(rows) + } else { + HashMap::new() + }; + let mut user_names = HashMap::new(); + if has_field(PsField::User) { + for uid in rows.iter().filter_map(|row| row.user) { + user_names + .entry(uid) + .or_insert_with(|| ps_user_name(uid).unwrap_or_else(|| uid.to_string())); + } + } + let values: Vec> = rows + .iter() + .map(|row| { + columns + .iter() + .map(|column| { + render_ps_value( + row, + column.field, + total_memory, + timezone.as_ref(), + &terminal_names, + &user_names, + ) + }) + .collect() + }) + .collect(); + let widths: Vec = columns + .iter() + .enumerate() + .map(|(index, column)| { + values + .iter() + .map(|row| row[index].chars().count()) + .fold(column.header.chars().count().max(column.min_width), usize::max) + }) + .collect(); + let mut output = String::new(); + if !no_headers && columns.iter().any(|column| !column.header.is_empty()) { + write_ps_line( + &mut output, + columns.iter().map(|column| column.header.as_str()), + columns, + &widths, + ); + } + for row in &values { + write_ps_line(&mut output, row.iter().map(String::as_str), columns, &widths); + } + output +} + +fn write_ps_line<'a>( + output: &mut String, + values: impl Iterator, + columns: &[PsColumn], + widths: &[usize], +) { + for (index, value) in values.enumerate() { + if index > 0 { + output.push(' '); + } + let width = widths[index]; + if columns[index].field.right_aligned() { + let _ = write!(output, "{value:>width$}"); + } else if index + 1 == columns.len() { + output.push_str(value); + } else { + let _ = write!(output, "{value:, + timezone: Option<&TimeZone>, + terminal_names: &HashMap, + user_names: &HashMap, +) -> String { + match field { + PsField::User => row + .user + .and_then(|uid| user_names.get(&uid).cloned()) + .unwrap_or_else(|| "?".to_string()), + PsField::Uid => row + .user + .map_or_else(|| "?".to_string(), |value| value.to_string()), + PsField::Pid => row.pid.to_string(), + PsField::Ppid => row + .ppid + .map_or_else(|| "?".to_string(), |value| value.to_string()), + PsField::Pgid => row + .pgid + .map_or_else(|| "?".to_string(), |value| value.to_string()), + PsField::Sid => row + .sid + .map_or_else(|| "?".to_string(), |value| value.to_string()), + PsField::Tty => row + .terminal + .and_then(|terminal| terminal_names.get(&terminal).cloned()) + .unwrap_or_else(|| "?".to_string()), + PsField::State => format_ps_state(row), + PsField::Start => timezone.map_or_else( + || "?".to_string(), + |timezone| format_ps_start(row.started_at, row.age, timezone, false), + ), + PsField::LongStart => timezone.map_or_else( + || "?".to_string(), + |timezone| format_ps_start(row.started_at, row.age, timezone, true), + ), + PsField::Elapsed => row.age.map_or_else(|| "?".to_string(), format_ps_elapsed), + PsField::ElapsedSeconds => row + .age + .map_or_else(|| "?".to_string(), |age| age.as_secs().to_string()), + PsField::CpuTime => row + .cpu_time + .map_or_else(|| "?".to_string(), format_ps_elapsed), + PsField::CpuPercent => row + .cpu_percent() + .map_or_else(|| "?".to_string(), |percent| format!("{percent:.1}")), + PsField::CpuInteger => row + .cpu_percent() + .map_or_else(|| "?".to_string(), |percent| format!("{percent:.0}")), + PsField::MemPercent => row + .memory_percent(total_memory) + .map_or_else(|| "?".to_string(), |percent| format!("{percent:.1}")), + PsField::VirtualSize => row + .virtual_size + .map_or_else(|| "?".to_string(), |bytes| (bytes / 1024).to_string()), + PsField::ResidentSize => row + .resident_size + .map_or_else(|| "?".to_string(), |bytes| (bytes / 1024).to_string()), + PsField::Nice => row + .nice + .map_or_else(|| "?".to_string(), |value| value.to_string()), + PsField::Threads => row + .threads + .map_or_else(|| "?".to_string(), |value| value.to_string()), + PsField::Command => row.command.clone(), + PsField::Args => row.args.clone(), + } +} + +fn format_ps_state(row: &PsProcessRow) -> String { + let mut state = row.state.to_string(); + match row.nice { + Some(value) if value < 0 => state.push('<'), + Some(value) if value > 0 => state.push('N'), + _ => {}, + } + if row.sid == Some(row.pid) { + state.push('s'); + } + if row.threads.is_some_and(|threads| threads > 1) { + state.push('l'); + } + state +} + +fn format_ps_start( + started_at: Option, + age: Option, + timezone: &TimeZone, + long: bool, +) -> String { + const DAY_SECONDS: u64 = 24 * 60 * 60; + const SIX_MONTH_SECONDS: u64 = 180 * DAY_SECONDS; + let Some(started_at) = started_at else { + return "?".to_string(); + }; + let Ok(since_epoch) = started_at.duration_since(UNIX_EPOCH) else { + return "?".to_string(); + }; + let Ok(nanoseconds) = i128::try_from(since_epoch.as_nanos()) else { + return "?".to_string(); + }; + let Ok(timestamp) = Timestamp::from_nanosecond(nanoseconds) else { + return "?".to_string(); + }; + let format = if long { + "%a %b %e %H:%M:%S %Y" + } else if age.is_some_and(|age| age.as_secs() < DAY_SECONDS) { + "%H:%M" + } else if age.is_some_and(|age| age.as_secs() < SIX_MONTH_SECONDS) { + "%b%d" + } else { + "%Y" + }; + strtime::format(format, ×tamp.to_zoned(timezone.clone())) + .unwrap_or_else(|_| "?".to_string()) +} + +fn format_ps_elapsed(duration: Duration) -> String { + let total_seconds = duration.as_secs(); + let days = total_seconds / 86_400; + let hours = total_seconds % 86_400 / 3_600; + let minutes = total_seconds % 3_600 / 60; + let seconds = total_seconds % 60; + if days > 0 { + format!("{days}-{hours:02}:{minutes:02}:{seconds:02}") + } else if hours > 0 { + format!("{hours:02}:{minutes:02}:{seconds:02}") + } else { + format!("{minutes:02}:{seconds:02}") + } +} + +#[cfg(target_os = "linux")] +fn ps_total_memory_bytes() -> Option { + let value = fs::read_to_string("/proc/meminfo") + .ok()? + .lines() + .find_map(|line| line.strip_prefix("MemTotal:"))? + .split_ascii_whitespace() + .next()? + .parse::() + .ok()?; + value.checked_mul(1024) +} + +#[cfg(target_os = "macos")] +fn ps_total_memory_bytes() -> Option { + let mut value = 0_u64; + let mut size = std::mem::size_of::(); + // SAFETY: the output pointer names a writable u64 and `size` reports its + // exact capacity; hw.memsize has no input buffer. + let status = unsafe { + libc::sysctlbyname( + c"hw.memsize".as_ptr(), + (&raw mut value).cast(), + &raw mut size, + std::ptr::null_mut(), + 0, + ) + }; + (status == 0 && size == std::mem::size_of::()).then_some(value) +} + +#[cfg(target_os = "windows")] +fn ps_total_memory_bytes() -> Option { + None +} + +#[cfg(unix)] +fn ps_user_name(uid: u32) -> Option { + use std::ffi::CStr; + let mut record = std::mem::MaybeUninit::::zeroed(); + let mut result = std::ptr::null_mut(); + let mut buffer = vec![0_u8; 16 * 1024]; + // SAFETY: all pointers refer to live storage for this call; a non-null + // result guarantees `record` and its pw_name pointer were initialized. + let status = unsafe { + libc::getpwuid_r( + uid, + record.as_mut_ptr(), + buffer.as_mut_ptr().cast(), + buffer.len(), + &raw mut result, + ) + }; + if status != 0 || result.is_null() { + return None; + } + // SAFETY: getpwuid_r succeeded and the backing buffer remains alive. + let name = unsafe { CStr::from_ptr(record.assume_init().pw_name) }; + Some(name.to_string_lossy().into_owned()) +} + +#[cfg(not(unix))] +fn ps_user_name(_uid: u32) -> Option { + None +} + +#[cfg(unix)] +fn ps_terminal_names(rows: &[PsProcessRow]) -> HashMap { + use std::os::unix::fs::MetadataExt; + let wanted: HashSet = rows.iter().filter_map(|row| row.terminal).collect(); + let mut names = HashMap::new(); + for directory in [Path::new("/dev"), Path::new("/dev/pts")] { + let Ok(entries) = fs::read_dir(directory) else { + continue; + }; + for entry in entries.flatten() { + let path = entry.path(); + let Ok(metadata) = fs::metadata(&path) else { + continue; + }; + let id = metadata.rdev(); + if !wanted.contains(&id) || names.contains_key(&id) { + continue; + } + let name = path + .strip_prefix("/dev") + .ok() + .map(|path| path.to_string_lossy().trim_start_matches('/').to_string()); + if let Some(name) = name.filter(|name| !name.is_empty()) { + names.insert(id, name); + } + } + } + names +} + +#[cfg(not(unix))] +fn ps_terminal_names(_rows: &[PsProcessRow]) -> HashMap { + HashMap::new() +} + +fn write_ps_help(mut output: impl Write) -> io::Result<()> { + writeln!( + output, + "Usage: ps [options]\n\nSelection:\n-A, -e, --all select every process\n-p, --pid \ + LIST select process IDs\n-P, --ppid LIST select parent process IDs\n-u, --user \ + LIST select effective users\n-U, --User LIST select real users\n-t, --tty LIST \ + select terminals\n\nOutput:\n-f full format\n-l long \ + format\n-o, --format LIST custom columns\n--sort LIST sort by columns; prefix \ + descending keys with '-'\n--no-headers omit column headings\n\nBSD forms such as 'ps \ + ax', 'ps aux', and 'ps axo pid,command' are supported." + ) +} + #[derive(Clone, Copy, Debug, clap::ValueEnum)] enum TopSortKey { Pid, @@ -4513,7 +5596,7 @@ impl builtins::Command for TopCommand { next_previous.insert(process.pid(), (start_time, cpu_time)); } - let command = sanitize_top_command(if full_command { + let command = sanitize_process_command(if full_command { let args = process.args(); if args.is_empty() { process.command_name() @@ -4682,7 +5765,7 @@ fn render_top_snapshot(rows: &[TopProcessRow], row_limit: Option, sample: output } -fn sanitize_top_command(command: String) -> String { +fn sanitize_process_command(command: String) -> String { command .chars() .map(|character| { @@ -5149,6 +6232,57 @@ mod tests { assert!(status.is_some(), "pidwait returned while its matching process was still running"); } + #[tokio::test(flavor = "multi_thread")] + async fn ps_builtin_supports_common_bsd_and_posix_forms() { + let pid = std::process::id(); + let (custom_result, custom) = + execute_captured(format!("ps -p {pid} -o pid=,ppid=,stat=,comm=")).await; + let (posix_result, posix) = execute_captured(format!("ps -ef -p {pid}")).await; + let (bsd_result, bsd) = execute_captured(format!("ps aux -p {pid}")).await; + + assert_eq!(custom_result.exit_code, Some(0)); + assert_eq!(custom.lines().count(), 1); + let mut fields = custom.split_whitespace(); + assert_eq!(fields.next(), Some(pid.to_string().as_str())); + assert!( + fields + .next() + .and_then(|value| value.parse::().ok()) + .is_some() + ); + assert!(fields.next().is_some_and(|value| !value.is_empty())); + assert!(fields.next().is_some_and(|value| !value.is_empty())); + + assert_eq!(posix_result.exit_code, Some(0)); + assert!( + posix + .lines() + .next() + .is_some_and(|line| line.contains("PPID")) + ); + assert!(posix.lines().skip(1).any(|line| { + line + .split_whitespace() + .any(|value| value == pid.to_string()) + })); + + assert_eq!(bsd_result.exit_code, Some(0)); + assert!(bsd.lines().next().is_some_and(|line| line.contains("%CPU"))); + assert!(bsd.lines().skip(1).any(|line| { + line + .split_whitespace() + .any(|value| value == pid.to_string()) + })); + } + + #[tokio::test(flavor = "multi_thread")] + async fn ps_builtin_formats_parseable_long_start_without_a_header() { + let pid = std::process::id(); + let (result, output) = execute_captured(format!("ps -p {pid} -o lstart=")).await; + assert_eq!(result.exit_code, Some(0)); + assert!(strtime::parse("%a %b %e %H:%M:%S %Y", output.trim()).is_ok(), "{output:?}"); + } + #[tokio::test(flavor = "multi_thread")] async fn top_builtin_emits_one_finite_snapshot() { #[cfg(target_os = "macos")] diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 9dc159aba..c051a7347 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -8,6 +8,8 @@ ### Added +- Added optional `timeoutMs` to `discovery` configuration in provider options (`models.yml` / `models.json`) to configure custom HTTP probe timeouts for llama.cpp, Ollama, and OpenAI-compatible discovery endpoints ([#6952](https://github.com/can1357/oh-my-pi/issues/6952)). +- Added a cross-platform, in-process `ps` shell builtin with BSD/procps selection forms, custom output columns, sorting, process metrics, and header suppression. - Added a `relay` browser mode that drives the user's own Chrome tabs through a local CDP relay plus the OMP Browser Relay extension: `omp browser-relay install` writes the bundled extension to disk, and `browser.relay` / `browser.relayUrl` (or per-call `app.relay`) route the browser tool through it. The relay server auto-starts under a profile-independent global daemon broker when the browser tool needs it; every relay consumer holds a broker lease, so the fixed-port singleton stops only after its last consumer across all projects exits. `omp browser-relay` remains available for `--token`/`--no-group`/custom ports, and a relay already serving the port is adopted. It multiplexes the supervisor and per-tab worker puppeteer connections over the single `chrome.debugger` attachment Chrome allows per tab, and gathers only the tabs the agent actively drives into a per-window "omp" tab group (released when the last client lets go of the tab, dissolved on disconnect, never re-grouping tabs the user pulls out). - Added a scriptable desktop session with persistent `desktop`/`Win`/`El` handles, window-targeted capture and input, native accessibility trees with `[ref=eN]` actions, clipboard access, streamed screenshots, and enforced read-only runs. - Added broker-shared language servers: one LSP server per (server, project) is now spawned by an `omp lsp mux` daemon under the per-project daemon broker (the same broker that owns the shared Chromium and `hub start` processes) and multiplexed to every omp instance in the project over a local socket — instances share the server's index, initialize result, diagnostics, and document state instead of each paying a private cold start. The mux reference-counts `didOpen`/`didClose`, remaps request ids and document versions per client, replays cached diagnostics/registrations/progress to late joiners, routes `workspace/applyEdit` to the most recently active instance, and intercepts per-session `shutdown`/`exit` so one instance leaving never kills the server for the rest; the broker still reaps everything when the last omp process in the project exits. Controlled by the new `lsp.shared` setting (default on); any broker/mux failure falls back to a private server spawn, and an external `lspmux` wrapper keeps precedence when configured. diff --git a/packages/coding-agent/src/prompts/tools/bash.md b/packages/coding-agent/src/prompts/tools/bash.md index dad660daa..6cfe38af2 100644 --- a/packages/coding-agent/src/prompts/tools/bash.md +++ b/packages/coding-agent/src/prompts/tools/bash.md @@ -8,7 +8,7 @@ Use ONLY for one binary or a short pipeline that computes a fact (`wc -l`, `sort - `pty: true` only for terminal interaction (`sudo`, `ssh`). - Order-dependent commands use `&&` in one call; independent calls may run concurrently. - Internal URIs (`skill://`, `agent://`, …) auto-resolve to paths. -{{#if hasShellBuiltins}}- aux utils available: mkdir, wc, sort, comm, diff, uniq, base64, cmp, md5sum, sha{1,224,256,384,512}sum, b2sum, basename, dirname, readlink, realpath, touch, stat, date, mktemp, seq, yes, printenv, truncate, tac, nproc, uname, whoami, hostname, which, pgrep, pkill, pidwait, top, cut, tee, tr, paste, sed, xargs, jq, rm, mv, ln, ts, sponge, ifne, isutf8, combine{{#unless isWindows}}, errno{{/unless}}{{/if}} +{{#if hasShellBuiltins}}- aux utils available: mkdir, wc, sort, comm, diff, uniq, base64, cmp, md5sum, sha{1,224,256,384,512}sum, b2sum, basename, dirname, readlink, realpath, touch, stat, date, mktemp, seq, yes, printenv, truncate, tac, nproc, uname, whoami, hostname, which, ps, pgrep, pkill, pidwait, top, cut, tee, tr, paste, sed, xargs, jq, rm, mv, ln, ts, sponge, ifne, isutf8, combine{{#unless isWindows}}, errno{{/unless}}{{/if}} {{#if asyncEnabled}}- `async: true` defers a finite command's result; it does not extend `timeout`.{{/if}} diff --git a/packages/natives/CHANGELOG.md b/packages/natives/CHANGELOG.md index 331136de8..572294ab2 100644 --- a/packages/natives/CHANGELOG.md +++ b/packages/natives/CHANGELOG.md @@ -8,6 +8,7 @@ ### Added +- Added a cross-platform, in-process `ps` shell builtin with BSD/procps selection forms, custom output columns, sorting, process metrics, and header suppression. - Added macOS, Win32, X11, and Wayland desktop backends behind one session API, including capture-free window discovery, isolated capture, explicit background/foreground delivery, native AX/UIA/AT-SPI trees with generational refs, and structured errors when a platform cannot honestly deliver background input. ### Fixed