Commit e719274

Eric Bower  ·  2026-04-10 15:12:34 -0400 EDT
parent bbbe245
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
......@@ -11,9 +11,14 @@ Use spec: https://common-changelog.org/
1111 - Non-leader clients are read-only until they send user input bytes and takeover leadership
1212 - When a leader is promoted we immediately resize to their window size
1313 - `zmx attach` now lets users switch to another session from within a session
14+- `zmx tail` will receive all outout from sessions in read-only mode
15+- `zmx write` will pipe data from stdin, convert to base64, chunk, and send data through pty to write to a file
1416
1517 ### Changed
1618
19+- *BREAKING* `zmx run` is now synchonous by default and tails the session
20+ - Use detached mode (`-d`) for previous behavior
21+- `zmx run` accepts `--fish` flag to indicate the session's shell is fish
1722 - `zmx kill` now supports multiple args and it will kill sessions that match a prefix
1823 - e.g. `zmx kill d.` will kill all sessions that match that prefix
1924
+12, -10
......@@ -71,16 +71,18 @@ zig build -Doptimize=ReleaseSafe --prefix ~/.local
7171 Usage: zmx <command> [args]
7272
7373 Commands:
74- [a]ttach <name> [command...] Attach to session, creating session if needed
75- [r]un <name> [command...] Send command without attaching, creating session if needed
76- [d]etach Detach all clients from current session (ctrl+\ for current client)
77- [l]ist [--short] List active sessions
78- [k]ill <name>... [--force] Kill a session and all attached clients
79- [hi]story <name> [--vt|--html] Output session scrollback (--vt or --html for escape sequences)
80- [w]ait <name>... Wait for session tasks to complete
81- [c]ompletions <shell> Completion scripts for shell integration (bash, zsh, or fish)
82- [v]ersion Show version information
83- [h]elp Show this help message
74+ [a]ttach <name> [command...] Attach to session, creating if needed
75+ [r]un <name> [-d] [--fish] [command...] Send command without attaching
76+ [wr]ite <name> <file_path> Write stdin to file_path through the session
77+ [d]etach Detach all clients (ctrl+\ for current client)
78+ [l]ist [--short] List active sessions
79+ [k]ill <name>... [--force] Kill session and all attached clients
80+ [hi]story <name> [--vt|--html] Output session scrollback
81+ [w]ait <name>... Wait for session tasks to complete
82+ [t]ail <name>... Follow session output
83+ [c]ompletions <shell> Shell completions (bash, zsh, fish)
84+ [v]ersion Show version
85+ [h]elp Show this help
8486 ```
8587
8688 ### examples
+0, -74
......@@ -1,74 +0,0 @@
1----
2-name: zmx-session
3-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.
4----
5-
6-# zmx Collaborative Terminal Sessions
7-
8-## Overview
9-
10-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.
11-
12-This skill covers using zmx for collaborative debugging where Claude can directly view session history and execute commands.
13-
14-Run `zmx help` to understand the commands and when to run them.
15-
16-## Session Setup
17-
18-The user starts a zmx session and works within it:
19-
20-```bash
21-# Create or attach to a named session
22-zmx attach <session-name>
23-```
24-
25-Naming convention suggestion: use descriptive names like `debug-prod`, `k8s-issue`, `logs-api`.
26-
27-## Viewing Session Context
28-
29-Claude can directly view the terminal history without user intervention:
30-
31-```bash
32-# List active sessions
33-zmx list
34-
35-# View recent scrollback from a session (always pipe to tail to limit context)
36-zmx history <session-name> | tail -200
37-```
38-
39-These are read-only commands—run them freely to understand what's happening.
40-
41-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.
42-
43-## Command Execution Protocol
44-
45-**Always ask permission before running commands that execute in the user's session.**
46-
47-To execute a command in a running session without attaching:
48-
49-```bash
50-zmx run <session-name> <command>
51-```
52-
53-Then you can wait for the task to complete by running:
54-
55-```bash
56-zmx wait <session-name>
57-```
58-
59-And you can track the exit code by running:
60-
61-```bash
62-zmx list | grep <session-name>
63-```
64-
65-Example workflow:
66-
67-1. User tells Claude the session name and describes the issue
68-1. Claude runs `zmx history <session-name> | tail -200` to see context
69-1. Claude analyzes and proposes a command
70-1. User approves
71-1. Claude runs via `zmx run <session-name> <command>`
72-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)
73-1. Claude evaluates the output and provides analysis
74-1. Repeat steps 3-7 as needed until the issue is resolved
+2, -0
......@@ -16,6 +16,8 @@ pub const Tag = enum(u8) {
1616 Run = 9,
1717 Ack = 10,
1818 Switch = 11,
19+ Write = 12,
20+ TaskComplete = 13,
1921 // Non-exhaustive: this enum comes off the wire via bytesToValue and
2022 // @enumFromInt, so out-of-range values (11-255) are representable
2123 // rather than UB. Switches must handle `_` (unknown tag).
+471, -75
......@@ -135,7 +135,22 @@ pub fn main() !void {
135135
136136 var cmd_args_raw: std.ArrayList([]const u8) = .empty;
137137 defer cmd_args_raw.deinit(alloc);
138+ const shell = util.detectShell();
139+ var shell_basename = std.fs.path.basename(shell);
140+ var detached = false;
138141 while (args.next()) |arg| {
142+ // TODO: detect shell within the session instead of asking the user to tell us
143+ // if the shell is fish.
144+ // Because fish tracks exit code status via $status instead of $? we need some
145+ // way to figure out what shell is being used inside the session.
146+ if (std.mem.startsWith(u8, arg, "--fish")) {
147+ shell_basename = "fish";
148+ continue;
149+ }
150+ if (std.mem.startsWith(u8, arg, "-d")) {
151+ detached = true;
152+ continue;
153+ }
139154 try cmd_args_raw.append(alloc, arg);
140155 }
141156 const clients = try std.ArrayList(*Client).initCapacity(alloc, 10);
......@@ -157,14 +172,14 @@ pub fn main() !void {
157172 .cwd = cwd,
158173 .created_at = @intCast(std.time.timestamp()),
159174 .is_task_mode = true,
160- .leader_client_fd = undefined,
175+ .leader_client_fd = null,
161176 };
162177 daemon.socket_path = socket.getSocketPath(alloc, cfg.socket_dir, sesh) catch |err| switch (err) {
163178 error.NameTooLong => return socket.printSessionNameTooLong(sesh, cfg.socket_dir),
164179 error.OutOfMemory => return err,
165180 };
166181 std.log.info("socket path={s}", .{daemon.socket_path});
167- return run(&daemon, cmd_args_raw.items);
182+ return run(&daemon, detached, shell_basename, cmd_args_raw.items);
168183 } else if (std.mem.eql(u8, cmd, "kill") or std.mem.eql(u8, cmd, "k")) {
169184 var stderr_buffer: [1024]u8 = undefined;
170185 var stderr_writer = std.fs.File.stderr().writer(&stderr_buffer);
......@@ -240,6 +255,76 @@ pub fn main() !void {
240255 try args_raw.append(alloc, prefix);
241256 }
242257 return wait(&cfg, args_raw);
258+ } else if (std.mem.eql(u8, cmd, "tail") or std.mem.eql(u8, cmd, "t")) {
259+ var session_names: std.ArrayList([]const u8) = .empty;
260+ defer {
261+ for (session_names.items) |sesh| {
262+ alloc.free(sesh);
263+ }
264+ session_names.deinit(alloc);
265+ }
266+ while (args.next()) |session_name| {
267+ const sesh = try socket.getSeshName(alloc, session_name);
268+ try session_names.append(alloc, sesh);
269+ }
270+ // if no args are provided we assume they want to wait for all sessions matching the
271+ // prefix.
272+ if (session_names.items.len == 0) {
273+ const prefix = socket.getSeshPrefix();
274+ if (prefix.len == 0) {
275+ return error.SessionNameRequired;
276+ }
277+ try session_names.append(alloc, prefix);
278+ }
279+
280+ var client_socket_fds = try std.ArrayList(i32).initCapacity(alloc, session_names.items.len);
281+ defer {
282+ for (client_socket_fds.items) |client_fd| {
283+ posix.close(client_fd);
284+ }
285+ client_socket_fds.deinit(alloc);
286+ }
287+
288+ for (session_names.items) |session_name| {
289+ const socket_path = socket.getSocketPath(alloc, cfg.socket_dir, session_name) catch |err| switch (err) {
290+ error.NameTooLong => return socket.printSessionNameTooLong(session_name, cfg.socket_dir),
291+ error.OutOfMemory => return err,
292+ };
293+ const client_sock = try socket.sessionConnect(socket_path);
294+ try client_socket_fds.append(alloc, client_sock);
295+ }
296+ _ = try tail(client_socket_fds, false, false);
297+ } else if (std.mem.eql(u8, cmd, "write") or std.mem.eql(u8, cmd, "wr")) {
298+ const session_name = args.next() orelse "";
299+ if (session_name.len == 0) return error.SessionNameRequired;
300+ const file_path = args.next() orelse "";
301+ if (file_path.len == 0) return error.FilePathRequired;
302+
303+ var cwd_buf: [std.fs.max_path_bytes]u8 = undefined;
304+ const cwd = std.posix.getcwd(&cwd_buf) catch "";
305+ const clients = try std.ArrayList(*Client).initCapacity(alloc, 10);
306+ const sesh = try socket.getSeshName(alloc, session_name);
307+ defer alloc.free(sesh);
308+ var daemon = Daemon{
309+ .running = true,
310+ .cfg = &cfg,
311+ .alloc = alloc,
312+ .clients = clients,
313+ .session_name = sesh,
314+ .socket_path = undefined,
315+ .pid = undefined,
316+ .command = null,
317+ .cwd = cwd,
318+ .created_at = @intCast(std.time.timestamp()),
319+ .is_task_mode = true,
320+ .leader_client_fd = null,
321+ };
322+ daemon.socket_path = socket.getSocketPath(alloc, cfg.socket_dir, sesh) catch |err| switch (err) {
323+ error.NameTooLong => return socket.printSessionNameTooLong(sesh, cfg.socket_dir),
324+ error.OutOfMemory => return err,
325+ };
326+ std.log.info("socket path={s}", .{daemon.socket_path});
327+ try writeFile(&daemon, file_path);
243328 } else {
244329 return help();
245330 }
......@@ -392,6 +477,7 @@ const Daemon = struct {
392477 is_task_mode: bool = false, // flag for when session is run as a task
393478 task_exit_code: ?u8 = null, // null = running or n/a, set when task completes
394479 task_ended_at: ?u64 = null, // timestamp when task exited
480+ is_fish: bool = false, // true if session shell is fish (affects exit code variable)
395481 pty_write_buf: std.ArrayList(u8) = .empty,
396482
397483 const EnsureSessionResult = struct {
......@@ -896,12 +982,86 @@ const Daemon = struct {
896982 self.task_ended_at = null;
897983 self.is_task_mode = true;
898984
899- self.queuePtyInput(payload);
985+ if (payload.len == 0) return;
986+
987+ // First byte indicates shell type (0=bash/zsh, 1=fish)
988+ self.is_fish = payload[0] == 1;
989+ const cmd = payload[1..];
990+
991+ // Daemon appends the task marker so the client never injects
992+ // shell-specific syntax, keeping Ctrl-C recovery clean.
993+ const marker = if (self.is_fish)
994+ "; echo ZMX_TASK_COMPLETED:$status"
995+ else
996+ "; echo ZMX_TASK_COMPLETED:$?";
997+
998+ if (cmd.len > 0 and cmd[cmd.len - 1] == '\r') {
999+ self.queuePtyInput(cmd[0 .. cmd.len - 1]);
1000+ } else {
1001+ self.queuePtyInput(cmd);
1002+ }
1003+ self.queuePtyInput(marker);
1004+ self.queuePtyInput("\r");
1005+
9001006 try ipc.appendMessage(self.alloc, &client.write_buf, .Ack, "");
9011007 client.has_pending_output = true;
9021008 self.has_had_client = true;
9031009 std.log.debug("run command len={d}", .{payload.len});
9041010 }
1011+
1012+ pub fn handleWrite(self: *Daemon, client: *Client, payload: []const u8) !void {
1013+ // Wire format: [u32 path len][path bytes][file content]
1014+ if (payload.len < @sizeOf(u32)) return error.InvalidPayload;
1015+ const path_len = std.mem.bytesToValue(u32, payload[0..@sizeOf(u32)]);
1016+ if (payload.len < @sizeOf(u32) + path_len) return error.InvalidPayload;
1017+ const file_path = payload[@sizeOf(u32)..][0..path_len];
1018+ const file_content = payload[@sizeOf(u32) + path_len ..];
1019+
1020+ // Inject file creation through the PTY so it works over SSH.
1021+ // Base64-encode content and pipe through printf | base64 -d > file.
1022+ // Chunk large files to stay under command-line length limits.
1023+ // 48000 is divisible by 3 (clean base64 boundaries) and encodes
1024+ // to ~64KB, well under typical ARG_MAX.
1025+ const chunk_size = 48000;
1026+ var offset: usize = 0;
1027+ var is_first = true;
1028+
1029+ while (offset < file_content.len or is_first) {
1030+ const end = @min(offset + chunk_size, file_content.len);
1031+ const chunk = file_content[offset..end];
1032+
1033+ const encoded_len = std.base64.standard.Encoder.calcSize(chunk.len);
1034+ const encoded = try self.alloc.alloc(u8, encoded_len);
1035+ defer self.alloc.free(encoded);
1036+ _ = std.base64.standard.Encoder.encode(encoded, chunk);
1037+
1038+ // Bracketed paste mode so the shell buffers input
1039+ // rather than processing each keystroke individually.
1040+ self.queuePtyInput("\x1b[200~");
1041+ self.queuePtyInput("printf '%s' '");
1042+ self.queuePtyInput(encoded);
1043+ if (is_first) {
1044+ self.queuePtyInput("' | base64 -d > '");
1045+ } else {
1046+ self.queuePtyInput("' | base64 -d >> '");
1047+ }
1048+ self.queuePtyInput(file_path);
1049+ self.queuePtyInput("'");
1050+ self.queuePtyInput("\x1b[201~");
1051+ self.queuePtyInput("\r");
1052+
1053+ offset = end;
1054+ is_first = false;
1055+ }
1056+
1057+ try ipc.appendMessage(self.alloc, &client.write_buf, .Ack, "");
1058+ client.has_pending_output = true;
1059+ self.has_had_client = true;
1060+ std.log.debug(
1061+ "write command len={d} file_path={s}",
1062+ .{ file_content.len, file_path },
1063+ );
1064+ }
9051065 };
9061066
9071067 fn printVersion(cfg: *Cfg) !void {
......@@ -930,37 +1090,211 @@ fn help() !void {
9301090 const help_text =
9311091 \\zmx - session persistence for terminal processes
9321092 \\
933- \\Usage: zmx <command> [args]
1093+ \\Usage: zmx <command> [args...]
9341094 \\
9351095 \\Commands:
936- \\ [a]ttach <name> [command...] Attach to session, creating session if needed
937- \\ [r]un <name> [command...] Send command without attaching, creating session if needed
938- \\ [d]etach Detach all clients from current session (ctrl+\ for current client)
939- \\ [l]ist [--short] List active sessions
940- \\ [k]ill <name>... [--force] Kill a session and all attached clients
941- \\ [hi]story <name> [--vt|--html] Output session scrollback (--vt or --html for escape sequences)
942- \\ [w]ait <name>... Wait for session tasks to complete
943- \\ [c]ompletions <shell> Completion scripts for shell integration (bash, zsh, or fish)
944- \\ [v]ersion Show version information
945- \\ [h]elp Show this help message
1096+ \\ [a]ttach <name> [command...] Attach to session, creating if needed
1097+ \\ [r]un <name> [-d] [--fish] [command...] Send command without attaching
1098+ \\ [wr]ite <name> <file_path> Write stdin to file_path through the session
1099+ \\ [d]etach Detach all clients (ctrl+\\ for current client)
1100+ \\ [l]ist [--short] List active sessions
1101+ \\ [k]ill <name>... [--force] Kill session and all attached clients
1102+ \\ [hi]story <name> [--vt|--html] Output session scrollback
1103+ \\ [w]ait <name>... Wait for session tasks to complete
1104+ \\ [t]ail <name>... Follow session output
1105+ \\ [c]ompletions <shell> Shell completions (bash, zsh, fish)
1106+ \\ [v]ersion Show version
1107+ \\ [h]elp Show this help
1108+ \\
1109+ \\Attach:
1110+ \\ This will spawn a login $SHELL with a PTY. You can provide a
1111+ \\ command instead of creating a shell.
1112+ \\
1113+ \\ Examples:
1114+ \\ zmx attach dev
1115+ \\ zmx attach dev vim
1116+ \\
1117+ \\History:
1118+ \\ This should generally be used with `tail` to print the last lines
1119+ \\ of the session's scrollback history.
1120+ \\
1121+ \\ Examples:
1122+ \\ zmx history <session> | tail -100
1123+ \\
1124+ \\Run:
1125+ \\ Commands are passed as-is; do not wrap in quotes.
1126+ \\ Commands run sequentially; do not send multiple in parallel.
1127+ \\ Avoid interactive programs (pagers, editors, prompts) -- they hang.
1128+ \\
1129+ \\ `-d` will detach from the calling terminal. Use `wait` to track
1130+ \\ its status.
1131+ \\
1132+ \\ `--fish` is required when the session runs fish shell.
1133+ \\
1134+ \\ If the command hangs, send Ctrl+C to recover:
1135+ \\ zmx run <session> $'\\x03'
1136+ \\
1137+ \\ Examples:
1138+ \\ zmx run dev ls
1139+ \\ zmx run dev --fish ls src
1140+ \\ zmx run dev zig build
1141+ \\ zmx run dev grep -r TODO src
1142+ \\ zmx run dev git -c core.pager=cat diff
1143+ \\
1144+ \\Write:
1145+ \\ Writes stdin to file_path inside the session. Works over SSH.
1146+ \\ file_path can be absolute or relative to the session shell's cwd.
1147+ \\ Requires base64 and printf in the remote environment.
1148+ \\ Large files are chunked automatically (~48KB per chunk).
1149+ \\ File path must not contain single quotes.
1150+ \\
1151+ \\ Examples:
1152+ \\ echo "hello" | zmx write dev /tmp/hello.txt
1153+ \\ cat main.zig | zmx write dev src/main.zig
1154+ \\
1155+ \\Wait:
1156+ \\ Used with a detached run task to track its status. Multiple
1157+ \\ sessions can be provided.
1158+ \\
1159+ \\ Examples:
1160+ \\ zmx run -d dev sleep 10
1161+ \\ zmx wait dev
1162+ \\ zmx wait dev other
9461163 \\
9471164 \\Environment variables:
948- \\ - SHELL Determines which shell is used when creating a session
949- \\ - ZMX_DIR Controls which folder is used to store unix socket files (prio: 1)
950- \\ - XDG_RUNTIME_DIR Controls which folder is used to store unix socket files (prio: 2)
951- \\ - TMPDIR Controls which folder is used to store unix socket files (prio: 3)
952- \\ - ZMX_SESSION The session name we inject into every zmx session automatically
953- \\ - ZMX_SESSION_PREFIX Adds this value to the start of every session name for all commands
954- \\ - ZMX_DIR_MODE Sets the mode for the socket and log directories (octal, defaults to 0750)
955- \\ - ZMX_LOG_MODE Sets the mode for the log files (octal, defaults to 0640)
1165+ \\ SHELL Default shell for new sessions
1166+ \\ ZMX_DIR Socket directory (priority 1)
1167+ \\ XDG_RUNTIME_DIR Socket directory (priority 2)
1168+ \\ TMPDIR Socket directory (priority 3)
1169+ \\ ZMX_SESSION Session name (injected automatically)
1170+ \\ ZMX_SESSION_PREFIX Prefix added to all session names
1171+ \\ ZMX_DIR_MODE Sets mode for socket and log directories (octal, defaults to 0750)
1172+ \\ ZMX_LOG_MODE Sets mode for log files (octal, defaults to 0640)
9561173 \\
9571174 ;
958- var buf: [4096]u8 = undefined;
1175+ var buf: [8192]u8 = undefined;
9591176 var w = std.fs.File.stdout().writer(&buf);
9601177 try w.interface.print(help_text, .{});
9611178 try w.interface.flush();
9621179 }
9631180
1181+fn tail(client_socket_fds: std.ArrayList(i32), detached: bool, is_run_cmd: bool) !u8 {
1182+ var gpa = std.heap.GeneralPurposeAllocator(.{}){};
1183+ defer _ = gpa.deinit();
1184+ const alloc = gpa.allocator();
1185+
1186+ var poll_fds = try std.ArrayList(posix.pollfd).initCapacity(alloc, 4);
1187+ defer poll_fds.deinit(alloc);
1188+
1189+ var read_buf = try ipc.SocketBuffer.init(alloc);
1190+ defer read_buf.deinit();
1191+
1192+ var stdout_buf = try std.ArrayList(u8).initCapacity(alloc, 4096);
1193+ defer stdout_buf.deinit(alloc);
1194+
1195+ var is_first_line = true;
1196+ var task_complete_code: ?u8 = null;
1197+
1198+ while (true) {
1199+ poll_fds.clearRetainingCapacity();
1200+
1201+ // Poll socket for read
1202+ for (client_socket_fds.items) |client_sock_fd| {
1203+ try poll_fds.append(alloc, .{
1204+ .fd = client_sock_fd,
1205+ .events = posix.POLL.IN,
1206+ .revents = 0,
1207+ });
1208+ }
1209+
1210+ // Poll for write if we have pending data
1211+ if (stdout_buf.items.len > 0) {
1212+ try poll_fds.append(alloc, .{
1213+ .fd = posix.STDOUT_FILENO,
1214+ .events = posix.POLL.OUT,
1215+ .revents = 0,
1216+ });
1217+ }
1218+
1219+ _ = posix.poll(poll_fds.items, -1) catch |err| {
1220+ if (err == error.Interrupted) continue; // EINTR from signal, loop again
1221+ return err;
1222+ };
1223+
1224+ // Handle socket read (incoming Output messages from daemon)
1225+ for (poll_fds.items) |*poll_fd| {
1226+ if (poll_fd.revents & posix.POLL.IN != 0) {
1227+ const n = read_buf.read(poll_fd.fd) catch |err| {
1228+ if (err == error.WouldBlock) continue;
1229+ if (err == error.ConnectionResetByPeer or err == error.BrokenPipe) {
1230+ return 1;
1231+ }
1232+ std.log.err("daemon read err={s}", .{@errorName(err)});
1233+ return err;
1234+ };
1235+ if (n == 0) {
1236+ // Server closed connection
1237+ return 0;
1238+ }
1239+
1240+ while (read_buf.next()) |msg| {
1241+ switch (msg.header.tag) {
1242+ .Ack => {
1243+ if (detached) {
1244+ _ = posix.write(posix.STDOUT_FILENO, "command sent!\n") catch |err| blk: {
1245+ if (err == error.WouldBlock) break :blk 0;
1246+ return err;
1247+ };
1248+ return 0;
1249+ }
1250+ },
1251+ .Output => {
1252+ if (msg.payload.len > 0) {
1253+ // strip the first line since it is an echo of
1254+ // the command.
1255+ if (!detached and is_run_cmd and is_first_line) {
1256+ if (std.mem.indexOfScalar(u8, msg.payload, '\n')) |nl| {
1257+ is_first_line = false;
1258+ if (nl + 1 < msg.payload.len) {
1259+ try stdout_buf.appendSlice(alloc, msg.payload[nl + 1 ..]);
1260+ }
1261+ }
1262+ } else {
1263+ try stdout_buf.appendSlice(alloc, msg.payload);
1264+ }
1265+ }
1266+ },
1267+ .TaskComplete => {
1268+ task_complete_code = if (msg.payload.len > 0) msg.payload[0] else 0;
1269+ },
1270+ else => {},
1271+ }
1272+ }
1273+ }
1274+ }
1275+
1276+ if (stdout_buf.items.len > 0) {
1277+ const n = posix.write(posix.STDOUT_FILENO, stdout_buf.items) catch |err| blk: {
1278+ if (err == error.WouldBlock) break :blk 0;
1279+ return err;
1280+ };
1281+ if (task_complete_code) |exit_code| {
1282+ return exit_code;
1283+ }
1284+ if (n > 0) {
1285+ try stdout_buf.replaceRange(alloc, 0, n, &[_]u8{});
1286+ }
1287+ }
1288+
1289+ // Check for HUP/ERR on any socket
1290+ for (poll_fds.items) |poll_fd| {
1291+ if (poll_fd.revents & (posix.POLL.HUP | posix.POLL.ERR | posix.POLL.NVAL) != 0) {
1292+ return 0;
1293+ }
1294+ }
1295+ }
1296+}
1297+
9641298 fn wait(cfg: *Cfg, session_names: std.ArrayList([]const u8)) !void {
9651299 var gpa = std.heap.GeneralPurposeAllocator(.{}){};
9661300 defer _ = gpa.deinit();
......@@ -1387,7 +1721,95 @@ fn attach(daemon: *Daemon) !void {
13871721 }
13881722 }
13891723
1390-fn run(daemon: *Daemon, command_args: [][]const u8) !void {
1724+fn writeFile(daemon: *Daemon, file_path: []const u8) !void {
1725+ var buf: [4096]u8 = undefined;
1726+ var w = std.fs.File.stdout().writer(&buf);
1727+ const sesh_result = try daemon.ensureSession();
1728+ if (sesh_result.is_daemon) return;
1729+
1730+ if (sesh_result.created) {
1731+ try w.interface.print("session \"{s}\" created\n", .{daemon.session_name});
1732+ try w.interface.flush();
1733+ }
1734+ const stdin_fd = posix.STDIN_FILENO;
1735+ var stdin_buf = try std.ArrayList(u8).initCapacity(daemon.alloc, 4096);
1736+ defer stdin_buf.deinit(daemon.alloc);
1737+
1738+ while (true) {
1739+ var tmp: [4096]u8 = undefined;
1740+ const n = posix.read(stdin_fd, &tmp) catch |err| {
1741+ if (err == error.WouldBlock) break;
1742+ return err;
1743+ };
1744+ if (n == 0) break;
1745+ try stdin_buf.appendSlice(daemon.alloc, tmp[0..n]);
1746+ }
1747+
1748+ const socket_path = socket.getSocketPath(
1749+ daemon.alloc,
1750+ daemon.cfg.socket_dir,
1751+ daemon.session_name,
1752+ ) catch |err| switch (err) {
1753+ error.NameTooLong => return socket.printSessionNameTooLong(
1754+ daemon.session_name,
1755+ daemon.cfg.socket_dir,
1756+ ),
1757+ error.OutOfMemory => return err,
1758+ };
1759+ var dir = try std.fs.openDirAbsolute(daemon.cfg.socket_dir, .{});
1760+ defer dir.close();
1761+
1762+ const result = ipc.probeSession(daemon.alloc, socket_path) catch |err| {
1763+ std.log.err("session unresponsive: {s}", .{@errorName(err)});
1764+ if (err == error.ConnectionRefused) {
1765+ socket.cleanupStaleSocket(dir, daemon.session_name);
1766+ w.interface.print("cleaned up stale session {s}\n", .{daemon.session_name}) catch {};
1767+ } else {
1768+ w.interface.print(
1769+ "session {s} is unresponsive ({s})\ndaemon may be busy: try again\n",
1770+ .{ daemon.session_name, @errorName(err) },
1771+ ) catch {};
1772+ }
1773+ w.interface.flush() catch {};
1774+ return;
1775+ };
1776+
1777+ defer posix.close(result.fd);
1778+
1779+ // Build wire payload: [u32 path len][path bytes][file content]
1780+ var wire_buf = try std.ArrayList(u8).initCapacity(
1781+ daemon.alloc,
1782+ @sizeOf(u32) + file_path.len + stdin_buf.items.len,
1783+ );
1784+ defer wire_buf.deinit(daemon.alloc);
1785+ const path_len: u32 = @intCast(file_path.len);
1786+ try wire_buf.appendSlice(daemon.alloc, std.mem.asBytes(&path_len));
1787+ try wire_buf.appendSlice(daemon.alloc, file_path);
1788+ try wire_buf.appendSlice(daemon.alloc, stdin_buf.items);
1789+
1790+ ipc.send(result.fd, .Write, wire_buf.items) catch |err| switch (err) {
1791+ error.BrokenPipe, error.ConnectionResetByPeer => return,
1792+ else => return err,
1793+ };
1794+
1795+ var sb = try ipc.SocketBuffer.init(daemon.alloc);
1796+ defer sb.deinit();
1797+
1798+ const n = sb.read(result.fd) catch return error.ReadFailed;
1799+ if (n == 0) return error.ConnectionClosed;
1800+
1801+ while (sb.next()) |msg| {
1802+ if (msg.header.tag == .Ack) {
1803+ try w.interface.print("file created {s}\n", .{file_path});
1804+ try w.interface.flush();
1805+ return;
1806+ }
1807+ }
1808+
1809+ return error.NoAckReceived;
1810+}
1811+
1812+fn run(daemon: *Daemon, detached: bool, shell_basename: []const u8, command_args: [][]const u8) !void {
13911813 const alloc = daemon.alloc;
13921814 var buf: [4096]u8 = undefined;
13931815 var w = std.fs.File.stdout().writer(&buf);
......@@ -1404,25 +1826,17 @@ fn run(daemon: *Daemon, command_args: [][]const u8) !void {
14041826 try w.interface.flush();
14051827 }
14061828
1407- const shell = util.detectShell();
1408- const shell_basename = std.fs.path.basename(shell);
1409- // We append a task marker so we can:
1410- // - know when the command finishes
1411- // - capture its exit status
1412- // This information is retrived when running `zmx list`
1413- const inline_task_marker = if (std.mem.eql(u8, shell_basename, "fish"))
1414- "; echo ZMX_TASK_COMPLETED:$status"
1415- else
1416- "; echo ZMX_TASK_COMPLETED:$?";
1417- const stdin_task_marker = if (std.mem.eql(u8, shell_basename, "fish"))
1418- "echo ZMX_TASK_COMPLETED:$status"
1419- else
1420- "echo ZMX_TASK_COMPLETED:$?";
1829+ // Prefix byte tells the daemon which shell syntax to use for the
1830+ // task-completion marker (0 = bash/zsh $?, 1 = fish $status).
1831+ // The daemon appends the marker itself so the client never injects
1832+ // shell-specific text -- keeping recovery (Ctrl-C) clean.
1833+ const is_fish: u8 = if (std.mem.eql(u8, shell_basename, "fish")) 1 else 0;
14211834
14221835 if (command_args.len > 0) {
14231836 var cmd_list = std.ArrayList(u8).empty;
14241837 defer cmd_list.deinit(alloc);
14251838
1839+ try cmd_list.append(alloc, is_fish);
14261840 for (command_args, 0..) |arg, i| {
14271841 if (i > 0) try cmd_list.append(alloc, ' ');
14281842 if (util.shellNeedsQuoting(arg)) {
......@@ -1434,7 +1848,6 @@ fn run(daemon: *Daemon, command_args: [][]const u8) !void {
14341848 }
14351849 }
14361850
1437- try cmd_list.appendSlice(alloc, inline_task_marker);
14381851 // \r, not \n: once the shell is at the readline prompt the PTY is in
14391852 // raw mode; readline's accept-line binds to CR. The first-ever run
14401853 // works with \n only because it arrives during shell startup while
......@@ -1449,6 +1862,7 @@ fn run(daemon: *Daemon, command_args: [][]const u8) !void {
14491862 var stdin_buf = try std.ArrayList(u8).initCapacity(alloc, 4096);
14501863 defer stdin_buf.deinit(alloc);
14511864
1865+ try stdin_buf.append(alloc, is_fish);
14521866 while (true) {
14531867 var tmp: [4096]u8 = undefined;
14541868 const n = posix.read(stdin_fd, &tmp) catch |err| {
......@@ -1459,7 +1873,7 @@ fn run(daemon: *Daemon, command_args: [][]const u8) !void {
14591873 try stdin_buf.appendSlice(alloc, tmp[0..n]);
14601874 }
14611875
1462- if (stdin_buf.items.len > 0) {
1876+ if (stdin_buf.items.len > 1) {
14631877 // Normalize any trailing newline to CR so readline (raw mode)
14641878 // accepts each line.
14651879 if (stdin_buf.items[stdin_buf.items.len - 1] == '\n') {
......@@ -1468,9 +1882,6 @@ fn run(daemon: *Daemon, command_args: [][]const u8) !void {
14681882 try stdin_buf.append(alloc, '\r');
14691883 }
14701884
1471- try stdin_buf.appendSlice(alloc, stdin_task_marker);
1472- try stdin_buf.append(alloc, '\r');
1473-
14741885 cmd_to_send = try alloc.dupe(u8, stdin_buf.items);
14751886 allocated_cmd = @constCast(cmd_to_send.?);
14761887 }
......@@ -1481,41 +1892,20 @@ fn run(daemon: *Daemon, command_args: [][]const u8) !void {
14811892 return error.CommandRequired;
14821893 }
14831894
1484- const probe_result = ipc.probeSession(alloc, daemon.socket_path) catch |err| {
1485- std.log.err("session not ready: {s}", .{@errorName(err)});
1486- return error.SessionNotReady;
1487- };
1488- defer posix.close(probe_result.fd);
1895+ const client_sock = try socket.sessionConnect(daemon.socket_path);
1896+ defer posix.close(client_sock);
1897+
1898+ var fds = try std.ArrayList(i32).initCapacity(alloc, 1);
1899+ defer fds.deinit(alloc);
1900+ try fds.append(alloc, client_sock);
14891901
1490- ipc.send(probe_result.fd, .Run, cmd_to_send.?) catch |err| switch (err) {
1902+ ipc.send(client_sock, .Run, cmd_to_send.?) catch |err| switch (err) {
14911903 error.ConnectionResetByPeer, error.BrokenPipe => return,
14921904 else => return err,
14931905 };
14941906
1495- var poll_fds = [_]posix.pollfd{
1496- .{ .fd = probe_result.fd, .events = posix.POLL.IN, .revents = 0 },
1497- };
1498- const poll_result = posix.poll(&poll_fds, 5000) catch return error.PollFailed;
1499- if (poll_result == 0) {
1500- std.log.err("timeout waiting for ack", .{});
1501- return error.Timeout;
1502- }
1503-
1504- var sb = try ipc.SocketBuffer.init(alloc);
1505- defer sb.deinit();
1506-
1507- const n = sb.read(probe_result.fd) catch return error.ReadFailed;
1508- if (n == 0) return error.ConnectionClosed;
1509-
1510- while (sb.next()) |msg| {
1511- if (msg.header.tag == .Ack) {
1512- try w.interface.print("command sent\n", .{});
1513- try w.interface.flush();
1514- return;
1515- }
1516- }
1517-
1518- return error.NoAckReceived;
1907+ const exit_code = try tail(fds, detached, true);
1908+ posix.exit(exit_code);
15191909 }
15201910
15211911 const ClientResult = struct {
......@@ -1826,7 +2216,12 @@ fn daemonLoop(daemon: *Daemon, server_sock_fd: i32, pty_fd: i32) !void {
18262216 daemon.task_ended_at = @intCast(std.time.timestamp());
18272217
18282218 std.log.info("task completed exit_code={d}", .{exit_code});
1829- // Shell continues running - no break here
2219+
2220+ // Notify connected clients
2221+ for (daemon.clients.items) |c| {
2222+ ipc.appendMessage(daemon.alloc, &c.write_buf, .TaskComplete, &[_]u8{exit_code}) catch {};
2223+ c.has_pending_output = true;
2224+ }
18302225 }
18312226 }
18322227
......@@ -1918,7 +2313,8 @@ fn daemonLoop(daemon: *Daemon, server_sock_fd: i32, pty_fd: i32) !void {
19182313 .Info => try daemon.handleInfo(client),
19192314 .History => try daemon.handleHistory(client, &term, msg.payload),
19202315 .Run => try daemon.handleRun(client, msg.payload),
1921- .Output, .Ack => {},
2316+ .Output, .Ack, .TaskComplete => {},
2317+ .Write => try daemon.handleWrite(client, msg.payload),
19222318 _ => std.log.warn(
19232319 "ignoring unknown IPC tag={d}",
19242320 .{@intFromEnum(msg.header.tag)},