Commit e719274
Eric Bower
·
2026-04-10 15:12:34 -0400 EDT
parent bbbe245
feat: tail, write, and run -d I'm calling this the ai portal integration. These features allow a local code agent to be fully operational against a zmx session that is remote. Everything works by sending key strokes directly into the zmx session. This means it doesn't matter where the remote session lives it should work as if you were typing the commands yourself. SSH'd into a server? Inside a container? It's all the same to zmx and the code agent. BREAKING CHANGE: `zmx run` is now synchronous by default *and* immediately tails the session output so the agent can get immediate feedback. To use the previous run behavior where the run command completes immediately use detached mode: `zmx run -d`
5 files changed,
+490,
-159
+5,
-0
| ... | ... | @@ -11,9 +11,14 @@ Use spec: https://common-changelog.org/ | |
| 11 | 11 | - Non-leader clients are read-only until they send user input bytes and takeover leadership | |
| 12 | 12 | - When a leader is promoted we immediately resize to their window size | |
| 13 | 13 | - `zmx attach` now lets users switch to another session from within a session | |
| 14 | + | - `zmx tail` will receive all outout from sessions in read-only mode | |
| 15 | + | - `zmx write` will pipe data from stdin, convert to base64, chunk, and send data through pty to write to a file | |
| 14 | 16 | ||
| 15 | 17 | ### Changed | |
| 16 | 18 | ||
| 19 | + | - *BREAKING* `zmx run` is now synchonous by default and tails the session | |
| 20 | + | - Use detached mode (`-d`) for previous behavior | |
| 21 | + | - `zmx run` accepts `--fish` flag to indicate the session's shell is fish | |
| 17 | 22 | - `zmx kill` now supports multiple args and it will kill sessions that match a prefix | |
| 18 | 23 | - e.g. `zmx kill d.` will kill all sessions that match that prefix | |
| 19 | 24 |
+12,
-10
| ... | ... | @@ -71,16 +71,18 @@ zig build -Doptimize=ReleaseSafe --prefix ~/.local | |
| 71 | 71 | Usage: zmx <command> [args] | |
| 72 | 72 | ||
| 73 | 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 | - | [k]ill <name>... [--force] Kill a session and all attached clients | |
| 79 | - | [hi]story <name> [--vt|--html] Output session scrollback (--vt or --html for escape sequences) | |
| 80 | - | [w]ait <name>... Wait for session tasks to complete | |
| 81 | - | [c]ompletions <shell> Completion scripts for shell integration (bash, zsh, or fish) | |
| 82 | - | [v]ersion Show version information | |
| 83 | - | [h]elp Show this help message | |
| 74 | + | [a]ttach <name> [command...] Attach to session, creating if needed | |
| 75 | + | [r]un <name> [-d] [--fish] [command...] Send command without attaching | |
| 76 | + | [wr]ite <name> <file_path> Write stdin to file_path through the session | |
| 77 | + | [d]etach Detach all clients (ctrl+\ for current client) | |
| 78 | + | [l]ist [--short] List active sessions | |
| 79 | + | [k]ill <name>... [--force] Kill session and all attached clients | |
| 80 | + | [hi]story <name> [--vt|--html] Output session scrollback | |
| 81 | + | [w]ait <name>... Wait for session tasks to complete | |
| 82 | + | [t]ail <name>... Follow session output | |
| 83 | + | [c]ompletions <shell> Shell completions (bash, zsh, fish) | |
| 84 | + | [v]ersion Show version | |
| 85 | + | [h]elp Show this help | |
| 84 | 86 | ``` | |
| 85 | 87 | ||
| 86 | 88 | ### examples |
D
SKILL.md
+0,
-74
| ... | ... | @@ -1,74 +0,0 @@ | |
| 1 | - | --- | |
| 2 | - | name: zmx-session | |
| 3 | - | description: This skill provides instructions for collaborative terminal debugging using zmx for session persistence. Use when the user wants to share a terminal session, debug server logs, troubleshoot infrastructure, or work together on a remote host via SSH. Triggers on mentions of "zmx", "shared session", "terminal debugging", or when user wants Claude to see terminal output. | |
| 4 | - | --- | |
| 5 | - | ||
| 6 | - | # zmx Collaborative Terminal Sessions | |
| 7 | - | ||
| 8 | - | ## Overview | |
| 9 | - | ||
| 10 | - | zmx is a lightweight terminal session persistence tool. It allows detaching from and reattaching to running shell sessions without killing processes. Unlike tmux, it focuses only on session persistence -- no windows, panes, or splits. | |
| 11 | - | ||
| 12 | - | This skill covers using zmx for collaborative debugging where Claude can directly view session history and execute commands. | |
| 13 | - | ||
| 14 | - | Run `zmx help` to understand the commands and when to run them. | |
| 15 | - | ||
| 16 | - | ## Session Setup | |
| 17 | - | ||
| 18 | - | The user starts a zmx session and works within it: | |
| 19 | - | ||
| 20 | - | ```bash | |
| 21 | - | # Create or attach to a named session | |
| 22 | - | zmx attach <session-name> | |
| 23 | - | ``` | |
| 24 | - | ||
| 25 | - | Naming convention suggestion: use descriptive names like `debug-prod`, `k8s-issue`, `logs-api`. | |
| 26 | - | ||
| 27 | - | ## Viewing Session Context | |
| 28 | - | ||
| 29 | - | Claude can directly view the terminal history without user intervention: | |
| 30 | - | ||
| 31 | - | ```bash | |
| 32 | - | # List active sessions | |
| 33 | - | zmx list | |
| 34 | - | ||
| 35 | - | # View recent scrollback from a session (always pipe to tail to limit context) | |
| 36 | - | zmx history <session-name> | tail -200 | |
| 37 | - | ``` | |
| 38 | - | ||
| 39 | - | These are read-only commands—run them freely to understand what's happening. | |
| 40 | - | ||
| 41 | - | If `zmx list` shows no sessions or the expected session is missing, inform the user and ask them to start or verify their zmx session. | |
| 42 | - | ||
| 43 | - | ## Command Execution Protocol | |
| 44 | - | ||
| 45 | - | **Always ask permission before running commands that execute in the user's session.** | |
| 46 | - | ||
| 47 | - | To execute a command in a running session without attaching: | |
| 48 | - | ||
| 49 | - | ```bash | |
| 50 | - | zmx run <session-name> <command> | |
| 51 | - | ``` | |
| 52 | - | ||
| 53 | - | Then you can wait for the task to complete by running: | |
| 54 | - | ||
| 55 | - | ```bash | |
| 56 | - | zmx wait <session-name> | |
| 57 | - | ``` | |
| 58 | - | ||
| 59 | - | And you can track the exit code by running: | |
| 60 | - | ||
| 61 | - | ```bash | |
| 62 | - | zmx list | grep <session-name> | |
| 63 | - | ``` | |
| 64 | - | ||
| 65 | - | Example workflow: | |
| 66 | - | ||
| 67 | - | 1. User tells Claude the session name and describes the issue | |
| 68 | - | 1. Claude runs `zmx history <session-name> | tail -200` to see context | |
| 69 | - | 1. Claude analyzes and proposes a command | |
| 70 | - | 1. User approves | |
| 71 | - | 1. Claude runs via `zmx run <session-name> <command>` | |
| 72 | - | 1. Claude runs `zmx history <session-name> | tail -50` to see the output (zmx run does not return output directly -- it goes to the session's scrollback) | |
| 73 | - | 1. Claude evaluates the output and provides analysis | |
| 74 | - | 1. Repeat steps 3-7 as needed until the issue is resolved |
+2,
-0
| ... | ... | @@ -16,6 +16,8 @@ pub const Tag = enum(u8) { | |
| 16 | 16 | Run = 9, | |
| 17 | 17 | Ack = 10, | |
| 18 | 18 | Switch = 11, | |
| 19 | + | Write = 12, | |
| 20 | + | TaskComplete = 13, | |
| 19 | 21 | // Non-exhaustive: this enum comes off the wire via bytesToValue and | |
| 20 | 22 | // @enumFromInt, so out-of-range values (11-255) are representable | |
| 21 | 23 | // rather than UB. Switches must handle `_` (unknown tag). |
+471,
-75
| ... | ... | @@ -135,7 +135,22 @@ pub fn main() !void { | |
| 135 | 135 | ||
| 136 | 136 | var cmd_args_raw: std.ArrayList([]const u8) = .empty; | |
| 137 | 137 | defer cmd_args_raw.deinit(alloc); | |
| 138 | + | const shell = util.detectShell(); | |
| 139 | + | var shell_basename = std.fs.path.basename(shell); | |
| 140 | + | var detached = false; | |
| 138 | 141 | while (args.next()) |arg| { | |
| 142 | + | // TODO: detect shell within the session instead of asking the user to tell us | |
| 143 | + | // if the shell is fish. | |
| 144 | + | // Because fish tracks exit code status via $status instead of $? we need some | |
| 145 | + | // way to figure out what shell is being used inside the session. | |
| 146 | + | if (std.mem.startsWith(u8, arg, "--fish")) { | |
| 147 | + | shell_basename = "fish"; | |
| 148 | + | continue; | |
| 149 | + | } | |
| 150 | + | if (std.mem.startsWith(u8, arg, "-d")) { | |
| 151 | + | detached = true; | |
| 152 | + | continue; | |
| 153 | + | } | |
| 139 | 154 | try cmd_args_raw.append(alloc, arg); | |
| 140 | 155 | } | |
| 141 | 156 | const clients = try std.ArrayList(*Client).initCapacity(alloc, 10); |
| ... | ... | @@ -157,14 +172,14 @@ pub fn main() !void { | |
| 157 | 172 | .cwd = cwd, | |
| 158 | 173 | .created_at = @intCast(std.time.timestamp()), | |
| 159 | 174 | .is_task_mode = true, | |
| 160 | - | .leader_client_fd = undefined, | |
| 175 | + | .leader_client_fd = null, | |
| 161 | 176 | }; | |
| 162 | 177 | daemon.socket_path = socket.getSocketPath(alloc, cfg.socket_dir, sesh) catch |err| switch (err) { | |
| 163 | 178 | error.NameTooLong => return socket.printSessionNameTooLong(sesh, cfg.socket_dir), | |
| 164 | 179 | error.OutOfMemory => return err, | |
| 165 | 180 | }; | |
| 166 | 181 | std.log.info("socket path={s}", .{daemon.socket_path}); | |
| 167 | - | return run(&daemon, cmd_args_raw.items); | |
| 182 | + | return run(&daemon, detached, shell_basename, cmd_args_raw.items); | |
| 168 | 183 | } else if (std.mem.eql(u8, cmd, "kill") or std.mem.eql(u8, cmd, "k")) { | |
| 169 | 184 | var stderr_buffer: [1024]u8 = undefined; | |
| 170 | 185 | var stderr_writer = std.fs.File.stderr().writer(&stderr_buffer); |
| ... | ... | @@ -240,6 +255,76 @@ pub fn main() !void { | |
| 240 | 255 | try args_raw.append(alloc, prefix); | |
| 241 | 256 | } | |
| 242 | 257 | return wait(&cfg, args_raw); | |
| 258 | + | } else if (std.mem.eql(u8, cmd, "tail") or std.mem.eql(u8, cmd, "t")) { | |
| 259 | + | var session_names: std.ArrayList([]const u8) = .empty; | |
| 260 | + | defer { | |
| 261 | + | for (session_names.items) |sesh| { | |
| 262 | + | alloc.free(sesh); | |
| 263 | + | } | |
| 264 | + | session_names.deinit(alloc); | |
| 265 | + | } | |
| 266 | + | while (args.next()) |session_name| { | |
| 267 | + | const sesh = try socket.getSeshName(alloc, session_name); | |
| 268 | + | try session_names.append(alloc, sesh); | |
| 269 | + | } | |
| 270 | + | // if no args are provided we assume they want to wait for all sessions matching the | |
| 271 | + | // prefix. | |
| 272 | + | if (session_names.items.len == 0) { | |
| 273 | + | const prefix = socket.getSeshPrefix(); | |
| 274 | + | if (prefix.len == 0) { | |
| 275 | + | return error.SessionNameRequired; | |
| 276 | + | } | |
| 277 | + | try session_names.append(alloc, prefix); | |
| 278 | + | } | |
| 279 | + | ||
| 280 | + | var client_socket_fds = try std.ArrayList(i32).initCapacity(alloc, session_names.items.len); | |
| 281 | + | defer { | |
| 282 | + | for (client_socket_fds.items) |client_fd| { | |
| 283 | + | posix.close(client_fd); | |
| 284 | + | } | |
| 285 | + | client_socket_fds.deinit(alloc); | |
| 286 | + | } | |
| 287 | + | ||
| 288 | + | for (session_names.items) |session_name| { | |
| 289 | + | const socket_path = socket.getSocketPath(alloc, cfg.socket_dir, session_name) catch |err| switch (err) { | |
| 290 | + | error.NameTooLong => return socket.printSessionNameTooLong(session_name, cfg.socket_dir), | |
| 291 | + | error.OutOfMemory => return err, | |
| 292 | + | }; | |
| 293 | + | const client_sock = try socket.sessionConnect(socket_path); | |
| 294 | + | try client_socket_fds.append(alloc, client_sock); | |
| 295 | + | } | |
| 296 | + | _ = try tail(client_socket_fds, false, false); | |
| 297 | + | } else if (std.mem.eql(u8, cmd, "write") or std.mem.eql(u8, cmd, "wr")) { | |
| 298 | + | const session_name = args.next() orelse ""; | |
| 299 | + | if (session_name.len == 0) return error.SessionNameRequired; | |
| 300 | + | const file_path = args.next() orelse ""; | |
| 301 | + | if (file_path.len == 0) return error.FilePathRequired; | |
| 302 | + | ||
| 303 | + | var cwd_buf: [std.fs.max_path_bytes]u8 = undefined; | |
| 304 | + | const cwd = std.posix.getcwd(&cwd_buf) catch ""; | |
| 305 | + | const clients = try std.ArrayList(*Client).initCapacity(alloc, 10); | |
| 306 | + | const sesh = try socket.getSeshName(alloc, session_name); | |
| 307 | + | defer alloc.free(sesh); | |
| 308 | + | var daemon = Daemon{ | |
| 309 | + | .running = true, | |
| 310 | + | .cfg = &cfg, | |
| 311 | + | .alloc = alloc, | |
| 312 | + | .clients = clients, | |
| 313 | + | .session_name = sesh, | |
| 314 | + | .socket_path = undefined, | |
| 315 | + | .pid = undefined, | |
| 316 | + | .command = null, | |
| 317 | + | .cwd = cwd, | |
| 318 | + | .created_at = @intCast(std.time.timestamp()), | |
| 319 | + | .is_task_mode = true, | |
| 320 | + | .leader_client_fd = null, | |
| 321 | + | }; | |
| 322 | + | daemon.socket_path = socket.getSocketPath(alloc, cfg.socket_dir, sesh) catch |err| switch (err) { | |
| 323 | + | error.NameTooLong => return socket.printSessionNameTooLong(sesh, cfg.socket_dir), | |
| 324 | + | error.OutOfMemory => return err, | |
| 325 | + | }; | |
| 326 | + | std.log.info("socket path={s}", .{daemon.socket_path}); | |
| 327 | + | try writeFile(&daemon, file_path); | |
| 243 | 328 | } else { | |
| 244 | 329 | return help(); | |
| 245 | 330 | } |
| ... | ... | @@ -392,6 +477,7 @@ const Daemon = struct { | |
| 392 | 477 | is_task_mode: bool = false, // flag for when session is run as a task | |
| 393 | 478 | task_exit_code: ?u8 = null, // null = running or n/a, set when task completes | |
| 394 | 479 | task_ended_at: ?u64 = null, // timestamp when task exited | |
| 480 | + | is_fish: bool = false, // true if session shell is fish (affects exit code variable) | |
| 395 | 481 | pty_write_buf: std.ArrayList(u8) = .empty, | |
| 396 | 482 | ||
| 397 | 483 | const EnsureSessionResult = struct { |
| ... | ... | @@ -896,12 +982,86 @@ const Daemon = struct { | |
| 896 | 982 | self.task_ended_at = null; | |
| 897 | 983 | self.is_task_mode = true; | |
| 898 | 984 | ||
| 899 | - | self.queuePtyInput(payload); | |
| 985 | + | if (payload.len == 0) return; | |
| 986 | + | ||
| 987 | + | // First byte indicates shell type (0=bash/zsh, 1=fish) | |
| 988 | + | self.is_fish = payload[0] == 1; | |
| 989 | + | const cmd = payload[1..]; | |
| 990 | + | ||
| 991 | + | // Daemon appends the task marker so the client never injects | |
| 992 | + | // shell-specific syntax, keeping Ctrl-C recovery clean. | |
| 993 | + | const marker = if (self.is_fish) | |
| 994 | + | "; echo ZMX_TASK_COMPLETED:$status" | |
| 995 | + | else | |
| 996 | + | "; echo ZMX_TASK_COMPLETED:$?"; | |
| 997 | + | ||
| 998 | + | if (cmd.len > 0 and cmd[cmd.len - 1] == '\r') { | |
| 999 | + | self.queuePtyInput(cmd[0 .. cmd.len - 1]); | |
| 1000 | + | } else { | |
| 1001 | + | self.queuePtyInput(cmd); | |
| 1002 | + | } | |
| 1003 | + | self.queuePtyInput(marker); | |
| 1004 | + | self.queuePtyInput("\r"); | |
| 1005 | + | ||
| 900 | 1006 | try ipc.appendMessage(self.alloc, &client.write_buf, .Ack, ""); | |
| 901 | 1007 | client.has_pending_output = true; | |
| 902 | 1008 | self.has_had_client = true; | |
| 903 | 1009 | std.log.debug("run command len={d}", .{payload.len}); | |
| 904 | 1010 | } | |
| 1011 | + | ||
| 1012 | + | pub fn handleWrite(self: *Daemon, client: *Client, payload: []const u8) !void { | |
| 1013 | + | // Wire format: [u32 path len][path bytes][file content] | |
| 1014 | + | if (payload.len < @sizeOf(u32)) return error.InvalidPayload; | |
| 1015 | + | const path_len = std.mem.bytesToValue(u32, payload[0..@sizeOf(u32)]); | |
| 1016 | + | if (payload.len < @sizeOf(u32) + path_len) return error.InvalidPayload; | |
| 1017 | + | const file_path = payload[@sizeOf(u32)..][0..path_len]; | |
| 1018 | + | const file_content = payload[@sizeOf(u32) + path_len ..]; | |
| 1019 | + | ||
| 1020 | + | // Inject file creation through the PTY so it works over SSH. | |
| 1021 | + | // Base64-encode content and pipe through printf | base64 -d > file. | |
| 1022 | + | // Chunk large files to stay under command-line length limits. | |
| 1023 | + | // 48000 is divisible by 3 (clean base64 boundaries) and encodes | |
| 1024 | + | // to ~64KB, well under typical ARG_MAX. | |
| 1025 | + | const chunk_size = 48000; | |
| 1026 | + | var offset: usize = 0; | |
| 1027 | + | var is_first = true; | |
| 1028 | + | ||
| 1029 | + | while (offset < file_content.len or is_first) { | |
| 1030 | + | const end = @min(offset + chunk_size, file_content.len); | |
| 1031 | + | const chunk = file_content[offset..end]; | |
| 1032 | + | ||
| 1033 | + | const encoded_len = std.base64.standard.Encoder.calcSize(chunk.len); | |
| 1034 | + | const encoded = try self.alloc.alloc(u8, encoded_len); | |
| 1035 | + | defer self.alloc.free(encoded); | |
| 1036 | + | _ = std.base64.standard.Encoder.encode(encoded, chunk); | |
| 1037 | + | ||
| 1038 | + | // Bracketed paste mode so the shell buffers input | |
| 1039 | + | // rather than processing each keystroke individually. | |
| 1040 | + | self.queuePtyInput("\x1b[200~"); | |
| 1041 | + | self.queuePtyInput("printf '%s' '"); | |
| 1042 | + | self.queuePtyInput(encoded); | |
| 1043 | + | if (is_first) { | |
| 1044 | + | self.queuePtyInput("' | base64 -d > '"); | |
| 1045 | + | } else { | |
| 1046 | + | self.queuePtyInput("' | base64 -d >> '"); | |
| 1047 | + | } | |
| 1048 | + | self.queuePtyInput(file_path); | |
| 1049 | + | self.queuePtyInput("'"); | |
| 1050 | + | self.queuePtyInput("\x1b[201~"); | |
| 1051 | + | self.queuePtyInput("\r"); | |
| 1052 | + | ||
| 1053 | + | offset = end; | |
| 1054 | + | is_first = false; | |
| 1055 | + | } | |
| 1056 | + | ||
| 1057 | + | try ipc.appendMessage(self.alloc, &client.write_buf, .Ack, ""); | |
| 1058 | + | client.has_pending_output = true; | |
| 1059 | + | self.has_had_client = true; | |
| 1060 | + | std.log.debug( | |
| 1061 | + | "write command len={d} file_path={s}", | |
| 1062 | + | .{ file_content.len, file_path }, | |
| 1063 | + | ); | |
| 1064 | + | } | |
| 905 | 1065 | }; | |
| 906 | 1066 | ||
| 907 | 1067 | fn printVersion(cfg: *Cfg) !void { |
| ... | ... | @@ -930,37 +1090,211 @@ fn help() !void { | |
| 930 | 1090 | const help_text = | |
| 931 | 1091 | \\zmx - session persistence for terminal processes | |
| 932 | 1092 | \\ | |
| 933 | - | \\Usage: zmx <command> [args] | |
| 1093 | + | \\Usage: zmx <command> [args...] | |
| 934 | 1094 | \\ | |
| 935 | 1095 | \\Commands: | |
| 936 | - | \\ [a]ttach <name> [command...] Attach to session, creating session if needed | |
| 937 | - | \\ [r]un <name> [command...] Send command without attaching, creating session if needed | |
| 938 | - | \\ [d]etach Detach all clients from current session (ctrl+\ for current client) | |
| 939 | - | \\ [l]ist [--short] List active sessions | |
| 940 | - | \\ [k]ill <name>... [--force] Kill a session and all attached clients | |
| 941 | - | \\ [hi]story <name> [--vt|--html] Output session scrollback (--vt or --html for escape sequences) | |
| 942 | - | \\ [w]ait <name>... Wait for session tasks to complete | |
| 943 | - | \\ [c]ompletions <shell> Completion scripts for shell integration (bash, zsh, or fish) | |
| 944 | - | \\ [v]ersion Show version information | |
| 945 | - | \\ [h]elp Show this help message | |
| 1096 | + | \\ [a]ttach <name> [command...] Attach to session, creating if needed | |
| 1097 | + | \\ [r]un <name> [-d] [--fish] [command...] Send command without attaching | |
| 1098 | + | \\ [wr]ite <name> <file_path> Write stdin to file_path through the session | |
| 1099 | + | \\ [d]etach Detach all clients (ctrl+\\ for current client) | |
| 1100 | + | \\ [l]ist [--short] List active sessions | |
| 1101 | + | \\ [k]ill <name>... [--force] Kill session and all attached clients | |
| 1102 | + | \\ [hi]story <name> [--vt|--html] Output session scrollback | |
| 1103 | + | \\ [w]ait <name>... Wait for session tasks to complete | |
| 1104 | + | \\ [t]ail <name>... Follow session output | |
| 1105 | + | \\ [c]ompletions <shell> Shell completions (bash, zsh, fish) | |
| 1106 | + | \\ [v]ersion Show version | |
| 1107 | + | \\ [h]elp Show this help | |
| 1108 | + | \\ | |
| 1109 | + | \\Attach: | |
| 1110 | + | \\ This will spawn a login $SHELL with a PTY. You can provide a | |
| 1111 | + | \\ command instead of creating a shell. | |
| 1112 | + | \\ | |
| 1113 | + | \\ Examples: | |
| 1114 | + | \\ zmx attach dev | |
| 1115 | + | \\ zmx attach dev vim | |
| 1116 | + | \\ | |
| 1117 | + | \\History: | |
| 1118 | + | \\ This should generally be used with `tail` to print the last lines | |
| 1119 | + | \\ of the session's scrollback history. | |
| 1120 | + | \\ | |
| 1121 | + | \\ Examples: | |
| 1122 | + | \\ zmx history <session> | tail -100 | |
| 1123 | + | \\ | |
| 1124 | + | \\Run: | |
| 1125 | + | \\ Commands are passed as-is; do not wrap in quotes. | |
| 1126 | + | \\ Commands run sequentially; do not send multiple in parallel. | |
| 1127 | + | \\ Avoid interactive programs (pagers, editors, prompts) -- they hang. | |
| 1128 | + | \\ | |
| 1129 | + | \\ `-d` will detach from the calling terminal. Use `wait` to track | |
| 1130 | + | \\ its status. | |
| 1131 | + | \\ | |
| 1132 | + | \\ `--fish` is required when the session runs fish shell. | |
| 1133 | + | \\ | |
| 1134 | + | \\ If the command hangs, send Ctrl+C to recover: | |
| 1135 | + | \\ zmx run <session> $'\\x03' | |
| 1136 | + | \\ | |
| 1137 | + | \\ Examples: | |
| 1138 | + | \\ zmx run dev ls | |
| 1139 | + | \\ zmx run dev --fish ls src | |
| 1140 | + | \\ zmx run dev zig build | |
| 1141 | + | \\ zmx run dev grep -r TODO src | |
| 1142 | + | \\ zmx run dev git -c core.pager=cat diff | |
| 1143 | + | \\ | |
| 1144 | + | \\Write: | |
| 1145 | + | \\ Writes stdin to file_path inside the session. Works over SSH. | |
| 1146 | + | \\ file_path can be absolute or relative to the session shell's cwd. | |
| 1147 | + | \\ Requires base64 and printf in the remote environment. | |
| 1148 | + | \\ Large files are chunked automatically (~48KB per chunk). | |
| 1149 | + | \\ File path must not contain single quotes. | |
| 1150 | + | \\ | |
| 1151 | + | \\ Examples: | |
| 1152 | + | \\ echo "hello" | zmx write dev /tmp/hello.txt | |
| 1153 | + | \\ cat main.zig | zmx write dev src/main.zig | |
| 1154 | + | \\ | |
| 1155 | + | \\Wait: | |
| 1156 | + | \\ Used with a detached run task to track its status. Multiple | |
| 1157 | + | \\ sessions can be provided. | |
| 1158 | + | \\ | |
| 1159 | + | \\ Examples: | |
| 1160 | + | \\ zmx run -d dev sleep 10 | |
| 1161 | + | \\ zmx wait dev | |
| 1162 | + | \\ zmx wait dev other | |
| 946 | 1163 | \\ | |
| 947 | 1164 | \\Environment variables: | |
| 948 | - | \\ - SHELL Determines which shell is used when creating a session | |
| 949 | - | \\ - ZMX_DIR Controls which folder is used to store unix socket files (prio: 1) | |
| 950 | - | \\ - XDG_RUNTIME_DIR Controls which folder is used to store unix socket files (prio: 2) | |
| 951 | - | \\ - TMPDIR Controls which folder is used to store unix socket files (prio: 3) | |
| 952 | - | \\ - ZMX_SESSION The session name we inject into every zmx session automatically | |
| 953 | - | \\ - ZMX_SESSION_PREFIX Adds this value to the start of every session name for all commands | |
| 954 | - | \\ - ZMX_DIR_MODE Sets the mode for the socket and log directories (octal, defaults to 0750) | |
| 955 | - | \\ - ZMX_LOG_MODE Sets the mode for the log files (octal, defaults to 0640) | |
| 1165 | + | \\ SHELL Default shell for new sessions | |
| 1166 | + | \\ ZMX_DIR Socket directory (priority 1) | |
| 1167 | + | \\ XDG_RUNTIME_DIR Socket directory (priority 2) | |
| 1168 | + | \\ TMPDIR Socket directory (priority 3) | |
| 1169 | + | \\ ZMX_SESSION Session name (injected automatically) | |
| 1170 | + | \\ ZMX_SESSION_PREFIX Prefix added to all session names | |
| 1171 | + | \\ ZMX_DIR_MODE Sets mode for socket and log directories (octal, defaults to 0750) | |
| 1172 | + | \\ ZMX_LOG_MODE Sets mode for log files (octal, defaults to 0640) | |
| 956 | 1173 | \\ | |
| 957 | 1174 | ; | |
| 958 | - | var buf: [4096]u8 = undefined; | |
| 1175 | + | var buf: [8192]u8 = undefined; | |
| 959 | 1176 | var w = std.fs.File.stdout().writer(&buf); | |
| 960 | 1177 | try w.interface.print(help_text, .{}); | |
| 961 | 1178 | try w.interface.flush(); | |
| 962 | 1179 | } | |
| 963 | 1180 | ||
| 1181 | + | fn tail(client_socket_fds: std.ArrayList(i32), detached: bool, is_run_cmd: bool) !u8 { | |
| 1182 | + | var gpa = std.heap.GeneralPurposeAllocator(.{}){}; | |
| 1183 | + | defer _ = gpa.deinit(); | |
| 1184 | + | const alloc = gpa.allocator(); | |
| 1185 | + | ||
| 1186 | + | var poll_fds = try std.ArrayList(posix.pollfd).initCapacity(alloc, 4); | |
| 1187 | + | defer poll_fds.deinit(alloc); | |
| 1188 | + | ||
| 1189 | + | var read_buf = try ipc.SocketBuffer.init(alloc); | |
| 1190 | + | defer read_buf.deinit(); | |
| 1191 | + | ||
| 1192 | + | var stdout_buf = try std.ArrayList(u8).initCapacity(alloc, 4096); | |
| 1193 | + | defer stdout_buf.deinit(alloc); | |
| 1194 | + | ||
| 1195 | + | var is_first_line = true; | |
| 1196 | + | var task_complete_code: ?u8 = null; | |
| 1197 | + | ||
| 1198 | + | while (true) { | |
| 1199 | + | poll_fds.clearRetainingCapacity(); | |
| 1200 | + | ||
| 1201 | + | // Poll socket for read | |
| 1202 | + | for (client_socket_fds.items) |client_sock_fd| { | |
| 1203 | + | try poll_fds.append(alloc, .{ | |
| 1204 | + | .fd = client_sock_fd, | |
| 1205 | + | .events = posix.POLL.IN, | |
| 1206 | + | .revents = 0, | |
| 1207 | + | }); | |
| 1208 | + | } | |
| 1209 | + | ||
| 1210 | + | // Poll for write if we have pending data | |
| 1211 | + | if (stdout_buf.items.len > 0) { | |
| 1212 | + | try poll_fds.append(alloc, .{ | |
| 1213 | + | .fd = posix.STDOUT_FILENO, | |
| 1214 | + | .events = posix.POLL.OUT, | |
| 1215 | + | .revents = 0, | |
| 1216 | + | }); | |
| 1217 | + | } | |
| 1218 | + | ||
| 1219 | + | _ = posix.poll(poll_fds.items, -1) catch |err| { | |
| 1220 | + | if (err == error.Interrupted) continue; // EINTR from signal, loop again | |
| 1221 | + | return err; | |
| 1222 | + | }; | |
| 1223 | + | ||
| 1224 | + | // Handle socket read (incoming Output messages from daemon) | |
| 1225 | + | for (poll_fds.items) |*poll_fd| { | |
| 1226 | + | if (poll_fd.revents & posix.POLL.IN != 0) { | |
| 1227 | + | const n = read_buf.read(poll_fd.fd) catch |err| { | |
| 1228 | + | if (err == error.WouldBlock) continue; | |
| 1229 | + | if (err == error.ConnectionResetByPeer or err == error.BrokenPipe) { | |
| 1230 | + | return 1; | |
| 1231 | + | } | |
| 1232 | + | std.log.err("daemon read err={s}", .{@errorName(err)}); | |
| 1233 | + | return err; | |
| 1234 | + | }; | |
| 1235 | + | if (n == 0) { | |
| 1236 | + | // Server closed connection | |
| 1237 | + | return 0; | |
| 1238 | + | } | |
| 1239 | + | ||
| 1240 | + | while (read_buf.next()) |msg| { | |
| 1241 | + | switch (msg.header.tag) { | |
| 1242 | + | .Ack => { | |
| 1243 | + | if (detached) { | |
| 1244 | + | _ = posix.write(posix.STDOUT_FILENO, "command sent!\n") catch |err| blk: { | |
| 1245 | + | if (err == error.WouldBlock) break :blk 0; | |
| 1246 | + | return err; | |
| 1247 | + | }; | |
| 1248 | + | return 0; | |
| 1249 | + | } | |
| 1250 | + | }, | |
| 1251 | + | .Output => { | |
| 1252 | + | if (msg.payload.len > 0) { | |
| 1253 | + | // strip the first line since it is an echo of | |
| 1254 | + | // the command. | |
| 1255 | + | if (!detached and is_run_cmd and is_first_line) { | |
| 1256 | + | if (std.mem.indexOfScalar(u8, msg.payload, '\n')) |nl| { | |
| 1257 | + | is_first_line = false; | |
| 1258 | + | if (nl + 1 < msg.payload.len) { | |
| 1259 | + | try stdout_buf.appendSlice(alloc, msg.payload[nl + 1 ..]); | |
| 1260 | + | } | |
| 1261 | + | } | |
| 1262 | + | } else { | |
| 1263 | + | try stdout_buf.appendSlice(alloc, msg.payload); | |
| 1264 | + | } | |
| 1265 | + | } | |
| 1266 | + | }, | |
| 1267 | + | .TaskComplete => { | |
| 1268 | + | task_complete_code = if (msg.payload.len > 0) msg.payload[0] else 0; | |
| 1269 | + | }, | |
| 1270 | + | else => {}, | |
| 1271 | + | } | |
| 1272 | + | } | |
| 1273 | + | } | |
| 1274 | + | } | |
| 1275 | + | ||
| 1276 | + | if (stdout_buf.items.len > 0) { | |
| 1277 | + | const n = posix.write(posix.STDOUT_FILENO, stdout_buf.items) catch |err| blk: { | |
| 1278 | + | if (err == error.WouldBlock) break :blk 0; | |
| 1279 | + | return err; | |
| 1280 | + | }; | |
| 1281 | + | if (task_complete_code) |exit_code| { | |
| 1282 | + | return exit_code; | |
| 1283 | + | } | |
| 1284 | + | if (n > 0) { | |
| 1285 | + | try stdout_buf.replaceRange(alloc, 0, n, &[_]u8{}); | |
| 1286 | + | } | |
| 1287 | + | } | |
| 1288 | + | ||
| 1289 | + | // Check for HUP/ERR on any socket | |
| 1290 | + | for (poll_fds.items) |poll_fd| { | |
| 1291 | + | if (poll_fd.revents & (posix.POLL.HUP | posix.POLL.ERR | posix.POLL.NVAL) != 0) { | |
| 1292 | + | return 0; | |
| 1293 | + | } | |
| 1294 | + | } | |
| 1295 | + | } | |
| 1296 | + | } | |
| 1297 | + | ||
| 964 | 1298 | fn wait(cfg: *Cfg, session_names: std.ArrayList([]const u8)) !void { | |
| 965 | 1299 | var gpa = std.heap.GeneralPurposeAllocator(.{}){}; | |
| 966 | 1300 | defer _ = gpa.deinit(); |
| ... | ... | @@ -1387,7 +1721,95 @@ fn attach(daemon: *Daemon) !void { | |
| 1387 | 1721 | } | |
| 1388 | 1722 | } | |
| 1389 | 1723 | ||
| 1390 | - | fn run(daemon: *Daemon, command_args: [][]const u8) !void { | |
| 1724 | + | fn writeFile(daemon: *Daemon, file_path: []const u8) !void { | |
| 1725 | + | var buf: [4096]u8 = undefined; | |
| 1726 | + | var w = std.fs.File.stdout().writer(&buf); | |
| 1727 | + | const sesh_result = try daemon.ensureSession(); | |
| 1728 | + | if (sesh_result.is_daemon) return; | |
| 1729 | + | ||
| 1730 | + | if (sesh_result.created) { | |
| 1731 | + | try w.interface.print("session \"{s}\" created\n", .{daemon.session_name}); | |
| 1732 | + | try w.interface.flush(); | |
| 1733 | + | } | |
| 1734 | + | const stdin_fd = posix.STDIN_FILENO; | |
| 1735 | + | var stdin_buf = try std.ArrayList(u8).initCapacity(daemon.alloc, 4096); | |
| 1736 | + | defer stdin_buf.deinit(daemon.alloc); | |
| 1737 | + | ||
| 1738 | + | while (true) { | |
| 1739 | + | var tmp: [4096]u8 = undefined; | |
| 1740 | + | const n = posix.read(stdin_fd, &tmp) catch |err| { | |
| 1741 | + | if (err == error.WouldBlock) break; | |
| 1742 | + | return err; | |
| 1743 | + | }; | |
| 1744 | + | if (n == 0) break; | |
| 1745 | + | try stdin_buf.appendSlice(daemon.alloc, tmp[0..n]); | |
| 1746 | + | } | |
| 1747 | + | ||
| 1748 | + | const socket_path = socket.getSocketPath( | |
| 1749 | + | daemon.alloc, | |
| 1750 | + | daemon.cfg.socket_dir, | |
| 1751 | + | daemon.session_name, | |
| 1752 | + | ) catch |err| switch (err) { | |
| 1753 | + | error.NameTooLong => return socket.printSessionNameTooLong( | |
| 1754 | + | daemon.session_name, | |
| 1755 | + | daemon.cfg.socket_dir, | |
| 1756 | + | ), | |
| 1757 | + | error.OutOfMemory => return err, | |
| 1758 | + | }; | |
| 1759 | + | var dir = try std.fs.openDirAbsolute(daemon.cfg.socket_dir, .{}); | |
| 1760 | + | defer dir.close(); | |
| 1761 | + | ||
| 1762 | + | const result = ipc.probeSession(daemon.alloc, socket_path) catch |err| { | |
| 1763 | + | std.log.err("session unresponsive: {s}", .{@errorName(err)}); | |
| 1764 | + | if (err == error.ConnectionRefused) { | |
| 1765 | + | socket.cleanupStaleSocket(dir, daemon.session_name); | |
| 1766 | + | w.interface.print("cleaned up stale session {s}\n", .{daemon.session_name}) catch {}; | |
| 1767 | + | } else { | |
| 1768 | + | w.interface.print( | |
| 1769 | + | "session {s} is unresponsive ({s})\ndaemon may be busy: try again\n", | |
| 1770 | + | .{ daemon.session_name, @errorName(err) }, | |
| 1771 | + | ) catch {}; | |
| 1772 | + | } | |
| 1773 | + | w.interface.flush() catch {}; | |
| 1774 | + | return; | |
| 1775 | + | }; | |
| 1776 | + | ||
| 1777 | + | defer posix.close(result.fd); | |
| 1778 | + | ||
| 1779 | + | // Build wire payload: [u32 path len][path bytes][file content] | |
| 1780 | + | var wire_buf = try std.ArrayList(u8).initCapacity( | |
| 1781 | + | daemon.alloc, | |
| 1782 | + | @sizeOf(u32) + file_path.len + stdin_buf.items.len, | |
| 1783 | + | ); | |
| 1784 | + | defer wire_buf.deinit(daemon.alloc); | |
| 1785 | + | const path_len: u32 = @intCast(file_path.len); | |
| 1786 | + | try wire_buf.appendSlice(daemon.alloc, std.mem.asBytes(&path_len)); | |
| 1787 | + | try wire_buf.appendSlice(daemon.alloc, file_path); | |
| 1788 | + | try wire_buf.appendSlice(daemon.alloc, stdin_buf.items); | |
| 1789 | + | ||
| 1790 | + | ipc.send(result.fd, .Write, wire_buf.items) catch |err| switch (err) { | |
| 1791 | + | error.BrokenPipe, error.ConnectionResetByPeer => return, | |
| 1792 | + | else => return err, | |
| 1793 | + | }; | |
| 1794 | + | ||
| 1795 | + | var sb = try ipc.SocketBuffer.init(daemon.alloc); | |
| 1796 | + | defer sb.deinit(); | |
| 1797 | + | ||
| 1798 | + | const n = sb.read(result.fd) catch return error.ReadFailed; | |
| 1799 | + | if (n == 0) return error.ConnectionClosed; | |
| 1800 | + | ||
| 1801 | + | while (sb.next()) |msg| { | |
| 1802 | + | if (msg.header.tag == .Ack) { | |
| 1803 | + | try w.interface.print("file created {s}\n", .{file_path}); | |
| 1804 | + | try w.interface.flush(); | |
| 1805 | + | return; | |
| 1806 | + | } | |
| 1807 | + | } | |
| 1808 | + | ||
| 1809 | + | return error.NoAckReceived; | |
| 1810 | + | } | |
| 1811 | + | ||
| 1812 | + | fn run(daemon: *Daemon, detached: bool, shell_basename: []const u8, command_args: [][]const u8) !void { | |
| 1391 | 1813 | const alloc = daemon.alloc; | |
| 1392 | 1814 | var buf: [4096]u8 = undefined; | |
| 1393 | 1815 | var w = std.fs.File.stdout().writer(&buf); |
| ... | ... | @@ -1404,25 +1826,17 @@ fn run(daemon: *Daemon, command_args: [][]const u8) !void { | |
| 1404 | 1826 | try w.interface.flush(); | |
| 1405 | 1827 | } | |
| 1406 | 1828 | ||
| 1407 | - | const shell = util.detectShell(); | |
| 1408 | - | const shell_basename = std.fs.path.basename(shell); | |
| 1409 | - | // We append a task marker so we can: | |
| 1410 | - | // - know when the command finishes | |
| 1411 | - | // - capture its exit status | |
| 1412 | - | // This information is retrived when running `zmx list` | |
| 1413 | - | const inline_task_marker = if (std.mem.eql(u8, shell_basename, "fish")) | |
| 1414 | - | "; echo ZMX_TASK_COMPLETED:$status" | |
| 1415 | - | else | |
| 1416 | - | "; echo ZMX_TASK_COMPLETED:$?"; | |
| 1417 | - | const stdin_task_marker = if (std.mem.eql(u8, shell_basename, "fish")) | |
| 1418 | - | "echo ZMX_TASK_COMPLETED:$status" | |
| 1419 | - | else | |
| 1420 | - | "echo ZMX_TASK_COMPLETED:$?"; | |
| 1829 | + | // Prefix byte tells the daemon which shell syntax to use for the | |
| 1830 | + | // task-completion marker (0 = bash/zsh $?, 1 = fish $status). | |
| 1831 | + | // The daemon appends the marker itself so the client never injects | |
| 1832 | + | // shell-specific text -- keeping recovery (Ctrl-C) clean. | |
| 1833 | + | const is_fish: u8 = if (std.mem.eql(u8, shell_basename, "fish")) 1 else 0; | |
| 1421 | 1834 | ||
| 1422 | 1835 | if (command_args.len > 0) { | |
| 1423 | 1836 | var cmd_list = std.ArrayList(u8).empty; | |
| 1424 | 1837 | defer cmd_list.deinit(alloc); | |
| 1425 | 1838 | ||
| 1839 | + | try cmd_list.append(alloc, is_fish); | |
| 1426 | 1840 | for (command_args, 0..) |arg, i| { | |
| 1427 | 1841 | if (i > 0) try cmd_list.append(alloc, ' '); | |
| 1428 | 1842 | if (util.shellNeedsQuoting(arg)) { |
| ... | ... | @@ -1434,7 +1848,6 @@ fn run(daemon: *Daemon, command_args: [][]const u8) !void { | |
| 1434 | 1848 | } | |
| 1435 | 1849 | } | |
| 1436 | 1850 | ||
| 1437 | - | try cmd_list.appendSlice(alloc, inline_task_marker); | |
| 1438 | 1851 | // \r, not \n: once the shell is at the readline prompt the PTY is in | |
| 1439 | 1852 | // raw mode; readline's accept-line binds to CR. The first-ever run | |
| 1440 | 1853 | // works with \n only because it arrives during shell startup while |
| ... | ... | @@ -1449,6 +1862,7 @@ fn run(daemon: *Daemon, command_args: [][]const u8) !void { | |
| 1449 | 1862 | var stdin_buf = try std.ArrayList(u8).initCapacity(alloc, 4096); | |
| 1450 | 1863 | defer stdin_buf.deinit(alloc); | |
| 1451 | 1864 | ||
| 1865 | + | try stdin_buf.append(alloc, is_fish); | |
| 1452 | 1866 | while (true) { | |
| 1453 | 1867 | var tmp: [4096]u8 = undefined; | |
| 1454 | 1868 | const n = posix.read(stdin_fd, &tmp) catch |err| { |
| ... | ... | @@ -1459,7 +1873,7 @@ fn run(daemon: *Daemon, command_args: [][]const u8) !void { | |
| 1459 | 1873 | try stdin_buf.appendSlice(alloc, tmp[0..n]); | |
| 1460 | 1874 | } | |
| 1461 | 1875 | ||
| 1462 | - | if (stdin_buf.items.len > 0) { | |
| 1876 | + | if (stdin_buf.items.len > 1) { | |
| 1463 | 1877 | // Normalize any trailing newline to CR so readline (raw mode) | |
| 1464 | 1878 | // accepts each line. | |
| 1465 | 1879 | if (stdin_buf.items[stdin_buf.items.len - 1] == '\n') { |
| ... | ... | @@ -1468,9 +1882,6 @@ fn run(daemon: *Daemon, command_args: [][]const u8) !void { | |
| 1468 | 1882 | try stdin_buf.append(alloc, '\r'); | |
| 1469 | 1883 | } | |
| 1470 | 1884 | ||
| 1471 | - | try stdin_buf.appendSlice(alloc, stdin_task_marker); | |
| 1472 | - | try stdin_buf.append(alloc, '\r'); | |
| 1473 | - | ||
| 1474 | 1885 | cmd_to_send = try alloc.dupe(u8, stdin_buf.items); | |
| 1475 | 1886 | allocated_cmd = @constCast(cmd_to_send.?); | |
| 1476 | 1887 | } |
| ... | ... | @@ -1481,41 +1892,20 @@ fn run(daemon: *Daemon, command_args: [][]const u8) !void { | |
| 1481 | 1892 | return error.CommandRequired; | |
| 1482 | 1893 | } | |
| 1483 | 1894 | ||
| 1484 | - | const probe_result = ipc.probeSession(alloc, daemon.socket_path) catch |err| { | |
| 1485 | - | std.log.err("session not ready: {s}", .{@errorName(err)}); | |
| 1486 | - | return error.SessionNotReady; | |
| 1487 | - | }; | |
| 1488 | - | defer posix.close(probe_result.fd); | |
| 1895 | + | const client_sock = try socket.sessionConnect(daemon.socket_path); | |
| 1896 | + | defer posix.close(client_sock); | |
| 1897 | + | ||
| 1898 | + | var fds = try std.ArrayList(i32).initCapacity(alloc, 1); | |
| 1899 | + | defer fds.deinit(alloc); | |
| 1900 | + | try fds.append(alloc, client_sock); | |
| 1489 | 1901 | ||
| 1490 | - | ipc.send(probe_result.fd, .Run, cmd_to_send.?) catch |err| switch (err) { | |
| 1902 | + | ipc.send(client_sock, .Run, cmd_to_send.?) catch |err| switch (err) { | |
| 1491 | 1903 | error.ConnectionResetByPeer, error.BrokenPipe => return, | |
| 1492 | 1904 | else => return err, | |
| 1493 | 1905 | }; | |
| 1494 | 1906 | ||
| 1495 | - | var poll_fds = [_]posix.pollfd{ | |
| 1496 | - | .{ .fd = probe_result.fd, .events = posix.POLL.IN, .revents = 0 }, | |
| 1497 | - | }; | |
| 1498 | - | const poll_result = posix.poll(&poll_fds, 5000) catch return error.PollFailed; | |
| 1499 | - | if (poll_result == 0) { | |
| 1500 | - | std.log.err("timeout waiting for ack", .{}); | |
| 1501 | - | return error.Timeout; | |
| 1502 | - | } | |
| 1503 | - | ||
| 1504 | - | var sb = try ipc.SocketBuffer.init(alloc); | |
| 1505 | - | defer sb.deinit(); | |
| 1506 | - | ||
| 1507 | - | const n = sb.read(probe_result.fd) catch return error.ReadFailed; | |
| 1508 | - | if (n == 0) return error.ConnectionClosed; | |
| 1509 | - | ||
| 1510 | - | while (sb.next()) |msg| { | |
| 1511 | - | if (msg.header.tag == .Ack) { | |
| 1512 | - | try w.interface.print("command sent\n", .{}); | |
| 1513 | - | try w.interface.flush(); | |
| 1514 | - | return; | |
| 1515 | - | } | |
| 1516 | - | } | |
| 1517 | - | ||
| 1518 | - | return error.NoAckReceived; | |
| 1907 | + | const exit_code = try tail(fds, detached, true); | |
| 1908 | + | posix.exit(exit_code); | |
| 1519 | 1909 | } | |
| 1520 | 1910 | ||
| 1521 | 1911 | const ClientResult = struct { |
| ... | ... | @@ -1826,7 +2216,12 @@ fn daemonLoop(daemon: *Daemon, server_sock_fd: i32, pty_fd: i32) !void { | |
| 1826 | 2216 | daemon.task_ended_at = @intCast(std.time.timestamp()); | |
| 1827 | 2217 | ||
| 1828 | 2218 | std.log.info("task completed exit_code={d}", .{exit_code}); | |
| 1829 | - | // Shell continues running - no break here | |
| 2219 | + | ||
| 2220 | + | // Notify connected clients | |
| 2221 | + | for (daemon.clients.items) |c| { | |
| 2222 | + | ipc.appendMessage(daemon.alloc, &c.write_buf, .TaskComplete, &[_]u8{exit_code}) catch {}; | |
| 2223 | + | c.has_pending_output = true; | |
| 2224 | + | } | |
| 1830 | 2225 | } | |
| 1831 | 2226 | } | |
| 1832 | 2227 |
| ... | ... | @@ -1918,7 +2313,8 @@ fn daemonLoop(daemon: *Daemon, server_sock_fd: i32, pty_fd: i32) !void { | |
| 1918 | 2313 | .Info => try daemon.handleInfo(client), | |
| 1919 | 2314 | .History => try daemon.handleHistory(client, &term, msg.payload), | |
| 1920 | 2315 | .Run => try daemon.handleRun(client, msg.payload), | |
| 1921 | - | .Output, .Ack => {}, | |
| 2316 | + | .Output, .Ack, .TaskComplete => {}, | |
| 2317 | + | .Write => try daemon.handleWrite(client, msg.payload), | |
| 1922 | 2318 | _ => std.log.warn( | |
| 1923 | 2319 | "ignoring unknown IPC tag={d}", | |
| 1924 | 2320 | .{@intFromEnum(msg.header.tag)}, |