- commit
- 758a137
- parent
- 5ebf61b
- author
- Eric Bower
- date
- 2026-04-24 16:08:27 -0400 EDT
refactor(run): check pty foreground process to detect shell This removes the need to provide the `--fish` flag at all for `zmx run` commands.
4 files changed,
+76,
-33
+59,
-0
1@@ -1,8 +1,11 @@
2 const builtin = @import("builtin");
3+const std = @import("std");
4+const posix = std.posix;
5
6 pub const c = switch (builtin.os.tag) {
7 .macos => @cImport({
8 @cInclude("sys/ioctl.h"); // ioctl and constants
9+ @cInclude("sys/sysctl.h"); // sysctl for process name lookup
10 @cInclude("termios.h");
11 @cInclude("stdlib.h");
12 @cInclude("unistd.h");
13@@ -28,3 +31,59 @@ pub const forkpty = if (builtin.os.tag == .macos)
14 }.forkpty
15 else
16 c.forkpty;
17+
18+/// Returns the basename of the foreground process running on the given PTY fd.
19+/// Writes into `buf` and returns a slice of it, or null on failure.
20+pub fn getForegroundProcessName(pty_fd: i32, buf: []u8) ?[]const u8 {
21+ const pgid = c.tcgetpgrp(pty_fd);
22+ if (pgid <= 0) return null;
23+
24+ switch (builtin.os.tag) {
25+ .macos => {
26+ // Use KERN_PROC_PGRP to find the process in the foreground group.
27+ // We walk the process list and find the first process whose pgid matches.
28+ var mib = [_]c_int{ c.CTL_KERN, c.KERN_PROC, c.KERN_PROC_PGRP, @intCast(pgid) };
29+ var size: usize = 0;
30+ if (c.sysctl(&mib, mib.len, null, &size, null, 0) != 0) return null;
31+ if (size == 0) return null;
32+
33+ // kinfo_proc is large; allocate on heap to avoid blowing the stack
34+ const kinfo_size = @sizeOf(c.struct_kinfo_proc);
35+ const count = size / kinfo_size;
36+ if (count == 0) return null;
37+
38+ // Use a stack buffer for small lists (usually 1-3 procs), heap otherwise.
39+ var stack_buf: [8 * @sizeOf(c.struct_kinfo_proc)]u8 align(@alignOf(c.struct_kinfo_proc)) = undefined;
40+ const heap_needed = size > stack_buf.len;
41+ const proc_buf: []u8 = if (heap_needed)
42+ std.heap.c_allocator.alloc(u8, size) catch return null
43+ else
44+ stack_buf[0..size];
45+ defer if (heap_needed) std.heap.c_allocator.free(proc_buf);
46+
47+ if (c.sysctl(&mib, mib.len, proc_buf.ptr, &size, null, 0) != 0) return null;
48+
49+ const procs: []c.struct_kinfo_proc = @alignCast(std.mem.bytesAsSlice(c.struct_kinfo_proc, proc_buf[0..size]));
50+ if (procs.len == 0) return null;
51+
52+ // p_comm is a null-terminated fixed-length field
53+ const comm: [*:0]const u8 = @ptrCast(&procs[0].kp_proc.p_comm);
54+ const name = std.mem.sliceTo(comm, 0);
55+ const copy_len = @min(name.len, buf.len);
56+ @memcpy(buf[0..copy_len], name[0..copy_len]);
57+ return buf[0..copy_len];
58+ },
59+ .linux => {
60+ // /proc/<pid>/comm contains just the process name + newline
61+ var path_buf: [64]u8 = undefined;
62+ const path = std.fmt.bufPrint(&path_buf, "/proc/{d}/comm", .{pgid}) catch return null;
63+ const file = std.fs.openFileAbsolute(path, .{}) catch return null;
64+ defer file.close();
65+ const n = file.read(buf) catch return null;
66+ // strip trailing newline
67+ const end = if (n > 0 and buf[n - 1] == '\n') n - 1 else n;
68+ return buf[0..end];
69+ },
70+ else => return null,
71+ }
72+}
+15,
-25
1@@ -154,15 +154,8 @@ pub fn main() !void {
2
3 var cmd_args_raw: std.ArrayList([]const u8) = .empty;
4 defer cmd_args_raw.deinit(alloc);
5- var shell_basename: []const u8 = "bash";
6 var detached = false;
7 while (args.next()) |arg| {
8- // Because fish tracks exit code status via $status instead of $? we need some
9- // way to figure out what shell is being used inside the session.
10- if (std.mem.startsWith(u8, arg, "--fish")) {
11- shell_basename = "fish";
12- continue;
13- }
14 if (std.mem.startsWith(u8, arg, "-d")) {
15 detached = true;
16 continue;
17@@ -195,7 +188,7 @@ pub fn main() !void {
18 error.OutOfMemory => return err,
19 };
20 std.log.info("socket path={s}", .{daemon.socket_path});
21- return run(&daemon, detached, shell_basename, cmd_args_raw.items);
22+ return run(&daemon, detached, cmd_args_raw.items);
23 } else if (std.mem.eql(u8, cmd, "send") or std.mem.eql(u8, cmd, "s")) {
24 const session_name = args.next() orelse "";
25 if (session_name.len == 0) return error.SessionNameRequired;
26@@ -551,6 +544,7 @@ const Daemon = struct {
27 task_exit_code: ?u8 = null, // null = running or n/a, set when task completes
28 task_ended_at: ?u64 = null, // timestamp when task exited
29 is_fish: bool = false, // true if session shell is fish (affects exit code variable)
30+ pty_fd: i32 = -1, // set by daemonLoop so handleRun can probe the foreground process
31 pty_write_buf: std.ArrayList(u8) = .empty,
32
33 const EnsureSessionResult = struct {
34@@ -1101,9 +1095,15 @@ const Daemon = struct {
35
36 if (payload.len == 0) return;
37
38- // First byte indicates shell type (0=bash/zsh, 1=fish)
39- self.is_fish = payload[0] == 1;
40- const cmd = payload[1..];
41+ // Auto-detect the foreground process on the PTY to determine shell type.
42+ if (self.pty_fd >= 0) {
43+ var name_buf: [64]u8 = undefined;
44+ if (cross.getForegroundProcessName(self.pty_fd, &name_buf)) |name| {
45+ self.is_fish = std.mem.eql(u8, name, "fish");
46+ std.log.debug("foreground process={s} is_fish={}", .{ name, self.is_fish });
47+ }
48+ }
49+ const cmd = payload;
50
51 // Daemon appends the task marker so the client never injects
52 // shell-specific syntax, keeping Ctrl-C recovery clean.
53@@ -1221,7 +1221,7 @@ fn help() !void {
54 \\
55 \\Commands:
56 \\ [a]ttach <name> [command...] Attach to session, creating if needed
57- \\ [r]un <name> [-d] [--fish] [command...] Send command without attaching
58+ \\ [r]un <name> [-d] [command...] Send command without attaching
59 \\ [s]end <name> <text...> Send raw input to session PTY
60 \\ [p]rint <name> <text...> Inject text into session display
61 \\ [wr]ite <name> <file_path> Write stdin to file_path through the session
62@@ -1255,8 +1255,6 @@ fn help() !void {
63 \\ Commands run sequentially: do not send multiple in parallel.
64 \\ Avoid interactive programs (pagers, editors, prompts): they hang.
65 \\
66- \\ `--fish` is required when the session runs fish shell.
67- \\
68 \\ If the command hangs, send Ctrl+C to recover:
69 \\ zmx run <session> $(printf '\x03')
70 \\
71@@ -1268,7 +1266,6 @@ fn help() !void {
72 \\
73 \\ Examples:
74 \\ zmx run dev ls
75- \\ zmx run dev --fish ls src
76 \\ zmx run dev zig build
77 \\ zmx run dev grep -r TODO src
78 \\ zmx run dev git -c core.pager=cat diff
79@@ -2029,7 +2026,7 @@ fn send(cfg: *Cfg, session_name: []const u8, socket_path: []const u8, text_parts
80 };
81 }
82
83-fn run(daemon: *Daemon, detached: bool, shell_basename: []const u8, command_args: [][]const u8) !void {
84+fn run(daemon: *Daemon, detached: bool, command_args: [][]const u8) !void {
85 const alloc = daemon.alloc;
86 var buf: [4096]u8 = undefined;
87 var w = std.fs.File.stdout().writer(&buf);
88@@ -2046,17 +2043,10 @@ fn run(daemon: *Daemon, detached: bool, shell_basename: []const u8, command_args
89 try w.interface.flush();
90 }
91
92- // Prefix byte tells the daemon which shell syntax to use for the
93- // task-completion marker (0 = bash/zsh $?, 1 = fish $status).
94- // The daemon appends the marker itself so the client never injects
95- // shell-specific text -- keeping recovery (Ctrl-C) clean.
96- const is_fish: u8 = if (std.mem.eql(u8, shell_basename, "fish")) 1 else 0;
97-
98 if (command_args.len > 0) {
99 var cmd_list = std.ArrayList(u8).empty;
100 defer cmd_list.deinit(alloc);
101
102- try cmd_list.append(alloc, is_fish);
103 for (command_args, 0..) |arg, i| {
104 if (i > 0) try cmd_list.append(alloc, ' ');
105 if (util.shellNeedsQuoting(arg)) {
106@@ -2082,7 +2072,6 @@ fn run(daemon: *Daemon, detached: bool, shell_basename: []const u8, command_args
107 var stdin_buf = try std.ArrayList(u8).initCapacity(alloc, 4096);
108 defer stdin_buf.deinit(alloc);
109
110- try stdin_buf.append(alloc, is_fish);
111 while (true) {
112 var tmp: [4096]u8 = undefined;
113 const n = posix.read(stdin_fd, &tmp) catch |err| {
114@@ -2093,7 +2082,7 @@ fn run(daemon: *Daemon, detached: bool, shell_basename: []const u8, command_args
115 try stdin_buf.appendSlice(alloc, tmp[0..n]);
116 }
117
118- if (stdin_buf.items.len > 1) {
119+ if (stdin_buf.items.len > 0) {
120 // Normalize any trailing newline to CR so readline (raw mode)
121 // accepts each line.
122 if (stdin_buf.items[stdin_buf.items.len - 1] == '\n') {
123@@ -2316,6 +2305,7 @@ fn clientLoop(client_sock_fd: i32) !ClientResult {
124 /// clients. It uses poll() as its non-blocking mechanism.
125 fn daemonLoop(daemon: *Daemon, server_sock_fd: i32, pty_fd: i32) !void {
126 std.log.info("daemon started session={s} pty_fd={d}", .{ daemon.session_name, pty_fd });
127+ daemon.pty_fd = pty_fd;
128 setupSigtermHandler();
129 var poll_fds = try std.ArrayList(posix.pollfd).initCapacity(daemon.alloc, 8);
130 defer poll_fds.deinit(daemon.alloc);
+2,
-2
1@@ -208,7 +208,7 @@ load test_helper
2 # ============================================================================
3
4 @test "wait: returns after session command completes" {
5- "$ZMX" run test-wait -d $SHELL_FLAG echo done
6+ "$ZMX" run test-wait -d echo done
7 wait_for_session test-wait
8 sleep 1 # give the command time to finish
9
10@@ -239,7 +239,7 @@ load test_helper
11 # ============================================================================
12
13 @test "print: text appears in history" {
14- "$ZMX" run test-print-hist -d $SHELL_FLAG echo ready
15+ "$ZMX" run test-print-hist -d echo ready
16 wait_for_session test-print-hist
17 sleep 0.3
18
+0,
-6
1@@ -9,12 +9,6 @@ setup() {
2 fi
3 ZMX="$REPO_DIR/zig-out/bin/zmx"
4
5- # Detect shell so task-completion markers use the right syntax
6- case "$(basename "$SHELL")" in
7- fish) SHELL_FLAG="--fish" ;;
8- *) SHELL_FLAG="" ;;
9- esac
10-
11 # Isolate socket dir so tests don't interfere with real sessions or each other
12 export ZMX_DIR="$BATS_TEST_TMPDIR/zmx-sockets"
13 mkdir -p "$ZMX_DIR"