- commit
- e719274
- parent
- bbbe245
- author
- Eric Bower
- date
- 2026-04-10 15:12:34 -0400 EDT
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
1@@ -11,9 +11,14 @@ Use spec: https://common-changelog.org/
2 - Non-leader clients are read-only until they send user input bytes and takeover leadership
3 - When a leader is promoted we immediately resize to their window size
4 - `zmx attach` now lets users switch to another session from within a session
5+- `zmx tail` will receive all outout from sessions in read-only mode
6+- `zmx write` will pipe data from stdin, convert to base64, chunk, and send data through pty to write to a file
7
8 ### Changed
9
10+- *BREAKING* `zmx run` is now synchonous by default and tails the session
11+ - Use detached mode (`-d`) for previous behavior
12+- `zmx run` accepts `--fish` flag to indicate the session's shell is fish
13 - `zmx kill` now supports multiple args and it will kill sessions that match a prefix
14 - e.g. `zmx kill d.` will kill all sessions that match that prefix
15
+12,
-10
1@@ -71,16 +71,18 @@ zig build -Doptimize=ReleaseSafe --prefix ~/.local
2 Usage: zmx <command> [args]
3
4 Commands:
5- [a]ttach <name> [command...] Attach to session, creating session if needed
6- [r]un <name> [command...] Send command without attaching, creating session if needed
7- [d]etach Detach all clients from current session (ctrl+\ for current client)
8- [l]ist [--short] List active sessions
9- [k]ill <name>... [--force] Kill a session and all attached clients
10- [hi]story <name> [--vt|--html] Output session scrollback (--vt or --html for escape sequences)
11- [w]ait <name>... Wait for session tasks to complete
12- [c]ompletions <shell> Completion scripts for shell integration (bash, zsh, or fish)
13- [v]ersion Show version information
14- [h]elp Show this help message
15+ [a]ttach <name> [command...] Attach to session, creating if needed
16+ [r]un <name> [-d] [--fish] [command...] Send command without attaching
17+ [wr]ite <name> <file_path> Write stdin to file_path through the session
18+ [d]etach Detach all clients (ctrl+\ for current client)
19+ [l]ist [--short] List active sessions
20+ [k]ill <name>... [--force] Kill session and all attached clients
21+ [hi]story <name> [--vt|--html] Output session scrollback
22+ [w]ait <name>... Wait for session tasks to complete
23+ [t]ail <name>... Follow session output
24+ [c]ompletions <shell> Shell completions (bash, zsh, fish)
25+ [v]ersion Show version
26+ [h]elp Show this help
27 ```
28
29 ### examples
D
SKILL.md
+0,
-74
1@@ -1,74 +0,0 @@
2----
3-name: zmx-session
4-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.
5----
6-
7-# zmx Collaborative Terminal Sessions
8-
9-## Overview
10-
11-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.
12-
13-This skill covers using zmx for collaborative debugging where Claude can directly view session history and execute commands.
14-
15-Run `zmx help` to understand the commands and when to run them.
16-
17-## Session Setup
18-
19-The user starts a zmx session and works within it:
20-
21-```bash
22-# Create or attach to a named session
23-zmx attach <session-name>
24-```
25-
26-Naming convention suggestion: use descriptive names like `debug-prod`, `k8s-issue`, `logs-api`.
27-
28-## Viewing Session Context
29-
30-Claude can directly view the terminal history without user intervention:
31-
32-```bash
33-# List active sessions
34-zmx list
35-
36-# View recent scrollback from a session (always pipe to tail to limit context)
37-zmx history <session-name> | tail -200
38-```
39-
40-These are read-only commands—run them freely to understand what's happening.
41-
42-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.
43-
44-## Command Execution Protocol
45-
46-**Always ask permission before running commands that execute in the user's session.**
47-
48-To execute a command in a running session without attaching:
49-
50-```bash
51-zmx run <session-name> <command>
52-```
53-
54-Then you can wait for the task to complete by running:
55-
56-```bash
57-zmx wait <session-name>
58-```
59-
60-And you can track the exit code by running:
61-
62-```bash
63-zmx list | grep <session-name>
64-```
65-
66-Example workflow:
67-
68-1. User tells Claude the session name and describes the issue
69-1. Claude runs `zmx history <session-name> | tail -200` to see context
70-1. Claude analyzes and proposes a command
71-1. User approves
72-1. Claude runs via `zmx run <session-name> <command>`
73-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)
74-1. Claude evaluates the output and provides analysis
75-1. Repeat steps 3-7 as needed until the issue is resolved
+2,
-0
1@@ -16,6 +16,8 @@ pub const Tag = enum(u8) {
2 Run = 9,
3 Ack = 10,
4 Switch = 11,
5+ Write = 12,
6+ TaskComplete = 13,
7 // Non-exhaustive: this enum comes off the wire via bytesToValue and
8 // @enumFromInt, so out-of-range values (11-255) are representable
9 // rather than UB. Switches must handle `_` (unknown tag).
+471,
-75
1@@ -135,7 +135,22 @@ pub fn main() !void {
2
3 var cmd_args_raw: std.ArrayList([]const u8) = .empty;
4 defer cmd_args_raw.deinit(alloc);
5+ const shell = util.detectShell();
6+ var shell_basename = std.fs.path.basename(shell);
7+ var detached = false;
8 while (args.next()) |arg| {
9+ // TODO: detect shell within the session instead of asking the user to tell us
10+ // if the shell is fish.
11+ // Because fish tracks exit code status via $status instead of $? we need some
12+ // way to figure out what shell is being used inside the session.
13+ if (std.mem.startsWith(u8, arg, "--fish")) {
14+ shell_basename = "fish";
15+ continue;
16+ }
17+ if (std.mem.startsWith(u8, arg, "-d")) {
18+ detached = true;
19+ continue;
20+ }
21 try cmd_args_raw.append(alloc, arg);
22 }
23 const clients = try std.ArrayList(*Client).initCapacity(alloc, 10);
24@@ -157,14 +172,14 @@ pub fn main() !void {
25 .cwd = cwd,
26 .created_at = @intCast(std.time.timestamp()),
27 .is_task_mode = true,
28- .leader_client_fd = undefined,
29+ .leader_client_fd = null,
30 };
31 daemon.socket_path = socket.getSocketPath(alloc, cfg.socket_dir, sesh) catch |err| switch (err) {
32 error.NameTooLong => return socket.printSessionNameTooLong(sesh, cfg.socket_dir),
33 error.OutOfMemory => return err,
34 };
35 std.log.info("socket path={s}", .{daemon.socket_path});
36- return run(&daemon, cmd_args_raw.items);
37+ return run(&daemon, detached, shell_basename, cmd_args_raw.items);
38 } else if (std.mem.eql(u8, cmd, "kill") or std.mem.eql(u8, cmd, "k")) {
39 var stderr_buffer: [1024]u8 = undefined;
40 var stderr_writer = std.fs.File.stderr().writer(&stderr_buffer);
41@@ -240,6 +255,76 @@ pub fn main() !void {
42 try args_raw.append(alloc, prefix);
43 }
44 return wait(&cfg, args_raw);
45+ } else if (std.mem.eql(u8, cmd, "tail") or std.mem.eql(u8, cmd, "t")) {
46+ var session_names: std.ArrayList([]const u8) = .empty;
47+ defer {
48+ for (session_names.items) |sesh| {
49+ alloc.free(sesh);
50+ }
51+ session_names.deinit(alloc);
52+ }
53+ while (args.next()) |session_name| {
54+ const sesh = try socket.getSeshName(alloc, session_name);
55+ try session_names.append(alloc, sesh);
56+ }
57+ // if no args are provided we assume they want to wait for all sessions matching the
58+ // prefix.
59+ if (session_names.items.len == 0) {
60+ const prefix = socket.getSeshPrefix();
61+ if (prefix.len == 0) {
62+ return error.SessionNameRequired;
63+ }
64+ try session_names.append(alloc, prefix);
65+ }
66+
67+ var client_socket_fds = try std.ArrayList(i32).initCapacity(alloc, session_names.items.len);
68+ defer {
69+ for (client_socket_fds.items) |client_fd| {
70+ posix.close(client_fd);
71+ }
72+ client_socket_fds.deinit(alloc);
73+ }
74+
75+ for (session_names.items) |session_name| {
76+ const socket_path = socket.getSocketPath(alloc, cfg.socket_dir, session_name) catch |err| switch (err) {
77+ error.NameTooLong => return socket.printSessionNameTooLong(session_name, cfg.socket_dir),
78+ error.OutOfMemory => return err,
79+ };
80+ const client_sock = try socket.sessionConnect(socket_path);
81+ try client_socket_fds.append(alloc, client_sock);
82+ }
83+ _ = try tail(client_socket_fds, false, false);
84+ } else if (std.mem.eql(u8, cmd, "write") or std.mem.eql(u8, cmd, "wr")) {
85+ const session_name = args.next() orelse "";
86+ if (session_name.len == 0) return error.SessionNameRequired;
87+ const file_path = args.next() orelse "";
88+ if (file_path.len == 0) return error.FilePathRequired;
89+
90+ var cwd_buf: [std.fs.max_path_bytes]u8 = undefined;
91+ const cwd = std.posix.getcwd(&cwd_buf) catch "";
92+ const clients = try std.ArrayList(*Client).initCapacity(alloc, 10);
93+ const sesh = try socket.getSeshName(alloc, session_name);
94+ defer alloc.free(sesh);
95+ var daemon = Daemon{
96+ .running = true,
97+ .cfg = &cfg,
98+ .alloc = alloc,
99+ .clients = clients,
100+ .session_name = sesh,
101+ .socket_path = undefined,
102+ .pid = undefined,
103+ .command = null,
104+ .cwd = cwd,
105+ .created_at = @intCast(std.time.timestamp()),
106+ .is_task_mode = true,
107+ .leader_client_fd = null,
108+ };
109+ daemon.socket_path = socket.getSocketPath(alloc, cfg.socket_dir, sesh) catch |err| switch (err) {
110+ error.NameTooLong => return socket.printSessionNameTooLong(sesh, cfg.socket_dir),
111+ error.OutOfMemory => return err,
112+ };
113+ std.log.info("socket path={s}", .{daemon.socket_path});
114+ try writeFile(&daemon, file_path);
115 } else {
116 return help();
117 }
118@@ -392,6 +477,7 @@ const Daemon = struct {
119 is_task_mode: bool = false, // flag for when session is run as a task
120 task_exit_code: ?u8 = null, // null = running or n/a, set when task completes
121 task_ended_at: ?u64 = null, // timestamp when task exited
122+ is_fish: bool = false, // true if session shell is fish (affects exit code variable)
123 pty_write_buf: std.ArrayList(u8) = .empty,
124
125 const EnsureSessionResult = struct {
126@@ -896,12 +982,86 @@ const Daemon = struct {
127 self.task_ended_at = null;
128 self.is_task_mode = true;
129
130- self.queuePtyInput(payload);
131+ if (payload.len == 0) return;
132+
133+ // First byte indicates shell type (0=bash/zsh, 1=fish)
134+ self.is_fish = payload[0] == 1;
135+ const cmd = payload[1..];
136+
137+ // Daemon appends the task marker so the client never injects
138+ // shell-specific syntax, keeping Ctrl-C recovery clean.
139+ const marker = if (self.is_fish)
140+ "; echo ZMX_TASK_COMPLETED:$status"
141+ else
142+ "; echo ZMX_TASK_COMPLETED:$?";
143+
144+ if (cmd.len > 0 and cmd[cmd.len - 1] == '\r') {
145+ self.queuePtyInput(cmd[0 .. cmd.len - 1]);
146+ } else {
147+ self.queuePtyInput(cmd);
148+ }
149+ self.queuePtyInput(marker);
150+ self.queuePtyInput("\r");
151+
152 try ipc.appendMessage(self.alloc, &client.write_buf, .Ack, "");
153 client.has_pending_output = true;
154 self.has_had_client = true;
155 std.log.debug("run command len={d}", .{payload.len});
156 }
157+
158+ pub fn handleWrite(self: *Daemon, client: *Client, payload: []const u8) !void {
159+ // Wire format: [u32 path len][path bytes][file content]
160+ if (payload.len < @sizeOf(u32)) return error.InvalidPayload;
161+ const path_len = std.mem.bytesToValue(u32, payload[0..@sizeOf(u32)]);
162+ if (payload.len < @sizeOf(u32) + path_len) return error.InvalidPayload;
163+ const file_path = payload[@sizeOf(u32)..][0..path_len];
164+ const file_content = payload[@sizeOf(u32) + path_len ..];
165+
166+ // Inject file creation through the PTY so it works over SSH.
167+ // Base64-encode content and pipe through printf | base64 -d > file.
168+ // Chunk large files to stay under command-line length limits.
169+ // 48000 is divisible by 3 (clean base64 boundaries) and encodes
170+ // to ~64KB, well under typical ARG_MAX.
171+ const chunk_size = 48000;
172+ var offset: usize = 0;
173+ var is_first = true;
174+
175+ while (offset < file_content.len or is_first) {
176+ const end = @min(offset + chunk_size, file_content.len);
177+ const chunk = file_content[offset..end];
178+
179+ const encoded_len = std.base64.standard.Encoder.calcSize(chunk.len);
180+ const encoded = try self.alloc.alloc(u8, encoded_len);
181+ defer self.alloc.free(encoded);
182+ _ = std.base64.standard.Encoder.encode(encoded, chunk);
183+
184+ // Bracketed paste mode so the shell buffers input
185+ // rather than processing each keystroke individually.
186+ self.queuePtyInput("\x1b[200~");
187+ self.queuePtyInput("printf '%s' '");
188+ self.queuePtyInput(encoded);
189+ if (is_first) {
190+ self.queuePtyInput("' | base64 -d > '");
191+ } else {
192+ self.queuePtyInput("' | base64 -d >> '");
193+ }
194+ self.queuePtyInput(file_path);
195+ self.queuePtyInput("'");
196+ self.queuePtyInput("\x1b[201~");
197+ self.queuePtyInput("\r");
198+
199+ offset = end;
200+ is_first = false;
201+ }
202+
203+ try ipc.appendMessage(self.alloc, &client.write_buf, .Ack, "");
204+ client.has_pending_output = true;
205+ self.has_had_client = true;
206+ std.log.debug(
207+ "write command len={d} file_path={s}",
208+ .{ file_content.len, file_path },
209+ );
210+ }
211 };
212
213 fn printVersion(cfg: *Cfg) !void {
214@@ -930,37 +1090,211 @@ fn help() !void {
215 const help_text =
216 \\zmx - session persistence for terminal processes
217 \\
218- \\Usage: zmx <command> [args]
219+ \\Usage: zmx <command> [args...]
220 \\
221 \\Commands:
222- \\ [a]ttach <name> [command...] Attach to session, creating session if needed
223- \\ [r]un <name> [command...] Send command without attaching, creating session if needed
224- \\ [d]etach Detach all clients from current session (ctrl+\ for current client)
225- \\ [l]ist [--short] List active sessions
226- \\ [k]ill <name>... [--force] Kill a session and all attached clients
227- \\ [hi]story <name> [--vt|--html] Output session scrollback (--vt or --html for escape sequences)
228- \\ [w]ait <name>... Wait for session tasks to complete
229- \\ [c]ompletions <shell> Completion scripts for shell integration (bash, zsh, or fish)
230- \\ [v]ersion Show version information
231- \\ [h]elp Show this help message
232+ \\ [a]ttach <name> [command...] Attach to session, creating if needed
233+ \\ [r]un <name> [-d] [--fish] [command...] Send command without attaching
234+ \\ [wr]ite <name> <file_path> Write stdin to file_path through the session
235+ \\ [d]etach Detach all clients (ctrl+\\ for current client)
236+ \\ [l]ist [--short] List active sessions
237+ \\ [k]ill <name>... [--force] Kill session and all attached clients
238+ \\ [hi]story <name> [--vt|--html] Output session scrollback
239+ \\ [w]ait <name>... Wait for session tasks to complete
240+ \\ [t]ail <name>... Follow session output
241+ \\ [c]ompletions <shell> Shell completions (bash, zsh, fish)
242+ \\ [v]ersion Show version
243+ \\ [h]elp Show this help
244+ \\
245+ \\Attach:
246+ \\ This will spawn a login $SHELL with a PTY. You can provide a
247+ \\ command instead of creating a shell.
248+ \\
249+ \\ Examples:
250+ \\ zmx attach dev
251+ \\ zmx attach dev vim
252+ \\
253+ \\History:
254+ \\ This should generally be used with `tail` to print the last lines
255+ \\ of the session's scrollback history.
256+ \\
257+ \\ Examples:
258+ \\ zmx history <session> | tail -100
259+ \\
260+ \\Run:
261+ \\ Commands are passed as-is; do not wrap in quotes.
262+ \\ Commands run sequentially; do not send multiple in parallel.
263+ \\ Avoid interactive programs (pagers, editors, prompts) -- they hang.
264+ \\
265+ \\ `-d` will detach from the calling terminal. Use `wait` to track
266+ \\ its status.
267+ \\
268+ \\ `--fish` is required when the session runs fish shell.
269+ \\
270+ \\ If the command hangs, send Ctrl+C to recover:
271+ \\ zmx run <session> $'\\x03'
272+ \\
273+ \\ Examples:
274+ \\ zmx run dev ls
275+ \\ zmx run dev --fish ls src
276+ \\ zmx run dev zig build
277+ \\ zmx run dev grep -r TODO src
278+ \\ zmx run dev git -c core.pager=cat diff
279+ \\
280+ \\Write:
281+ \\ Writes stdin to file_path inside the session. Works over SSH.
282+ \\ file_path can be absolute or relative to the session shell's cwd.
283+ \\ Requires base64 and printf in the remote environment.
284+ \\ Large files are chunked automatically (~48KB per chunk).
285+ \\ File path must not contain single quotes.
286+ \\
287+ \\ Examples:
288+ \\ echo "hello" | zmx write dev /tmp/hello.txt
289+ \\ cat main.zig | zmx write dev src/main.zig
290+ \\
291+ \\Wait:
292+ \\ Used with a detached run task to track its status. Multiple
293+ \\ sessions can be provided.
294+ \\
295+ \\ Examples:
296+ \\ zmx run -d dev sleep 10
297+ \\ zmx wait dev
298+ \\ zmx wait dev other
299 \\
300 \\Environment variables:
301- \\ - SHELL Determines which shell is used when creating a session
302- \\ - ZMX_DIR Controls which folder is used to store unix socket files (prio: 1)
303- \\ - XDG_RUNTIME_DIR Controls which folder is used to store unix socket files (prio: 2)
304- \\ - TMPDIR Controls which folder is used to store unix socket files (prio: 3)
305- \\ - ZMX_SESSION The session name we inject into every zmx session automatically
306- \\ - ZMX_SESSION_PREFIX Adds this value to the start of every session name for all commands
307- \\ - ZMX_DIR_MODE Sets the mode for the socket and log directories (octal, defaults to 0750)
308- \\ - ZMX_LOG_MODE Sets the mode for the log files (octal, defaults to 0640)
309+ \\ SHELL Default shell for new sessions
310+ \\ ZMX_DIR Socket directory (priority 1)
311+ \\ XDG_RUNTIME_DIR Socket directory (priority 2)
312+ \\ TMPDIR Socket directory (priority 3)
313+ \\ ZMX_SESSION Session name (injected automatically)
314+ \\ ZMX_SESSION_PREFIX Prefix added to all session names
315+ \\ ZMX_DIR_MODE Sets mode for socket and log directories (octal, defaults to 0750)
316+ \\ ZMX_LOG_MODE Sets mode for log files (octal, defaults to 0640)
317 \\
318 ;
319- var buf: [4096]u8 = undefined;
320+ var buf: [8192]u8 = undefined;
321 var w = std.fs.File.stdout().writer(&buf);
322 try w.interface.print(help_text, .{});
323 try w.interface.flush();
324 }
325
326+fn tail(client_socket_fds: std.ArrayList(i32), detached: bool, is_run_cmd: bool) !u8 {
327+ var gpa = std.heap.GeneralPurposeAllocator(.{}){};
328+ defer _ = gpa.deinit();
329+ const alloc = gpa.allocator();
330+
331+ var poll_fds = try std.ArrayList(posix.pollfd).initCapacity(alloc, 4);
332+ defer poll_fds.deinit(alloc);
333+
334+ var read_buf = try ipc.SocketBuffer.init(alloc);
335+ defer read_buf.deinit();
336+
337+ var stdout_buf = try std.ArrayList(u8).initCapacity(alloc, 4096);
338+ defer stdout_buf.deinit(alloc);
339+
340+ var is_first_line = true;
341+ var task_complete_code: ?u8 = null;
342+
343+ while (true) {
344+ poll_fds.clearRetainingCapacity();
345+
346+ // Poll socket for read
347+ for (client_socket_fds.items) |client_sock_fd| {
348+ try poll_fds.append(alloc, .{
349+ .fd = client_sock_fd,
350+ .events = posix.POLL.IN,
351+ .revents = 0,
352+ });
353+ }
354+
355+ // Poll for write if we have pending data
356+ if (stdout_buf.items.len > 0) {
357+ try poll_fds.append(alloc, .{
358+ .fd = posix.STDOUT_FILENO,
359+ .events = posix.POLL.OUT,
360+ .revents = 0,
361+ });
362+ }
363+
364+ _ = posix.poll(poll_fds.items, -1) catch |err| {
365+ if (err == error.Interrupted) continue; // EINTR from signal, loop again
366+ return err;
367+ };
368+
369+ // Handle socket read (incoming Output messages from daemon)
370+ for (poll_fds.items) |*poll_fd| {
371+ if (poll_fd.revents & posix.POLL.IN != 0) {
372+ const n = read_buf.read(poll_fd.fd) catch |err| {
373+ if (err == error.WouldBlock) continue;
374+ if (err == error.ConnectionResetByPeer or err == error.BrokenPipe) {
375+ return 1;
376+ }
377+ std.log.err("daemon read err={s}", .{@errorName(err)});
378+ return err;
379+ };
380+ if (n == 0) {
381+ // Server closed connection
382+ return 0;
383+ }
384+
385+ while (read_buf.next()) |msg| {
386+ switch (msg.header.tag) {
387+ .Ack => {
388+ if (detached) {
389+ _ = posix.write(posix.STDOUT_FILENO, "command sent!\n") catch |err| blk: {
390+ if (err == error.WouldBlock) break :blk 0;
391+ return err;
392+ };
393+ return 0;
394+ }
395+ },
396+ .Output => {
397+ if (msg.payload.len > 0) {
398+ // strip the first line since it is an echo of
399+ // the command.
400+ if (!detached and is_run_cmd and is_first_line) {
401+ if (std.mem.indexOfScalar(u8, msg.payload, '\n')) |nl| {
402+ is_first_line = false;
403+ if (nl + 1 < msg.payload.len) {
404+ try stdout_buf.appendSlice(alloc, msg.payload[nl + 1 ..]);
405+ }
406+ }
407+ } else {
408+ try stdout_buf.appendSlice(alloc, msg.payload);
409+ }
410+ }
411+ },
412+ .TaskComplete => {
413+ task_complete_code = if (msg.payload.len > 0) msg.payload[0] else 0;
414+ },
415+ else => {},
416+ }
417+ }
418+ }
419+ }
420+
421+ if (stdout_buf.items.len > 0) {
422+ const n = posix.write(posix.STDOUT_FILENO, stdout_buf.items) catch |err| blk: {
423+ if (err == error.WouldBlock) break :blk 0;
424+ return err;
425+ };
426+ if (task_complete_code) |exit_code| {
427+ return exit_code;
428+ }
429+ if (n > 0) {
430+ try stdout_buf.replaceRange(alloc, 0, n, &[_]u8{});
431+ }
432+ }
433+
434+ // Check for HUP/ERR on any socket
435+ for (poll_fds.items) |poll_fd| {
436+ if (poll_fd.revents & (posix.POLL.HUP | posix.POLL.ERR | posix.POLL.NVAL) != 0) {
437+ return 0;
438+ }
439+ }
440+ }
441+}
442+
443 fn wait(cfg: *Cfg, session_names: std.ArrayList([]const u8)) !void {
444 var gpa = std.heap.GeneralPurposeAllocator(.{}){};
445 defer _ = gpa.deinit();
446@@ -1387,7 +1721,95 @@ fn attach(daemon: *Daemon) !void {
447 }
448 }
449
450-fn run(daemon: *Daemon, command_args: [][]const u8) !void {
451+fn writeFile(daemon: *Daemon, file_path: []const u8) !void {
452+ var buf: [4096]u8 = undefined;
453+ var w = std.fs.File.stdout().writer(&buf);
454+ const sesh_result = try daemon.ensureSession();
455+ if (sesh_result.is_daemon) return;
456+
457+ if (sesh_result.created) {
458+ try w.interface.print("session \"{s}\" created\n", .{daemon.session_name});
459+ try w.interface.flush();
460+ }
461+ const stdin_fd = posix.STDIN_FILENO;
462+ var stdin_buf = try std.ArrayList(u8).initCapacity(daemon.alloc, 4096);
463+ defer stdin_buf.deinit(daemon.alloc);
464+
465+ while (true) {
466+ var tmp: [4096]u8 = undefined;
467+ const n = posix.read(stdin_fd, &tmp) catch |err| {
468+ if (err == error.WouldBlock) break;
469+ return err;
470+ };
471+ if (n == 0) break;
472+ try stdin_buf.appendSlice(daemon.alloc, tmp[0..n]);
473+ }
474+
475+ const socket_path = socket.getSocketPath(
476+ daemon.alloc,
477+ daemon.cfg.socket_dir,
478+ daemon.session_name,
479+ ) catch |err| switch (err) {
480+ error.NameTooLong => return socket.printSessionNameTooLong(
481+ daemon.session_name,
482+ daemon.cfg.socket_dir,
483+ ),
484+ error.OutOfMemory => return err,
485+ };
486+ var dir = try std.fs.openDirAbsolute(daemon.cfg.socket_dir, .{});
487+ defer dir.close();
488+
489+ const result = ipc.probeSession(daemon.alloc, socket_path) catch |err| {
490+ std.log.err("session unresponsive: {s}", .{@errorName(err)});
491+ if (err == error.ConnectionRefused) {
492+ socket.cleanupStaleSocket(dir, daemon.session_name);
493+ w.interface.print("cleaned up stale session {s}\n", .{daemon.session_name}) catch {};
494+ } else {
495+ w.interface.print(
496+ "session {s} is unresponsive ({s})\ndaemon may be busy: try again\n",
497+ .{ daemon.session_name, @errorName(err) },
498+ ) catch {};
499+ }
500+ w.interface.flush() catch {};
501+ return;
502+ };
503+
504+ defer posix.close(result.fd);
505+
506+ // Build wire payload: [u32 path len][path bytes][file content]
507+ var wire_buf = try std.ArrayList(u8).initCapacity(
508+ daemon.alloc,
509+ @sizeOf(u32) + file_path.len + stdin_buf.items.len,
510+ );
511+ defer wire_buf.deinit(daemon.alloc);
512+ const path_len: u32 = @intCast(file_path.len);
513+ try wire_buf.appendSlice(daemon.alloc, std.mem.asBytes(&path_len));
514+ try wire_buf.appendSlice(daemon.alloc, file_path);
515+ try wire_buf.appendSlice(daemon.alloc, stdin_buf.items);
516+
517+ ipc.send(result.fd, .Write, wire_buf.items) catch |err| switch (err) {
518+ error.BrokenPipe, error.ConnectionResetByPeer => return,
519+ else => return err,
520+ };
521+
522+ var sb = try ipc.SocketBuffer.init(daemon.alloc);
523+ defer sb.deinit();
524+
525+ const n = sb.read(result.fd) catch return error.ReadFailed;
526+ if (n == 0) return error.ConnectionClosed;
527+
528+ while (sb.next()) |msg| {
529+ if (msg.header.tag == .Ack) {
530+ try w.interface.print("file created {s}\n", .{file_path});
531+ try w.interface.flush();
532+ return;
533+ }
534+ }
535+
536+ return error.NoAckReceived;
537+}
538+
539+fn run(daemon: *Daemon, detached: bool, shell_basename: []const u8, command_args: [][]const u8) !void {
540 const alloc = daemon.alloc;
541 var buf: [4096]u8 = undefined;
542 var w = std.fs.File.stdout().writer(&buf);
543@@ -1404,25 +1826,17 @@ fn run(daemon: *Daemon, command_args: [][]const u8) !void {
544 try w.interface.flush();
545 }
546
547- const shell = util.detectShell();
548- const shell_basename = std.fs.path.basename(shell);
549- // We append a task marker so we can:
550- // - know when the command finishes
551- // - capture its exit status
552- // This information is retrived when running `zmx list`
553- const inline_task_marker = if (std.mem.eql(u8, shell_basename, "fish"))
554- "; echo ZMX_TASK_COMPLETED:$status"
555- else
556- "; echo ZMX_TASK_COMPLETED:$?";
557- const stdin_task_marker = if (std.mem.eql(u8, shell_basename, "fish"))
558- "echo ZMX_TASK_COMPLETED:$status"
559- else
560- "echo ZMX_TASK_COMPLETED:$?";
561+ // Prefix byte tells the daemon which shell syntax to use for the
562+ // task-completion marker (0 = bash/zsh $?, 1 = fish $status).
563+ // The daemon appends the marker itself so the client never injects
564+ // shell-specific text -- keeping recovery (Ctrl-C) clean.
565+ const is_fish: u8 = if (std.mem.eql(u8, shell_basename, "fish")) 1 else 0;
566
567 if (command_args.len > 0) {
568 var cmd_list = std.ArrayList(u8).empty;
569 defer cmd_list.deinit(alloc);
570
571+ try cmd_list.append(alloc, is_fish);
572 for (command_args, 0..) |arg, i| {
573 if (i > 0) try cmd_list.append(alloc, ' ');
574 if (util.shellNeedsQuoting(arg)) {
575@@ -1434,7 +1848,6 @@ fn run(daemon: *Daemon, command_args: [][]const u8) !void {
576 }
577 }
578
579- try cmd_list.appendSlice(alloc, inline_task_marker);
580 // \r, not \n: once the shell is at the readline prompt the PTY is in
581 // raw mode; readline's accept-line binds to CR. The first-ever run
582 // works with \n only because it arrives during shell startup while
583@@ -1449,6 +1862,7 @@ fn run(daemon: *Daemon, command_args: [][]const u8) !void {
584 var stdin_buf = try std.ArrayList(u8).initCapacity(alloc, 4096);
585 defer stdin_buf.deinit(alloc);
586
587+ try stdin_buf.append(alloc, is_fish);
588 while (true) {
589 var tmp: [4096]u8 = undefined;
590 const n = posix.read(stdin_fd, &tmp) catch |err| {
591@@ -1459,7 +1873,7 @@ fn run(daemon: *Daemon, command_args: [][]const u8) !void {
592 try stdin_buf.appendSlice(alloc, tmp[0..n]);
593 }
594
595- if (stdin_buf.items.len > 0) {
596+ if (stdin_buf.items.len > 1) {
597 // Normalize any trailing newline to CR so readline (raw mode)
598 // accepts each line.
599 if (stdin_buf.items[stdin_buf.items.len - 1] == '\n') {
600@@ -1468,9 +1882,6 @@ fn run(daemon: *Daemon, command_args: [][]const u8) !void {
601 try stdin_buf.append(alloc, '\r');
602 }
603
604- try stdin_buf.appendSlice(alloc, stdin_task_marker);
605- try stdin_buf.append(alloc, '\r');
606-
607 cmd_to_send = try alloc.dupe(u8, stdin_buf.items);
608 allocated_cmd = @constCast(cmd_to_send.?);
609 }
610@@ -1481,41 +1892,20 @@ fn run(daemon: *Daemon, command_args: [][]const u8) !void {
611 return error.CommandRequired;
612 }
613
614- const probe_result = ipc.probeSession(alloc, daemon.socket_path) catch |err| {
615- std.log.err("session not ready: {s}", .{@errorName(err)});
616- return error.SessionNotReady;
617- };
618- defer posix.close(probe_result.fd);
619+ const client_sock = try socket.sessionConnect(daemon.socket_path);
620+ defer posix.close(client_sock);
621+
622+ var fds = try std.ArrayList(i32).initCapacity(alloc, 1);
623+ defer fds.deinit(alloc);
624+ try fds.append(alloc, client_sock);
625
626- ipc.send(probe_result.fd, .Run, cmd_to_send.?) catch |err| switch (err) {
627+ ipc.send(client_sock, .Run, cmd_to_send.?) catch |err| switch (err) {
628 error.ConnectionResetByPeer, error.BrokenPipe => return,
629 else => return err,
630 };
631
632- var poll_fds = [_]posix.pollfd{
633- .{ .fd = probe_result.fd, .events = posix.POLL.IN, .revents = 0 },
634- };
635- const poll_result = posix.poll(&poll_fds, 5000) catch return error.PollFailed;
636- if (poll_result == 0) {
637- std.log.err("timeout waiting for ack", .{});
638- return error.Timeout;
639- }
640-
641- var sb = try ipc.SocketBuffer.init(alloc);
642- defer sb.deinit();
643-
644- const n = sb.read(probe_result.fd) catch return error.ReadFailed;
645- if (n == 0) return error.ConnectionClosed;
646-
647- while (sb.next()) |msg| {
648- if (msg.header.tag == .Ack) {
649- try w.interface.print("command sent\n", .{});
650- try w.interface.flush();
651- return;
652- }
653- }
654-
655- return error.NoAckReceived;
656+ const exit_code = try tail(fds, detached, true);
657+ posix.exit(exit_code);
658 }
659
660 const ClientResult = struct {
661@@ -1826,7 +2216,12 @@ fn daemonLoop(daemon: *Daemon, server_sock_fd: i32, pty_fd: i32) !void {
662 daemon.task_ended_at = @intCast(std.time.timestamp());
663
664 std.log.info("task completed exit_code={d}", .{exit_code});
665- // Shell continues running - no break here
666+
667+ // Notify connected clients
668+ for (daemon.clients.items) |c| {
669+ ipc.appendMessage(daemon.alloc, &c.write_buf, .TaskComplete, &[_]u8{exit_code}) catch {};
670+ c.has_pending_output = true;
671+ }
672 }
673 }
674
675@@ -1918,7 +2313,8 @@ fn daemonLoop(daemon: *Daemon, server_sock_fd: i32, pty_fd: i32) !void {
676 .Info => try daemon.handleInfo(client),
677 .History => try daemon.handleHistory(client, &term, msg.payload),
678 .Run => try daemon.handleRun(client, msg.payload),
679- .Output, .Ack => {},
680+ .Output, .Ack, .TaskComplete => {},
681+ .Write => try daemon.handleWrite(client, msg.payload),
682 _ => std.log.warn(
683 "ignoring unknown IPC tag={d}",
684 .{@intFromEnum(msg.header.tag)},