- 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
+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
+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
+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 {
+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 }