repos / zmx

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

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
M CHANGELOG.md
+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 
M README.md
+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
M src/ipc.zig
+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).
M src/main.zig
+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)},