repos / zmx

session persistence for terminal processes
git clone https://github.com/neurosnap/zmx.git

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
M src/cross.zig
+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+}
M src/main.zig
+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);
M test/session.bats
+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 
M test/test_helper.bash
+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"