- 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
+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
+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;
+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 # ============================================================================