repos / zmx

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

commit
6feca74
parent
3750464
author
Jay Botte
date
2026-04-21 23:50:01 -0400 EDT
feat: send raw bytes to pty input

This is a wrapper around the `.Input` event.

Send delivers bytes to the PTY exactly as-is with no automatic
carriage return. The caller is responsible for appending \r when
shell command submission is desired.
3 files changed,  +142, -4
M src/completions.zig
+6, -4
 1@@ -29,7 +29,7 @@ const bash_completions =
 2     \\  cur="${COMP_WORDS[COMP_CWORD]}"
 3     \\  prev="${COMP_WORDS[COMP_CWORD-1]}"
 4     \\
 5-    \\  local commands="attach run detach list completions kill history version help"
 6+    \\  local commands="attach run send detach list completions kill history version help"
 7     \\
 8     \\  if [[ $COMP_CWORD -eq 1 ]]; then
 9     \\    COMPREPLY=($(compgen -W "$commands" -- "$cur"))
10@@ -37,7 +37,7 @@ const bash_completions =
11     \\  fi
12     \\
13     \\  case "$prev" in
14-    \\    attach|run|kill|history)
15+    \\    attach|run|send|kill|history)
16     \\      local sessions=$(zmx list --short 2>/dev/null | tr '\n' ' ')
17     \\      COMPREPLY=($(compgen -W "$sessions" -- "$cur"))
18     \\      ;;
19@@ -72,6 +72,7 @@ const zsh_completions =
20     \\      commands=(
21     \\        'attach:Attach to session, creating if needed'
22     \\        'run:Send command without attaching'
23+    \\        'send:Send raw input to session PTY'
24     \\        'detach:Detach all clients from current session'
25     \\        'list:List active sessions'
26     \\        'completions:Shell completion scripts'
27@@ -84,7 +85,7 @@ const zsh_completions =
28     \\      ;;
29     \\    args)
30     \\      case $words[2] in
31-    \\        attach|a|kill|k|run|r|history|hi)
32+    \\        attach|a|kill|k|run|r|send|s|history|hi)
33     \\          _zmx_sessions
34     \\          ;;
35     \\        completions|c)
36@@ -125,6 +126,7 @@ const fish_completions =
37     \\# zmx subcommands
38     \\complete -c zmx -n "__fish_is_nth_token 1" -a 'a attach' -d 'Attach to session, creating if needed'
39     \\complete -c zmx -n "__fish_is_nth_token 1" -a 'r run' -d 'Send command without attaching'
40+    \\complete -c zmx -n "__fish_is_nth_token 1" -a 's send' -d 'Send raw input to session PTY'
41     \\complete -c zmx -n "__fish_is_nth_token 1" -a 'wr write' -d 'Write stdin to file_path through the session'
42     \\complete -c zmx -n "__fish_is_nth_token 1" -a 'd detach' -d 'Detach all clients (ctrl+\ for current client)'
43     \\complete -c zmx -n "__fish_is_nth_token 1" -a 'l list' -d 'List active sessions'
44@@ -137,7 +139,7 @@ const fish_completions =
45     \\complete -c zmx -n "__fish_is_nth_token 1" -a 'h help' -d 'Show help message'
46     \\
47     \\# Complete session names and shells
48-    \\complete -c zmx -n "__fish_is_nth_token 2; and __fish_seen_subcommand_from a attach r run wr write hi history" -a '(zmx list --short 2>/dev/null)' -d 'Session name'
49+    \\complete -c zmx -n "__fish_is_nth_token 2; and __fish_seen_subcommand_from a attach r run s send wr write hi history" -a '(zmx list --short 2>/dev/null)' -d 'Session name'
50     \\complete -c zmx -n "not __fish_is_nth_token 1; and __fish_seen_subcommand_from k kill w wait t tail" -a '(zmx list --short 2>/dev/null)' -d 'Session name'
51     \\
52     \\complete -c zmx -n "__fish_is_nth_token 2; and __fish_seen_subcommand_from c completions" -a 'bash zsh fish' -d Shell
M src/main.zig
+96, -0
  1@@ -196,6 +196,23 @@ pub fn main() !void {
  2         };
  3         std.log.info("socket path={s}", .{daemon.socket_path});
  4         return run(&daemon, detached, shell_basename, cmd_args_raw.items);
  5+    } else if (std.mem.eql(u8, cmd, "send") or std.mem.eql(u8, cmd, "s")) {
  6+        const session_name = args.next() orelse "";
  7+        if (session_name.len == 0) return error.SessionNameRequired;
  8+
  9+        var text_parts: std.ArrayList([]const u8) = .empty;
 10+        defer text_parts.deinit(alloc);
 11+        while (args.next()) |arg| {
 12+            try text_parts.append(alloc, arg);
 13+        }
 14+
 15+        const sesh = try socket.getSeshName(alloc, session_name);
 16+        defer alloc.free(sesh);
 17+        const socket_path = socket.getSocketPath(alloc, cfg.socket_dir, sesh) catch |err| switch (err) {
 18+            error.NameTooLong => return socket.printSessionNameTooLong(sesh, cfg.socket_dir),
 19+            error.OutOfMemory => return err,
 20+        };
 21+        return send(&cfg, sesh, socket_path, text_parts.items);
 22     } else if (std.mem.eql(u8, cmd, "kill") or std.mem.eql(u8, cmd, "k")) {
 23         var stderr_buffer: [1024]u8 = undefined;
 24         var stderr_writer = std.fs.File.stderr().writer(&stderr_buffer);
 25@@ -1174,6 +1191,7 @@ fn help() !void {
 26         \\Commands:
 27         \\  [a]ttach <name> [command...]             Attach to session, creating if needed
 28         \\  [r]un <name> [-d] [--fish] [command...]  Send command without attaching
 29+        \\  [s]end <name> <text...>                   Send raw input to session PTY
 30         \\  [wr]ite <name> <file_path>               Write stdin to file_path through the session
 31         \\  [d]etach                                 Detach all clients (ctrl+\\ for current client)
 32         \\  [l]ist [--short]                         List active sessions
 33@@ -1223,6 +1241,23 @@ fn help() !void {
 34         \\    zmx run dev grep -r TODO src
 35         \\    zmx run dev git -c core.pager=cat diff
 36         \\
 37+        \\Send:
 38+        \\  Sends raw text to the session's PTY input (fire-and-forget).
 39+        \\  Unlike `run`, no completion marker is appended and no exit code
 40+        \\  is tracked.  Useful for TUI applications, interactive prompts,
 41+        \\  or any program that reads stdin directly.
 42+        \\
 43+        \\  Text is sent byte-for-byte with no automatic carriage return.
 44+        \\  Append \r yourself when you want the shell to execute a command.
 45+        \\
 46+        \\  Text can also be piped via stdin:
 47+        \\    printf 'ls -la\r' | zmx send dev
 48+        \\
 49+        \\  Examples:
 50+        \\    printf 'echo hello\r' | zmx send dev
 51+        \\    zmx send dev $(printf '\x03')
 52+        \\    zmx send dev /compact
 53+        \\
 54         \\Write:
 55         \\  Writes stdin to file_path inside the session. Works over SSH.
 56         \\  file_path can be absolute or relative to the session shell's cwd.
 57@@ -1891,6 +1926,67 @@ fn writeFile(daemon: *Daemon, file_path: []const u8) !void {
 58     return error.NoAckReceived;
 59 }
 60 
 61+fn send(cfg: *Cfg, session_name: []const u8, socket_path: []const u8, text_parts: [][]const u8) !void {
 62+    const alloc = std.heap.c_allocator;
 63+    var buf: [4096]u8 = undefined;
 64+    var w = std.fs.File.stdout().writer(&buf);
 65+
 66+    var payload = std.ArrayList(u8).empty;
 67+    defer payload.deinit(alloc);
 68+
 69+    if (text_parts.len > 0) {
 70+        for (text_parts, 0..) |part, i| {
 71+            if (i > 0) try payload.append(alloc, ' ');
 72+            try payload.appendSlice(alloc, part);
 73+        }
 74+    } else {
 75+        // Read from stdin when no text arguments provided.
 76+        const stdin_fd = posix.STDIN_FILENO;
 77+        if (!std.posix.isatty(stdin_fd)) {
 78+            while (true) {
 79+                var tmp: [4096]u8 = undefined;
 80+                const n = posix.read(stdin_fd, &tmp) catch |err| {
 81+                    if (err == error.WouldBlock) break;
 82+                    return err;
 83+                };
 84+                if (n == 0) break;
 85+                try payload.appendSlice(alloc, tmp[0..n]);
 86+            }
 87+            // Strip trailing newline from piped input; the caller is
 88+            // responsible for including \r when submission is desired.
 89+            if (payload.items.len > 0 and payload.items[payload.items.len - 1] == '\n') {
 90+                _ = payload.pop();
 91+            }
 92+        }
 93+    }
 94+
 95+    if (payload.items.len == 0) return error.TextRequired;
 96+
 97+    var dir = try std.fs.openDirAbsolute(cfg.socket_dir, .{});
 98+    defer dir.close();
 99+
100+    const probe_result = ipc.probeSession(alloc, socket_path) catch |err| {
101+        std.log.err("session unresponsive: {s}", .{@errorName(err)});
102+        if (err == error.ConnectionRefused) {
103+            socket.cleanupStaleSocket(dir, session_name);
104+            try w.interface.print("cleaned up stale session {s}\n", .{session_name});
105+        } else {
106+            try w.interface.print(
107+                "session {s} is unresponsive ({s})\ndaemon may be busy: try again\n",
108+                .{ session_name, @errorName(err) },
109+            );
110+        }
111+        try w.interface.flush();
112+        return;
113+    };
114+    defer posix.close(probe_result.fd);
115+
116+    ipc.send(probe_result.fd, .Input, payload.items) catch |err| switch (err) {
117+        error.ConnectionResetByPeer, error.BrokenPipe => return,
118+        else => return err,
119+    };
120+}
121+
122 fn run(daemon: *Daemon, detached: bool, shell_basename: []const u8, command_args: [][]const u8) !void {
123     const alloc = daemon.alloc;
124     var buf: [4096]u8 = undefined;
M test/session.bats
+40, -0
 1@@ -50,6 +50,46 @@ load test_helper
 2   [ "$status" -ne 0 ]
 3 }
 4 
 5+# ============================================================================
 6+# Send (raw PTY input)
 7+# ============================================================================
 8+
 9+@test "send: does not append CR by default" {
10+  "$ZMX" run test-send-raw -d echo ready
11+  wait_for_session test-send-raw
12+  sleep 0.5
13+
14+  # Send text without \r — it should NOT execute as a command
15+  run "$ZMX" send test-send-raw "partial-text"
16+  [ "$status" -eq 0 ]
17+}
18+
19+@test "send: requires a session name" {
20+  run "$ZMX" send
21+  [ "$status" -ne 0 ]
22+}
23+
24+@test "send: requires text argument" {
25+  "$ZMX" run test-send-notext -d true
26+  wait_for_session test-send-notext
27+
28+  run "$ZMX" send test-send-notext
29+  [ "$status" -ne 0 ]
30+}
31+
32+@test "send: accepts piped stdin" {
33+  "$ZMX" run test-send-pipe -d echo ready
34+  wait_for_session test-send-pipe
35+  sleep 0.5
36+
37+  run bash -c 'printf "echo piped-marker-xyz789\r" | "$0" send test-send-pipe' "$ZMX"
38+  [ "$status" -eq 0 ]
39+
40+  sleep 0.5
41+  run "$ZMX" history test-send-pipe
42+  [[ "$output" == *"piped-marker-xyz789"* ]]
43+}
44+
45 # ============================================================================
46 # Session listing
47 # ============================================================================