repos / zmx

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

commit
fbcec11
parent
969ef90
author
Eric Bower
date
2026-02-08 21:26:56 -0500 EST
feat(run): track task completion with a marker and record exit code
2 files changed,  +101, -9
M src/ipc.zig
+3, -0
 1@@ -35,6 +35,9 @@ pub const Info = extern struct {
 2     cwd_len: u16,
 3     cmd: [MAX_CMD_LEN]u8,
 4     cwd: [MAX_CWD_LEN]u8,
 5+    created_at: u64,
 6+    task_ended_at: u64,
 7+    task_exit_code: i32,
 8 };
 9 
10 pub fn expectedLength(data: []const u8) ?usize {
M src/main.zig
+98, -9
  1@@ -121,6 +121,13 @@ const Cfg = struct {
  2     }
  3 };
  4 
  5+const SessionMetadata = struct {
  6+    created_at: u64, // unix timestamp (ns) - all sessions
  7+    task_exit_code: ?i32 = null, // null = running, set when task completes
  8+    task_end_time: ?u64 = null, // timestamp when task exited
  9+    task_command: []const u8 = "", // original task command string
 10+};
 11+
 12 const Daemon = struct {
 13     cfg: *Cfg,
 14     alloc: std.mem.Allocator,
 15@@ -133,6 +140,11 @@ const Daemon = struct {
 16     cwd: []const u8 = "",
 17     has_pty_output: bool = false,
 18     has_had_client: bool = false,
 19+    created_at: u64, // unix timestamp (ns)
 20+    is_task_mode: bool = false, // flag for when session is run as a task
 21+    task_exit_code: ?i32 = null, // null = running or n/a, set when task completes
 22+    task_ended_at: ?u64 = null, // timestamp when task exited
 23+    task_command: ?[]const []const u8 = null,
 24 
 25     pub fn deinit(self: *Daemon) void {
 26         self.clients.deinit(self.alloc);
 27@@ -265,7 +277,8 @@ const Daemon = struct {
 28         // Build command string from args
 29         var cmd_buf: [ipc.MAX_CMD_LEN]u8 = undefined;
 30         var cmd_len: u16 = 0;
 31-        if (self.command) |args| {
 32+        const cur_cmd = self.command orelse self.task_command;
 33+        if (cur_cmd) |args| {
 34             for (args, 0..) |arg, i| {
 35                 if (i > 0) {
 36                     if (cmd_len < ipc.MAX_CMD_LEN) {
 37@@ -292,6 +305,9 @@ const Daemon = struct {
 38             .cwd_len = cwd_len,
 39             .cmd = cmd_buf,
 40             .cwd = cwd_buf,
 41+            .created_at = self.created_at,
 42+            .task_ended_at = self.task_ended_at orelse 0,
 43+            .task_exit_code = self.task_exit_code orelse 0,
 44         };
 45         try ipc.appendMessage(self.alloc, &client.write_buf, .Info, std.mem.asBytes(&info));
 46         client.has_pending_output = true;
 47@@ -407,6 +423,7 @@ pub fn main() !void {
 48             .pid = undefined,
 49             .command = command,
 50             .cwd = cwd,
 51+            .created_at = @intCast(std.time.nanoTimestamp()),
 52         };
 53         daemon.socket_path = try getSocketPath(alloc, cfg.socket_dir, session_name);
 54         std.log.info("socket path={s}", .{daemon.socket_path});
 55@@ -416,12 +433,23 @@ pub fn main() !void {
 56             return error.SessionNameRequired;
 57         };
 58 
 59-        var command_args: std.ArrayList([]const u8) = .empty;
 60-        defer command_args.deinit(alloc);
 61+        var cmd_args_raw: std.ArrayList([]const u8) = .empty;
 62+        defer cmd_args_raw.deinit(alloc);
 63         while (args.next()) |arg| {
 64-            try command_args.append(alloc, arg);
 65+            try cmd_args_raw.append(alloc, arg);
 66+        }
 67+        var cmd_args = try cmd_args_raw.clone(alloc);
 68+        defer cmd_args.deinit(alloc);
 69+
 70+        const shell = detectShell();
 71+        // add a task completed marker so we know when the cmd is finished
 72+        // we also capture the exit status
 73+        if (std.mem.eql(u8, std.fs.path.basename(shell), "fish")) {
 74+            // fish has special handling for capturing exit status
 75+            try cmd_args.append(alloc, "; echo ZMX_TASK_COMPLETED:$status");
 76+        } else {
 77+            try cmd_args.append(alloc, "; echo ZMX_TASK_COMPLETED:$?");
 78         }
 79-
 80         const clients = try std.ArrayList(*Client).initCapacity(alloc, 10);
 81 
 82         var cwd_buf: [std.fs.max_path_bytes]u8 = undefined;
 83@@ -437,10 +465,13 @@ pub fn main() !void {
 84             .pid = undefined,
 85             .command = null,
 86             .cwd = cwd,
 87+            .created_at = @intCast(std.time.nanoTimestamp()),
 88+            .is_task_mode = true,
 89+            .task_command = cmd_args_raw.items,
 90         };
 91         daemon.socket_path = try getSocketPath(alloc, cfg.socket_dir, session_name);
 92         std.log.info("socket path={s}", .{daemon.socket_path});
 93-        return run(&daemon, command_args.items);
 94+        return run(&daemon, cmd_args.items);
 95     } else {
 96         return help();
 97     }
 98@@ -500,6 +531,9 @@ const SessionEntry = struct {
 99     error_name: ?[]const u8,
100     cmd: ?[]const u8 = null,
101     cwd: ?[]const u8 = null,
102+    created_at: u64,
103+    task_ended_at: ?u64,
104+    task_exit_code: ?i32,
105 
106     fn lessThan(_: void, a: SessionEntry, b: SessionEntry) bool {
107         return std.mem.order(u8, a.name, b.name) == .lt;
108@@ -551,8 +585,11 @@ fn list(cfg: *Cfg, short: bool) !void {
109                     .clients_len = null,
110                     .is_error = true,
111                     .error_name = @errorName(err),
112+                    .created_at = 0,
113+                    .task_exit_code = 1,
114+                    .task_ended_at = 0,
115                 });
116-                cleanupStaleSocket(dir, entry.name);
117+                // cleanupStaleSocket(dir, entry.name);
118                 continue;
119             };
120             posix.close(result.fd);
121@@ -575,6 +612,9 @@ fn list(cfg: *Cfg, short: bool) !void {
122                 .error_name = null,
123                 .cmd = cmd,
124                 .cwd = cwd,
125+                .created_at = result.info.created_at,
126+                .task_ended_at = result.info.task_ended_at,
127+                .task_exit_code = result.info.task_exit_code,
128             });
129         }
130     }
131@@ -1098,6 +1138,33 @@ fn clientLoop(_: *Cfg, client_sock_fd: i32) !void {
132     }
133 }
134 
135+fn findTaskExitMarker(output: []const u8) ?i32 {
136+    const marker = "ZMX_TASK_COMPLETED:";
137+
138+    // Search for marker in output
139+    if (std.mem.indexOf(u8, output, marker)) |idx| {
140+        const after_marker = output[idx + marker.len ..];
141+
142+        // Find the exit code number and newline
143+        var end_idx: usize = 0;
144+        while (end_idx < after_marker.len and after_marker[end_idx] != '\n' and after_marker[end_idx] != '\r') {
145+            end_idx += 1;
146+        }
147+
148+        const exit_code_str = after_marker[0..end_idx];
149+
150+        // Parse exit code
151+        if (std.fmt.parseInt(i32, exit_code_str, 10)) |exit_code| {
152+            return exit_code;
153+        } else |_| {
154+            std.log.warn("failed to parse task exit code from: {s}", .{exit_code_str});
155+            return null;
156+        }
157+    }
158+
159+    return null;
160+}
161+
162 fn daemonLoop(daemon: *Daemon, server_sock_fd: i32, pty_fd: i32) !void {
163     std.log.info("daemon started session={s} pty_fd={d}", .{ daemon.session_name, pty_fd });
164     setupSigtermHandler();
165@@ -1185,6 +1252,17 @@ fn daemonLoop(daemon: *Daemon, server_sock_fd: i32, pty_fd: i32) !void {
166                     try vt_stream.nextSlice(buf[0..n]);
167                     daemon.has_pty_output = true;
168 
169+                    // In run mode, scan output for exit code marker
170+                    if (daemon.is_task_mode and daemon.task_exit_code == null) {
171+                        if (findTaskExitMarker(buf[0..n])) |exit_code| {
172+                            daemon.task_exit_code = exit_code;
173+                            daemon.task_ended_at = @intCast(std.time.nanoTimestamp());
174+
175+                            std.log.info("task completed exit_code={d}", .{exit_code});
176+                            // Shell continues running - no break here
177+                        }
178+                    }
179+
180                     // Broadcast data to all clients
181                     for (daemon.clients.items) |client| {
182                         ipc.appendMessage(daemon.alloc, &client.write_buf, .Output, buf[0..n]) catch |err| {
183@@ -1312,7 +1390,7 @@ fn spawnPty(daemon: *Daemon) !c_int {
184             std.log.err("execvpe failed: cmd={s} err={s}", .{ cmd_args[0], @errorName(err) });
185             std.posix.exit(1);
186         } else {
187-            const shell = std.posix.getenv("SHELL") orelse "/bin/sh";
188+            const shell = detectShell();
189             // Use "-shellname" as argv[0] to signal login shell (traditional method)
190             var buf: [64]u8 = undefined;
191             const login_shell = try std.fmt.bufPrintZ(&buf, "-{s}", .{std.fs.path.basename(shell)});
192@@ -1332,6 +1410,10 @@ fn spawnPty(daemon: *Daemon) !c_int {
193     return master_fd;
194 }
195 
196+fn detectShell() [:0]const u8 {
197+    return std.posix.getenv("SHELL") orelse "/bin/sh";
198+}
199+
200 fn sessionConnect(fname: []const u8) !i32 {
201     var unix_addr = try std.net.Address.initUnix(fname);
202     const socket_fd = try posix.socket(posix.AF.UNIX, posix.SOCK.STREAM | posix.SOCK.CLOEXEC, 0);
203@@ -1483,12 +1565,19 @@ fn writeSessionLine(writer: *std.Io.Writer, session: SessionEntry, short: bool,
204         return;
205     }
206 
207-    try writer.print("{s}session_name={s}\tpid={d}\tclients={d}", .{
208+    try writer.print("{s}session_name={s}\tpid={d}\tclients={d}\tcreated_at={d}", .{
209         prefix,
210         session.name,
211         session.pid.?,
212         session.clients_len.?,
213+        session.created_at,
214     });
215+    if (session.task_ended_at) |ended_at| {
216+        try writer.print("\ttask_ended_at={d}", .{ended_at});
217+    }
218+    if (session.task_exit_code) |exit_code| {
219+        try writer.print("\ttask_exit_code={d}", .{exit_code});
220+    }
221     if (session.cwd) |cwd| {
222         try writer.print("\tstarted_in={s}", .{cwd});
223     }