repos / zmx

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

commit
d3a13c3
parent
3cdcc40
author
Eric Bower
date
2025-11-26 15:08:07 -0500 EST
feat: provide attach with a command
2 files changed,  +45, -12
M README.md
+10, -2
 1@@ -18,14 +18,22 @@ session persistence for terminal processes
 2 
 3 ## usage
 4 
 5-- `zmx attach {session_name}` - create or attach to a session
 6+- `zmx attach {session_name} [command...]` - create or attach to a session, optionally running a command instead of shell
 7 - `zmx detach [{session_name}]` (or Ctrl+\\) - detach all connected clients to session, can be used inside session without providing name
 8 - `zmx list` - list sessions
 9 - `zmx kill {session_name}` kill pty and all clients attached to session
10 
11+### examples
12+
13+```bash
14+zmx attach dev              # start a shell session
15+zmx attach dev nvim .       # start nvim in a persistent session
16+zmx attach build make -j8   # run a build, reattach to check progress
17+zmx attach mux dvtm         # run a multiplexer inside zmx
18+```
19+
20 ## todo
21 
22-- [ ] Ability to pass a command to attach `zmx attach mux dvtm`
23 - [ ] Integrate with `libghostty` to restore terminal state on re-attach
24 - [ ] Binary distribution (e.g. pkg managers)
25 
M src/main.zig
+35, -10
 1@@ -79,6 +79,7 @@ const Daemon = struct {
 2     socket_path: []const u8,
 3     running: bool,
 4     pid: i32,
 5+    command: ?[]const []const u8 = null,
 6 
 7     pub fn deinit(self: *Daemon) void {
 8         self.clients.deinit(self.alloc);
 9@@ -147,6 +148,13 @@ pub fn main() !void {
10             std.log.err("session name required", .{});
11             return;
12         };
13+
14+        var command_args: std.ArrayList([]const u8) = .empty;
15+        defer command_args.deinit(alloc);
16+        while (args.next()) |arg| {
17+            try command_args.append(alloc, arg);
18+        }
19+
20         const clients = try std.ArrayList(*Client).initCapacity(alloc, 10);
21         var daemon = Daemon{
22             .running = true,
23@@ -156,6 +164,7 @@ pub fn main() !void {
24             .session_name = session_name,
25             .socket_path = undefined,
26             .pid = undefined,
27+            .command = if (command_args.items.len > 0) command_args.items else null,
28         };
29         daemon.socket_path = try getSocketPath(alloc, cfg.socket_dir, session_name);
30         std.log.info("socket path={s}", .{daemon.socket_path});
31@@ -172,11 +181,11 @@ fn help() !void {
32         \\Usage: zmx <command> [args]
33         \\
34         \\Commands:
35-        \\  attach <name>   Create or attach to a session
36-        \\  detach          Detach from current session (or Ctrl+\)
37-        \\  list            List active sessions
38-        \\  kill <name>     Kill a session and all attached clients
39-        \\  help            Show this help message
40+        \\  attach <name> [command...]  Create or attach to a session
41+        \\  detach                      Detach from current session (or Ctrl+\)
42+        \\  list                        List active sessions
43+        \\  kill <name>                 Kill a session and all attached clients
44+        \\  help                        Show this help message
45         \\
46     ;
47     try std.fs.File.stdout().writeAll(help_text);
48@@ -315,6 +324,9 @@ fn attach(daemon: *Daemon) !void {
49         };
50         if (fd != -1) {
51             posix.close(fd);
52+            if (daemon.command != null) {
53+                std.log.warn("session already exists, ignoring command session={s}", .{daemon.session_name});
54+            }
55         }
56     }
57 
58@@ -753,11 +765,24 @@ fn spawnPty(daemon: *Daemon) !c_int {
59         const session_env = try std.fmt.allocPrint(daemon.alloc, "ZMX_SESSION={s}\x00", .{daemon.session_name});
60         _ = c.putenv(@ptrCast(session_env.ptr));
61 
62-        const shell = std.posix.getenv("SHELL") orelse "/bin/sh";
63-        const argv = [_:null]?[*:0]const u8{ shell, null };
64-        const err = std.posix.execveZ(shell, &argv, std.c.environ);
65-        std.log.err("execve failed: err={s}", .{@errorName(err)});
66-        std.posix.exit(1);
67+        if (daemon.command) |cmd_args| {
68+            const cmd = cmd_args[0];
69+            var argv_buf: [64:null]?[*:0]const u8 = undefined;
70+            for (cmd_args, 0..) |arg, i| {
71+                argv_buf[i] = @ptrCast(arg.ptr);
72+            }
73+            argv_buf[cmd_args.len] = null;
74+            const argv: [*:null]const ?[*:0]const u8 = &argv_buf;
75+            const err = std.posix.execvpeZ(@ptrCast(cmd.ptr), argv, std.c.environ);
76+            std.log.err("execvpe failed: cmd={s} err={s}", .{ cmd, @errorName(err) });
77+            std.posix.exit(1);
78+        } else {
79+            const shell = std.posix.getenv("SHELL") orelse "/bin/sh";
80+            const argv = [_:null]?[*:0]const u8{ shell, null };
81+            const err = std.posix.execveZ(shell, &argv, std.c.environ);
82+            std.log.err("execve failed: err={s}", .{@errorName(err)});
83+            std.posix.exit(1);
84+        }
85     }
86     // master pid code path
87     daemon.pid = pid;