Commit 6feca74

Jay Botte  ·  2026-04-21 23:50:01 -0400 EDT
parent 3750464
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
+6, -4
......@@ -29,7 +29,7 @@ const bash_completions =
2929 \\ cur="${COMP_WORDS[COMP_CWORD]}"
3030 \\ prev="${COMP_WORDS[COMP_CWORD-1]}"
3131 \\
32- \\ local commands="attach run detach list completions kill history version help"
32+ \\ local commands="attach run send detach list completions kill history version help"
3333 \\
3434 \\ if [[ $COMP_CWORD -eq 1 ]]; then
3535 \\ COMPREPLY=($(compgen -W "$commands" -- "$cur"))
......@@ -37,7 +37,7 @@ const bash_completions =
3737 \\ fi
3838 \\
3939 \\ case "$prev" in
40- \\ attach|run|kill|history)
40+ \\ attach|run|send|kill|history)
4141 \\ local sessions=$(zmx list --short 2>/dev/null | tr '\n' ' ')
4242 \\ COMPREPLY=($(compgen -W "$sessions" -- "$cur"))
4343 \\ ;;
......@@ -72,6 +72,7 @@ const zsh_completions =
7272 \\ commands=(
7373 \\ 'attach:Attach to session, creating if needed'
7474 \\ 'run:Send command without attaching'
75+ \\ 'send:Send raw input to session PTY'
7576 \\ 'detach:Detach all clients from current session'
7677 \\ 'list:List active sessions'
7778 \\ 'completions:Shell completion scripts'
......@@ -84,7 +85,7 @@ const zsh_completions =
8485 \\ ;;
8586 \\ args)
8687 \\ case $words[2] in
87- \\ attach|a|kill|k|run|r|history|hi)
88+ \\ attach|a|kill|k|run|r|send|s|history|hi)
8889 \\ _zmx_sessions
8990 \\ ;;
9091 \\ completions|c)
......@@ -125,6 +126,7 @@ const fish_completions =
125126 \\# zmx subcommands
126127 \\complete -c zmx -n "__fish_is_nth_token 1" -a 'a attach' -d 'Attach to session, creating if needed'
127128 \\complete -c zmx -n "__fish_is_nth_token 1" -a 'r run' -d 'Send command without attaching'
129+ \\complete -c zmx -n "__fish_is_nth_token 1" -a 's send' -d 'Send raw input to session PTY'
128130 \\complete -c zmx -n "__fish_is_nth_token 1" -a 'wr write' -d 'Write stdin to file_path through the session'
129131 \\complete -c zmx -n "__fish_is_nth_token 1" -a 'd detach' -d 'Detach all clients (ctrl+\ for current client)'
130132 \\complete -c zmx -n "__fish_is_nth_token 1" -a 'l list' -d 'List active sessions'
......@@ -137,7 +139,7 @@ const fish_completions =
137139 \\complete -c zmx -n "__fish_is_nth_token 1" -a 'h help' -d 'Show help message'
138140 \\
139141 \\# Complete session names and shells
140- \\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'
142+ \\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'
141143 \\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'
142144 \\
143145 \\complete -c zmx -n "__fish_is_nth_token 2; and __fish_seen_subcommand_from c completions" -a 'bash zsh fish' -d Shell
+96, -0
......@@ -196,6 +196,23 @@ pub fn main() !void {
196196 };
197197 std.log.info("socket path={s}", .{daemon.socket_path});
198198 return run(&daemon, detached, shell_basename, cmd_args_raw.items);
199+ } else if (std.mem.eql(u8, cmd, "send") or std.mem.eql(u8, cmd, "s")) {
200+ const session_name = args.next() orelse "";
201+ if (session_name.len == 0) return error.SessionNameRequired;
202+
203+ var text_parts: std.ArrayList([]const u8) = .empty;
204+ defer text_parts.deinit(alloc);
205+ while (args.next()) |arg| {
206+ try text_parts.append(alloc, arg);
207+ }
208+
209+ const sesh = try socket.getSeshName(alloc, session_name);
210+ defer alloc.free(sesh);
211+ const socket_path = socket.getSocketPath(alloc, cfg.socket_dir, sesh) catch |err| switch (err) {
212+ error.NameTooLong => return socket.printSessionNameTooLong(sesh, cfg.socket_dir),
213+ error.OutOfMemory => return err,
214+ };
215+ return send(&cfg, sesh, socket_path, text_parts.items);
199216 } else if (std.mem.eql(u8, cmd, "kill") or std.mem.eql(u8, cmd, "k")) {
200217 var stderr_buffer: [1024]u8 = undefined;
201218 var stderr_writer = std.fs.File.stderr().writer(&stderr_buffer);
......@@ -1174,6 +1191,7 @@ fn help() !void {
11741191 \\Commands:
11751192 \\ [a]ttach <name> [command...] Attach to session, creating if needed
11761193 \\ [r]un <name> [-d] [--fish] [command...] Send command without attaching
1194+ \\ [s]end <name> <text...> Send raw input to session PTY
11771195 \\ [wr]ite <name> <file_path> Write stdin to file_path through the session
11781196 \\ [d]etach Detach all clients (ctrl+\\ for current client)
11791197 \\ [l]ist [--short] List active sessions
......@@ -1223,6 +1241,23 @@ fn help() !void {
12231241 \\ zmx run dev grep -r TODO src
12241242 \\ zmx run dev git -c core.pager=cat diff
12251243 \\
1244+ \\Send:
1245+ \\ Sends raw text to the session's PTY input (fire-and-forget).
1246+ \\ Unlike `run`, no completion marker is appended and no exit code
1247+ \\ is tracked. Useful for TUI applications, interactive prompts,
1248+ \\ or any program that reads stdin directly.
1249+ \\
1250+ \\ Text is sent byte-for-byte with no automatic carriage return.
1251+ \\ Append \r yourself when you want the shell to execute a command.
1252+ \\
1253+ \\ Text can also be piped via stdin:
1254+ \\ printf 'ls -la\r' | zmx send dev
1255+ \\
1256+ \\ Examples:
1257+ \\ printf 'echo hello\r' | zmx send dev
1258+ \\ zmx send dev $(printf '\x03')
1259+ \\ zmx send dev /compact
1260+ \\
12261261 \\Write:
12271262 \\ Writes stdin to file_path inside the session. Works over SSH.
12281263 \\ file_path can be absolute or relative to the session shell's cwd.
......@@ -1891,6 +1926,67 @@ fn writeFile(daemon: *Daemon, file_path: []const u8) !void {
18911926 return error.NoAckReceived;
18921927 }
18931928
1929+fn send(cfg: *Cfg, session_name: []const u8, socket_path: []const u8, text_parts: [][]const u8) !void {
1930+ const alloc = std.heap.c_allocator;
1931+ var buf: [4096]u8 = undefined;
1932+ var w = std.fs.File.stdout().writer(&buf);
1933+
1934+ var payload = std.ArrayList(u8).empty;
1935+ defer payload.deinit(alloc);
1936+
1937+ if (text_parts.len > 0) {
1938+ for (text_parts, 0..) |part, i| {
1939+ if (i > 0) try payload.append(alloc, ' ');
1940+ try payload.appendSlice(alloc, part);
1941+ }
1942+ } else {
1943+ // Read from stdin when no text arguments provided.
1944+ const stdin_fd = posix.STDIN_FILENO;
1945+ if (!std.posix.isatty(stdin_fd)) {
1946+ while (true) {
1947+ var tmp: [4096]u8 = undefined;
1948+ const n = posix.read(stdin_fd, &tmp) catch |err| {
1949+ if (err == error.WouldBlock) break;
1950+ return err;
1951+ };
1952+ if (n == 0) break;
1953+ try payload.appendSlice(alloc, tmp[0..n]);
1954+ }
1955+ // Strip trailing newline from piped input; the caller is
1956+ // responsible for including \r when submission is desired.
1957+ if (payload.items.len > 0 and payload.items[payload.items.len - 1] == '\n') {
1958+ _ = payload.pop();
1959+ }
1960+ }
1961+ }
1962+
1963+ if (payload.items.len == 0) return error.TextRequired;
1964+
1965+ var dir = try std.fs.openDirAbsolute(cfg.socket_dir, .{});
1966+ defer dir.close();
1967+
1968+ const probe_result = ipc.probeSession(alloc, socket_path) catch |err| {
1969+ std.log.err("session unresponsive: {s}", .{@errorName(err)});
1970+ if (err == error.ConnectionRefused) {
1971+ socket.cleanupStaleSocket(dir, session_name);
1972+ try w.interface.print("cleaned up stale session {s}\n", .{session_name});
1973+ } else {
1974+ try w.interface.print(
1975+ "session {s} is unresponsive ({s})\ndaemon may be busy: try again\n",
1976+ .{ session_name, @errorName(err) },
1977+ );
1978+ }
1979+ try w.interface.flush();
1980+ return;
1981+ };
1982+ defer posix.close(probe_result.fd);
1983+
1984+ ipc.send(probe_result.fd, .Input, payload.items) catch |err| switch (err) {
1985+ error.ConnectionResetByPeer, error.BrokenPipe => return,
1986+ else => return err,
1987+ };
1988+}
1989+
18941990 fn run(daemon: *Daemon, detached: bool, shell_basename: []const u8, command_args: [][]const u8) !void {
18951991 const alloc = daemon.alloc;
18961992 var buf: [4096]u8 = undefined;
+40, -0
......@@ -50,6 +50,46 @@ load test_helper
5050 [ "$status" -ne 0 ]
5151 }
5252
53+# ============================================================================
54+# Send (raw PTY input)
55+# ============================================================================
56+
57+@test "send: does not append CR by default" {
58+ "$ZMX" run test-send-raw -d echo ready
59+ wait_for_session test-send-raw
60+ sleep 0.5
61+
62+ # Send text without \r — it should NOT execute as a command
63+ run "$ZMX" send test-send-raw "partial-text"
64+ [ "$status" -eq 0 ]
65+}
66+
67+@test "send: requires a session name" {
68+ run "$ZMX" send
69+ [ "$status" -ne 0 ]
70+}
71+
72+@test "send: requires text argument" {
73+ "$ZMX" run test-send-notext -d true
74+ wait_for_session test-send-notext
75+
76+ run "$ZMX" send test-send-notext
77+ [ "$status" -ne 0 ]
78+}
79+
80+@test "send: accepts piped stdin" {
81+ "$ZMX" run test-send-pipe -d echo ready
82+ wait_for_session test-send-pipe
83+ sleep 0.5
84+
85+ run bash -c 'printf "echo piped-marker-xyz789\r" | "$0" send test-send-pipe' "$ZMX"
86+ [ "$status" -eq 0 ]
87+
88+ sleep 0.5
89+ run "$ZMX" history test-send-pipe
90+ [[ "$output" == *"piped-marker-xyz789"* ]]
91+}
92+
5393 # ============================================================================
5494 # Session listing
5595 # ============================================================================