repos / zmx

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

commit
5ebf61b
parent
4164523
author
Eric Bower
date
2026-04-23 23:12:11 -0400 EDT
feat: print cmd

This sends text directly to client's stdout as-is.  Typically you'll want to wrap
the text in '\r\n' but we leave that up to the end-user
3 files changed,  +102, -6
M README.md
+28, -0
 1@@ -79,6 +79,8 @@ Usage: zmx <command> [args...]
 2 Commands:
 3   [a]ttach <name> [command...]             Attach to session, creating if needed
 4   [r]un <name> [-d] [--fish] [command...]  Send command without attaching
 5+  [s]end <name> <text...>                  Send raw input to session PTY
 6+  [p]rint <name> <text...>                 Inject text into session display
 7   [wr]ite <name> <file_path>               Write stdin to file_path through the session
 8   [d]etach                                 Detach all clients (ctrl+\\ for current client)
 9   [l]ist [--short]                         List active sessions
10@@ -128,6 +130,32 @@ Run:
11     zmx run dev grep -r TODO src
12     zmx run dev git -c core.pager=cat diff
13 
14+Send:
15+  Sends raw text to the session's PTY input (fire-and-forget).
16+  Unlike `run`, no completion marker is appended and no exit code
17+  is tracked.  Useful for TUI applications, interactive prompts,
18+  or any program that reads stdin directly.
19+
20+  Text is sent byte-for-byte with no automatic carriage return.
21+  Append \r yourself when you want the shell to execute a command.
22+
23+  Text can also be piped via stdin:
24+    printf 'ls -la\r' | zmx send dev
25+
26+  Examples:
27+    printf 'echo hello\r' | zmx send dev
28+    zmx send dev $(printf '\x03')
29+    zmx send dev /compact
30+
31+Print:
32+  Injects text directly into the session display and scrollback.
33+  Never touches the PTY input -- the shell sees nothing.
34+  Caller is responsible for newlines (\\r\\n).
35+
36+  Examples:
37+    printf '\\r\\nhello\\r\\n' | zmx print dev
38+    zmx print dev "$(printf '\\r\\nalert\\r\\n')"
39+
40 Write:
41   Writes stdin to file_path inside the session. Works over SSH.
42   file_path can be absolute or relative to the session shell's cwd.
M src/main.zig
+49, -6
  1@@ -212,7 +212,24 @@ pub fn main() !void {
  2             error.NameTooLong => return socket.printSessionNameTooLong(sesh, cfg.socket_dir),
  3             error.OutOfMemory => return err,
  4         };
  5-        return send(&cfg, sesh, socket_path, text_parts.items);
  6+        return send(&cfg, sesh, socket_path, text_parts.items, .Input);
  7+    } else if (std.mem.eql(u8, cmd, "print") or std.mem.eql(u8, cmd, "p")) {
  8+        const session_name = args.next() orelse "";
  9+        if (session_name.len == 0) return error.SessionNameRequired;
 10+
 11+        var text_parts: std.ArrayList([]const u8) = .empty;
 12+        defer text_parts.deinit(alloc);
 13+        while (args.next()) |arg| {
 14+            try text_parts.append(alloc, arg);
 15+        }
 16+
 17+        const sesh = try socket.getSeshName(alloc, session_name);
 18+        defer alloc.free(sesh);
 19+        const socket_path = socket.getSocketPath(alloc, cfg.socket_dir, sesh) catch |err| switch (err) {
 20+            error.NameTooLong => return socket.printSessionNameTooLong(sesh, cfg.socket_dir),
 21+            error.OutOfMemory => return err,
 22+        };
 23+        return send(&cfg, sesh, socket_path, text_parts.items, .Output);
 24     } else if (std.mem.eql(u8, cmd, "kill") or std.mem.eql(u8, cmd, "k")) {
 25         var stderr_buffer: [1024]u8 = undefined;
 26         var stderr_writer = std.fs.File.stderr().writer(&stderr_buffer);
 27@@ -1109,6 +1126,20 @@ const Daemon = struct {
 28         std.log.debug("run command len={d}", .{payload.len});
 29     }
 30 
 31+    pub fn handleOutput(self: *Daemon, payload: []const u8, vt_stream: anytype) !void {
 32+        try vt_stream.nextSlice(payload);
 33+        self.has_pty_output = true;
 34+        for (self.clients.items) |client| {
 35+            try ipc.appendMessage(self.alloc, &client.write_buf, .Output, payload);
 36+            client.has_pending_output = true;
 37+        }
 38+        if (self.clients.items.len > 0) {
 39+            posix.kill(self.pid, posix.SIG.WINCH) catch |err| {
 40+                std.log.warn("failed to send SIGWINCH err={s}", .{@errorName(err)});
 41+            };
 42+        }
 43+    }
 44+
 45     pub fn handleWrite(self: *Daemon, client: *Client, payload: []const u8) !void {
 46         // Wire format: [u32 path len][path bytes][file content]
 47         if (payload.len < @sizeOf(u32)) return error.InvalidPayload;
 48@@ -1191,7 +1222,8 @@ fn help() !void {
 49         \\Commands:
 50         \\  [a]ttach <name> [command...]             Attach to session, creating if needed
 51         \\  [r]un <name> [-d] [--fish] [command...]  Send command without attaching
 52-        \\  [s]end <name> <text...>                   Send raw input to session PTY
 53+        \\  [s]end <name> <text...>                  Send raw input to session PTY
 54+        \\  [p]rint <name> <text...>                 Inject text into session display
 55         \\  [wr]ite <name> <file_path>               Write stdin to file_path through the session
 56         \\  [d]etach                                 Detach all clients (ctrl+\\ for current client)
 57         \\  [l]ist [--short]                         List active sessions
 58@@ -1258,6 +1290,15 @@ fn help() !void {
 59         \\    zmx send dev $(printf '\x03')
 60         \\    zmx send dev /compact
 61         \\
 62+        \\Print:
 63+        \\  Injects text directly into the session display and scrollback.
 64+        \\  Never touches the PTY input -- the shell sees nothing.
 65+        \\  Caller is responsible for newlines (\\r\\n).
 66+        \\
 67+        \\  Examples:
 68+        \\    printf '\\r\\nhello\\r\\n' | zmx print dev
 69+        \\    zmx print dev "$(printf '\\r\\nalert\\r\\n')"
 70+        \\
 71         \\Write:
 72         \\  Writes stdin to file_path inside the session. Works over SSH.
 73         \\  file_path can be absolute or relative to the session shell's cwd.
 74@@ -1926,7 +1967,7 @@ fn writeFile(daemon: *Daemon, file_path: []const u8) !void {
 75     return error.NoAckReceived;
 76 }
 77 
 78-fn send(cfg: *Cfg, session_name: []const u8, socket_path: []const u8, text_parts: [][]const u8) !void {
 79+fn send(cfg: *Cfg, session_name: []const u8, socket_path: []const u8, text_parts: [][]const u8, tag: ipc.Tag) !void {
 80     const alloc = std.heap.c_allocator;
 81     var buf: [4096]u8 = undefined;
 82     var w = std.fs.File.stdout().writer(&buf);
 83@@ -1954,7 +1995,8 @@ fn send(cfg: *Cfg, session_name: []const u8, socket_path: []const u8, text_parts
 84             }
 85             // Strip trailing newline from piped input; the caller is
 86             // responsible for including \r when submission is desired.
 87-            if (payload.items.len > 0 and payload.items[payload.items.len - 1] == '\n') {
 88+            // For .Output the caller controls exact bytes, so don't strip.
 89+            if (tag != .Output and payload.items.len > 0 and payload.items[payload.items.len - 1] == '\n') {
 90                 _ = payload.pop();
 91             }
 92         }
 93@@ -1981,7 +2023,7 @@ fn send(cfg: *Cfg, session_name: []const u8, socket_path: []const u8, text_parts
 94     };
 95     defer posix.close(probe_result.fd);
 96 
 97-    ipc.send(probe_result.fd, .Input, payload.items) catch |err| switch (err) {
 98+    ipc.send(probe_result.fd, tag, payload.items) catch |err| switch (err) {
 99         error.ConnectionResetByPeer, error.BrokenPipe => return,
100         else => return err,
101     };
102@@ -2474,6 +2516,7 @@ fn daemonLoop(daemon: *Daemon, server_sock_fd: i32, pty_fd: i32) !void {
103                 while (client.read_buf.next()) |msg| {
104                     switch (msg.header.tag) {
105                         .Input => try daemon.handleInput(client, msg.payload),
106+                        .Output => try daemon.handleOutput(msg.payload, &vt_stream),
107                         .Init => try daemon.handleInit(client, pty_fd, &term, msg.payload),
108                         .Switch => try daemon.handleSwitch(msg.payload),
109                         .Resize => try daemon.handleResize(client, pty_fd, &term, msg.payload),
110@@ -2491,7 +2534,7 @@ fn daemonLoop(daemon: *Daemon, server_sock_fd: i32, pty_fd: i32) !void {
111                         .Info => try daemon.handleInfo(client),
112                         .History => try daemon.handleHistory(client, &term, msg.payload),
113                         .Run => try daemon.handleRun(client, msg.payload),
114-                        .Output, .Ack, .TaskComplete => {},
115+                        .Ack, .TaskComplete => {},
116                         .Write => try daemon.handleWrite(client, msg.payload),
117                         _ => std.log.warn(
118                             "ignoring unknown IPC tag={d}",
M test/session.bats
+25, -0
 1@@ -232,3 +232,28 @@ load test_helper
 2   [ "$status" -eq 0 ]
 3   [ -z "$output" ]
 4 }
 5+
 6+
 7+# ============================================================================
 8+# Print (inject text into terminal state)
 9+# ============================================================================
10+
11+@test "print: text appears in history" {
12+  "$ZMX" run test-print-hist -d $SHELL_FLAG echo ready
13+  wait_for_session test-print-hist
14+  sleep 0.3
15+
16+  # Caller is responsible for newlines; trailing \r\n ensures the text
17+  # lands on its own line before SIGWINCH triggers a prompt redraw.
18+  printf "\r\nbats-print-marker-abc123\r\n" | "$ZMX" print test-print-hist
19+  sleep 0.3
20+
21+  run "$ZMX" history test-print-hist
22+  [ "$status" -eq 0 ]
23+  [[ "$output" == *"bats-print-marker-abc123"* ]]
24+}
25+
26+@test "print: requires a session name" {
27+  run "$ZMX" print
28+  [ "$status" -ne 0 ]
29+}