repos / zmx

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

commit
fd0874e
parent
15c4a9c
author
Eric Bower
date
2026-02-19 15:32:39 -0500 EST
feat: wait command

This command will wait for all tasks to be completed and return an "aggregate" exit code.
3 files changed,  +122, -44
M README.md
+1, -0
1@@ -66,6 +66,7 @@ Commands:
2   [c]ompletions <shell>          Completion scripts for shell integration (bash, zsh, or fish)
3   [k]ill <name>                  Kill a session and all attached clients
4   [hi]story <name> [--vt|--html] Output session scrollback (--vt or --html for escape sequences)
5+  [w]ait <name>...               Wait for session tasks to complete
6   [v]ersion                      Show version information
7   [h]elp                         Show this help message
8 ```
M src/ipc.zig
+1, -1
1@@ -37,7 +37,7 @@ pub const Info = extern struct {
2     cwd: [MAX_CWD_LEN]u8,
3     created_at: u64,
4     task_ended_at: u64,
5-    task_exit_code: i32,
6+    task_exit_code: u8,
7 };
8 
9 pub fn expectedLength(data: []const u8) ?usize {
M src/main.zig
+120, -43
  1@@ -142,7 +142,7 @@ const Daemon = struct {
  2     has_had_client: bool = false,
  3     created_at: u64, // unix timestamp (ns)
  4     is_task_mode: bool = false, // flag for when session is run as a task
  5-    task_exit_code: ?i32 = null, // null = running or n/a, set when task completes
  6+    task_exit_code: ?u8 = null, // null = running or n/a, set when task completes
  7     task_ended_at: ?u64 = null, // timestamp when task exited
  8     task_command: ?[]const []const u8 = null,
  9 
 10@@ -373,9 +373,7 @@ pub fn main() !void {
 11     } else if (std.mem.eql(u8, cmd, "detach") or std.mem.eql(u8, cmd, "d")) {
 12         return detachAll(&cfg);
 13     } else if (std.mem.eql(u8, cmd, "kill") or std.mem.eql(u8, cmd, "k")) {
 14-        const session_name = args.next() orelse {
 15-            return error.SessionNameRequired;
 16-        };
 17+        const session_name = args.next() orelse "";
 18         const sesh = try getSeshName(alloc, session_name);
 19         defer alloc.free(sesh);
 20         return kill(&cfg, sesh);
 21@@ -391,16 +389,11 @@ pub fn main() !void {
 22                 session_name = arg;
 23             }
 24         }
 25-        if (session_name == null) {
 26-            return error.SessionNameRequired;
 27-        }
 28         const sesh = try getSeshName(alloc, session_name.?);
 29         defer alloc.free(sesh);
 30         return history(&cfg, sesh, format);
 31     } else if (std.mem.eql(u8, cmd, "attach") or std.mem.eql(u8, cmd, "a")) {
 32-        const session_name = args.next() orelse {
 33-            return error.SessionNameRequired;
 34-        };
 35+        const session_name = args.next() orelse "";
 36 
 37         var command_args: std.ArrayList([]const u8) = .empty;
 38         defer command_args.deinit(alloc);
 39@@ -435,9 +428,7 @@ pub fn main() !void {
 40         std.log.info("socket path={s}", .{daemon.socket_path});
 41         return attach(&daemon);
 42     } else if (std.mem.eql(u8, cmd, "run") or std.mem.eql(u8, cmd, "r")) {
 43-        const session_name = args.next() orelse {
 44-            return error.SessionNameRequired;
 45-        };
 46+        const session_name = args.next() orelse "";
 47 
 48         var cmd_args_raw: std.ArrayList([]const u8) = .empty;
 49         defer cmd_args_raw.deinit(alloc);
 50@@ -480,6 +471,19 @@ pub fn main() !void {
 51         daemon.socket_path = try getSocketPath(alloc, cfg.socket_dir, sesh);
 52         std.log.info("socket path={s}", .{daemon.socket_path});
 53         return run(&daemon, cmd_args.items);
 54+    } else if (std.mem.eql(u8, cmd, "wait")) {
 55+        var args_raw: std.ArrayList([]const u8) = .empty;
 56+        defer {
 57+            args_raw.deinit(alloc);
 58+            for (args_raw.items) |sesh| {
 59+                alloc.free(sesh);
 60+            }
 61+        }
 62+        while (args.next()) |session_name| {
 63+            const sesh = try getSeshName(alloc, session_name);
 64+            try args_raw.append(alloc, sesh);
 65+        }
 66+        return wait(&cfg, args_raw);
 67     } else {
 68         return help();
 69     }
 70@@ -514,15 +518,24 @@ fn help() !void {
 71         \\Usage: zmx <command> [args]
 72         \\
 73         \\Commands:
 74-        \\  [a]ttach <name> [command...]  Attach to session, creating session if needed
 75-        \\  [r]un <name> [command...]     Send command without attaching, creating session if needed
 76-        \\  [d]etach                      Detach all clients from current session (ctrl+\ for current client)
 77-        \\  [l]ist [--short]              List active sessions
 78-        \\  [c]ompletions <shell>         Completion scripts for shell integration (bash, zsh, or fish)
 79-        \\  [k]ill <name>                 Kill a session and all attached clients
 80+        \\  [a]ttach <name> [command...]   Attach to session, creating session if needed
 81+        \\  [r]un <name> [command...]      Send command without attaching, creating session if needed
 82+        \\  [d]etach                       Detach all clients from current session (ctrl+\ for current client)
 83+        \\  [l]ist [--short]               List active sessions
 84+        \\  [c]ompletions <shell>          Completion scripts for shell integration (bash, zsh, or fish)
 85+        \\  [k]ill <name>                  Kill a session and all attached clients
 86         \\  [hi]story <name> [--vt|--html] Output session scrollback (--vt or --html for escape sequences)
 87-        \\  [v]ersion                     Show version information
 88-        \\  [h]elp                        Show this help message
 89+        \\  [w]ait <name>...               Wait for session tasks to complete
 90+        \\  [v]ersion                      Show version information
 91+        \\  [h]elp                         Show this help message
 92+        \\
 93+        \\Environment variables:
 94+        \\  - SHELL                Determines which shell is used when creating a session
 95+        \\  - ZMX_DIR              Controls which folder is used to store unix socket files (prio: 1)
 96+        \\  - XDG_RUNTIME_DIR      Controls which folder is used to store unix socket files (prio: 2)
 97+        \\  - TMPDIR               Controls which folder is used to store unix socket files (prio: 3)
 98+        \\  - ZMX_SESSION          This variable is injected into every zmx session automatically
 99+        \\  - ZMX_SESSION_PREFIX   Adds this value to the start of every session name for all commands
100         \\
101     ;
102     var buf: [4096]u8 = undefined;
103@@ -541,41 +554,80 @@ const SessionEntry = struct {
104     cwd: ?[]const u8 = null,
105     created_at: u64,
106     task_ended_at: ?u64,
107-    task_exit_code: ?i32,
108+    task_exit_code: ?u8,
109 
110     fn lessThan(_: void, a: SessionEntry, b: SessionEntry) bool {
111         return std.mem.order(u8, a.name, b.name) == .lt;
112     }
113 };
114 
115-const current_arrow = "→";
116-
117-fn list(cfg: *Cfg, short: bool) !void {
118+fn wait(cfg: *Cfg, session_names: std.ArrayList([]const u8)) !void {
119     var gpa = std.heap.GeneralPurposeAllocator(.{}){};
120     defer _ = gpa.deinit();
121     const alloc = gpa.allocator();
122 
123-    const current_session = std.process.getEnvVarOwned(alloc, "ZMX_SESSION") catch |err| switch (err) {
124-        error.EnvironmentVariableNotFound => null,
125-        else => return err,
126-    };
127-    defer if (current_session) |name| alloc.free(name);
128+    var stdout_buffer: [1024]u8 = undefined;
129+    var stdout_writer = std.fs.File.stdout().writer(&stdout_buffer);
130+    const stdout = &stdout_writer.interface;
131 
132-    var dir = try std.fs.openDirAbsolute(cfg.socket_dir, .{ .iterate = true });
133-    defer dir.close();
134-    var iter = dir.iterate();
135-    var buf: [4096]u8 = undefined;
136-    var w = std.fs.File.stdout().writer(&buf);
137+    while (true) {
138+        var sessions = try get_session_entries(alloc, cfg);
139+        var total: i32 = 0;
140+        var done: i32 = 0;
141+        var agg_exit_code: u8 = 0;
142 
143-    var sessions = try std.ArrayList(SessionEntry).initCapacity(alloc, 16);
144-    defer {
145         for (sessions.items) |session| {
146-            alloc.free(session.name);
147-            if (session.cmd) |cmd| alloc.free(cmd);
148-            if (session.cwd) |cwd| alloc.free(cwd);
149+            var found = false;
150+            for (session_names.items) |prefix| {
151+                if (std.mem.startsWith(u8, session.name, prefix)) {
152+                    found = true;
153+                    break;
154+                }
155+            }
156+            if (!found) {
157+                continue;
158+            }
159+
160+            total += 1;
161+            if (session.task_ended_at == 0) {
162+                try stdout.print("still waiting task={s}\n", .{session.name});
163+                try stdout.flush();
164+                continue;
165+            }
166+            if (session.task_exit_code != 0) {
167+                agg_exit_code = session.task_exit_code orelse 0;
168+            }
169+            done += 1;
170         }
171-        sessions.deinit(alloc);
172+
173+        session_entries_deinit(alloc, &sessions);
174+
175+        if (total == done) {
176+            try stdout.print("tasks completed!\n", .{});
177+            try stdout.flush();
178+            std.process.exit(agg_exit_code);
179+            return;
180+        }
181+
182+        std.Thread.sleep(1000 * std.time.ns_per_ms);
183+    }
184+}
185+
186+fn session_entries_deinit(alloc: std.mem.Allocator, sessions: *std.ArrayList(SessionEntry)) void {
187+    for (sessions.items) |session| {
188+        alloc.free(session.name);
189+        if (session.cmd) |cmd| alloc.free(cmd);
190+        if (session.cwd) |cwd| alloc.free(cwd);
191     }
192+    sessions.deinit(alloc);
193+}
194+
195+fn get_session_entries(alloc: std.mem.Allocator, cfg: *Cfg) !std.ArrayList(SessionEntry) {
196+    var dir = try std.fs.openDirAbsolute(cfg.socket_dir, .{ .iterate = true });
197+    defer dir.close();
198+    var iter = dir.iterate();
199+
200+    var sessions = try std.ArrayList(SessionEntry).initCapacity(alloc, 30);
201 
202     while (try iter.next()) |entry| {
203         const exists = sessionExists(dir, entry.name) catch continue;
204@@ -627,6 +679,27 @@ fn list(cfg: *Cfg, short: bool) !void {
205         }
206     }
207 
208+    return sessions;
209+}
210+
211+const current_arrow = "→";
212+
213+fn list(cfg: *Cfg, short: bool) !void {
214+    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
215+    defer _ = gpa.deinit();
216+    const alloc = gpa.allocator();
217+
218+    const current_session = std.process.getEnvVarOwned(alloc, "ZMX_SESSION") catch |err| switch (err) {
219+        error.EnvironmentVariableNotFound => null,
220+        else => return err,
221+    };
222+    defer if (current_session) |name| alloc.free(name);
223+    var buf: [4096]u8 = undefined;
224+    var w = std.fs.File.stdout().writer(&buf);
225+
226+    var sessions = try get_session_entries(alloc, cfg);
227+    defer session_entries_deinit(alloc, &sessions);
228+
229     if (sessions.items.len == 0) {
230         if (short) return;
231         try w.interface.print("no sessions found in {s}\n", .{cfg.socket_dir});
232@@ -1146,7 +1219,7 @@ fn clientLoop(_: *Cfg, client_sock_fd: i32) !void {
233     }
234 }
235 
236-fn findTaskExitMarker(output: []const u8) ?i32 {
237+fn findTaskExitMarker(output: []const u8) ?u8 {
238     const marker = "ZMX_TASK_COMPLETED:";
239 
240     // Search for marker in output
241@@ -1162,7 +1235,7 @@ fn findTaskExitMarker(output: []const u8) ?i32 {
242         const exit_code_str = after_marker[0..end_idx];
243 
244         // Parse exit code
245-        if (std.fmt.parseInt(i32, exit_code_str, 10)) |exit_code| {
246+        if (std.fmt.parseInt(u8, exit_code_str, 10)) |exit_code| {
247             return exit_code;
248         } else |_| {
249             std.log.warn("failed to parse task exit code from: {s}", .{exit_code_str});
250@@ -1427,6 +1500,10 @@ fn seshPrefix() []const u8 {
251 }
252 
253 fn getSeshName(alloc: std.mem.Allocator, sesh: []const u8) ![]const u8 {
254+    const prefix = seshPrefix();
255+    if (std.mem.eql(u8, prefix, "") and std.mem.eql(u8, sesh, "")) {
256+        return error.SessionNameRequired;
257+    }
258     return std.fmt.allocPrint(alloc, "{s}{s}", .{ seshPrefix(), sesh });
259 }
260