repos / zmx

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

commit
cb35332
parent
0a8bd81
author
Eric Bower
date
2025-12-28 21:56:47 -0500 EST
feat: `zmx [r]un` to send a command to a session without attaching to it
4 files changed,  +172, -12
M CHANGELOG.md
+2, -1
 1@@ -6,7 +6,8 @@ Use spec: https://common-changelog.org/
 2 
 3 ### Added
 4 
 5-- `zmx [hi]story` command which prints the session scrollback as plain text
 6+- New command `zmx [hi]story <name>` which prints the session scrollback as plain text
 7+- New command `zmx [r]un <name> <cmd>...` which sends a command without attaching, creating session if needed
 8 - Use `XDG_RUNTIME_DIR` environment variable for socket directory (takes precedence over `TMPDIR` and `/tmp`)
 9 
10 ### Changed
M README.md
+9, -2
 1@@ -13,6 +13,8 @@ Reason for this tool: [You might not need `tmux`](https://bower.sh/you-might-not
 2 - Native terminal scrollback
 3 - Multiple clients can connect to the same session
 4 - Re-attaching to a session restores previous terminal state and output
 5+- Send commands to a session without attaching to it
 6+- Print scrollback history of a terminal session in plain text
 7 - Works on mac and linux
 8 - This project does **NOT** provide windows, tabs, or splits
 9 
10@@ -52,7 +54,8 @@ zig build -Doptimize=ReleaseSafe --prefix ~/.local
11 Usage: zmx <command> [args]
12 
13 Commands:
14-  [a]ttach <name> [command...]  Create or attach to a session
15+  [a]ttach <name> [command...]  Attach to session, creating session if needed
16+  [r]un <name> [command...]     Send command without attaching, creating session if needed
17   [d]etach                      Detach all clients from current session  (ctrl+\ for current client)
18   [l]ist                        List active sessions
19   [k]ill <name>                 Kill a session and all attached clients
20@@ -65,9 +68,13 @@ Commands:
21 
22 ```bash
23 zmx attach dev              # start a shell session
24-zmx attach dev nvim .       # start nvim in a persistent session
25+zmx a dev nvim .            # start nvim in a persistent session
26 zmx attach build make -j8   # run a build, reattach to check progress
27 zmx attach mux dvtm         # run a multiplexer inside zmx
28+
29+zmx run dev cat README.md   # run the command without attaching to the session
30+zmx r dev cat CHANGELOG.md  # alias
31+echo "ls -lah" | zmx r dev  # use stdin to run the command
32 ```
33 
34 ## shell prompt
M src/ipc.zig
+2, -0
1@@ -11,6 +11,8 @@ pub const Tag = enum(u8) {
2     Info = 6,
3     Init = 7,
4     History = 8,
5+    Run = 9,
6+    Ack = 10,
7 };
8 
9 pub const Header = packed struct {
M src/main.zig
+159, -9
  1@@ -267,6 +267,16 @@ const Daemon = struct {
  2             client.has_pending_output = true;
  3         }
  4     }
  5+
  6+    pub fn handleRun(self: *Daemon, client: *Client, pty_fd: i32, payload: []const u8) !void {
  7+        if (payload.len > 0) {
  8+            _ = try posix.write(pty_fd, payload);
  9+        }
 10+        try ipc.appendMessage(self.alloc, &client.write_buf, .Ack, "");
 11+        client.has_pending_output = true;
 12+        self.has_had_client = true;
 13+        std.log.debug("run command len={d}", .{payload.len});
 14+    }
 15 };
 16 
 17 pub fn main() !void {
 18@@ -336,6 +346,31 @@ pub fn main() !void {
 19         daemon.socket_path = try getSocketPath(alloc, cfg.socket_dir, session_name);
 20         std.log.info("socket path={s}", .{daemon.socket_path});
 21         return attach(&daemon);
 22+    } else if (std.mem.eql(u8, cmd, "run") or std.mem.eql(u8, cmd, "r")) {
 23+        const session_name = args.next() orelse {
 24+            return error.SessionNameRequired;
 25+        };
 26+
 27+        var command_args: std.ArrayList([]const u8) = .empty;
 28+        defer command_args.deinit(alloc);
 29+        while (args.next()) |arg| {
 30+            try command_args.append(alloc, arg);
 31+        }
 32+
 33+        const clients = try std.ArrayList(*Client).initCapacity(alloc, 10);
 34+        var daemon = Daemon{
 35+            .running = true,
 36+            .cfg = &cfg,
 37+            .alloc = alloc,
 38+            .clients = clients,
 39+            .session_name = session_name,
 40+            .socket_path = undefined,
 41+            .pid = undefined,
 42+            .command = null,
 43+        };
 44+        daemon.socket_path = try getSocketPath(alloc, cfg.socket_dir, session_name);
 45+        std.log.info("socket path={s}", .{daemon.socket_path});
 46+        return run(&daemon, command_args.items);
 47     } else {
 48         return help();
 49     }
 50@@ -355,7 +390,8 @@ fn help() !void {
 51         \\Usage: zmx <command> [args]
 52         \\
 53         \\Commands:
 54-        \\  [a]ttach <name> [command...]  Create or attach to a session
 55+        \\  [a]ttach <name> [command...]  Attach to session, creating session if needed
 56+        \\  [r]un <name> [command...]     Send command without attaching, creating session if needed
 57         \\  [d]etach                      Detach all clients from current session (ctrl+\ for current client)
 58         \\  [l]ist                        List active sessions
 59         \\  [k]ill <name>                 Kill a session and all attached clients
 60@@ -569,11 +605,12 @@ fn history(cfg: *Cfg, session_name: []const u8) !void {
 61     }
 62 }
 63 
 64-fn attach(daemon: *Daemon) !void {
 65-    if (std.posix.getenv("ZMX_SESSION")) |_| {
 66-        return error.CannotAttachToSessionInSession;
 67-    }
 68+const EnsureSessionResult = struct {
 69+    created: bool,
 70+    is_daemon: bool,
 71+};
 72 
 73+fn ensureSession(daemon: *Daemon) !EnsureSessionResult {
 74     var dir = try std.fs.openDirAbsolute(daemon.cfg.socket_dir, .{});
 75     defer dir.close();
 76 
 77@@ -597,7 +634,7 @@ fn attach(daemon: *Daemon) !void {
 78         const server_sock_fd = try createSocket(daemon.socket_path);
 79 
 80         const pid = try posix.fork();
 81-        if (pid == 0) { // child
 82+        if (pid == 0) { // child (daemon)
 83             _ = try posix.setsid();
 84 
 85             log_system.deinit();
 86@@ -621,15 +658,26 @@ fn attach(daemon: *Daemon) !void {
 87                 };
 88             }
 89             try daemonLoop(daemon, server_sock_fd, pty_fd);
 90-            // Reap PTY child to prevent zombie
 91             _ = posix.waitpid(daemon.pid, 0);
 92             daemon.deinit();
 93-            return;
 94+            return .{ .created = true, .is_daemon = true };
 95         }
 96         posix.close(server_sock_fd);
 97         std.Thread.sleep(10 * std.time.ns_per_ms);
 98+        return .{ .created = true, .is_daemon = false };
 99+    }
100+
101+    return .{ .created = false, .is_daemon = false };
102+}
103+
104+fn attach(daemon: *Daemon) !void {
105+    if (std.posix.getenv("ZMX_SESSION")) |_| {
106+        return error.CannotAttachToSessionInSession;
107     }
108 
109+    const result = try ensureSession(daemon);
110+    if (result.is_daemon) return;
111+
112     const client_sock = try sessionConnect(daemon.socket_path);
113     std.log.info("attached session={s}", .{daemon.session_name});
114     //  this is typically used with tcsetattr() to modify terminal settings.
115@@ -677,6 +725,107 @@ fn attach(daemon: *Daemon) !void {
116     try clientLoop(daemon.cfg, client_sock);
117 }
118 
119+fn run(daemon: *Daemon, command_args: [][]const u8) !void {
120+    const alloc = daemon.alloc;
121+    var buf: [4096]u8 = undefined;
122+    var w = std.fs.File.stdout().writer(&buf);
123+
124+    const result = try ensureSession(daemon);
125+    if (result.is_daemon) return;
126+
127+    if (result.created) {
128+        try w.interface.print("session \"{s}\" created\n", .{daemon.session_name});
129+        try w.interface.flush();
130+    }
131+
132+    var cmd_to_send: ?[]const u8 = null;
133+    var allocated_cmd: ?[]u8 = null;
134+    defer if (allocated_cmd) |cmd| alloc.free(cmd);
135+
136+    if (command_args.len > 0) {
137+        var total_len: usize = 0;
138+        for (command_args) |arg| {
139+            total_len += arg.len + 1;
140+        }
141+
142+        const cmd_buf = try alloc.alloc(u8, total_len);
143+        allocated_cmd = cmd_buf;
144+
145+        var offset: usize = 0;
146+        for (command_args, 0..) |arg, i| {
147+            @memcpy(cmd_buf[offset .. offset + arg.len], arg);
148+            offset += arg.len;
149+            if (i < command_args.len - 1) {
150+                cmd_buf[offset] = ' ';
151+            } else {
152+                cmd_buf[offset] = '\n';
153+            }
154+            offset += 1;
155+        }
156+        cmd_to_send = cmd_buf;
157+    } else {
158+        const stdin_fd = posix.STDIN_FILENO;
159+        if (!std.posix.isatty(stdin_fd)) {
160+            var stdin_buf = try std.ArrayList(u8).initCapacity(alloc, 4096);
161+            defer stdin_buf.deinit(alloc);
162+
163+            while (true) {
164+                var tmp: [4096]u8 = undefined;
165+                const n = posix.read(stdin_fd, &tmp) catch |err| {
166+                    if (err == error.WouldBlock) break;
167+                    return err;
168+                };
169+                if (n == 0) break;
170+                try stdin_buf.appendSlice(alloc, tmp[0..n]);
171+            }
172+
173+            if (stdin_buf.items.len > 0) {
174+                const needs_newline = stdin_buf.items[stdin_buf.items.len - 1] != '\n';
175+                if (needs_newline) {
176+                    try stdin_buf.append(alloc, '\n');
177+                }
178+                cmd_to_send = try alloc.dupe(u8, stdin_buf.items);
179+                allocated_cmd = @constCast(cmd_to_send.?);
180+            }
181+        }
182+    }
183+
184+    if (cmd_to_send == null) {
185+        return error.CommandRequired;
186+    }
187+
188+    const probe_result = probeSession(alloc, daemon.socket_path) catch |err| {
189+        std.log.err("session not ready: {s}", .{@errorName(err)});
190+        return error.SessionNotReady;
191+    };
192+    defer posix.close(probe_result.fd);
193+
194+    try ipc.send(probe_result.fd, .Run, cmd_to_send.?);
195+
196+    var poll_fds = [_]posix.pollfd{.{ .fd = probe_result.fd, .events = posix.POLL.IN, .revents = 0 }};
197+    const poll_result = posix.poll(&poll_fds, 5000) catch return error.PollFailed;
198+    if (poll_result == 0) {
199+        std.log.err("timeout waiting for ack", .{});
200+        return error.Timeout;
201+    }
202+
203+    var sb = try ipc.SocketBuffer.init(alloc);
204+    defer sb.deinit();
205+
206+    const n = sb.read(probe_result.fd) catch return error.ReadFailed;
207+    if (n == 0) return error.ConnectionClosed;
208+
209+    while (sb.next()) |msg| {
210+        if (msg.header.tag == .Ack) {
211+            try w.interface.print("command sent\n", .{});
212+            try w.interface.flush();
213+            return;
214+        }
215+    }
216+
217+    return error.NoAckReceived;
218+}
219+
220 fn clientLoop(_: *Cfg, client_sock_fd: i32) !void {
221     // use c_allocator to avoid "reached unreachable code" panic in DebugAllocator when forking
222     const alloc = std.heap.c_allocator;
223@@ -976,7 +1125,8 @@ fn daemonLoop(daemon: *Daemon, server_sock_fd: i32, pty_fd: i32) !void {
224                         },
225                         .Info => try daemon.handleInfo(client),
226                         .History => try daemon.handleHistory(client, &term),
227-                        .Output => {},
228+                        .Run => try daemon.handleRun(client, pty_fd, msg.payload),
229+                        .Output, .Ack => {},
230                     }
231                 }
232             }