Commit 0d66096

Eric Bower  ·  2026-07-27 09:28:10 -0400 EDT
parent 9e5eb55
refactor(daemon): restructure io and allocators

Because we perform a double-fork when spawning a daemon we have to be careful
about multithreading and mutex locks within io and allocators or else we could
end up with a deadlock.  I don't fully understand how this happens within the
DebugAllocator but I've hit deadlocks previously.  So I restructured the Daemon
to not store io or an allocator and made it much more clear what is going on
and why we have to create a new allocator after the double-fork.

I also extracted the daemonize logic into its own file and set of fns mainly
because that code doesn't need to change much and it is mostly fork() machinery.
11 files changed,  +1818, -1743
+10, -10
 1@@ -13,7 +13,7 @@ const macos_targets: []const std.Target.Query = &.{
 2 
 3 pub fn build(b: *std.Build) void {
 4     const target = b.standardTargetOptions(.{});
 5-    const is_macos = target.result.os.tag == .macos;
 6+    // const is_macos = target.result.os.tag == .macos;
 7     const optimize = b.standardOptimizeOption(.{});
 8     const version = b.option([]const u8, "version", "Version string for release") orelse
 9         @as([]const u8, build_zig_zon.version);
10@@ -51,8 +51,8 @@ pub fn build(b: *std.Build) void {
11         const run_step = b.step("run", "Run the app");
12         const exe = b.addExecutable(.{
13             .name = "zmx",
14-            .use_llvm = true,
15-            .use_lld = !is_macos,
16+            // .use_llvm = true,
17+            // .use_lld = !is_macos,
18             .root_module = exe_mod,
19         });
20 
21@@ -85,8 +85,8 @@ pub fn build(b: *std.Build) void {
22         );
23         const exe_unit_tests = b.addTest(.{
24             .root_module = test_module,
25-            .use_llvm = true,
26-            .use_lld = !is_macos,
27+            // .use_llvm = true,
28+            // .use_lld = !is_macos,
29         });
30 
31         const run_exe_unit_tests = b.addRunArtifact(exe_unit_tests);
32@@ -98,8 +98,8 @@ pub fn build(b: *std.Build) void {
33         const check = b.step("check", "Check if zmx compiles");
34         const exe_check = b.addExecutable(.{
35             .name = "zmx",
36-            .use_llvm = true,
37-            .use_lld = !is_macos,
38+            // .use_llvm = true,
39+            // .use_lld = !is_macos,
40             .root_module = exe_mod,
41         });
42 
43@@ -136,11 +136,11 @@ pub fn build(b: *std.Build) void {
44                 release_mod.addImport("ghostty-vt", release_dep.module("ghostty-vt"));
45             }
46 
47-            const is_local_macos = resolved.result.os.tag == .macos;
48+            // const is_local_macos = resolved.result.os.tag == .macos;
49             const release_exe = b.addExecutable(.{
50                 .name = "zmx",
51-                .use_llvm = true,
52-                .use_lld = !is_local_macos,
53+                // .use_llvm = true,
54+                // .use_lld = !is_local_macos,
55                 .root_module = release_mod,
56             });
57 
+133, -0
  1@@ -0,0 +1,133 @@
  2+/// Cfg is zmx's configuration container.
  3+///
  4+/// The purpose of this container is to hold anything that can be modified by the user.
  5+pub const Cfg = @This();
  6+
  7+const std = @import("std");
  8+const lib_posix = @import("posix.zig");
  9+const cross = @import("cross.zig");
 10+
 11+socket_dir: []const u8,
 12+log_dir: []const u8,
 13+max_scrollback: usize = 10_000_000,
 14+dir_mode: u32 = 0o750,
 15+log_mode: u32 = 0o640,
 16+
 17+pub fn init(alloc: std.mem.Allocator, io: std.Io) !Cfg {
 18+    const socket_dir = try socketDir(alloc);
 19+    errdefer alloc.free(socket_dir);
 20+    const log_dir = try logDir(alloc);
 21+    errdefer alloc.free(log_dir);
 22+
 23+    const dir_mode = if (lib_posix.getenv("ZMX_DIR_MODE")) |m|
 24+        std.fmt.parseInt(u32, m, 8) catch 0o750
 25+    else
 26+        0o750;
 27+
 28+    const log_mode = if (lib_posix.getenv("ZMX_LOG_MODE")) |m|
 29+        std.fmt.parseInt(u32, m, 8) catch 0o640
 30+    else
 31+        0o640;
 32+
 33+    var cfg = Cfg{
 34+        .socket_dir = socket_dir,
 35+        .log_dir = log_dir,
 36+        .dir_mode = dir_mode,
 37+        .log_mode = log_mode,
 38+    };
 39+
 40+    try cfg.mkdir(io);
 41+
 42+    return cfg;
 43+}
 44+
 45+fn socketDir(alloc: std.mem.Allocator) ![]const u8 {
 46+    const tmpdir = std.mem.trimEnd(u8, lib_posix.getenv("TMPDIR") orelse "/tmp", "/");
 47+    const uid = lib_posix.getuid();
 48+
 49+    const socket_dir: []const u8 = if (lib_posix.getenv("ZMX_DIR")) |zmxdir|
 50+        try alloc.dupe(u8, zmxdir)
 51+    else if (lib_posix.getenv("XDG_RUNTIME_DIR")) |xdg_runtime|
 52+        try std.fmt.allocPrint(alloc, "{s}/zmx", .{xdg_runtime})
 53+    else
 54+        try std.fmt.allocPrint(alloc, "{s}/zmx-{d}", .{ tmpdir, uid });
 55+
 56+    return socket_dir;
 57+}
 58+
 59+fn logDir(alloc: std.mem.Allocator) ![]const u8 {
 60+    const log_dir = if (lib_posix.getenv("ZMX_DIR")) |zmxdir|
 61+        try std.fmt.allocPrint(alloc, "{s}/logs", .{zmxdir})
 62+    else if (lib_posix.getenv("XDG_STATE_HOME")) |xdg_state_home|
 63+        try std.fmt.allocPrint(alloc, "{s}/zmx/logs", .{xdg_state_home})
 64+    else if (lib_posix.getenv("HOME")) |home_dir|
 65+        try std.fmt.allocPrint(alloc, "{s}/.local/state/zmx/logs", .{home_dir})
 66+    else fallback: {
 67+        // This is the last resort: falling back to /tmp/$UID if HOME is unset.
 68+        const tmpdir = std.mem.trimEnd(u8, lib_posix.getenv("TMPDIR") orelse "/tmp", "/");
 69+        const uid = lib_posix.getuid();
 70+        break :fallback try std.fmt.allocPrint(alloc, "{s}/zmx-{d}", .{ tmpdir, uid });
 71+    };
 72+
 73+    return log_dir;
 74+}
 75+
 76+pub fn deinit(self: *Cfg, alloc: std.mem.Allocator) void {
 77+    if (self.socket_dir.len > 0) alloc.free(self.socket_dir);
 78+    if (self.log_dir.len > 0) alloc.free(self.log_dir);
 79+}
 80+
 81+pub fn mkdir(self: *Cfg, io: std.Io) !void {
 82+    const sock_perms = std.Io.Dir.Permissions.fromMode(@intCast(self.dir_mode));
 83+    try mkdirAll(io, self.socket_dir, sock_perms);
 84+    const log_perms = std.Io.Dir.Permissions.fromMode(@intCast(self.dir_mode));
 85+    try mkdirAll(io, self.log_dir, log_perms);
 86+}
 87+
 88+fn mkdirAll(io: std.Io, sub_dir_path: []const u8, permissions: std.Io.Dir.Permissions) !void {
 89+    var it = std.fs.path.componentIterator(sub_dir_path);
 90+    var component = it.last() orelse return error.BadPathName;
 91+    while (true) {
 92+        std.Io.Dir.createDirAbsolute(io, component.path, permissions) catch |err| switch (err) {
 93+            error.PathAlreadyExists => {},
 94+            error.FileNotFound => |e| {
 95+                component = it.previous() orelse return e;
 96+                continue;
 97+            },
 98+            else => |e| return e,
 99+        };
100+        component = it.next() orelse return;
101+    }
102+}
103+
104+test "Cfg.init uses default modes when env vars are not set" {
105+    const alloc = std.testing.allocator;
106+
107+    // Ensure they are not set
108+    _ = cross.c.unsetenv("ZMX_DIR_MODE");
109+    _ = cross.c.unsetenv("ZMX_LOG_MODE");
110+
111+    var cfg = try Cfg.init(alloc, std.testing.io);
112+    defer cfg.deinit(alloc);
113+
114+    try std.testing.expectEqual(@as(u32, 0o750), cfg.dir_mode);
115+    try std.testing.expectEqual(@as(u32, 0o640), cfg.log_mode);
116+}
117+
118+test "Cfg.init uses custom modes from env vars" {
119+    const alloc = std.testing.allocator;
120+
121+    // Set custom octal values
122+    _ = cross.c.setenv("ZMX_DIR_MODE", "770", 1);
123+    _ = cross.c.setenv("ZMX_LOG_MODE", "660", 1);
124+    defer {
125+        _ = cross.c.unsetenv("ZMX_DIR_MODE");
126+        _ = cross.c.unsetenv("ZMX_LOG_MODE");
127+    }
128+
129+    var cfg = try Cfg.init(alloc, std.testing.io);
130+    defer cfg.deinit(alloc);
131+
132+    try std.testing.expectEqual(@as(u32, 0o770), cfg.dir_mode);
133+    try std.testing.expectEqual(@as(u32, 0o660), cfg.log_mode);
134+}
+211, -0
  1@@ -0,0 +1,211 @@
  2+const std = @import("std");
  3+const lib_posix = @import("posix.zig");
  4+const Cfg = @import("cfg.zig");
  5+const socket = @import("socket.zig");
  6+const ipc = @import("ipc.zig");
  7+const assert = std.debug.assert;
  8+const log = @import("log.zig");
  9+const cross = @import("cross.zig");
 10+
 11+const Cmd = struct {
 12+    file: [*:0]const u8,
 13+    argv_ptr: [*:null]const ?[*:0]const u8,
 14+};
 15+
 16+pub fn createCmdZ(def_shell: []const u8, is_task_mode: bool, command: ?[]const []const u8) !Cmd {
 17+    const gpa = std.heap.c_allocator;
 18+
 19+    if (command) |cmd_args| {
 20+        const argv = try gpa.allocSentinel(?[*:0]const u8, cmd_args.len, null);
 21+        for (cmd_args, 0..) |arg, i| {
 22+            argv[i] = try gpa.dupeZ(u8, arg);
 23+        }
 24+        return .{
 25+            .file = argv[0].?,
 26+            .argv_ptr = argv.ptr,
 27+        };
 28+    }
 29+
 30+    const z = try std.fmt.allocPrintSentinel(gpa, "{s}", .{def_shell}, 0);
 31+    const shell: [:0]const u8 = if (is_task_mode) "bash" else z;
 32+
 33+    // Use "-shellname" as argv[0] to signal login shell (traditional method)
 34+    const login_shell = try std.fmt.allocPrintSentinel(gpa, "-{s}", .{std.fs.path.basename(shell)}, 0);
 35+    const argv = try gpa.allocSentinel(?[*:0]const u8, 1, null);
 36+    argv[0] = login_shell.ptr;
 37+
 38+    return .{
 39+        .file = shell,
 40+        .argv_ptr = argv,
 41+    };
 42+}
 43+
 44+/// Runs in the forked child. Either execs or returns an error (caller
 45+/// must exit on error -- returning would fall through to parent code).
 46+fn exec(sesh_name: []const u8, cmd: Cmd) !noreturn {
 47+    const gpa = std.heap.c_allocator;
 48+
 49+    // main() set SIGPIPE to SIG_IGN, which (unlike handlers) survives
 50+    // exec. Restore the default so the shell and its children behave
 51+    // normally (e.g. `yes | head` should exit 141 via SIGPIPE).
 52+    const dfl: lib_posix.Sigaction = .{
 53+        .handler = .{ .handler = lib_posix.SIG.DFL },
 54+        .mask = lib_posix.sigemptyset(),
 55+        .flags = 0,
 56+    };
 57+    lib_posix.sigaction(lib_posix.SIG.PIPE, &dfl, null);
 58+
 59+    const session_env = try std.fmt.allocPrintSentinel(
 60+        gpa,
 61+        "ZMX_SESSION={s}",
 62+        .{sesh_name},
 63+        0,
 64+    );
 65+    _ = cross.c.putenv(session_env.ptr);
 66+
 67+    const err = lib_posix.execvpeZ(cmd.file, cmd.argv_ptr, std.c.environ);
 68+    std.log.err("execvpe failed: cmd={s} err={s}", .{ cmd.file, @errorName(err) });
 69+    lib_posix.exit(1);
 70+}
 71+
 72+pub const PtyInfo = struct {
 73+    master_fd: c_int = undefined,
 74+    pid: c_int = undefined,
 75+};
 76+
 77+/// spawnPty runs forkpty() and executes the shell or shell command the user
 78+/// provides.
 79+///
 80+/// This is the second fork in the double-fork technique explained in the
 81+/// daemonize() comment.
 82+pub fn spawnPty(sesh_name: []const u8, cmd: Cmd) !PtyInfo {
 83+    const size = ipc.getTerminalSize(lib_posix.STDOUT_FILENO);
 84+    var ws: cross.c.struct_winsize = .{
 85+        .ws_row = size.rows,
 86+        .ws_col = size.cols,
 87+        .ws_xpixel = size.xpixel,
 88+        .ws_ypixel = size.ypixel,
 89+    };
 90+
 91+    var master_fd: c_int = undefined;
 92+    const pid = cross.forkpty(&master_fd, null, null, &ws);
 93+    if (pid < 0) {
 94+        return error.ForkPtyFailed;
 95+    }
 96+
 97+    if (pid == 0) { // child pid code path
 98+        // In the forked child, ANY error must exit rather than propagate:
 99+        // a returned error falls through to the parent code path below,
100+        // running a second daemon on the same socket (or worse, hitting
101+        // errdefers that delete the parent's socket file).
102+        exec(sesh_name, cmd) catch |err| {
103+            std.log.err("child setup failed: {s}", .{@errorName(err)});
104+            lib_posix.exit(1);
105+        };
106+        unreachable; // exec() either execs or exits, never returns ok
107+    }
108+    // master pid code path
109+    std.log.info("pty spawned session={s} pid={d}", .{ sesh_name, pid });
110+
111+    // make pty non-blocking
112+    const flags = try lib_posix.fcntl(master_fd, lib_posix.F.GETFL, 0);
113+    _ = try lib_posix.fcntl(master_fd, lib_posix.F.SETFL, flags | lib_posix.O_NONBLOCK);
114+
115+    return .{
116+        .master_fd = master_fd,
117+        .pid = pid,
118+    };
119+}
120+
121+/// daemonize is the first fork in a double-fork technique to create a
122+/// completely disconnected session (group of processes).
123+///
124+/// When launching a daemon, you normally set the child process of the fork to
125+/// be the session leader via setsid() which creates a new session that does
126+/// *not* have a controlling terminal. This is important because we don't want
127+/// a controlling terminal for our daemon or else our daemon could receive
128+/// signals to shutdown when the controlling terminal closes.
129+///
130+/// However, if the first fork's child process is also the daemon process, then
131+/// it's technically possible for the daemon to open a terminal device
132+/// (e.g. open("/dev/console", O_RDWR)) and then it would acquire a controlling
133+/// terminal! A controlling terminal would expose the daemon to
134+/// terminal-generated signals (e.g. SIGINT) or SIGHUP from terminal disconnect
135+/// which could kill the daemon.
136+///
137+/// By forking a second time, the grandchild process (the daemon) is not the
138+/// session leader. Per POSIX, only a process that is the session leader can
139+/// acquire a controlling terminal.
140+///
141+/// Apparently this is considered "being paranoid" but appears to be a standard
142+/// practice for daemons so we're doing it anyway.
143+///
144+/// https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap11.html#tag_11_01_03
145+/// https://stackoverflow.com/a/16317668
146+pub fn daemonize(sesh_name: []const u8, cmd: Cmd, keep_fds_open: []i32) !PtyInfo {
147+    // creates the daemon
148+    const pid = try lib_posix.fork();
149+    assert(pid != -1);
150+
151+    if (pid > 0) { // parent (client)
152+        // cannot use a passed-in io or alloc after a fork so we create what we need
153+        // after the fork()
154+        var threaded: std.Io.Threaded = .init_single_threaded;
155+        defer threaded.deinit();
156+        const io = threaded.io();
157+        std.Io.sleep(io, std.Io.Duration.fromMilliseconds(10), .real) catch unreachable;
158+        return error.IsClientProc;
159+    }
160+
161+    assert(pid == 0); // child (daemon's parent in double-fork)
162+    // becomes the session leader and detaches process from its controlling terminal
163+    _ = try lib_posix.setsid();
164+
165+    // Redirect stdin/stdout/stderr to /dev/null. The daemon
166+    // communicates via its unix socket, not stdio. Without
167+    // this, any pipe on FDs 0-2 (e.g. from bats' `run`
168+    // keyword) stays open for the daemon's lifetime, causing
169+    // the caller to hang waiting for EOF.
170+    {
171+        const devnull = lib_posix.open(
172+            "/dev/null",
173+            .{ .ACCMODE = .RDWR },
174+            0,
175+        ) catch |err| {
176+            std.log.warn("failed to open /dev/null: {s}", .{@errorName(err)});
177+            return err;
178+        };
179+        inline for (.{ lib_posix.STDIN_FILENO, lib_posix.STDOUT_FILENO, lib_posix.STDERR_FILENO }) |fd| {
180+            _ = lib_posix.dup2(devnull, fd) catch |err| {
181+                std.log.warn("dup2 /dev/null -> {d}: {s}", .{ fd, @errorName(err) });
182+                return err;
183+            };
184+        }
185+        var found = false;
186+        for (keep_fds_open) |fd| {
187+            if (devnull == fd) found = true;
188+        }
189+        if (devnull > 2 and !found) lib_posix.close(devnull);
190+    }
191+
192+    // Close file descriptors inherited from the parent that the
193+    // daemon doesn't need. This prevents test harnesses (like
194+    // bats) from hanging: they wait for their internal FDs (3+)
195+    // to close before exiting.
196+    //
197+    // Skip any fds that the caller wants to keep open, e.g. server_sock_fd
198+    // (needed for IPC) and dir.fd (needed to delete the socket file on
199+    // shutdown).
200+    {
201+        var fd: i32 = 3;
202+        while (fd < 64) : (fd += 1) {
203+            var found = false;
204+            for (keep_fds_open) |kfd| {
205+                if (fd == kfd) found = true;
206+            }
207+            if (!found) _ = std.c.close(fd);
208+        }
209+    }
210+
211+    return spawnPty(sesh_name, cmd);
212+}
+2, -2
 1@@ -94,7 +94,7 @@ pub fn send(fd: i32, tag: Tag, data: []const u8) !void {
 2 }
 3 
 4 pub fn appendMessage(
 5-    alloc: std.mem.Allocator,
 6+    gpa: std.mem.Allocator,
 7     list: *std.ArrayList(u8),
 8     tag: Tag,
 9     data: []const u8,
10@@ -105,7 +105,7 @@ pub fn appendMessage(
11     };
12     // Guarantee capacity for header + payload in one check to avoid
13     // intermediate realloc between the two appends on the hot path.
14-    try list.ensureTotalCapacity(alloc, list.items.len + @sizeOf(Header) + data.len);
15+    try list.ensureTotalCapacity(gpa, list.items.len + @sizeOf(Header) + data.len);
16     list.appendSliceAssumeCapacity(std.mem.asBytes(&header));
17     if (data.len > 0) {
18         list.appendSliceAssumeCapacity(data);
+18, -18
 1@@ -1,19 +1,28 @@
 2 const std = @import("std");
 3 
 4+pub var log_system = LogSystem{};
 5+
 6+pub fn zmxLogFn(
 7+    comptime level: std.log.Level,
 8+    comptime scope: anytype,
 9+    comptime format: []const u8,
10+    args: anytype,
11+) void {
12+    log_system.log(level, scope, format, args) catch {};
13+}
14+
15 pub const LogSystem = struct {
16     file: ?std.Io.File = null,
17     mutex: std.Io.Mutex = .init,
18     current_size: u64 = 0,
19-    max_size: u64 = 5 * 1024 * 1024, // 5MB
20+    max_size: u64 = 2 * 1024 * 1024, // 2MB
21     path: []const u8 = "",
22-    alloc: std.mem.Allocator = undefined,
23     io: std.Io = undefined,
24     mode: std.Io.File.Permissions = std.Io.File.Permissions.fromMode(0o640),
25 
26-    pub fn init(self: *LogSystem, alloc: std.mem.Allocator, io: std.Io, path: []const u8, mode: std.Io.File.Permissions) !void {
27-        self.alloc = alloc;
28+    pub fn init(self: *LogSystem, io: std.Io, path: []const u8, mode: std.Io.File.Permissions) !void {
29         self.io = io;
30-        self.path = try alloc.dupe(u8, path);
31+        self.path = path;
32         self.mode = mode;
33 
34         const file = std.Io.Dir.openFileAbsolute(self.io, path, .{ .mode = .read_write }) catch |err| switch (err) {
35@@ -35,7 +44,6 @@ pub const LogSystem = struct {
36 
37     pub fn deinit(self: *LogSystem) void {
38         if (self.file) |f| std.Io.File.close(f, self.io);
39-        if (self.path.len > 0) self.alloc.free(self.path);
40     }
41 
42     pub fn log(
43@@ -54,12 +62,12 @@ pub const LogSystem = struct {
44         }
45 
46         if (self.current_size >= self.max_size) {
47-            self.rotate() catch |err| {
48-                std.debug.print("Log rotation failed: {s}\n", .{@errorName(err)});
49+            self.wipe() catch |err| {
50+                std.debug.print("Log wipe failed: {s}\n", .{@errorName(err)});
51             };
52         }
53 
54-        const now: i64 = @intCast(@divTrunc(std.Io.Timestamp.now(self.io, .real).nanoseconds, std.time.ns_per_ms));
55+        const now: std.Io.Timestamp = .now(self.io, .real);
56         const prefix = "[{d}] [{s}] ({s}): ";
57         const scope_name = @tagName(scope);
58         const level_name = level.asText();
59@@ -84,20 +92,12 @@ pub const LogSystem = struct {
60         }
61     }
62 
63-    fn rotate(self: *LogSystem) !void {
64+    fn wipe(self: *LogSystem) !void {
65         if (self.file) |f| {
66             std.Io.File.close(f, self.io);
67             self.file = null;
68         }
69 
70-        const old_path = try std.fmt.allocPrint(self.alloc, "{s}.old", .{self.path});
71-        defer self.alloc.free(old_path);
72-
73-        std.Io.Dir.renameAbsolute(self.path, old_path, self.io) catch |err| switch (err) {
74-            error.FileNotFound => {},
75-            else => return err,
76-        };
77-
78         self.file = try std.Io.Dir.createFileAbsolute(
79             self.io,
80             self.path,
+1212, -0
   1@@ -0,0 +1,1212 @@
   2+const std = @import("std");
   3+const ghostty_vt = @import("ghostty-vt");
   4+const ipc = @import("ipc.zig");
   5+const log = @import("log.zig");
   6+const util = @import("util.zig");
   7+const cross = @import("cross.zig");
   8+const socket = @import("socket.zig");
   9+const label = @import("label.zig");
  10+const lib_posix = @import("posix.zig");
  11+const Cfg = @import("cfg.zig");
  12+const signal = @import("signal.zig");
  13+const assert = std.debug.assert;
  14+const daemonize = @import("daemonize.zig");
  15+const builtin = @import("builtin");
  16+
  17+/// clientLoop sends ipc commands to its corresponding daemon.  It uses poll() as its non-blocking
  18+/// mechanism. It will send stdin to the daemon and receive stdout from the daemon.
  19+pub fn clientLoop(client_sock_fd: i32) !ClientResult {
  20+    std.log.info("client loop fd={d}", .{client_sock_fd});
  21+    const gpa: std.mem.Allocator = blk: {
  22+        if (builtin.mode == .Debug) {
  23+            const GPA = std.heap.DebugAllocator(.{});
  24+            const Static = struct {
  25+                var gpa: GPA = .{};
  26+            };
  27+            break :blk Static.gpa.allocator();
  28+        }
  29+        break :blk std.heap.c_allocator;
  30+    };
  31+    defer lib_posix.close(client_sock_fd);
  32+
  33+    try signal.openSignalPipe();
  34+    signal.installWakeHandler(@intFromEnum(lib_posix.SIG.WINCH));
  35+
  36+    // Make socket non-blocking to avoid blocking on writes
  37+    var sock_flags = try lib_posix.fcntl(client_sock_fd, lib_posix.F.GETFL, 0);
  38+    sock_flags |= lib_posix.O_NONBLOCK;
  39+    _ = try lib_posix.fcntl(client_sock_fd, lib_posix.F.SETFL, sock_flags);
  40+
  41+    // Buffer for outgoing socket writes
  42+    var sock_write_buf = try std.ArrayList(u8).initCapacity(gpa, 4096);
  43+    defer sock_write_buf.deinit(gpa);
  44+
  45+    // Send init message with terminal size (buffered)
  46+    const size = ipc.getTerminalSize(lib_posix.STDOUT_FILENO);
  47+    try ipc.appendMessage(gpa, &sock_write_buf, .Init, std.mem.asBytes(&size));
  48+
  49+    var poll_fds = try std.ArrayList(lib_posix.pollfd).initCapacity(gpa, 4);
  50+    defer poll_fds.deinit(gpa);
  51+
  52+    var read_buf = try ipc.SocketBuffer.init(gpa);
  53+    defer read_buf.deinit();
  54+
  55+    var stdout_buf = try std.ArrayList(u8).initCapacity(gpa, 4096);
  56+    defer stdout_buf.deinit(gpa);
  57+
  58+    const stdin_fd = lib_posix.STDIN_FILENO;
  59+
  60+    // Make stdin non-blocking. O_NONBLOCK is set on the open file description,
  61+    // which is shared with the parent shell; restore on exit to avoid
  62+    // corrupting the parent's stdin.
  63+    const stdin_orig_flags = try lib_posix.fcntl(stdin_fd, lib_posix.F.GETFL, 0);
  64+    _ = try lib_posix.fcntl(stdin_fd, lib_posix.F.SETFL, stdin_orig_flags | lib_posix.O_NONBLOCK);
  65+    defer _ = lib_posix.fcntl(stdin_fd, lib_posix.F.SETFL, stdin_orig_flags) catch {};
  66+
  67+    while (true) {
  68+        poll_fds.clearRetainingCapacity();
  69+
  70+        try poll_fds.append(gpa, .{
  71+            .fd = stdin_fd,
  72+            .events = lib_posix.POLL.IN,
  73+            .revents = 0,
  74+        });
  75+
  76+        // Poll socket for read, and also for write if we have pending data
  77+        var sock_events: i16 = lib_posix.POLL.IN;
  78+        if (sock_write_buf.items.len > 0) {
  79+            sock_events |= lib_posix.POLL.OUT;
  80+        }
  81+        try poll_fds.append(gpa, .{
  82+            .fd = client_sock_fd,
  83+            .events = sock_events,
  84+            .revents = 0,
  85+        });
  86+
  87+        try poll_fds.append(gpa, .{ .fd = signal.sig_pipe[0], .events = lib_posix.POLL.IN, .revents = 0 });
  88+
  89+        if (stdout_buf.items.len > 0) {
  90+            try poll_fds.append(gpa, .{
  91+                .fd = lib_posix.STDOUT_FILENO,
  92+                .events = lib_posix.POLL.OUT,
  93+                .revents = 0,
  94+            });
  95+        }
  96+
  97+        _ = try lib_posix.poll(poll_fds.items, -1);
  98+
  99+        if (poll_fds.items[2].revents & lib_posix.POLL.IN != 0) {
 100+            signal.drainSignalPipe();
 101+            const next_size = ipc.getTerminalSize(lib_posix.STDOUT_FILENO);
 102+            try ipc.appendMessage(gpa, &sock_write_buf, .Resize, std.mem.asBytes(&next_size));
 103+        }
 104+
 105+        // Handle stdin -> socket (Input)
 106+        const inp_flags = (lib_posix.POLL.IN | lib_posix.POLL.HUP | lib_posix.POLL.ERR | lib_posix.POLL.NVAL);
 107+        if (poll_fds.items[0].revents & inp_flags != 0) {
 108+            var buf: [4096]u8 = undefined;
 109+            const n_opt: ?usize = lib_posix.read(stdin_fd, &buf) catch |err| blk: {
 110+                if (err == error.WouldBlock) break :blk null;
 111+                return err;
 112+            };
 113+
 114+            if (n_opt) |n| {
 115+                if (n > 0) {
 116+                    // Check for detach sequences (ctrl+\ as first byte or Kitty escape sequence)
 117+                    if (util.isCtrlBackslash(buf[0..n])) {
 118+                        std.log.info("detach key detected", .{});
 119+                        try ipc.appendMessage(gpa, &sock_write_buf, .Detach, "");
 120+                    } else {
 121+                        try ipc.appendMessage(gpa, &sock_write_buf, .Input, buf[0..n]);
 122+                    }
 123+                } else {
 124+                    std.log.info("eof stdin", .{});
 125+                    // EOF on stdin
 126+                    return ClientResult{ .kind = .detach, .session_name = null };
 127+                }
 128+            }
 129+        }
 130+
 131+        // Handle socket read (incoming Output messages from daemon)
 132+        if (poll_fds.items[1].revents & lib_posix.POLL.IN != 0) {
 133+            const n = read_buf.read(client_sock_fd) catch |err| {
 134+                if (err == error.WouldBlock) continue;
 135+                if (err == error.ConnectionResetByPeer or err == error.BrokenPipe) {
 136+                    return ClientResult{ .kind = .detach, .session_name = null };
 137+                }
 138+                std.log.err("daemon read err={s}", .{@errorName(err)});
 139+                return err;
 140+            };
 141+            if (n == 0) {
 142+                std.log.info("server closed connection", .{});
 143+                // Server closed connection
 144+                return ClientResult{ .kind = .detach, .session_name = null };
 145+            }
 146+
 147+            while (read_buf.next()) |msg| {
 148+                switch (msg.header.tag) {
 149+                    .Output => {
 150+                        if (msg.payload.len > 0) {
 151+                            try stdout_buf.appendSlice(gpa, msg.payload);
 152+                        }
 153+                    },
 154+                    .Resize => {
 155+                        // daemon is asking for the client's window size usually in response
 156+                        // to this client being set as leader.
 157+                        const next_size = ipc.getTerminalSize(lib_posix.STDOUT_FILENO);
 158+                        try ipc.appendMessage(
 159+                            gpa,
 160+                            &sock_write_buf,
 161+                            .Resize,
 162+                            std.mem.asBytes(&next_size),
 163+                        );
 164+                    },
 165+                    .Switch => {
 166+                        std.log.info("switch session", .{});
 167+                        return ClientResult{ .kind = .switch_session, .session_name = try gpa.dupe(u8, msg.payload) };
 168+                    },
 169+                    else => {},
 170+                }
 171+            }
 172+        }
 173+
 174+        // Handle socket write (flush buffered messages to daemon)
 175+        if (poll_fds.items[1].revents & lib_posix.POLL.OUT != 0) {
 176+            if (sock_write_buf.items.len > 0) {
 177+                const n = lib_posix.write(client_sock_fd, sock_write_buf.items) catch |err| blk: {
 178+                    if (err == error.WouldBlock) break :blk 0;
 179+                    if (err == error.ConnectionResetByPeer or err == error.BrokenPipe) {
 180+                        std.log.info("connection reset or broken pipe", .{});
 181+                        return ClientResult{ .kind = .detach, .session_name = null };
 182+                    }
 183+                    return err;
 184+                };
 185+                if (n > 0) {
 186+                    try sock_write_buf.replaceRange(gpa, 0, n, &[_]u8{});
 187+                }
 188+            }
 189+        }
 190+
 191+        if (stdout_buf.items.len > 0) {
 192+            const n = lib_posix.write(lib_posix.STDOUT_FILENO, stdout_buf.items) catch |err| blk: {
 193+                if (err == error.WouldBlock) break :blk 0;
 194+                return err;
 195+            };
 196+            if (n > 0) {
 197+                try stdout_buf.replaceRange(gpa, 0, n, &[_]u8{});
 198+            }
 199+        }
 200+
 201+        if (poll_fds.items[1].revents & (lib_posix.POLL.HUP | lib_posix.POLL.ERR | lib_posix.POLL.NVAL) != 0) {
 202+            std.log.info("poll hup|err|nval", .{});
 203+            return ClientResult{ .kind = .detach, .session_name = null };
 204+        }
 205+    }
 206+}
 207+
 208+/// dameonLoop is what the daemon runs to send and receive ipc commands from its corresponding
 209+/// clients.  It uses poll() as its non-blocking mechanism.
 210+fn daemonLoop(daemon: *Daemon, gpa: std.mem.Allocator, io: std.Io, server_sock_fd: lib_posix.socket_t, pty_fd: i32) !void {
 211+    std.log.info("daemon started session={s} pty_fd={d}", .{ daemon.session_name, pty_fd });
 212+
 213+    try signal.openSignalPipe();
 214+    signal.installWakeHandler(@intFromEnum(lib_posix.SIG.TERM));
 215+    var poll_fds = try std.ArrayList(lib_posix.pollfd).initCapacity(gpa, 8);
 216+    defer poll_fds.deinit(gpa);
 217+
 218+    const init_size = ipc.getTerminalSize(pty_fd);
 219+    var term = try ghostty_vt.Terminal.init(io, gpa, .{
 220+        .cols = init_size.cols,
 221+        .rows = init_size.rows,
 222+        .max_scrollback = daemon.cfg.max_scrollback,
 223+    });
 224+    defer term.deinit(gpa);
 225+    var vt_stream = term.vtStream();
 226+    defer vt_stream.deinit();
 227+
 228+    // Carries the tail of the previous PTY read so the task-exit marker
 229+    // search below can see across a read() boundary. Sized to comfortably
 230+    // hold "ZMX_TASK_COMPLETED:" (19 bytes) plus a u8 exit code and CRLF.
 231+    var marker_carry: [32]u8 = undefined;
 232+    var marker_carry_len: usize = 0;
 233+
 234+    daemon_loop: while (daemon.running) {
 235+        poll_fds.clearRetainingCapacity();
 236+
 237+        try poll_fds.append(gpa, .{
 238+            .fd = server_sock_fd,
 239+            .events = lib_posix.POLL.IN,
 240+            .revents = 0,
 241+        });
 242+
 243+        var pty_events: i16 = lib_posix.POLL.IN;
 244+        if (daemon.pty_write_buf.items.len > 0) {
 245+            pty_events |= lib_posix.POLL.OUT;
 246+        }
 247+        try poll_fds.append(gpa, .{
 248+            .fd = pty_fd,
 249+            .events = pty_events,
 250+            .revents = 0,
 251+        });
 252+
 253+        try poll_fds.append(gpa, .{ .fd = signal.sig_pipe[0], .events = lib_posix.POLL.IN, .revents = 0 });
 254+
 255+        for (daemon.clients.items) |client| {
 256+            var events: i16 = lib_posix.POLL.IN;
 257+            if (client.has_pending_output) {
 258+                events |= lib_posix.POLL.OUT;
 259+            }
 260+            try poll_fds.append(gpa, .{
 261+                .fd = client.socket_fd,
 262+                .events = events,
 263+                .revents = 0,
 264+            });
 265+        }
 266+
 267+        _ = try lib_posix.poll(poll_fds.items, -1);
 268+
 269+        if (poll_fds.items[2].revents & lib_posix.POLL.IN != 0) {
 270+            signal.drainSignalPipe();
 271+            std.log.info(
 272+                "SIGTERM received, shutting down gracefully session={s}",
 273+                .{daemon.session_name},
 274+            );
 275+            break :daemon_loop;
 276+        }
 277+
 278+        if (poll_fds.items[0].revents & (lib_posix.POLL.ERR | lib_posix.POLL.HUP | lib_posix.POLL.NVAL) != 0) {
 279+            std.log.err("server socket error revents={d}", .{poll_fds.items[0].revents});
 280+            break :daemon_loop;
 281+        } else if (poll_fds.items[0].revents & lib_posix.POLL.IN != 0) {
 282+            const client_fd = try lib_posix.accept(
 283+                server_sock_fd,
 284+                null,
 285+                null,
 286+                lib_posix.SOCK.NONBLOCK | lib_posix.SOCK.CLOEXEC,
 287+            );
 288+            const client = try gpa.create(Client);
 289+            client.* = Client{
 290+                .alloc = gpa,
 291+                .socket_fd = client_fd,
 292+                .read_buf = try ipc.SocketBuffer.init(gpa),
 293+                .write_buf = undefined,
 294+            };
 295+            // 64KB initial capacity lets ~15 broadcast cycles (N_TTY_BUF_SIZE reads
 296+            // * header) accumulate before the first ArrayList growth. The write
 297+            // buffer is userspace-only: it drains via POLLOUT to the client socket,
 298+            // which has no corresponding kernel-imposed per-write limit.
 299+            client.write_buf = try std.ArrayList(u8).initCapacity(client.alloc, 65536);
 300+            try daemon.clients.append(gpa, client);
 301+            std.log.info(
 302+                "client connected fd={d} total={d}",
 303+                .{ client_fd, daemon.clients.items.len },
 304+            );
 305+        }
 306+
 307+        const inp_flags = lib_posix.POLL.IN | lib_posix.POLL.HUP | lib_posix.POLL.ERR | lib_posix.POLL.NVAL;
 308+        if (poll_fds.items[1].revents & inp_flags != 0) {
 309+            // Read from PTY. Buffer is sized to N_TTY_BUF_SIZE (4096): the hard
 310+            // kernel limit for the N_TTY line discipline. A larger buffer doesn't
 311+            // help: each read() from a PTY master returns at most 4096 bytes
 312+            // regardless of the userspace buffer size.
 313+            var buf: [4096]u8 = undefined;
 314+            const n_opt: ?usize = lib_posix.read(pty_fd, &buf) catch |err| blk: {
 315+                if (err == error.WouldBlock) break :blk null;
 316+                break :blk 0;
 317+            };
 318+
 319+            if (n_opt) |n| {
 320+                if (n == 0) {
 321+                    // EOF: Shell exited
 322+                    std.log.info("shell exited pty_fd={d}", .{pty_fd});
 323+                    // Let the rest of this poll iteration complete so client
 324+                    // write buffers are flushed via the normal POLLOUT path.
 325+                    // On the next iteration, daemon.running will be false.
 326+                    daemon.running = false;
 327+                } else {
 328+                    // Feed PTY output to terminal emulator for state tracking
 329+                    vt_stream.nextSlice(buf[0..n]);
 330+                    daemon.has_pty_output = true;
 331+
 332+                    // When no real terminal client has attached yet, respond to
 333+                    // terminal queries (e.g. DA1/DA2) on behalf of the terminal.
 334+                    // This prevents fish from waiting 10s for unanswered queries.
 335+                    // `has_terminal_client` is only set when a client sends .Init
 336+                    // (a real zmx attach), not when a `zmx run` tail-only client
 337+                    // connects.
 338+                    if (!daemon.has_terminal_client and
 339+                        daemon.pty_write_buf.items.len < Daemon.PTY_WRITE_BUF_MAX)
 340+                    {
 341+                        util.respondToDeviceAttributes(gpa, &daemon.pty_write_buf, buf[0..n]);
 342+                    }
 343+
 344+                    // In run mode, scan output for exit code marker. The marker
 345+                    // can straddle two PTY reads (more likely under a throttled
 346+                    // scheduler, e.g. containers), so prepend the tail carried
 347+                    // over from the previous read before searching.
 348+                    if (daemon.is_task_mode and daemon.task_exit_code == null) {
 349+                        var scan_buf: [marker_carry.len + buf.len]u8 = undefined;
 350+                        @memcpy(scan_buf[0..marker_carry_len], marker_carry[0..marker_carry_len]);
 351+                        @memcpy(scan_buf[marker_carry_len..][0..n], buf[0..n]);
 352+                        const scan_len = marker_carry_len + n;
 353+
 354+                        if (util.findTaskExitMarker(scan_buf[0..scan_len])) |exit_code| {
 355+                            daemon.task_exit_code = exit_code;
 356+                            daemon.task_ended_at = @intCast(std.Io.Timestamp.now(io, .real).toSeconds());
 357+
 358+                            std.log.info("task completed exit_code={d}", .{exit_code});
 359+
 360+                            // Notify connected clients
 361+                            for (daemon.clients.items) |c| {
 362+                                ipc.appendMessage(gpa, &c.write_buf, .TaskComplete, &[_]u8{exit_code}) catch {};
 363+                                c.has_pending_output = true;
 364+                            }
 365+                        }
 366+
 367+                        marker_carry_len = @min(marker_carry.len, scan_len);
 368+                        @memcpy(
 369+                            marker_carry[0..marker_carry_len],
 370+                            scan_buf[scan_len - marker_carry_len .. scan_len],
 371+                        );
 372+                    }
 373+
 374+                    // Broadcast data to all clients.
 375+                    // Rewrite OSC 133;A to include redraw=0 so the outer terminal
 376+                    // does not clear prompt lines on resize (issue #111).
 377+                    const broadcast_data = util.rewritePromptRedraw(gpa, buf[0..n]) orelse buf[0..n];
 378+                    defer if (broadcast_data.ptr != buf[0..n].ptr) gpa.free(broadcast_data);
 379+                    for (daemon.clients.items) |client| {
 380+                        ipc.appendMessage(gpa, &client.write_buf, .Output, broadcast_data) catch |err| {
 381+                            std.log.warn(
 382+                                "failed to buffer output for client err={s}",
 383+                                .{@errorName(err)},
 384+                            );
 385+                            continue;
 386+                        };
 387+                        client.has_pending_output = true;
 388+                    }
 389+                }
 390+            }
 391+        }
 392+
 393+        if (poll_fds.items[1].revents & lib_posix.POLL.OUT != 0) {
 394+            while (daemon.pty_write_buf.items.len > 0) {
 395+                const n = lib_posix.write(pty_fd, daemon.pty_write_buf.items) catch |err| {
 396+                    if (err != error.WouldBlock) {
 397+                        std.log.warn("pty write failed: {s}", .{@errorName(err)});
 398+                        daemon.pty_write_buf.clearRetainingCapacity();
 399+                    }
 400+                    break;
 401+                };
 402+                if (n == 0) break;
 403+                daemon.pty_write_buf.replaceRange(gpa, 0, n, &[_]u8{}) catch unreachable;
 404+            }
 405+        }
 406+
 407+        var i: usize = daemon.clients.items.len;
 408+        // Only iterate over clients that were present when poll_fds was constructed
 409+        // poll_fds contains [server, pty, sig_pipe, client0, client1, ...]
 410+        // So number of clients in poll_fds is poll_fds.items.len - 3
 411+        const num_polled_clients = poll_fds.items.len - 3;
 412+        if (i > num_polled_clients) {
 413+            // If we have more clients than polled (i.e. we just accepted one), start from the
 414+            // polled ones
 415+            i = num_polled_clients;
 416+        }
 417+
 418+        clients_loop: while (i > 0) {
 419+            i -= 1;
 420+            const client = daemon.clients.items[i];
 421+            const revents = poll_fds.items[i + 3].revents;
 422+
 423+            if (revents & lib_posix.POLL.IN != 0) {
 424+                const n = client.read_buf.read(client.socket_fd) catch |err| {
 425+                    if (err == error.WouldBlock) continue;
 426+                    std.log.debug(
 427+                        "client read err={s} fd={d}",
 428+                        .{ @errorName(err), client.socket_fd },
 429+                    );
 430+                    const last = daemon.closeClient(gpa, client, i, false);
 431+                    if (last) break :daemon_loop;
 432+                    continue;
 433+                };
 434+
 435+                if (n == 0) {
 436+                    // Client closed connection
 437+                    const last = daemon.closeClient(gpa, client, i, false);
 438+                    if (last) break :daemon_loop;
 439+                    continue;
 440+                }
 441+
 442+                while (client.read_buf.next()) |msg| {
 443+                    switch (msg.header.tag) {
 444+                        .Input => try daemon.handleInput(gpa, client, msg.payload),
 445+                        .Send => daemon.handleSend(gpa, msg.payload),
 446+                        .Output => try daemon.handleOutput(gpa, msg.payload, &vt_stream),
 447+                        .Init => try daemon.handleInit(gpa, client, pty_fd, &term, msg.payload),
 448+                        .Switch => try daemon.handleSwitch(gpa, msg.payload),
 449+                        .Resize => try daemon.handleResize(gpa, client, pty_fd, &term, msg.payload),
 450+                        .Detach => {
 451+                            daemon.handleDetach(gpa, client, i);
 452+                            break :clients_loop;
 453+                        },
 454+                        .DetachAll => {
 455+                            daemon.handleDetachAll(gpa);
 456+                            break :clients_loop;
 457+                        },
 458+                        .Kill => {
 459+                            break :daemon_loop;
 460+                        },
 461+                        .Info => try daemon.handleInfo(gpa, client),
 462+                        .LabelGet => try daemon.handleLabelGet(gpa, client),
 463+                        .LabelSet => try daemon.handleLabelSet(gpa, client, msg.payload),
 464+                        .LabelClear => try daemon.handleLabelClear(gpa, client),
 465+                        .History => try daemon.handleHistory(gpa, client, &term, msg.payload),
 466+                        .Run => try daemon.handleRun(gpa, client, msg.payload),
 467+                        .Ack, .TaskComplete, .LabelData => {},
 468+                        .Write => try daemon.handleWrite(gpa, client, msg.payload),
 469+                        _ => std.log.warn(
 470+                            "ignoring unknown IPC tag={d}",
 471+                            .{@intFromEnum(msg.header.tag)},
 472+                        ),
 473+                    }
 474+                }
 475+            }
 476+
 477+            if (revents & lib_posix.POLL.OUT != 0) {
 478+                // Flush pending output buffers
 479+                const n = lib_posix.write(client.socket_fd, client.write_buf.items) catch |err| blk: {
 480+                    if (err == error.WouldBlock) break :blk 0;
 481+                    // Error on write, close client
 482+                    const last = daemon.closeClient(gpa, client, i, false);
 483+                    if (last) break :daemon_loop;
 484+                    continue;
 485+                };
 486+
 487+                if (n > 0) {
 488+                    client.write_buf.replaceRange(gpa, 0, n, &[_]u8{}) catch unreachable;
 489+                }
 490+
 491+                if (client.write_buf.items.len == 0) {
 492+                    client.has_pending_output = false;
 493+                }
 494+            }
 495+
 496+            if (revents & (lib_posix.POLL.HUP | lib_posix.POLL.ERR | lib_posix.POLL.NVAL) != 0) {
 497+                const last = daemon.closeClient(gpa, client, i, false);
 498+                if (last) break :daemon_loop;
 499+            }
 500+        }
 501+    }
 502+}
 503+
 504+const ClientResult = struct {
 505+    kind: enum {
 506+        detach,
 507+        switch_session,
 508+    },
 509+    session_name: ?[]const u8,
 510+};
 511+
 512+/// Client represents each terminal that has connected to a session.
 513+///
 514+/// Multiple Clients can connect to a single session.
 515+pub const Client = struct {
 516+    alloc: std.mem.Allocator,
 517+    socket_fd: i32,
 518+    has_pending_output: bool = false,
 519+    read_buf: ipc.SocketBuffer,
 520+    write_buf: std.ArrayList(u8),
 521+
 522+    pub fn deinit(self: *Client) void {
 523+        lib_posix.close(self.socket_fd);
 524+        self.read_buf.deinit();
 525+        self.write_buf.deinit(self.alloc);
 526+    }
 527+};
 528+
 529+/// Daemon is responsible for managing a zmx session.
 530+///
 531+/// It holds all the state for a running session.  Instead of a single daemon for all sessions, we
 532+/// create a daemon for every session.  This has some benefits. The ipc communication between
 533+/// session clients and the daemon doesn't need to be tagged with the session name.  If a daemon
 534+/// crashes for one session won't crash all the other sessions.
 535+///
 536+/// Conceptually it's also much simpler to reason about.
 537+pub const Daemon = struct {
 538+    cfg: *Cfg,
 539+    session_name: []const u8,
 540+    socket_path: []const u8,
 541+    // === opt ===
 542+    pty_write_buf: std.ArrayList(u8) = .empty,
 543+    clients: std.ArrayList(*Client) = .empty,
 544+    labels: std.StringHashMapUnmanaged([]u8) = .empty,
 545+    // This control which client is the leader.  The leader controls terminal state and
 546+    // cols/rows of session.
 547+    leader_client_fd: ?i32 = null,
 548+    running: bool = true,
 549+    pid: i32 = undefined,
 550+    command: ?[]const []const u8 = null,
 551+    cwd: []const u8 = "",
 552+    has_pty_output: bool = false,
 553+    has_had_client: bool = false,
 554+    has_terminal_client: bool = false, // true only after a real attach (.Init received)
 555+    created_at: u64, // unix timestamp (ns)
 556+    is_task_mode: bool = false, // flag for when session is run as a task
 557+    task_exit_code: ?u8 = null, // null = running or n/a, set when task completes
 558+    task_ended_at: ?u64 = null, // timestamp when task exited
 559+    pty_fd: i32 = -1, // set by daemonLoop so handleRun can probe the foreground process
 560+    shell: []const u8 = "/bin/sh",
 561+
 562+    /// Create a Daemon. Caller is responsible for freeing all variables passed
 563+    /// into the init fn.
 564+    pub fn init(io: std.Io, cfg: *Cfg, sesh_name: []const u8, socket_path: []const u8) Daemon {
 565+        return .{
 566+            .cfg = cfg,
 567+            .session_name = sesh_name,
 568+            .socket_path = socket_path,
 569+            .created_at = @intCast(std.Io.Timestamp.now(io, .real).toSeconds()),
 570+        };
 571+    }
 572+
 573+    pub fn deinit(self: *Daemon, gpa: std.mem.Allocator) void {
 574+        self.clients.deinit(gpa);
 575+        var it = self.labels.iterator();
 576+        while (it.next()) |entry| {
 577+            gpa.free(entry.key_ptr.*);
 578+            gpa.free(entry.value_ptr.*);
 579+        }
 580+        self.labels.deinit(gpa);
 581+        self.pty_write_buf.deinit(gpa);
 582+        gpa.free(self.socket_path);
 583+    }
 584+
 585+    pub fn shutdown(self: *Daemon, gpa: std.mem.Allocator) void {
 586+        std.log.info("shutting down daemon session={s}", .{self.session_name});
 587+        self.running = false;
 588+
 589+        for (self.clients.items) |client| {
 590+            client.deinit();
 591+            gpa.destroy(client);
 592+        }
 593+        self.clients.clearRetainingCapacity();
 594+    }
 595+
 596+    pub fn closeClient(self: *Daemon, gpa: std.mem.Allocator, client: *Client, i: usize, shutdown_on_last: bool) bool {
 597+        const fd = client.socket_fd;
 598+        // leader is disconnected, remove ref and let another client claim leader on input
 599+        if (self.leader_client_fd == client.socket_fd) {
 600+            std.log.info(
 601+                "unsetting leader session={s} fd={d}",
 602+                .{ self.session_name, client.socket_fd },
 603+            );
 604+            self.leader_client_fd = null;
 605+        }
 606+        client.deinit();
 607+        gpa.destroy(client);
 608+        _ = self.clients.orderedRemove(i);
 609+        std.log.info("client disconnected fd={d} remaining={d}", .{ fd, self.clients.items.len });
 610+        if (shutdown_on_last and self.clients.items.len == 0) {
 611+            self.shutdown(gpa);
 612+            return true;
 613+        }
 614+        return false;
 615+    }
 616+
 617+    /// ensureSession will either create or re-use the daemon used for a session.
 618+    /// It will spin up a unix socket, double-fork the process (so it survives
 619+    /// the terminal dying), and automatically attach the client to the ipc unix
 620+    /// socket.
 621+    ///
 622+    /// The return bool value indicates if the current process is the daemon
 623+    /// or the client since they have different behaviors post-fork.
 624+    ///
 625+    /// E.g. If it's the client process then we need to connect to the unix socket
 626+    /// and run the clientLoop.  If it's the daemon then we need to bail since
 627+    /// the daemonLoop is created inside this fn and when it returns that means
 628+    /// the daemon stopped and needs to exit.
 629+    pub fn ensureSession(self: *Daemon, io: std.Io) !bool {
 630+        const sesh_name = self.session_name;
 631+        std.log.info("ensure session session={s}", .{sesh_name});
 632+        var dir = try std.Io.Dir.openDirAbsolute(io, self.cfg.socket_dir, .{});
 633+        defer dir.close(io);
 634+
 635+        const exists = try socket.sessionExists(io, dir, sesh_name);
 636+        // if daemon is gone then we flip this to true
 637+        var should_create = !exists;
 638+
 639+        if (exists) {
 640+            if (ipc.connectSession(self.socket_path)) |fd| {
 641+                lib_posix.close(fd);
 642+                if (self.command != null) {
 643+                    std.log.warn(
 644+                        "session already exists, ignoring command session={s}",
 645+                        .{sesh_name},
 646+                    );
 647+                }
 648+            } else |err| switch (err) {
 649+                // Daemon is definitively gone: safe to replace.
 650+                error.ConnectionRefused => {
 651+                    socket.cleanupStaleSocket(io, dir, sesh_name);
 652+                    should_create = true;
 653+                },
 654+                // Connect failed for an unusual reason. The check is only to
 655+                // decide create-vs-attach; the socket file exists, so proceed
 656+                // to attach rather than fail or orphan.
 657+                else => {
 658+                    std.log.warn(
 659+                        "connect failed ({s}), proceeding to attach session={s}",
 660+                        .{ @errorName(err), sesh_name },
 661+                    );
 662+                },
 663+            }
 664+        }
 665+
 666+        if (!should_create) {
 667+            return false;
 668+        }
 669+
 670+        return self.run(io, dir, sesh_name);
 671+    }
 672+
 673+    fn run(self: *Daemon, io: std.Io, dir: std.Io.Dir, sesh_name: []const u8) !bool {
 674+        std.log.info("creating session={s}", .{sesh_name});
 675+        const server_sock_fd: lib_posix.socket_t = try socket.createSocket(self.socket_path);
 676+        const log_fd = log.log_system.file.?.handle;
 677+
 678+        var keep_fds_open = [_]i32{ server_sock_fd, dir.handle, log_fd };
 679+        const cmd = try daemonize.createCmdZ(self.shell, self.is_task_mode, self.command);
 680+        const pty_info = daemonize.daemonize(
 681+            sesh_name,
 682+            cmd,
 683+            &keep_fds_open,
 684+        ) catch |err| {
 685+            switch (err) {
 686+                error.IsClientProc => {
 687+                    // send a msg to the client that the session was created.
 688+                    var w_buf: [2048]u8 = undefined;
 689+                    var w = std.Io.File.stdout().writer(io, &w_buf);
 690+                    try w.interface.print("session \"{s}\" created\n", .{sesh_name});
 691+                    try w.interface.flush();
 692+                    lib_posix.close(server_sock_fd);
 693+                    return false;
 694+                },
 695+                else => {
 696+                    lib_posix.close(server_sock_fd);
 697+                    dir.deleteFile(io, self.session_name) catch {};
 698+                    return err;
 699+                },
 700+            }
 701+        };
 702+        // =======
 703+        // WARNING: cannot use upstream allocator or io after this point since
 704+        // we forked the process and there's a risk of a mutex (e.g. thread-safe
 705+        // allocator) being locked by a thread prior to fork which can cause a
 706+        // deadlock.
 707+        // =======
 708+
 709+        self.pid = pty_info.pid;
 710+
 711+        var threaded: std.Io.Threaded = .init_single_threaded;
 712+        defer threaded.deinit();
 713+        const new_io = threaded.io();
 714+
 715+        { // re-initialize logs with the session name as the filename
 716+            log.log_system.deinit();
 717+            var log_buf: [4096]u8 = undefined;
 718+            const session_log_name = try std.fmt.bufPrint(
 719+                &log_buf,
 720+                "{s}.log",
 721+                .{sesh_name},
 722+            );
 723+            var fba_buf: [4096]u8 = undefined;
 724+            var fba = std.heap.FixedBufferAllocator.init(&fba_buf);
 725+            const session_log_path = try std.fs.path.join(
 726+                fba.allocator(),
 727+                &.{ self.cfg.log_dir, session_log_name },
 728+            );
 729+            const log_mode = std.Io.File.Permissions.fromMode(self.cfg.log_mode);
 730+            log.log_system.init(new_io, session_log_path, log_mode) catch {};
 731+        }
 732+
 733+        const gpa: std.mem.Allocator = blk: {
 734+            if (builtin.mode == .Debug) {
 735+                const GPA = std.heap.DebugAllocator(.{});
 736+                const Static = struct {
 737+                    var gpa: GPA = .{};
 738+                };
 739+                break :blk Static.gpa.allocator();
 740+            }
 741+            break :blk std.heap.c_allocator;
 742+        };
 743+
 744+        defer {
 745+            // Close and unlink the listen socket BEFORE handleKill()'s
 746+            // 500ms SIGHUP->SIGKILL grace sleep. Otherwise a `zmx run`
 747+            // for the same name issued in that window will hang waiting
 748+            // for a connect.
 749+            lib_posix.close(server_sock_fd);
 750+            std.log.info("deleting socket file session={s}", .{sesh_name});
 751+            dir.deleteFile(new_io, sesh_name) catch |err| {
 752+                std.log.warn("failed to delete socket file err={s}", .{@errorName(err)});
 753+            };
 754+            self.handleKill(gpa, new_io);
 755+            self.deinit(gpa);
 756+            lib_posix.close(pty_info.master_fd);
 757+            _ = lib_posix.waitpid(self.pid, 0);
 758+        }
 759+
 760+        try daemonLoop(self, gpa, new_io, server_sock_fd, pty_info.master_fd);
 761+        std.log.info("daemon loop shutdown", .{});
 762+        return true;
 763+    }
 764+
 765+    fn setLeader(self: *Daemon, gpa: std.mem.Allocator, client: *Client) !void {
 766+        std.log.info("setting new leader client_fd={d}", .{client.socket_fd});
 767+        self.leader_client_fd = client.socket_fd;
 768+        // Send a resize message to the client so it can send us back their window size
 769+        // so we can resize the pty and ghostty state.
 770+        try ipc.appendMessage(gpa, &client.write_buf, .Resize, "");
 771+        client.has_pending_output = true;
 772+    }
 773+
 774+    const PTY_WRITE_BUF_MAX = 256 * 1024;
 775+
 776+    /// Queue bytes for the PTY's stdin. Flushed by daemonLoop on POLLOUT.
 777+    /// Drops the payload if the buffer is over cap -- same failure mode as
 778+    /// the old direct-write ptyWrite (drop on EAGAIN), just at a 64x higher
 779+    /// threshold. Capping avoids OOM when the shell stops reading; dropping
 780+    /// new (not old) bytes avoids tearing a partially-accepted sequence.
 781+    fn queuePtyInput(self: *Daemon, gpa: std.mem.Allocator, data: []const u8) void {
 782+        if (data.len == 0) return;
 783+        if (self.pty_write_buf.items.len + data.len > PTY_WRITE_BUF_MAX) {
 784+            std.log.warn(
 785+                "pty input dropped {d} bytes (buffer full, shell not reading)",
 786+                .{data.len},
 787+            );
 788+            return;
 789+        }
 790+        std.log.debug("buffering pty input data={x}", .{data});
 791+        self.pty_write_buf.appendSlice(gpa, data) catch |err| {
 792+            std.log.warn(
 793+                "pty input dropped {d} bytes: {s}",
 794+                .{ data.len, @errorName(err) },
 795+            );
 796+        };
 797+    }
 798+
 799+    pub fn handleInput(self: *Daemon, gpa: std.mem.Allocator, client: *Client, payload: []const u8) !void {
 800+        std.log.debug("buffering pty input data={x}", .{payload});
 801+        // client is leader, send entire payload (ansi escape codes + text)
 802+        if (self.leader_client_fd == client.socket_fd) {
 803+            self.queuePtyInput(gpa, payload);
 804+            return;
 805+        }
 806+
 807+        // check if leader needs to be updated by detecting any user input
 808+        if (util.isUserInput(payload)) {
 809+            try self.setLeader(gpa, client);
 810+            self.queuePtyInput(gpa, payload);
 811+        }
 812+    }
 813+
 814+    /// Queue input from `zmx send` without changing interactive client leadership.
 815+    pub fn handleSend(self: *Daemon, gpa: std.mem.Allocator, payload: []const u8) void {
 816+        self.queuePtyInput(gpa, payload);
 817+    }
 818+
 819+    pub fn handleSwitch(self: *Daemon, gpa: std.mem.Allocator, session_name: []const u8) !void {
 820+        for (self.clients.items) |client| {
 821+            if (self.leader_client_fd == client.socket_fd) {
 822+                ipc.appendMessage(
 823+                    gpa,
 824+                    &client.write_buf,
 825+                    .Switch,
 826+                    session_name,
 827+                ) catch |err| {
 828+                    std.log.warn(
 829+                        "failed to buffer terminal state for client err={s}",
 830+                        .{@errorName(err)},
 831+                    );
 832+                };
 833+                client.has_pending_output = true;
 834+                return;
 835+            }
 836+        }
 837+        return error.NoLeaderFound;
 838+    }
 839+
 840+    pub fn handleInit(
 841+        self: *Daemon,
 842+        gpa: std.mem.Allocator,
 843+        client: *Client,
 844+        pty_fd: i32,
 845+        term: *ghostty_vt.Terminal,
 846+        payload: []const u8,
 847+    ) !void {
 848+        if (payload.len != @sizeOf(ipc.Resize)) return;
 849+
 850+        // Serialize terminal state BEFORE resize to capture correct cursor position.
 851+        // Resizing triggers reflow which can move the cursor, and the shell's
 852+        // SIGWINCH-triggered redraw will run after our snapshot is sent.
 853+        // Only serialize on re-attach (has_had_client), not first attach, to avoid
 854+        // interfering with shell initialization (DA1 queries, etc.)
 855+        if (self.has_pty_output and self.has_had_client) {
 856+            const cursor = &term.screens.active.cursor;
 857+            std.log.debug(
 858+                "cursor before serialize: x={d} y={d} pending_wrap={}",
 859+                .{ cursor.x, cursor.y, cursor.pending_wrap },
 860+            );
 861+            if (util.serializeTerminalState(gpa, term)) |term_output| {
 862+                std.log.debug("serialize terminal state", .{});
 863+                // Rewrite OSC 133;A to include redraw=0 so the outer terminal
 864+                // does not clear prompt lines on resize (issue #111).
 865+                const restore_data = util.rewritePromptRedraw(gpa, term_output) orelse term_output;
 866+                defer gpa.free(term_output);
 867+                defer if (restore_data.ptr != term_output.ptr) gpa.free(restore_data);
 868+                ipc.appendMessage(gpa, &client.write_buf, .Output, restore_data) catch |err| {
 869+                    std.log.warn(
 870+                        "failed to buffer terminal state for client err={s}",
 871+                        .{@errorName(err)},
 872+                    );
 873+                };
 874+                client.has_pending_output = true;
 875+            }
 876+        }
 877+
 878+        // no leader is set so set one
 879+        if (self.leader_client_fd == null) {
 880+            try self.setLeader(gpa, client);
 881+        }
 882+
 883+        // only resize if leader
 884+        if (self.leader_client_fd == client.socket_fd) {
 885+            const resize = std.mem.bytesToValue(ipc.Resize, payload);
 886+            var ws: cross.c.struct_winsize = .{
 887+                .ws_row = resize.rows,
 888+                .ws_col = resize.cols,
 889+                .ws_xpixel = resize.xpixel,
 890+                .ws_ypixel = resize.ypixel,
 891+            };
 892+            _ = cross.c.ioctl(pty_fd, cross.c.TIOCSWINSZ, &ws);
 893+            // Disable prompt_redraw before resize. The daemon's internal terminal
 894+            // would otherwise clear prompt lines expecting the shell to redraw them,
 895+            // but the shell's redraw goes to the PTY (forwarded to clients), not to
 896+            // this daemon terminal. The clearing corrupts the daemon's snapshot state.
 897+            const saved_prompt_redraw = term.flags.shell_redraws_prompt;
 898+            term.flags.shell_redraws_prompt = .false;
 899+            defer term.flags.shell_redraws_prompt = saved_prompt_redraw;
 900+            const opts = ghostty_vt.Terminal.Resize{
 901+                .cols = resize.cols,
 902+                .rows = resize.rows,
 903+            };
 904+            try term.resize(gpa, opts);
 905+
 906+            // Mark that we've had a client init, so subsequent clients get terminal state
 907+            self.has_had_client = true;
 908+            self.has_terminal_client = true;
 909+
 910+            std.log.debug("init resize rows={d} cols={d}", .{ resize.rows, resize.cols });
 911+        }
 912+    }
 913+
 914+    pub fn handleResize(
 915+        self: *Daemon,
 916+        gpa: std.mem.Allocator,
 917+        client: *Client,
 918+        pty_fd: i32,
 919+        term: *ghostty_vt.Terminal,
 920+        payload: []const u8,
 921+    ) !void {
 922+        if (payload.len != @sizeOf(ipc.Resize)) return;
 923+        if (self.leader_client_fd == null) {
 924+            try self.setLeader(gpa, client);
 925+        }
 926+        // only leader can resize
 927+        if (self.leader_client_fd != client.socket_fd) return;
 928+
 929+        const resize = std.mem.bytesToValue(ipc.Resize, payload);
 930+        var ws: cross.c.struct_winsize = .{
 931+            .ws_row = resize.rows,
 932+            .ws_col = resize.cols,
 933+            .ws_xpixel = resize.xpixel,
 934+            .ws_ypixel = resize.ypixel,
 935+        };
 936+        _ = cross.c.ioctl(pty_fd, cross.c.TIOCSWINSZ, &ws);
 937+        // Disable prompt_redraw before resize (same rationale as handleInit).
 938+        const saved_prompt_redraw = term.flags.shell_redraws_prompt;
 939+        term.flags.shell_redraws_prompt = .false;
 940+        defer term.flags.shell_redraws_prompt = saved_prompt_redraw;
 941+        const opts = ghostty_vt.Terminal.Resize{
 942+            .cols = resize.cols,
 943+            .rows = resize.rows,
 944+        };
 945+        try term.resize(gpa, opts);
 946+        std.log.debug("resize rows={d} cols={d}", .{ resize.rows, resize.cols });
 947+    }
 948+
 949+    pub fn handleDetach(self: *Daemon, gpa: std.mem.Allocator, client: *Client, i: usize) void {
 950+        std.log.info("client detach session={s} fd={d}", .{ self.session_name, client.socket_fd });
 951+        _ = self.closeClient(gpa, client, i, false);
 952+    }
 953+
 954+    pub fn handleDetachAll(self: *Daemon, gpa: std.mem.Allocator) void {
 955+        std.log.info("detach all clients={d}", .{self.clients.items.len});
 956+        for (self.clients.items) |client_to_close| {
 957+            client_to_close.deinit();
 958+            gpa.destroy(client_to_close);
 959+        }
 960+        self.clients.clearRetainingCapacity();
 961+    }
 962+
 963+    pub fn handleKill(self: *Daemon, gpa: std.mem.Allocator, io: std.Io) void {
 964+        std.log.info("kill received session={s}", .{self.session_name});
 965+        self.shutdown(gpa);
 966+        // gracefully shutdown shell processes, shells tend to ignore SIGTERM so we send SIGHUP
 967+        // instead
 968+        //   https://www.gnu.org/software/bash/manual/html_node/Signals.html
 969+        // negative pid means kill process and children
 970+        std.log.info("sending SIGHUP session={s} pid={d}", .{ self.session_name, self.pid });
 971+        lib_posix.kill(-self.pid, lib_posix.SIG.HUP) catch |err| {
 972+            std.log.warn("failed to send SIGHUP to pty child err={s}", .{@errorName(err)});
 973+        };
 974+        std.Io.sleep(io, std.Io.Duration.fromMilliseconds(500), .real) catch unreachable;
 975+        lib_posix.kill(-self.pid, lib_posix.SIG.KILL) catch |err| {
 976+            std.log.warn("failed to send SIGKILL to pty child err={s}", .{@errorName(err)});
 977+        };
 978+    }
 979+
 980+    pub fn handleInfo(self: *Daemon, gpa: std.mem.Allocator, client: *Client) !void {
 981+        // zeroes() so asBytes() doesn't ship struct padding + unused cmd/cwd
 982+        // tail bytes (daemon stack contents) to clients.
 983+        var info = std.mem.zeroes(ipc.Info);
 984+        info.clients_len = self.clients.items.len - 1;
 985+        info.pid = self.pid;
 986+        info.created_at = self.created_at;
 987+        info.task_ended_at = self.task_ended_at orelse 0;
 988+        info.task_exit_code = self.task_exit_code orelse 0;
 989+
 990+        // Build command string from args, re-quoting args that contain
 991+        // shell-special characters so the displayed command is copy-pasteable.
 992+        const cur_cmd = self.command;
 993+        if (cur_cmd) |args| {
 994+            for (args, 0..) |arg, i| {
 995+                const quoted = if (util.shellNeedsQuoting(arg))
 996+                    util.shellQuote(gpa, arg) catch null
 997+                else
 998+                    null;
 999+                defer if (quoted) |q| gpa.free(q);
1000+                const src = quoted orelse arg;
1001+
1002+                const need = src.len + @as(usize, if (i > 0) 1 else 0);
1003+                if (info.cmd_len + need > ipc.MAX_CMD_LEN) {
1004+                    const ellipsis = "...";
1005+                    if (info.cmd_len + ellipsis.len <= ipc.MAX_CMD_LEN) {
1006+                        @memcpy(info.cmd[info.cmd_len..][0..ellipsis.len], ellipsis);
1007+                        info.cmd_len += ellipsis.len;
1008+                    }
1009+                    break;
1010+                }
1011+
1012+                if (i > 0) {
1013+                    info.cmd[info.cmd_len] = ' ';
1014+                    info.cmd_len += 1;
1015+                }
1016+                @memcpy(info.cmd[info.cmd_len..][0..src.len], src);
1017+                info.cmd_len += @intCast(src.len);
1018+            }
1019+        }
1020+
1021+        info.cwd_len = @intCast(@min(self.cwd.len, ipc.MAX_CWD_LEN));
1022+        @memcpy(info.cwd[0..info.cwd_len], self.cwd[0..info.cwd_len]);
1023+
1024+        try ipc.appendMessage(gpa, &client.write_buf, .Info, std.mem.asBytes(&info));
1025+        client.has_pending_output = true;
1026+    }
1027+
1028+    pub fn handleHistory(
1029+        _: *Daemon,
1030+        gpa: std.mem.Allocator,
1031+        client: *Client,
1032+        term: *ghostty_vt.Terminal,
1033+        payload: []const u8,
1034+    ) !void {
1035+        const format: util.HistoryFormat = if (payload.len > 0)
1036+            @enumFromInt(payload[0])
1037+        else
1038+            .plain;
1039+        if (util.serializeTerminal(gpa, term, format)) |output| {
1040+            defer gpa.free(output);
1041+            try ipc.appendMessage(gpa, &client.write_buf, .History, output);
1042+            client.has_pending_output = true;
1043+        } else {
1044+            try ipc.appendMessage(gpa, &client.write_buf, .History, "");
1045+            client.has_pending_output = true;
1046+        }
1047+    }
1048+
1049+    pub fn handleRun(self: *Daemon, gpa: std.mem.Allocator, client: *Client, payload: []const u8) !void {
1050+        // Reset task tracking so the new command's exit marker is detected.
1051+        // Without this, a second `zmx run` on the same session is ignored
1052+        // because task_exit_code is still set from the first run.
1053+        self.task_exit_code = null;
1054+        self.task_ended_at = null;
1055+        self.is_task_mode = true;
1056+
1057+        if (payload.len == 0) return;
1058+
1059+        const cmd = payload;
1060+
1061+        // Chain the exit marker with `;` on the same line. `$?` captures the
1062+        // exit code of the command (not the `;`). The sole exception is when
1063+        // the command contains a heredoc (`<<`), the delimiter must be alone
1064+        // on its line, so the marker goes on the next line instead.
1065+        const single_line_marker = "; echo ZMX_TASK_COMPLETED:$?\r";
1066+        const heredoc_marker = "\r\necho ZMX_TASK_COMPLETED:$?\r";
1067+        const uses_heredoc = std.mem.indexOf(u8, cmd, "<<") != null;
1068+
1069+        if (cmd.len > 0 and cmd[cmd.len - 1] == '\r') {
1070+            self.queuePtyInput(gpa, cmd[0 .. cmd.len - 1]);
1071+        } else {
1072+            self.queuePtyInput(gpa, cmd);
1073+        }
1074+        self.queuePtyInput(gpa, if (uses_heredoc) heredoc_marker else single_line_marker);
1075+
1076+        try ipc.appendMessage(gpa, &client.write_buf, .Ack, "");
1077+        client.has_pending_output = true;
1078+        self.has_had_client = true;
1079+        std.log.debug("run command len={d}", .{payload.len});
1080+    }
1081+
1082+    pub fn handleOutput(self: *Daemon, gpa: std.mem.Allocator, payload: []const u8, vt_stream: anytype) !void {
1083+        vt_stream.nextSlice(payload);
1084+        self.has_pty_output = true;
1085+        for (self.clients.items) |client| {
1086+            try ipc.appendMessage(gpa, &client.write_buf, .Output, payload);
1087+            client.has_pending_output = true;
1088+        }
1089+        if (self.clients.items.len > 0) {
1090+            lib_posix.kill(self.pid, lib_posix.SIG.WINCH) catch |err| {
1091+                std.log.warn("failed to send SIGWINCH err={s}", .{@errorName(err)});
1092+            };
1093+        }
1094+    }
1095+
1096+    pub fn handleWrite(self: *Daemon, gpa: std.mem.Allocator, client: *Client, payload: []const u8) !void {
1097+        // Wire format: [u32 path len][path bytes][file content]
1098+        if (payload.len < @sizeOf(u32)) return error.InvalidPayload;
1099+        const path_len = std.mem.bytesToValue(u32, payload[0..@sizeOf(u32)]);
1100+        if (payload.len < @sizeOf(u32) + path_len) return error.InvalidPayload;
1101+        const file_path = payload[@sizeOf(u32)..][0..path_len];
1102+        const file_content = payload[@sizeOf(u32) + path_len ..];
1103+
1104+        // Inject file creation through the PTY so it works over SSH.
1105+        // Base64-encode content and pipe through printf | base64 -d > file.
1106+        // Chunk large files to stay under command-line length limits.
1107+        // 48000 is divisible by 3 (clean base64 boundaries) and encodes
1108+        // to ~64KB, well under typical ARG_MAX.
1109+        const chunk_size = 48000;
1110+        var offset: usize = 0;
1111+        var is_first = true;
1112+
1113+        while (offset < file_content.len or is_first) {
1114+            const end = @min(offset + chunk_size, file_content.len);
1115+            const chunk = file_content[offset..end];
1116+
1117+            const encoded_len = std.base64.standard.Encoder.calcSize(chunk.len);
1118+            const encoded = try gpa.alloc(u8, encoded_len);
1119+            defer gpa.free(encoded);
1120+            _ = std.base64.standard.Encoder.encode(encoded, chunk);
1121+
1122+            self.queuePtyInput(gpa, "printf '%s' '");
1123+            self.queuePtyInput(gpa, encoded);
1124+            if (is_first) {
1125+                self.queuePtyInput(gpa, "' | base64 -d > '");
1126+            } else {
1127+                self.queuePtyInput(gpa, "' | base64 -d >> '");
1128+            }
1129+            self.queuePtyInput(gpa, file_path);
1130+            self.queuePtyInput(gpa, "'");
1131+            self.queuePtyInput(gpa, "\r");
1132+
1133+            offset = end;
1134+            is_first = false;
1135+        }
1136+
1137+        try ipc.appendMessage(gpa, &client.write_buf, .Ack, "");
1138+        client.has_pending_output = true;
1139+        self.has_had_client = true;
1140+        std.log.debug(
1141+            "write command len={d} file_path={s}",
1142+            .{ file_content.len, file_path },
1143+        );
1144+    }
1145+
1146+    fn handleLabelGet(self: *Daemon, gpa: std.mem.Allocator, client: *Client) !void {
1147+        const out = try label.labelsToU8(gpa, self.labels);
1148+        defer gpa.free(out);
1149+        try ipc.appendMessage(gpa, &client.write_buf, .LabelData, out);
1150+        client.has_pending_output = true;
1151+    }
1152+
1153+    fn handleLabelSet(self: *Daemon, gpa: std.mem.Allocator, client: *Client, labels: []const u8) !void {
1154+        std.log.info("handle label set payload={s}", .{labels});
1155+
1156+        var kvs = label.LabelIterator.init(labels);
1157+        while (kvs.next()) |kv| {
1158+            if (kv.value.len == 0) {
1159+                if (self.labels.fetchRemove(kv.key)) |existing| {
1160+                    gpa.free(existing.key);
1161+                    gpa.free(existing.value);
1162+                }
1163+                continue;
1164+            }
1165+
1166+            const owned_key = try gpa.dupe(u8, kv.key);
1167+            errdefer gpa.free(owned_key);
1168+            const owned_value = try gpa.dupe(u8, kv.value);
1169+            errdefer gpa.free(owned_value);
1170+            if (try self.labels.fetchPut(gpa, owned_key, owned_value)) |existing| {
1171+                // fetchPut does NOT replace the key in the map, the old
1172+                // key pointer stays. So free the new (unused) key and the
1173+                // old value.
1174+                gpa.free(owned_key);
1175+                gpa.free(existing.value);
1176+            }
1177+        }
1178+
1179+        try ipc.appendMessage(gpa, &client.write_buf, .Ack, "");
1180+        client.has_pending_output = true;
1181+    }
1182+
1183+    fn handleLabelClear(self: *Daemon, gpa: std.mem.Allocator, client: *Client) !void {
1184+        var it = self.labels.iterator();
1185+        while (it.next()) |entry| {
1186+            gpa.free(entry.key_ptr.*);
1187+            gpa.free(entry.value_ptr.*);
1188+        }
1189+        self.labels.clearRetainingCapacity();
1190+        try ipc.appendMessage(gpa, &client.write_buf, .Ack, "");
1191+        client.has_pending_output = true;
1192+    }
1193+};
1194+
1195+test "send queues PTY input without changing leader" {
1196+    const alloc = std.testing.allocator;
1197+    var daemon = Daemon{
1198+        .cfg = undefined,
1199+        .clients = .empty,
1200+        .leader_client_fd = 42,
1201+        .session_name = "test",
1202+        .socket_path = "",
1203+        .running = true,
1204+        .pid = 0,
1205+        .created_at = 0,
1206+    };
1207+    defer daemon.pty_write_buf.deinit(alloc);
1208+
1209+    daemon.handleSend(alloc, "hello");
1210+
1211+    try std.testing.expectEqual(@as(?i32, 42), daemon.leader_client_fd);
1212+    try std.testing.expectEqualStrings("hello", daemon.pty_write_buf.items);
1213+}
+139, -1711
   1@@ -9,88 +9,20 @@ const cross = @import("cross.zig");
   2 const socket = @import("socket.zig");
   3 const label = @import("label.zig");
   4 const lib_posix = @import("posix.zig");
   5-
   6-pub const version = build_options.version;
   7-pub const ghostty_version = build_options.ghostty_version;
   8-
   9-var log_system = log.LogSystem{};
  10+const signal = @import("signal.zig");
  11+const Cfg = @import("cfg.zig");
  12+const loop = @import("loop.zig");
  13+const Client = loop.Client;
  14+const Daemon = loop.Daemon;
  15+const version = build_options.version;
  16+const ghostty_version = build_options.ghostty_version;
  17 
  18 pub const std_options: std.Options = .{
  19-    .logFn = zmxLogFn,
  20+    .logFn = log.zmxLogFn,
  21     .log_level = .debug,
  22 };
  23 
  24-fn zmxLogFn(
  25-    comptime level: std.log.Level,
  26-    comptime scope: anytype,
  27-    comptime format: []const u8,
  28-    args: anytype,
  29-) void {
  30-    log_system.log(level, scope, format, args) catch {};
  31-}
  32-
  33-/// Self-pipe woken by signal handlers. std.posix.poll loops on .INTR internally
  34-/// (PollError has no Interrupted member), so a signal that lands during poll()
  35-/// never surfaces; the handler writes a byte here and poll() wakes on POLLIN.
  36-var sig_pipe: [2]lib_posix.fd_t = .{ -1, -1 };
  37-
  38-// https://github.com/ziglang/zig/blob/738d2be9d6b6ef3ff3559130c05159ef53336224/lib/std/posix.zig#L3505
  39-const O_NONBLOCK: usize = 1 << @bitOffsetOf(lib_posix.O, "NONBLOCK");
  40-
  41-const SessionMatch = struct {
  42-    name: []const u8,
  43-    is_prefix: bool,
  44-
  45-    fn matches(self: SessionMatch, session_name: []const u8) bool {
  46-        if (self.is_prefix) return std.mem.startsWith(u8, session_name, self.name);
  47-        return std.mem.eql(u8, session_name, self.name);
  48-    }
  49-};
  50-
  51-fn resolveSessionOrEnv(alloc: std.mem.Allocator, io: std.Io, session_name: ?[]const u8) ![]const u8 {
  52-    const sesh_env = socket.getSeshNameFromEnv();
  53-    const raw = if (session_name) |name|
  54-        if (std.mem.eql(u8, name, ".")) blk: {
  55-            if (sesh_env.len > 0) break :blk sesh_env;
  56-            var buf: [4096]u8 = undefined;
  57-            var w = std.Io.File.stderr().writer(io, &buf);
  58-            w.interface.print("error: \".\" requires ZMX_SESSION (are you inside a zmx session?)\n", .{}) catch {};
  59-            w.interface.flush() catch {};
  60-            return error.SessionNameRequired;
  61-        } else name
  62-    else if (sesh_env.len > 0)
  63-        sesh_env
  64-    else {
  65-        return error.SessionNameRequired;
  66-    };
  67-    return socket.getSeshName(alloc, raw);
  68-}
  69-
  70-fn parseSessionArg(alloc: std.mem.Allocator, raw: []const u8) !SessionMatch {
  71-    if (raw.len > 0 and raw[raw.len - 1] == '*') {
  72-        const name = try socket.getSeshName(alloc, raw[0 .. raw.len - 1]);
  73-        return .{ .name = name, .is_prefix = true };
  74-    }
  75-    const name = try socket.getSeshName(alloc, raw);
  76-    return .{ .name = name, .is_prefix = false };
  77-}
  78-
  79-fn openSignalPipe() !void {
  80-    sig_pipe = try lib_posix.pipe2(.{ .CLOEXEC = true, .NONBLOCK = true });
  81-}
  82-
  83-fn drainSignalPipe() void {
  84-    var b: [16]u8 = undefined;
  85-    while (true) {
  86-        const n = lib_posix.read(sig_pipe[0], &b) catch return;
  87-        if (n == 0) return;
  88-    }
  89-}
  90-
  91-fn detectHelp(arg: []const u8) bool {
  92-    return (std.mem.eql(u8, arg, "--help") or std.mem.eql(u8, arg, "-h"));
  93-}
  94-
  95+/// This is the entry point for the CLI.
  96 pub fn main(init: std.process.Init) !void {
  97     const gpa = init.gpa;
  98     const io = init.io;
  99@@ -99,7 +31,7 @@ pub fn main(init: std.process.Init) !void {
 100     // disappears between probe and send would otherwise kill us before
 101     // write() can return BrokenPipe. Inherited across fork, so this also
 102     // covers the daemon.
 103-    ignoreSigpipe();
 104+    signal.ignoreSigpipe();
 105 
 106     var args = init.minimal.args.iterate();
 107     defer args.deinit();
 108@@ -111,8 +43,8 @@ pub fn main(init: std.process.Init) !void {
 109     const log_path = try std.fs.path.join(gpa, &.{ cfg.log_dir, "zmx.log" });
 110     defer gpa.free(log_path);
 111     const log_mode = std.Io.File.Permissions.fromMode(cfg.log_mode);
 112-    try log_system.init(gpa, io, log_path, log_mode);
 113-    defer log_system.deinit();
 114+    try log.log_system.init(io, log_path, log_mode);
 115+    defer log.log_system.deinit();
 116 
 117     const shell_env = init.environ_map.get("SHELL") orelse "/bin/sh";
 118 
 119@@ -134,14 +66,14 @@ pub fn main(init: std.process.Init) !void {
 120     } else if (std.mem.eql(u8, cmd, "get") or std.mem.eql(u8, cmd, "g")) {
 121         const sesh_name = args.next() orelse return error.SessionNameRequired;
 122         if (detectHelp(sesh_name)) return help(io);
 123-        const sesh = try resolveSessionOrEnv(gpa, io, sesh_name);
 124+        const sesh = try socket.resolveSessionOrEnv(gpa, io, sesh_name);
 125         defer gpa.free(sesh);
 126         const single_kv = args.next() orelse "";
 127         return labelGet(gpa, io, &cfg, sesh, single_kv);
 128     } else if (std.mem.eql(u8, cmd, "set")) {
 129         const sesh_name = args.next() orelse return error.SessionNameRequired;
 130         if (detectHelp(sesh_name)) return help(io);
 131-        const sesh = try resolveSessionOrEnv(gpa, io, sesh_name);
 132+        const sesh = try socket.resolveSessionOrEnv(gpa, io, sesh_name);
 133         defer gpa.free(sesh);
 134 
 135         var kvs = std.ArrayList(u8).empty;
 136@@ -156,7 +88,7 @@ pub fn main(init: std.process.Init) !void {
 137     } else if (std.mem.eql(u8, cmd, "clear")) {
 138         const sesh_name = args.next() orelse return error.SessionNameRequired;
 139         if (detectHelp(sesh_name)) return help(io);
 140-        const sesh = try resolveSessionOrEnv(gpa, io, sesh_name);
 141+        const sesh = try socket.resolveSessionOrEnv(gpa, io, sesh_name);
 142         defer gpa.free(sesh);
 143         return labelClear(gpa, io, &cfg, sesh);
 144     } else if (std.mem.eql(u8, cmd, "completions") or std.mem.eql(u8, cmd, "c")) {
 145@@ -198,7 +130,6 @@ pub fn main(init: std.process.Init) !void {
 146             try command_args.append(gpa, arg);
 147         }
 148 
 149-        const clients = try std.ArrayList(*Client).initCapacity(gpa, 10);
 150         var command: ?[][]const u8 = null;
 151         if (command_args.items.len > 0) {
 152             command = command_args.items;
 153@@ -210,27 +141,16 @@ pub fn main(init: std.process.Init) !void {
 154 
 155         const sesh = try socket.getSeshName(gpa, session_name);
 156         defer gpa.free(sesh);
 157-        var daemon = Daemon{
 158-            .io = io,
 159-            .running = true,
 160-            .cfg = &cfg,
 161-            .alloc = std.heap.c_allocator,
 162-            .clients = clients,
 163-            .session_name = sesh,
 164-            .socket_path = undefined,
 165-            .pid = undefined,
 166-            .command = command,
 167-            .cwd = cwd,
 168-            .created_at = @intCast(std.Io.Timestamp.now(io, .real).nanoseconds),
 169-            .leader_client_fd = null,
 170-            .shell = shell_env,
 171-        };
 172-        daemon.socket_path = socket.getSocketPath(gpa, cfg.socket_dir, sesh) catch |err| switch (err) {
 173-            error.NameTooLong => return socket.printSessionNameTooLong(daemon.io, sesh, cfg.socket_dir),
 174+        const socket_path = socket.getSocketPath(gpa, cfg.socket_dir, sesh) catch |err| switch (err) {
 175+            error.NameTooLong => return socket.printSessionNameTooLong(io, sesh, cfg.socket_dir),
 176             error.OutOfMemory => return err,
 177         };
 178+        var daemon = Daemon.init(io, &cfg, sesh, socket_path);
 179+        daemon.command = command;
 180+        daemon.cwd = cwd;
 181+        daemon.shell = shell_env;
 182         std.log.info("socket path={s}", .{daemon.socket_path});
 183-        return attach(&daemon);
 184+        return attach(gpa, io, &daemon);
 185     } else if (std.mem.eql(u8, cmd, "run") or std.mem.eql(u8, cmd, "r")) {
 186         const session_name = args.next() orelse "";
 187         if (std.mem.eql(u8, session_name, "--help") or std.mem.eql(u8, session_name, "-h")) {
 188@@ -247,7 +167,6 @@ pub fn main(init: std.process.Init) !void {
 189                 try cmd_args_raw.append(gpa, arg);
 190             }
 191         }
 192-        const clients = try std.ArrayList(*Client).initCapacity(gpa, 10);
 193 
 194         var cwd_buf: [std.fs.max_path_bytes]u8 = undefined;
 195         const cwd_len = std.process.currentPath(io, &cwd_buf) catch 0;
 196@@ -255,28 +174,17 @@ pub fn main(init: std.process.Init) !void {
 197 
 198         const sesh = try socket.getSeshName(gpa, session_name);
 199         defer gpa.free(sesh);
 200-        var daemon = Daemon{
 201-            .io = io,
 202-            .running = true,
 203-            .cfg = &cfg,
 204-            .alloc = std.heap.c_allocator,
 205-            .clients = clients,
 206-            .session_name = sesh,
 207-            .socket_path = undefined,
 208-            .pid = undefined,
 209-            .command = null,
 210-            .cwd = cwd,
 211-            .created_at = @intCast(std.Io.Timestamp.now(io, .real).nanoseconds),
 212-            .is_task_mode = true,
 213-            .leader_client_fd = null,
 214-            .shell = shell_env,
 215-        };
 216-        daemon.socket_path = socket.getSocketPath(gpa, cfg.socket_dir, sesh) catch |err| switch (err) {
 217-            error.NameTooLong => return socket.printSessionNameTooLong(daemon.io, sesh, cfg.socket_dir),
 218+        const socket_path = socket.getSocketPath(gpa, cfg.socket_dir, sesh) catch |err| switch (err) {
 219+            error.NameTooLong => return socket.printSessionNameTooLong(io, sesh, cfg.socket_dir),
 220             error.OutOfMemory => return err,
 221         };
 222+        defer gpa.free(socket_path);
 223+        var daemon = Daemon.init(io, &cfg, sesh, socket_path);
 224+        daemon.cwd = cwd;
 225+        daemon.is_task_mode = true;
 226+        daemon.shell = shell_env;
 227         std.log.info("socket path={s}", .{daemon.socket_path});
 228-        return run(&daemon, detached, cmd_args_raw.items);
 229+        return run(gpa, io, &daemon, detached, cmd_args_raw.items);
 230     } else if (std.mem.eql(u8, cmd, "send") or std.mem.eql(u8, cmd, "s")) {
 231         const session_name = args.next() orelse "";
 232         if (std.mem.eql(u8, session_name, "--help") or std.mem.eql(u8, session_name, "-h")) {
 233@@ -322,7 +230,7 @@ pub fn main(init: std.process.Init) !void {
 234         var stderr_writer = std.Io.File.stderr().writer(io, &stderr_buffer);
 235         const stderr = &stderr_writer.interface;
 236 
 237-        var matchers: std.ArrayList(SessionMatch) = .empty;
 238+        var matchers: std.ArrayList(socket.SessionMatch) = .empty;
 239         defer {
 240             for (matchers.items) |m| {
 241                 gpa.free(m.name);
 242@@ -338,7 +246,7 @@ pub fn main(init: std.process.Init) !void {
 243                 force = true;
 244                 continue;
 245             }
 246-            const m = try parseSessionArg(gpa, session_name);
 247+            const m = try socket.parseSessionArg(gpa, session_name);
 248             try matchers.append(gpa, m);
 249         }
 250         if (matchers.items.len == 0) {
 251@@ -369,7 +277,7 @@ pub fn main(init: std.process.Init) !void {
 252             }
 253         }
 254     } else if (std.mem.eql(u8, cmd, "wait") or std.mem.eql(u8, cmd, "w")) {
 255-        var matchers: std.ArrayList(SessionMatch) = .empty;
 256+        var matchers: std.ArrayList(socket.SessionMatch) = .empty;
 257         defer {
 258             for (matchers.items) |m| {
 259                 gpa.free(m.name);
 260@@ -380,7 +288,7 @@ pub fn main(init: std.process.Init) !void {
 261             if (std.mem.eql(u8, session_name, "--help") or std.mem.eql(u8, session_name, "-h")) {
 262                 return help(io);
 263             }
 264-            const m = try parseSessionArg(gpa, session_name);
 265+            const m = try socket.parseSessionArg(gpa, session_name);
 266             try matchers.append(gpa, m);
 267         }
 268         if (matchers.items.len == 0) {
 269@@ -388,7 +296,7 @@ pub fn main(init: std.process.Init) !void {
 270         }
 271         return wait(gpa, io, &cfg, matchers);
 272     } else if (std.mem.eql(u8, cmd, "tail") or std.mem.eql(u8, cmd, "t")) {
 273-        var matchers: std.ArrayList(SessionMatch) = .empty;
 274+        var matchers: std.ArrayList(socket.SessionMatch) = .empty;
 275         defer {
 276             for (matchers.items) |m| {
 277                 gpa.free(m.name);
 278@@ -399,7 +307,7 @@ pub fn main(init: std.process.Init) !void {
 279             if (std.mem.eql(u8, session_name, "--help") or std.mem.eql(u8, session_name, "-h")) {
 280                 return help(io);
 281             }
 282-            const m = try parseSessionArg(gpa, session_name);
 283+            const m = try socket.parseSessionArg(gpa, session_name);
 284             try matchers.append(gpa, m);
 285         }
 286         if (matchers.items.len == 0) {
 287@@ -479,962 +387,23 @@ pub fn main(init: std.process.Init) !void {
 288         var cwd_buf: [std.fs.max_path_bytes]u8 = undefined;
 289         const cwd_len = std.process.currentPath(io, &cwd_buf) catch 0;
 290         const cwd = cwd_buf[0..cwd_len];
 291-        const clients = try std.ArrayList(*Client).initCapacity(gpa, 10);
 292         const sesh = try socket.getSeshName(gpa, session_name);
 293         defer gpa.free(sesh);
 294-        var daemon = Daemon{
 295-            .io = io,
 296-            .running = true,
 297-            .cfg = &cfg,
 298-            .alloc = std.heap.c_allocator,
 299-            .clients = clients,
 300-            .session_name = sesh,
 301-            .socket_path = undefined,
 302-            .pid = undefined,
 303-            .command = null,
 304-            .cwd = cwd,
 305-            .created_at = @intCast(std.Io.Timestamp.now(io, .real).nanoseconds),
 306-            .is_task_mode = true,
 307-            .leader_client_fd = null,
 308-            .shell = shell_env,
 309-        };
 310-        daemon.socket_path = socket.getSocketPath(gpa, cfg.socket_dir, sesh) catch |err| switch (err) {
 311-            error.NameTooLong => return socket.printSessionNameTooLong(daemon.io, sesh, cfg.socket_dir),
 312+        const socket_path = socket.getSocketPath(gpa, cfg.socket_dir, sesh) catch |err| switch (err) {
 313+            error.NameTooLong => return socket.printSessionNameTooLong(io, sesh, cfg.socket_dir),
 314             error.OutOfMemory => return err,
 315         };
 316+        var daemon = Daemon.init(io, &cfg, sesh, socket_path);
 317+        daemon.is_task_mode = true;
 318+        daemon.cwd = cwd;
 319+        daemon.shell = shell_env;
 320         std.log.info("socket path={s}", .{daemon.socket_path});
 321-        try writeFile(&daemon, file_path);
 322+        try writeFile(gpa, io, &daemon, file_path);
 323     } else {
 324         return help(io);
 325     }
 326 }
 327 
 328-/// Client represents each terminal that has connected to a session.
 329-///
 330-/// Multiple Clients can connect to a single session.
 331-const Client = struct {
 332-    alloc: std.mem.Allocator,
 333-    socket_fd: i32,
 334-    has_pending_output: bool = false,
 335-    read_buf: ipc.SocketBuffer,
 336-    write_buf: std.ArrayList(u8),
 337-
 338-    pub fn deinit(self: *Client) void {
 339-        lib_posix.close(self.socket_fd);
 340-        self.read_buf.deinit();
 341-        self.write_buf.deinit(self.alloc);
 342-    }
 343-};
 344-
 345-/// Cfg is zmx's configuration container.
 346-///
 347-/// The purpose of this container is to hold anything that can be modified by the user.
 348-const Cfg = struct {
 349-    socket_dir: []const u8,
 350-    log_dir: []const u8,
 351-    max_scrollback: usize = 10_000_000,
 352-    dir_mode: u32 = 0o750,
 353-    log_mode: u32 = 0o640,
 354-
 355-    pub fn init(alloc: std.mem.Allocator, io: std.Io) !Cfg {
 356-        const socket_dir = try socketDir(alloc);
 357-        errdefer alloc.free(socket_dir);
 358-        const log_dir = try logDir(alloc);
 359-        errdefer alloc.free(log_dir);
 360-
 361-        const dir_mode = if (lib_posix.getenv("ZMX_DIR_MODE")) |m|
 362-            std.fmt.parseInt(u32, m, 8) catch 0o750
 363-        else
 364-            0o750;
 365-
 366-        const log_mode = if (lib_posix.getenv("ZMX_LOG_MODE")) |m|
 367-            std.fmt.parseInt(u32, m, 8) catch 0o640
 368-        else
 369-            0o640;
 370-
 371-        var cfg = Cfg{
 372-            .socket_dir = socket_dir,
 373-            .log_dir = log_dir,
 374-            .dir_mode = dir_mode,
 375-            .log_mode = log_mode,
 376-        };
 377-
 378-        try cfg.mkdir(io);
 379-
 380-        return cfg;
 381-    }
 382-
 383-    fn socketDir(alloc: std.mem.Allocator) ![]const u8 {
 384-        const tmpdir = std.mem.trimEnd(u8, lib_posix.getenv("TMPDIR") orelse "/tmp", "/");
 385-        const uid = lib_posix.getuid();
 386-
 387-        const socket_dir: []const u8 = if (lib_posix.getenv("ZMX_DIR")) |zmxdir|
 388-            try alloc.dupe(u8, zmxdir)
 389-        else if (lib_posix.getenv("XDG_RUNTIME_DIR")) |xdg_runtime|
 390-            try std.fmt.allocPrint(alloc, "{s}/zmx", .{xdg_runtime})
 391-        else
 392-            try std.fmt.allocPrint(alloc, "{s}/zmx-{d}", .{ tmpdir, uid });
 393-
 394-        return socket_dir;
 395-    }
 396-
 397-    fn logDir(alloc: std.mem.Allocator) ![]const u8 {
 398-        const log_dir = if (lib_posix.getenv("ZMX_DIR")) |zmxdir|
 399-            try std.fmt.allocPrint(alloc, "{s}/logs", .{zmxdir})
 400-        else if (lib_posix.getenv("XDG_STATE_HOME")) |xdg_state_home|
 401-            try std.fmt.allocPrint(alloc, "{s}/zmx/logs", .{xdg_state_home})
 402-        else if (lib_posix.getenv("HOME")) |home_dir|
 403-            try std.fmt.allocPrint(alloc, "{s}/.local/state/zmx/logs", .{home_dir})
 404-        else fallback: {
 405-            // This is the last resort: falling back to /tmp/$UID if HOME is unset.
 406-            const tmpdir = std.mem.trimEnd(u8, lib_posix.getenv("TMPDIR") orelse "/tmp", "/");
 407-            const uid = lib_posix.getuid();
 408-            break :fallback try std.fmt.allocPrint(alloc, "{s}/zmx-{d}", .{ tmpdir, uid });
 409-        };
 410-
 411-        return log_dir;
 412-    }
 413-
 414-    pub fn deinit(self: *Cfg, alloc: std.mem.Allocator) void {
 415-        if (self.socket_dir.len > 0) alloc.free(self.socket_dir);
 416-        if (self.log_dir.len > 0) alloc.free(self.log_dir);
 417-    }
 418-
 419-    pub fn mkdir(self: *Cfg, io: std.Io) !void {
 420-        const sock_perms = std.Io.Dir.Permissions.fromMode(@intCast(self.dir_mode));
 421-        try mkdirAll(io, self.socket_dir, sock_perms);
 422-        const log_perms = std.Io.Dir.Permissions.fromMode(@intCast(self.dir_mode));
 423-        try mkdirAll(io, self.log_dir, log_perms);
 424-    }
 425-
 426-    fn mkdirAll(io: std.Io, sub_dir_path: []const u8, permissions: std.Io.Dir.Permissions) !void {
 427-        var it = std.fs.path.componentIterator(sub_dir_path);
 428-        var component = it.last() orelse return error.BadPathName;
 429-        while (true) {
 430-            std.Io.Dir.createDirAbsolute(io, component.path, permissions) catch |err| switch (err) {
 431-                error.PathAlreadyExists => {},
 432-                error.FileNotFound => |e| {
 433-                    component = it.previous() orelse return e;
 434-                    continue;
 435-                },
 436-                else => |e| return e,
 437-            };
 438-            component = it.next() orelse return;
 439-        }
 440-    }
 441-};
 442-
 443-test "Cfg.init uses default modes when env vars are not set" {
 444-    const alloc = std.testing.allocator;
 445-
 446-    // Ensure they are not set
 447-    _ = cross.c.unsetenv("ZMX_DIR_MODE");
 448-    _ = cross.c.unsetenv("ZMX_LOG_MODE");
 449-
 450-    var cfg = try Cfg.init(alloc, std.testing.io);
 451-    defer cfg.deinit(alloc);
 452-
 453-    try std.testing.expectEqual(@as(u32, 0o750), cfg.dir_mode);
 454-    try std.testing.expectEqual(@as(u32, 0o640), cfg.log_mode);
 455-}
 456-
 457-test "Cfg.init uses custom modes from env vars" {
 458-    const alloc = std.testing.allocator;
 459-
 460-    // Set custom octal values
 461-    _ = cross.c.setenv("ZMX_DIR_MODE", "770", 1);
 462-    _ = cross.c.setenv("ZMX_LOG_MODE", "660", 1);
 463-    defer {
 464-        _ = cross.c.unsetenv("ZMX_DIR_MODE");
 465-        _ = cross.c.unsetenv("ZMX_LOG_MODE");
 466-    }
 467-
 468-    var cfg = try Cfg.init(alloc, std.testing.io);
 469-    defer cfg.deinit(alloc);
 470-
 471-    try std.testing.expectEqual(@as(u32, 0o770), cfg.dir_mode);
 472-    try std.testing.expectEqual(@as(u32, 0o660), cfg.log_mode);
 473-}
 474-
 475-/// Daemon is responsible for managing a zmx session.
 476-///
 477-/// It holds all the state for a running session.  Instead of a single daemon for all sessions, we
 478-/// create a daemon for every session.  This has some benefits. The ipc communication between
 479-/// session clients and the daemon doesn't need to be tagged with the session name.  If a daemon
 480-/// crashes for one session won't crash all the other sessions.
 481-///
 482-/// Conceptually it's also much simpler to reason about.
 483-const Daemon = struct {
 484-    io: std.Io,
 485-    cfg: *Cfg,
 486-    alloc: std.mem.Allocator,
 487-    clients: std.ArrayList(*Client),
 488-    labels: std.StringHashMapUnmanaged([]u8) = .empty,
 489-    // This control which client is the leader.  The leader controls terminal state and
 490-    // cols/rows of session.
 491-    leader_client_fd: ?i32,
 492-    session_name: []const u8,
 493-    socket_path: []const u8,
 494-    running: bool,
 495-    pid: i32,
 496-    command: ?[]const []const u8 = null,
 497-    cwd: []const u8 = "",
 498-    has_pty_output: bool = false,
 499-    has_had_client: bool = false,
 500-    has_terminal_client: bool = false, // true only after a real attach (.Init received)
 501-    created_at: u64, // unix timestamp (ns)
 502-    is_task_mode: bool = false, // flag for when session is run as a task
 503-    task_exit_code: ?u8 = null, // null = running or n/a, set when task completes
 504-    task_ended_at: ?u64 = null, // timestamp when task exited
 505-    pty_fd: i32 = -1, // set by daemonLoop so handleRun can probe the foreground process
 506-    pty_write_buf: std.ArrayList(u8) = .empty,
 507-    shell: []const u8 = "/bin/sh",
 508-
 509-    const EnsureSessionResult = struct {
 510-        created: bool,
 511-        is_daemon: bool,
 512-    };
 513-
 514-    pub fn deinit(self: *Daemon) void {
 515-        self.clients.deinit(self.alloc);
 516-        var it = self.labels.iterator();
 517-        while (it.next()) |entry| {
 518-            self.alloc.free(entry.key_ptr.*);
 519-            self.alloc.free(entry.value_ptr.*);
 520-        }
 521-        self.labels.deinit(self.alloc);
 522-        self.pty_write_buf.deinit(self.alloc);
 523-        self.alloc.free(self.socket_path);
 524-    }
 525-
 526-    fn handleLabelGet(self: *Daemon, client: *Client) !void {
 527-        const out = try label.labelsToU8(self.alloc, self.labels);
 528-        defer self.alloc.free(out);
 529-        try ipc.appendMessage(self.alloc, &client.write_buf, .LabelData, out);
 530-        client.has_pending_output = true;
 531-    }
 532-
 533-    fn handleLabelSet(self: *Daemon, client: *Client, labels: []const u8) !void {
 534-        std.log.info("handle label set payload={s}", .{labels});
 535-
 536-        var kvs = label.LabelIterator.init(labels);
 537-        while (kvs.next()) |kv| {
 538-            if (kv.value.len == 0) {
 539-                if (self.labels.fetchRemove(kv.key)) |existing| {
 540-                    self.alloc.free(existing.key);
 541-                    self.alloc.free(existing.value);
 542-                }
 543-                continue;
 544-            }
 545-
 546-            const owned_key = try self.alloc.dupe(u8, kv.key);
 547-            errdefer self.alloc.free(owned_key);
 548-            const owned_value = try self.alloc.dupe(u8, kv.value);
 549-            errdefer self.alloc.free(owned_value);
 550-            if (try self.labels.fetchPut(self.alloc, owned_key, owned_value)) |existing| {
 551-                // fetchPut does NOT replace the key in the map, the old
 552-                // key pointer stays. So free the new (unused) key and the
 553-                // old value.
 554-                self.alloc.free(owned_key);
 555-                self.alloc.free(existing.value);
 556-            }
 557-        }
 558-
 559-        try ipc.appendMessage(self.alloc, &client.write_buf, .Ack, "");
 560-        client.has_pending_output = true;
 561-    }
 562-
 563-    fn handleLabelClear(self: *Daemon, client: *Client) !void {
 564-        var it = self.labels.iterator();
 565-        while (it.next()) |entry| {
 566-            self.alloc.free(entry.key_ptr.*);
 567-            self.alloc.free(entry.value_ptr.*);
 568-        }
 569-        self.labels.clearRetainingCapacity();
 570-        try ipc.appendMessage(self.alloc, &client.write_buf, .Ack, "");
 571-        client.has_pending_output = true;
 572-    }
 573-
 574-    pub fn shutdown(self: *Daemon) void {
 575-        std.log.info("shutting down daemon session={s}", .{self.session_name});
 576-        self.running = false;
 577-
 578-        for (self.clients.items) |client| {
 579-            client.deinit();
 580-            self.alloc.destroy(client);
 581-        }
 582-        self.clients.clearRetainingCapacity();
 583-    }
 584-
 585-    pub fn closeClient(self: *Daemon, client: *Client, i: usize, shutdown_on_last: bool) bool {
 586-        const fd = client.socket_fd;
 587-        // leader is disconnected, remove ref and let another client claim leader on input
 588-        if (self.leader_client_fd == client.socket_fd) {
 589-            std.log.info(
 590-                "unsetting leader session={s} fd={d}",
 591-                .{ self.session_name, client.socket_fd },
 592-            );
 593-            self.leader_client_fd = null;
 594-        }
 595-        client.deinit();
 596-        self.alloc.destroy(client);
 597-        _ = self.clients.orderedRemove(i);
 598-        std.log.info("client disconnected fd={d} remaining={d}", .{ fd, self.clients.items.len });
 599-        if (shutdown_on_last and self.clients.items.len == 0) {
 600-            self.shutdown();
 601-            return true;
 602-        }
 603-        return false;
 604-    }
 605-
 606-    fn setLeader(self: *Daemon, client: *Client) !void {
 607-        std.log.info("setting new leader client_fd={d}", .{client.socket_fd});
 608-        self.leader_client_fd = client.socket_fd;
 609-        // Send a resize message to the client so it can send us back their window size
 610-        // so we can resize the pty and ghostty state.
 611-        try ipc.appendMessage(self.alloc, &client.write_buf, .Resize, "");
 612-        client.has_pending_output = true;
 613-    }
 614-
 615-    /// Runs in the forked child. Either execs or returns an error (caller
 616-    /// must exit on error -- returning would fall through to parent code).
 617-    fn execChild(self: *Daemon) !noreturn {
 618-        const alloc = std.heap.c_allocator;
 619-
 620-        // main() set SIGPIPE to SIG_IGN, which (unlike handlers) survives
 621-        // exec. Restore the default so the shell and its children behave
 622-        // normally (e.g. `yes | head` should exit 141 via SIGPIPE).
 623-        const dfl: lib_posix.Sigaction = .{
 624-            .handler = .{ .handler = lib_posix.SIG.DFL },
 625-            .mask = lib_posix.sigemptyset(),
 626-            .flags = 0,
 627-        };
 628-        lib_posix.sigaction(lib_posix.SIG.PIPE, &dfl, null);
 629-
 630-        const session_env = try std.fmt.allocPrintSentinel(
 631-            alloc,
 632-            "ZMX_SESSION={s}",
 633-            .{self.session_name},
 634-            0,
 635-        );
 636-        _ = cross.c.putenv(session_env.ptr);
 637-
 638-        if (self.command) |cmd_args| {
 639-            const argv = try alloc.allocSentinel(?[*:0]const u8, cmd_args.len, null);
 640-            for (cmd_args, 0..) |arg, i| {
 641-                argv[i] = try alloc.dupeZ(u8, arg);
 642-            }
 643-            const err = lib_posix.execvpeZ(argv[0].?, argv.ptr, std.c.environ);
 644-            std.log.err("execvpe failed: cmd={s} err={s}", .{ cmd_args[0], @errorName(err) });
 645-            lib_posix.exit(1);
 646-        }
 647-
 648-        var buf: [256]u8 = undefined;
 649-        const z = try std.fmt.bufPrintZ(&buf, "{s}", .{self.shell});
 650-        const shell: [:0]const u8 = if (self.is_task_mode) "bash" else z;
 651-        // Use "-shellname" as argv[0] to signal login shell (traditional method)
 652-        const login_shell = try std.fmt.allocPrintSentinel(
 653-            alloc,
 654-            "-{s}",
 655-            .{std.fs.path.basename(shell)},
 656-            0,
 657-        );
 658-        const argv = [_:null]?[*:0]const u8{ login_shell, null };
 659-        const err = lib_posix.execvpeZ(shell, &argv, std.c.environ);
 660-        std.log.err("execvpe failed: shell={s} err={s}", .{ shell, @errorName(err) });
 661-        lib_posix.exit(1);
 662-    }
 663-
 664-    /// spawnPty runs forkpty() and executes the shell or shell command the user provides.
 665-    fn spawnPty(self: *Daemon) !c_int {
 666-        const size = ipc.getTerminalSize(lib_posix.STDOUT_FILENO);
 667-        var ws: cross.c.struct_winsize = .{
 668-            .ws_row = size.rows,
 669-            .ws_col = size.cols,
 670-            .ws_xpixel = size.xpixel,
 671-            .ws_ypixel = size.ypixel,
 672-        };
 673-
 674-        var master_fd: c_int = undefined;
 675-        const pid = cross.forkpty(&master_fd, null, null, &ws);
 676-        if (pid < 0) {
 677-            return error.ForkPtyFailed;
 678-        }
 679-
 680-        if (pid == 0) { // child pid code path
 681-            // In the forked child, ANY error must exit rather than propagate:
 682-            // a returned error falls through to the parent code path below,
 683-            // running a second daemon on the same socket (or worse, hitting
 684-            // errdefers that delete the parent's socket file).
 685-            execChild(self) catch |err| {
 686-                std.log.err("child setup failed: {s}", .{@errorName(err)});
 687-                lib_posix.exit(1);
 688-            };
 689-            unreachable; // execChild either execs or exits, never returns ok
 690-        }
 691-        // master pid code path
 692-        self.pid = pid;
 693-        std.log.info("pty spawned session={s} pid={d}", .{ self.session_name, pid });
 694-
 695-        // make pty non-blocking
 696-        const flags = try lib_posix.fcntl(master_fd, lib_posix.F.GETFL, 0);
 697-        _ = try lib_posix.fcntl(master_fd, lib_posix.F.SETFL, flags | O_NONBLOCK);
 698-        return master_fd;
 699-    }
 700-
 701-    /// ensureSession "upserts" a session by checking if the unix socket exists already.
 702-    /// If not it creates one and spawns the daemon.
 703-    fn ensureSession(self: *Daemon) !EnsureSessionResult {
 704-        std.log.info("ensure session session={s}", .{self.session_name});
 705-        var dir = try std.Io.Dir.openDirAbsolute(self.io, self.cfg.socket_dir, .{});
 706-        defer dir.close(self.io);
 707-
 708-        const exists = try socket.sessionExists(self.io, dir, self.session_name);
 709-        var should_create = !exists;
 710-
 711-        if (exists) {
 712-            if (ipc.connectSession(self.socket_path)) |fd| {
 713-                lib_posix.close(fd);
 714-                if (self.command != null) {
 715-                    std.log.warn(
 716-                        "session already exists, ignoring command session={s}",
 717-                        .{self.session_name},
 718-                    );
 719-                }
 720-            } else |err| switch (err) {
 721-                // Daemon is definitively gone: safe to replace.
 722-                error.ConnectionRefused => {
 723-                    socket.cleanupStaleSocket(self.io, dir, self.session_name);
 724-                    should_create = true;
 725-                },
 726-                // Connect failed for an unusual reason. The check is only to
 727-                // decide create-vs-attach; the socket file exists, so proceed
 728-                // to attach rather than fail or orphan.
 729-                else => {
 730-                    std.log.warn(
 731-                        "connect failed ({s}), proceeding to attach session={s}",
 732-                        .{ @errorName(err), self.session_name },
 733-                    );
 734-                },
 735-            }
 736-        }
 737-
 738-        if (should_create) {
 739-            std.log.info("creating session={s}", .{self.session_name});
 740-            const server_sock_fd = try socket.createSocket(self.socket_path);
 741-
 742-            // creates the daemon
 743-            const pid = try lib_posix.fork();
 744-            if (pid == 0) { // child (daemon)
 745-                // becomes the session leader and detaches process from its controlling terminal
 746-                _ = try lib_posix.setsid();
 747-
 748-                log_system.deinit();
 749-
 750-                // Redirect stdin/stdout/stderr to /dev/null. The daemon
 751-                // communicates via its unix socket, not stdio. Without
 752-                // this, any pipe on FDs 0-2 (e.g. from bats' `run`
 753-                // keyword) stays open for the daemon's lifetime, causing
 754-                // the caller to hang waiting for EOF.
 755-                {
 756-                    const devnull = lib_posix.open(
 757-                        "/dev/null",
 758-                        .{ .ACCMODE = .RDWR },
 759-                        0,
 760-                    ) catch |err| {
 761-                        std.log.warn("failed to open /dev/null: {s}", .{@errorName(err)});
 762-                        return err;
 763-                    };
 764-                    inline for (.{ lib_posix.STDIN_FILENO, lib_posix.STDOUT_FILENO, lib_posix.STDERR_FILENO }) |fd| {
 765-                        _ = lib_posix.dup2(devnull, fd) catch |err| {
 766-                            std.log.warn("dup2 /dev/null -> {d}: {s}", .{ fd, @errorName(err) });
 767-                            return err;
 768-                        };
 769-                    }
 770-                    if (devnull > 2) lib_posix.close(devnull);
 771-                }
 772-
 773-                // Close file descriptors inherited from the parent that the
 774-                // daemon doesn't need. This prevents test harnesses (like
 775-                // bats) from hanging -- they wait for their internal FDs (3+)
 776-                // to close before exiting.
 777-                //
 778-                // Must run BEFORE log_system.init() otherwise the new log
 779-                // FD gets closed, and spawnPty() reuses that FD number for
 780-                // the PTY master, causing log writes to leak into the terminal.
 781-                //
 782-                // Skip server_sock_fd (needed for IPC) and dir.fd (needed to
 783-                // delete the socket file on shutdown).
 784-                {
 785-                    const dir_fd = @as(i32, @intCast(dir.handle));
 786-                    var fd: i32 = 3;
 787-                    while (fd < 64) : (fd += 1) {
 788-                        if (fd == server_sock_fd or fd == dir_fd) continue;
 789-                        _ = std.c.close(fd);
 790-                    }
 791-                }
 792-
 793-                const session_log_name = try std.fmt.allocPrint(
 794-                    self.alloc,
 795-                    "{s}.log",
 796-                    .{self.session_name},
 797-                );
 798-                defer self.alloc.free(session_log_name);
 799-                const session_log_path = try std.fs.path.join(
 800-                    self.alloc,
 801-                    &.{ self.cfg.log_dir, session_log_name },
 802-                );
 803-                defer self.alloc.free(session_log_path);
 804-                const log_mode = std.Io.File.Permissions.fromMode(self.cfg.log_mode);
 805-                try log_system.init(self.alloc, self.io, session_log_path, log_mode);
 806-
 807-                // If spawnPty fails, clean up here. Once it succeeds,
 808-                // the inner block's defer takes ownership of cleanup to
 809-                // avoid double-closing server_sock_fd on daemonLoop error.
 810-                const pty_fd = self.spawnPty() catch |err| {
 811-                    lib_posix.close(server_sock_fd);
 812-                    dir.deleteFile(self.io, self.session_name) catch {};
 813-                    return err;
 814-                };
 815-
 816-                defer {
 817-                    // Close and unlink the listen socket BEFORE handleKill()'s
 818-                    // 500ms SIGHUP->SIGKILL grace sleep. Otherwise a `zmx run`
 819-                    // for the same name issued in that window will hang waiting
 820-                    // for a connect.
 821-                    lib_posix.close(server_sock_fd);
 822-                    std.log.info("deleting socket file session={s}", .{self.session_name});
 823-                    dir.deleteFile(self.io, self.session_name) catch |err| {
 824-                        std.log.warn("failed to delete socket file err={s}", .{@errorName(err)});
 825-                    };
 826-                    self.handleKill();
 827-                    self.deinit();
 828-                    lib_posix.close(pty_fd);
 829-                    _ = lib_posix.waitpid(self.pid, 0);
 830-                }
 831-
 832-                try daemonLoop(self, server_sock_fd, pty_fd);
 833-                std.log.info("daemon loop shutdown", .{});
 834-                return .{ .created = true, .is_daemon = true };
 835-            }
 836-            lib_posix.close(server_sock_fd);
 837-            std.Io.sleep(self.io, std.Io.Duration.fromMilliseconds(10), .real) catch unreachable;
 838-            return .{ .created = true, .is_daemon = false };
 839-        }
 840-
 841-        return .{ .created = false, .is_daemon = false };
 842-    }
 843-
 844-    const PTY_WRITE_BUF_MAX = 256 * 1024;
 845-
 846-    /// Queue bytes for the PTY's stdin. Flushed by daemonLoop on POLLOUT.
 847-    /// Drops the payload if the buffer is over cap -- same failure mode as
 848-    /// the old direct-write ptyWrite (drop on EAGAIN), just at a 64x higher
 849-    /// threshold. Capping avoids OOM when the shell stops reading; dropping
 850-    /// new (not old) bytes avoids tearing a partially-accepted sequence.
 851-    fn queuePtyInput(self: *Daemon, data: []const u8) void {
 852-        if (data.len == 0) return;
 853-        if (self.pty_write_buf.items.len + data.len > PTY_WRITE_BUF_MAX) {
 854-            std.log.warn(
 855-                "pty input dropped {d} bytes (buffer full, shell not reading)",
 856-                .{data.len},
 857-            );
 858-            return;
 859-        }
 860-        std.log.debug("buffering pty input data={x}", .{data});
 861-        self.pty_write_buf.appendSlice(self.alloc, data) catch |err| {
 862-            std.log.warn(
 863-                "pty input dropped {d} bytes: {s}",
 864-                .{ data.len, @errorName(err) },
 865-            );
 866-        };
 867-    }
 868-
 869-    pub fn handleInput(self: *Daemon, client: *Client, payload: []const u8) !void {
 870-        std.log.debug("buffering pty input data={x}", .{payload});
 871-        // client is leader, send entire payload (ansi escape codes + text)
 872-        if (self.leader_client_fd == client.socket_fd) {
 873-            self.queuePtyInput(payload);
 874-            return;
 875-        }
 876-
 877-        // check if leader needs to be updated by detecting any user input
 878-        if (util.isUserInput(payload)) {
 879-            try self.setLeader(client);
 880-            self.queuePtyInput(payload);
 881-        }
 882-    }
 883-
 884-    /// Queue input from `zmx send` without changing interactive client leadership.
 885-    pub fn handleSend(self: *Daemon, payload: []const u8) void {
 886-        self.queuePtyInput(payload);
 887-    }
 888-
 889-    pub fn handleSwitch(self: *Daemon, session_name: []const u8) !void {
 890-        for (self.clients.items) |client| {
 891-            if (self.leader_client_fd == client.socket_fd) {
 892-                ipc.appendMessage(
 893-                    self.alloc,
 894-                    &client.write_buf,
 895-                    .Switch,
 896-                    session_name,
 897-                ) catch |err| {
 898-                    std.log.warn(
 899-                        "failed to buffer terminal state for client err={s}",
 900-                        .{@errorName(err)},
 901-                    );
 902-                };
 903-                client.has_pending_output = true;
 904-                return;
 905-            }
 906-        }
 907-        return error.NoLeaderFound;
 908-    }
 909-
 910-    pub fn handleInit(
 911-        self: *Daemon,
 912-        client: *Client,
 913-        pty_fd: i32,
 914-        term: *ghostty_vt.Terminal,
 915-        payload: []const u8,
 916-    ) !void {
 917-        if (payload.len != @sizeOf(ipc.Resize)) return;
 918-
 919-        // Serialize terminal state BEFORE resize to capture correct cursor position.
 920-        // Resizing triggers reflow which can move the cursor, and the shell's
 921-        // SIGWINCH-triggered redraw will run after our snapshot is sent.
 922-        // Only serialize on re-attach (has_had_client), not first attach, to avoid
 923-        // interfering with shell initialization (DA1 queries, etc.)
 924-        if (self.has_pty_output and self.has_had_client) {
 925-            const cursor = &term.screens.active.cursor;
 926-            std.log.debug(
 927-                "cursor before serialize: x={d} y={d} pending_wrap={}",
 928-                .{ cursor.x, cursor.y, cursor.pending_wrap },
 929-            );
 930-            if (util.serializeTerminalState(self.alloc, term)) |term_output| {
 931-                std.log.debug("serialize terminal state", .{});
 932-                // Rewrite OSC 133;A to include redraw=0 so the outer terminal
 933-                // does not clear prompt lines on resize (issue #111).
 934-                const restore_data = util.rewritePromptRedraw(self.alloc, term_output) orelse term_output;
 935-                defer self.alloc.free(term_output);
 936-                defer if (restore_data.ptr != term_output.ptr) self.alloc.free(restore_data);
 937-                ipc.appendMessage(self.alloc, &client.write_buf, .Output, restore_data) catch |err| {
 938-                    std.log.warn(
 939-                        "failed to buffer terminal state for client err={s}",
 940-                        .{@errorName(err)},
 941-                    );
 942-                };
 943-                client.has_pending_output = true;
 944-            }
 945-        }
 946-
 947-        // no leader is set so set one
 948-        if (self.leader_client_fd == null) {
 949-            try self.setLeader(client);
 950-        }
 951-
 952-        // only resize if leader
 953-        if (self.leader_client_fd == client.socket_fd) {
 954-            const resize = std.mem.bytesToValue(ipc.Resize, payload);
 955-            var ws: cross.c.struct_winsize = .{
 956-                .ws_row = resize.rows,
 957-                .ws_col = resize.cols,
 958-                .ws_xpixel = resize.xpixel,
 959-                .ws_ypixel = resize.ypixel,
 960-            };
 961-            _ = cross.c.ioctl(pty_fd, cross.c.TIOCSWINSZ, &ws);
 962-            // Disable prompt_redraw before resize. The daemon's internal terminal
 963-            // would otherwise clear prompt lines expecting the shell to redraw them,
 964-            // but the shell's redraw goes to the PTY (forwarded to clients), not to
 965-            // this daemon terminal. The clearing corrupts the daemon's snapshot state.
 966-            const saved_prompt_redraw = term.flags.shell_redraws_prompt;
 967-            term.flags.shell_redraws_prompt = .false;
 968-            defer term.flags.shell_redraws_prompt = saved_prompt_redraw;
 969-            const opts = ghostty_vt.Terminal.Resize{
 970-                .cols = resize.cols,
 971-                .rows = resize.rows,
 972-            };
 973-            try term.resize(self.alloc, opts);
 974-
 975-            // Mark that we've had a client init, so subsequent clients get terminal state
 976-            self.has_had_client = true;
 977-            self.has_terminal_client = true;
 978-
 979-            std.log.debug("init resize rows={d} cols={d}", .{ resize.rows, resize.cols });
 980-        }
 981-    }
 982-
 983-    pub fn handleResize(
 984-        self: *Daemon,
 985-        client: *Client,
 986-        pty_fd: i32,
 987-        term: *ghostty_vt.Terminal,
 988-        payload: []const u8,
 989-    ) !void {
 990-        if (payload.len != @sizeOf(ipc.Resize)) return;
 991-        if (self.leader_client_fd == null) {
 992-            try self.setLeader(client);
 993-        }
 994-        // only leader can resize
 995-        if (self.leader_client_fd != client.socket_fd) return;
 996-
 997-        const resize = std.mem.bytesToValue(ipc.Resize, payload);
 998-        var ws: cross.c.struct_winsize = .{
 999-            .ws_row = resize.rows,
1000-            .ws_col = resize.cols,
1001-            .ws_xpixel = resize.xpixel,
1002-            .ws_ypixel = resize.ypixel,
1003-        };
1004-        _ = cross.c.ioctl(pty_fd, cross.c.TIOCSWINSZ, &ws);
1005-        // Disable prompt_redraw before resize (same rationale as handleInit).
1006-        const saved_prompt_redraw = term.flags.shell_redraws_prompt;
1007-        term.flags.shell_redraws_prompt = .false;
1008-        defer term.flags.shell_redraws_prompt = saved_prompt_redraw;
1009-        const opts = ghostty_vt.Terminal.Resize{
1010-            .cols = resize.cols,
1011-            .rows = resize.rows,
1012-        };
1013-        try term.resize(self.alloc, opts);
1014-        std.log.debug("resize rows={d} cols={d}", .{ resize.rows, resize.cols });
1015-    }
1016-
1017-    pub fn handleDetach(self: *Daemon, client: *Client, i: usize) void {
1018-        std.log.info("client detach session={s} fd={d}", .{ self.session_name, client.socket_fd });
1019-        _ = self.closeClient(client, i, false);
1020-    }
1021-
1022-    pub fn handleDetachAll(self: *Daemon) void {
1023-        std.log.info("detach all clients={d}", .{self.clients.items.len});
1024-        for (self.clients.items) |client_to_close| {
1025-            client_to_close.deinit();
1026-            self.alloc.destroy(client_to_close);
1027-        }
1028-        self.clients.clearRetainingCapacity();
1029-    }
1030-
1031-    pub fn handleKill(self: *Daemon) void {
1032-        std.log.info("kill received session={s}", .{self.session_name});
1033-        self.shutdown();
1034-        // gracefully shutdown shell processes, shells tend to ignore SIGTERM so we send SIGHUP
1035-        // instead
1036-        //   https://www.gnu.org/software/bash/manual/html_node/Signals.html
1037-        // negative pid means kill process and children
1038-        std.log.info("sending SIGHUP session={s} pid={d}", .{ self.session_name, self.pid });
1039-        lib_posix.kill(-self.pid, lib_posix.SIG.HUP) catch |err| {
1040-            std.log.warn("failed to send SIGHUP to pty child err={s}", .{@errorName(err)});
1041-        };
1042-        std.Io.sleep(self.io, std.Io.Duration.fromMilliseconds(500), .real) catch unreachable;
1043-        lib_posix.kill(-self.pid, lib_posix.SIG.KILL) catch |err| {
1044-            std.log.warn("failed to send SIGKILL to pty child err={s}", .{@errorName(err)});
1045-        };
1046-    }
1047-
1048-    pub fn handleInfo(self: *Daemon, client: *Client) !void {
1049-        // zeroes() so asBytes() doesn't ship struct padding + unused cmd/cwd
1050-        // tail bytes (daemon stack contents) to clients.
1051-        var info = std.mem.zeroes(ipc.Info);
1052-        info.clients_len = self.clients.items.len - 1;
1053-        info.pid = self.pid;
1054-        info.created_at = self.created_at;
1055-        info.task_ended_at = self.task_ended_at orelse 0;
1056-        info.task_exit_code = self.task_exit_code orelse 0;
1057-
1058-        // Build command string from args, re-quoting args that contain
1059-        // shell-special characters so the displayed command is copy-pasteable.
1060-        const cur_cmd = self.command;
1061-        if (cur_cmd) |args| {
1062-            for (args, 0..) |arg, i| {
1063-                const quoted = if (util.shellNeedsQuoting(arg))
1064-                    util.shellQuote(self.alloc, arg) catch null
1065-                else
1066-                    null;
1067-                defer if (quoted) |q| self.alloc.free(q);
1068-                const src = quoted orelse arg;
1069-
1070-                const need = src.len + @as(usize, if (i > 0) 1 else 0);
1071-                if (info.cmd_len + need > ipc.MAX_CMD_LEN) {
1072-                    const ellipsis = "...";
1073-                    if (info.cmd_len + ellipsis.len <= ipc.MAX_CMD_LEN) {
1074-                        @memcpy(info.cmd[info.cmd_len..][0..ellipsis.len], ellipsis);
1075-                        info.cmd_len += ellipsis.len;
1076-                    }
1077-                    break;
1078-                }
1079-
1080-                if (i > 0) {
1081-                    info.cmd[info.cmd_len] = ' ';
1082-                    info.cmd_len += 1;
1083-                }
1084-                @memcpy(info.cmd[info.cmd_len..][0..src.len], src);
1085-                info.cmd_len += @intCast(src.len);
1086-            }
1087-        }
1088-
1089-        info.cwd_len = @intCast(@min(self.cwd.len, ipc.MAX_CWD_LEN));
1090-        @memcpy(info.cwd[0..info.cwd_len], self.cwd[0..info.cwd_len]);
1091-
1092-        try ipc.appendMessage(self.alloc, &client.write_buf, .Info, std.mem.asBytes(&info));
1093-        client.has_pending_output = true;
1094-    }
1095-
1096-    pub fn handleHistory(
1097-        self: *Daemon,
1098-        client: *Client,
1099-        term: *ghostty_vt.Terminal,
1100-        payload: []const u8,
1101-    ) !void {
1102-        const format: util.HistoryFormat = if (payload.len > 0)
1103-            @enumFromInt(payload[0])
1104-        else
1105-            .plain;
1106-        if (util.serializeTerminal(self.alloc, term, format)) |output| {
1107-            defer self.alloc.free(output);
1108-            try ipc.appendMessage(self.alloc, &client.write_buf, .History, output);
1109-            client.has_pending_output = true;
1110-        } else {
1111-            try ipc.appendMessage(self.alloc, &client.write_buf, .History, "");
1112-            client.has_pending_output = true;
1113-        }
1114-    }
1115-
1116-    pub fn handleRun(self: *Daemon, client: *Client, payload: []const u8) !void {
1117-        // Reset task tracking so the new command's exit marker is detected.
1118-        // Without this, a second `zmx run` on the same session is ignored
1119-        // because task_exit_code is still set from the first run.
1120-        self.task_exit_code = null;
1121-        self.task_ended_at = null;
1122-        self.is_task_mode = true;
1123-
1124-        if (payload.len == 0) return;
1125-
1126-        const cmd = payload;
1127-
1128-        // Chain the exit marker with `;` on the same line. `$?` captures the
1129-        // exit code of the command (not the `;`). The sole exception is when
1130-        // the command contains a heredoc (`<<`), the delimiter must be alone
1131-        // on its line, so the marker goes on the next line instead.
1132-        const single_line_marker = "; echo ZMX_TASK_COMPLETED:$?\r";
1133-        const heredoc_marker = "\r\necho ZMX_TASK_COMPLETED:$?\r";
1134-        const uses_heredoc = std.mem.indexOf(u8, cmd, "<<") != null;
1135-
1136-        if (cmd.len > 0 and cmd[cmd.len - 1] == '\r') {
1137-            self.queuePtyInput(cmd[0 .. cmd.len - 1]);
1138-        } else {
1139-            self.queuePtyInput(cmd);
1140-        }
1141-        self.queuePtyInput(if (uses_heredoc) heredoc_marker else single_line_marker);
1142-
1143-        try ipc.appendMessage(self.alloc, &client.write_buf, .Ack, "");
1144-        client.has_pending_output = true;
1145-        self.has_had_client = true;
1146-        std.log.debug("run command len={d}", .{payload.len});
1147-    }
1148-
1149-    pub fn handleOutput(self: *Daemon, payload: []const u8, vt_stream: anytype) !void {
1150-        vt_stream.nextSlice(payload);
1151-        self.has_pty_output = true;
1152-        for (self.clients.items) |client| {
1153-            try ipc.appendMessage(self.alloc, &client.write_buf, .Output, payload);
1154-            client.has_pending_output = true;
1155-        }
1156-        if (self.clients.items.len > 0) {
1157-            lib_posix.kill(self.pid, lib_posix.SIG.WINCH) catch |err| {
1158-                std.log.warn("failed to send SIGWINCH err={s}", .{@errorName(err)});
1159-            };
1160-        }
1161-    }
1162-
1163-    pub fn handleWrite(self: *Daemon, client: *Client, payload: []const u8) !void {
1164-        // Wire format: [u32 path len][path bytes][file content]
1165-        if (payload.len < @sizeOf(u32)) return error.InvalidPayload;
1166-        const path_len = std.mem.bytesToValue(u32, payload[0..@sizeOf(u32)]);
1167-        if (payload.len < @sizeOf(u32) + path_len) return error.InvalidPayload;
1168-        const file_path = payload[@sizeOf(u32)..][0..path_len];
1169-        const file_content = payload[@sizeOf(u32) + path_len ..];
1170-
1171-        // Inject file creation through the PTY so it works over SSH.
1172-        // Base64-encode content and pipe through printf | base64 -d > file.
1173-        // Chunk large files to stay under command-line length limits.
1174-        // 48000 is divisible by 3 (clean base64 boundaries) and encodes
1175-        // to ~64KB, well under typical ARG_MAX.
1176-        const chunk_size = 48000;
1177-        var offset: usize = 0;
1178-        var is_first = true;
1179-
1180-        while (offset < file_content.len or is_first) {
1181-            const end = @min(offset + chunk_size, file_content.len);
1182-            const chunk = file_content[offset..end];
1183-
1184-            const encoded_len = std.base64.standard.Encoder.calcSize(chunk.len);
1185-            const encoded = try self.alloc.alloc(u8, encoded_len);
1186-            defer self.alloc.free(encoded);
1187-            _ = std.base64.standard.Encoder.encode(encoded, chunk);
1188-
1189-            self.queuePtyInput("printf '%s' '");
1190-            self.queuePtyInput(encoded);
1191-            if (is_first) {
1192-                self.queuePtyInput("' | base64 -d > '");
1193-            } else {
1194-                self.queuePtyInput("' | base64 -d >> '");
1195-            }
1196-            self.queuePtyInput(file_path);
1197-            self.queuePtyInput("'");
1198-            self.queuePtyInput("\r");
1199-
1200-            offset = end;
1201-            is_first = false;
1202-        }
1203-
1204-        try ipc.appendMessage(self.alloc, &client.write_buf, .Ack, "");
1205-        client.has_pending_output = true;
1206-        self.has_had_client = true;
1207-        std.log.debug(
1208-            "write command len={d} file_path={s}",
1209-            .{ file_content.len, file_path },
1210-        );
1211-    }
1212-};
1213-
1214-test "send queues PTY input without changing leader" {
1215-    const alloc = std.testing.allocator;
1216-    var daemon = Daemon{
1217-        .cfg = undefined,
1218-        .alloc = alloc,
1219-        .clients = .empty,
1220-        .leader_client_fd = 42,
1221-        .session_name = "test",
1222-        .socket_path = "",
1223-        .io = std.testing.io,
1224-        .running = true,
1225-        .pid = 0,
1226-        .created_at = 0,
1227-    };
1228-    defer daemon.pty_write_buf.deinit(alloc);
1229-
1230-    daemon.handleSend("hello");
1231-
1232-    try std.testing.expectEqual(@as(?i32, 42), daemon.leader_client_fd);
1233-    try std.testing.expectEqualStrings("hello", daemon.pty_write_buf.items);
1234-}
1235-
1236-fn printVersion(io: std.Io, cfg: *Cfg) !void {
1237-    var buf: [256]u8 = undefined;
1238-    var w = std.Io.File.stdout().writer(io, &buf);
1239-    try w.interface.print(
1240-        "zmx\t\t{s}\nghostty_vt\t{s}\nsocket_dir\t{s}\nlog_dir\t\t{s}\n",
1241-        .{ version, ghostty_version, cfg.socket_dir, cfg.log_dir },
1242-    );
1243-    try w.interface.flush();
1244-}
1245-
1246-fn printCompletions(io: std.Io, shell: completions.Shell) !void {
1247-    const script = shell.getCompletionScript();
1248-    var buf: [8192]u8 = undefined;
1249-    var w = std.Io.File.stdout().writer(io, &buf);
1250-    try w.interface.print("{s}\n", .{script});
1251-    try w.interface.flush();
1252-}
1253-
1254 fn help(io: std.Io) !void {
1255     const help_text =
1256         \\zmx - session persistence for terminal processes
1257@@ -1578,6 +547,28 @@ fn help(io: std.Io) !void {
1258     try w.interface.flush();
1259 }
1260 
1261+fn printVersion(io: std.Io, cfg: *Cfg) !void {
1262+    var buf: [256]u8 = undefined;
1263+    var w = std.Io.File.stdout().writer(io, &buf);
1264+    try w.interface.print(
1265+        "zmx\t\t{s}\nghostty_vt\t{s}\nsocket_dir\t{s}\nlog_dir\t\t{s}\n",
1266+        .{ version, ghostty_version, cfg.socket_dir, cfg.log_dir },
1267+    );
1268+    try w.interface.flush();
1269+}
1270+
1271+fn printCompletions(io: std.Io, shell: completions.Shell) !void {
1272+    const script = shell.getCompletionScript();
1273+    var buf: [8192]u8 = undefined;
1274+    var w = std.Io.File.stdout().writer(io, &buf);
1275+    try w.interface.print("{s}\n", .{script});
1276+    try w.interface.flush();
1277+}
1278+
1279+fn detectHelp(arg: []const u8) bool {
1280+    return (std.mem.eql(u8, arg, "--help") or std.mem.eql(u8, arg, "-h"));
1281+}
1282+
1283 fn tail(alloc: std.mem.Allocator, client_socket_fds: std.ArrayList(i32), detached: bool, is_run_cmd: bool) !u8 {
1284     var poll_fds = try std.ArrayList(lib_posix.pollfd).initCapacity(alloc, 4);
1285     defer poll_fds.deinit(alloc);
1286@@ -1731,7 +722,7 @@ fn tail(alloc: std.mem.Allocator, client_socket_fds: std.ArrayList(i32), detache
1287     }
1288 }
1289 
1290-fn wait(alloc: std.mem.Allocator, io: std.Io, cfg: *Cfg, matchers: std.ArrayList(SessionMatch)) !void {
1291+fn wait(alloc: std.mem.Allocator, io: std.Io, cfg: *Cfg, matchers: std.ArrayList(socket.SessionMatch)) !void {
1292     var stdout_buffer: [1024]u8 = undefined;
1293     var stdout_writer = std.Io.File.stdout().writer(io, &stdout_buffer);
1294     const stdout = &stdout_writer.interface;
1295@@ -1747,7 +738,7 @@ fn wait(alloc: std.mem.Allocator, io: std.Io, cfg: *Cfg, matchers: std.ArrayList
1296     var zero_match_iters: u32 = 0;
1297 
1298     var agg_exit_code: u8 = 0;
1299-    var last_print: i96 = 0;
1300+    var last_print: std.Io.Timestamp = .zero;
1301     var prev_done: i32 = 0;
1302     while (true) {
1303         agg_exit_code = 0;
1304@@ -1775,7 +766,11 @@ fn wait(alloc: std.mem.Allocator, io: std.Io, cfg: *Cfg, matchers: std.ArrayList
1305                 // waiting". Count it as done+failed so wait terminates.
1306                 try stderr.print(
1307                     "[{d}] task unreachable: {s} ({s})\n",
1308-                    .{ std.Io.Timestamp.now(io, .real).nanoseconds, session.name, session.error_name orelse "unknown" },
1309+                    .{
1310+                        std.Io.Timestamp.now(io, .real).toSeconds(),
1311+                        session.name,
1312+                        session.error_name orelse "unknown",
1313+                    },
1314                 );
1315                 try stderr.flush();
1316                 agg_exit_code = 1;
1317@@ -1783,11 +778,11 @@ fn wait(alloc: std.mem.Allocator, io: std.Io, cfg: *Cfg, matchers: std.ArrayList
1318                 continue;
1319             }
1320             if (session.task_ended_at == 0) {
1321-                const now = std.Io.Timestamp.now(io, .real).nanoseconds;
1322-                if (now - last_print >= 5) {
1323+                const now = std.Io.Timestamp.now(io, .real);
1324+                if (now.toSeconds() - last_print.toSeconds() >= 5) {
1325                     try stdout.print(
1326                         "[{d}] waiting task={s}\n",
1327-                        .{ now, session.name },
1328+                        .{ now.toSeconds(), session.name },
1329                     );
1330                     try stdout.flush();
1331                     last_print = now;
1332@@ -2254,32 +1249,32 @@ fn history(alloc: std.mem.Allocator, io: std.Io, cfg: *Cfg, session_name: []cons
1333     }
1334 }
1335 
1336-fn switchSesh(daemon: *Daemon, current_sesh: []const u8) !void {
1337+fn switchSesh(gpa: std.mem.Allocator, io: std.Io, daemon: *Daemon, current_sesh: []const u8) !void {
1338     // we want daemon.session_name because that's the session name the user provided during zmx attach
1339     // instead of the name of the session they are currently inside of.
1340     const next_session = daemon.session_name;
1341     std.log.info("switch session cur={s} next={s}", .{ current_sesh, next_session });
1342 
1343-    const socket_path = socket.getSocketPath(daemon.alloc, daemon.cfg.socket_dir, current_sesh) catch |err| switch (err) {
1344-        error.NameTooLong => return socket.printSessionNameTooLong(daemon.io, current_sesh, daemon.cfg.socket_dir),
1345+    const socket_path = socket.getSocketPath(gpa, daemon.cfg.socket_dir, current_sesh) catch |err| switch (err) {
1346+        error.NameTooLong => return socket.printSessionNameTooLong(io, current_sesh, daemon.cfg.socket_dir),
1347         error.OutOfMemory => return err,
1348     };
1349-    defer daemon.alloc.free(socket_path);
1350+    defer gpa.free(socket_path);
1351 
1352-    var dir = try std.Io.Dir.openDirAbsolute(daemon.io, daemon.cfg.socket_dir, .{});
1353-    defer dir.close(daemon.io);
1354+    var dir = try std.Io.Dir.openDirAbsolute(io, daemon.cfg.socket_dir, .{});
1355+    defer dir.close(io);
1356 
1357-    const exists = try socket.sessionExists(daemon.io, dir, current_sesh);
1358+    const exists = try socket.sessionExists(io, dir, current_sesh);
1359     if (!exists) {
1360         var buf: [4096]u8 = undefined;
1361-        var w = std.Io.File.stderr().writer(daemon.io, &buf);
1362+        var w = std.Io.File.stderr().writer(io, &buf);
1363         w.interface.print("error: session \"{s}\" does not exist\n", .{current_sesh}) catch {};
1364         w.interface.flush() catch {};
1365         return error.SessionNotFound;
1366     }
1367     const fd = ipc.connectSession(socket_path) catch |err| {
1368         std.log.err("session unresponsive: {s}", .{@errorName(err)});
1369-        if (err == error.ConnectionRefused) socket.cleanupStaleSocket(daemon.io, dir, current_sesh);
1370+        if (err == error.ConnectionRefused) socket.cleanupStaleSocket(io, dir, current_sesh);
1371         return;
1372     };
1373     defer lib_posix.close(fd);
1374@@ -2290,14 +1285,14 @@ fn switchSesh(daemon: *Daemon, current_sesh: []const u8) !void {
1375     };
1376 }
1377 
1378-fn attach(daemon: *Daemon) !void {
1379+fn attach(gpa: std.mem.Allocator, io: std.Io, daemon: *Daemon) !void {
1380     const sesh = socket.getSeshNameFromEnv();
1381     if (sesh.len > 0) {
1382-        return switchSesh(daemon, sesh);
1383+        return switchSesh(gpa, io, daemon, sesh);
1384     }
1385 
1386-    const result = try daemon.ensureSession();
1387-    if (result.is_daemon) return;
1388+    const is_daemon_proc = try daemon.ensureSession(io);
1389+    if (is_daemon_proc) return;
1390 
1391     const client_sock = try socket.sessionConnect(daemon.socket_path);
1392     std.log.info("attached session={s}", .{daemon.session_name});
1393@@ -2347,60 +1342,45 @@ fn attach(daemon: *Daemon) !void {
1394     const clear_seq = "\x1b[2J\x1b[H";
1395     _ = try lib_posix.write(lib_posix.STDOUT_FILENO, clear_seq);
1396 
1397-    const looper = try clientLoop(client_sock);
1398+    const looper = try loop.clientLoop(client_sock);
1399     switch (looper.kind) {
1400         .detach => return,
1401         .switch_session => {
1402             if (looper.session_name) |session_name| {
1403                 var cwd_buf: [std.fs.max_path_bytes]u8 = undefined;
1404-                const cwd_len = std.process.currentPath(daemon.io, &cwd_buf) catch 0;
1405+                const cwd_len = std.process.currentPath(io, &cwd_buf) catch 0;
1406                 const cwd = cwd_buf[0..cwd_len];
1407                 const target_path = socket.getSocketPath(
1408-                    daemon.alloc,
1409+                    gpa,
1410                     daemon.cfg.socket_dir,
1411                     session_name,
1412                 ) catch |err| switch (err) {
1413                     error.NameTooLong => return socket.printSessionNameTooLong(
1414-                        daemon.io,
1415+                        io,
1416                         session_name,
1417                         daemon.cfg.socket_dir,
1418                     ),
1419                     error.OutOfMemory => return err,
1420                 };
1421 
1422-                const clients = try std.ArrayList(*Client).initCapacity(daemon.alloc, 10);
1423-                var target_daemon = Daemon{
1424-                    .io = daemon.io,
1425-                    .running = true,
1426-                    .cfg = daemon.cfg,
1427-                    .alloc = daemon.alloc,
1428-                    .clients = clients,
1429-                    .session_name = session_name,
1430-                    .socket_path = target_path,
1431-                    .pid = undefined,
1432-                    .cwd = cwd,
1433-                    .created_at = @intCast(std.Io.Timestamp.now(daemon.io, .real).nanoseconds),
1434-                    .leader_client_fd = null,
1435-                };
1436-                return attach(&target_daemon);
1437+                var target_daemon = Daemon.init(io, daemon.cfg, session_name, target_path);
1438+                target_daemon.cwd = cwd;
1439+                return attach(gpa, io, &target_daemon);
1440             }
1441         },
1442     }
1443 }
1444 
1445-fn writeFile(daemon: *Daemon, file_path: []const u8) !void {
1446+fn writeFile(gpa: std.mem.Allocator, io: std.Io, daemon: *Daemon, file_path: []const u8) !void {
1447+    const is_daemon_proc = try daemon.ensureSession(io);
1448+    if (is_daemon_proc) return;
1449+
1450     var buf: [4096]u8 = undefined;
1451-    var w = std.Io.File.stdout().writer(daemon.io, &buf);
1452-    const sesh_result = try daemon.ensureSession();
1453-    if (sesh_result.is_daemon) return;
1454+    var w = std.Io.File.stdout().writer(io, &buf);
1455 
1456-    if (sesh_result.created) {
1457-        try w.interface.print("session \"{s}\" created\n", .{daemon.session_name});
1458-        try w.interface.flush();
1459-    }
1460     const stdin_fd = lib_posix.STDIN_FILENO;
1461-    var stdin_buf = try std.ArrayList(u8).initCapacity(daemon.alloc, 4096);
1462-    defer stdin_buf.deinit(daemon.alloc);
1463+    var stdin_buf = try std.ArrayList(u8).initCapacity(gpa, 4096);
1464+    defer stdin_buf.deinit(gpa);
1465 
1466     while (true) {
1467         var tmp: [4096]u8 = undefined;
1468@@ -2409,28 +1389,28 @@ fn writeFile(daemon: *Daemon, file_path: []const u8) !void {
1469             return err;
1470         };
1471         if (n == 0) break;
1472-        try stdin_buf.appendSlice(daemon.alloc, tmp[0..n]);
1473+        try stdin_buf.appendSlice(gpa, tmp[0..n]);
1474     }
1475 
1476     const socket_path = socket.getSocketPath(
1477-        daemon.alloc,
1478+        gpa,
1479         daemon.cfg.socket_dir,
1480         daemon.session_name,
1481     ) catch |err| switch (err) {
1482         error.NameTooLong => return socket.printSessionNameTooLong(
1483-            daemon.io,
1484+            io,
1485             daemon.session_name,
1486             daemon.cfg.socket_dir,
1487         ),
1488         error.OutOfMemory => return err,
1489     };
1490-    var dir = try std.Io.Dir.openDirAbsolute(daemon.io, daemon.cfg.socket_dir, .{});
1491-    defer dir.close(daemon.io);
1492+    var dir = try std.Io.Dir.openDirAbsolute(io, daemon.cfg.socket_dir, .{});
1493+    defer dir.close(io);
1494 
1495-    const result = ipc.probeSession(daemon.alloc, socket_path) catch |err| {
1496+    const result = ipc.probeSession(gpa, socket_path) catch |err| {
1497         std.log.err("session unresponsive: {s}", .{@errorName(err)});
1498         if (err == error.ConnectionRefused) {
1499-            socket.cleanupStaleSocket(daemon.io, dir, daemon.session_name);
1500+            socket.cleanupStaleSocket(io, dir, daemon.session_name);
1501             w.interface.print("cleaned up stale session {s}\n", .{daemon.session_name}) catch {};
1502         } else {
1503             w.interface.print(
1504@@ -2446,21 +1426,21 @@ fn writeFile(daemon: *Daemon, file_path: []const u8) !void {
1505 
1506     // Build wire payload: [u32 path len][path bytes][file content]
1507     var wire_buf = try std.ArrayList(u8).initCapacity(
1508-        daemon.alloc,
1509+        gpa,
1510         @sizeOf(u32) + file_path.len + stdin_buf.items.len,
1511     );
1512-    defer wire_buf.deinit(daemon.alloc);
1513+    defer wire_buf.deinit(gpa);
1514     const path_len: u32 = @intCast(file_path.len);
1515-    try wire_buf.appendSlice(daemon.alloc, std.mem.asBytes(&path_len));
1516-    try wire_buf.appendSlice(daemon.alloc, file_path);
1517-    try wire_buf.appendSlice(daemon.alloc, stdin_buf.items);
1518+    try wire_buf.appendSlice(gpa, std.mem.asBytes(&path_len));
1519+    try wire_buf.appendSlice(gpa, file_path);
1520+    try wire_buf.appendSlice(gpa, stdin_buf.items);
1521 
1522     ipc.send(result.fd, .Write, wire_buf.items) catch |err| switch (err) {
1523         error.BrokenPipe, error.ConnectionResetByPeer => return,
1524         else => return err,
1525     };
1526 
1527-    var sb = try ipc.SocketBuffer.init(daemon.alloc);
1528+    var sb = try ipc.SocketBuffer.init(gpa);
1529     defer sb.deinit();
1530 
1531     const n = sb.read(result.fd) catch return error.ReadFailed;
1532@@ -2539,35 +1519,26 @@ fn send(alloc: std.mem.Allocator, io: std.Io, cfg: *Cfg, session_name: []const u
1533     };
1534 }
1535 
1536-fn run(daemon: *Daemon, detached: bool, command_args: [][]const u8) !void {
1537-    const alloc = daemon.alloc;
1538-    var buf: [4096]u8 = undefined;
1539-    var w = std.Io.File.stdout().writer(daemon.io, &buf);
1540-
1541+fn run(gpa: std.mem.Allocator, io: std.Io, daemon: *Daemon, detached: bool, command_args: [][]const u8) !void {
1542     var cmd_to_send: ?[]const u8 = null;
1543     var allocated_cmd: ?[]u8 = null;
1544-    defer if (allocated_cmd) |cmd| alloc.free(cmd);
1545+    defer if (allocated_cmd) |cmd| gpa.free(cmd);
1546 
1547-    const result = try daemon.ensureSession();
1548-    if (result.is_daemon) return;
1549-
1550-    if (result.created) {
1551-        try w.interface.print("session \"{s}\" created\n", .{daemon.session_name});
1552-        try w.interface.flush();
1553-    }
1554+    const is_daemon_proc = try daemon.ensureSession(io);
1555+    if (is_daemon_proc) return;
1556 
1557     if (command_args.len > 0) {
1558         var cmd_list = std.ArrayList(u8).empty;
1559-        defer cmd_list.deinit(alloc);
1560+        defer cmd_list.deinit(gpa);
1561 
1562         for (command_args, 0..) |arg, i| {
1563-            if (i > 0) try cmd_list.append(alloc, ' ');
1564+            if (i > 0) try cmd_list.append(gpa, ' ');
1565             if (util.shellNeedsQuoting(arg)) {
1566-                const quoted = try util.shellQuote(alloc, arg);
1567-                defer alloc.free(quoted);
1568-                try cmd_list.appendSlice(alloc, quoted);
1569+                const quoted = try util.shellQuote(gpa, arg);
1570+                defer gpa.free(quoted);
1571+                try cmd_list.appendSlice(gpa, quoted);
1572             } else {
1573-                try cmd_list.appendSlice(alloc, arg);
1574+                try cmd_list.appendSlice(gpa, arg);
1575             }
1576         }
1577 
1578@@ -2575,24 +1546,24 @@ fn run(daemon: *Daemon, detached: bool, command_args: [][]const u8) !void {
1579         // raw mode; readline's accept-line binds to CR. The first-ever run
1580         // works with \n only because it arrives during shell startup while
1581         // the line discipline is still canonical.
1582-        try cmd_list.append(alloc, '\r');
1583+        try cmd_list.append(gpa, '\r');
1584 
1585-        cmd_to_send = try cmd_list.toOwnedSlice(alloc);
1586+        cmd_to_send = try cmd_list.toOwnedSlice(gpa);
1587         allocated_cmd = @constCast(cmd_to_send.?);
1588     } else {
1589         // Read from stdin when no text arguments provided.
1590         const stdin_file = std.Io.File.stdin();
1591-        defer stdin_file.close(daemon.io);
1592-        var stdin_buf = try std.ArrayList(u8).initCapacity(alloc, 4096);
1593-        defer stdin_buf.deinit(alloc);
1594+        defer stdin_file.close(io);
1595+        var stdin_buf = try std.ArrayList(u8).initCapacity(gpa, 4096);
1596+        defer stdin_buf.deinit(gpa);
1597         var stdbuf: [4096]u8 = undefined;
1598-        var reader = stdin_file.reader(daemon.io, &stdbuf);
1599-        if (!try stdin_file.isTty(daemon.io)) {
1600+        var reader = stdin_file.reader(io, &stdbuf);
1601+        if (!try stdin_file.isTty(io)) {
1602             while (true) {
1603                 var dest: [1024]u8 = undefined;
1604                 const n = try reader.interface.readSliceShort(&dest);
1605                 if (n == 0) break; // EOF
1606-                try stdin_buf.appendSlice(alloc, dest[0..n]);
1607+                try stdin_buf.appendSlice(gpa, dest[0..n]);
1608             }
1609 
1610             if (stdin_buf.items.len > 0) {
1611@@ -2601,42 +1572,13 @@ fn run(daemon: *Daemon, detached: bool, command_args: [][]const u8) !void {
1612                 if (stdin_buf.items[stdin_buf.items.len - 1] == '\n') {
1613                     stdin_buf.items[stdin_buf.items.len - 1] = '\r';
1614                 } else {
1615-                    try stdin_buf.append(alloc, '\r');
1616+                    try stdin_buf.append(gpa, '\r');
1617                 }
1618 
1619-                cmd_to_send = try alloc.dupe(u8, stdin_buf.items);
1620+                cmd_to_send = try gpa.dupe(u8, stdin_buf.items);
1621                 allocated_cmd = @constCast(cmd_to_send.?);
1622             }
1623         }
1624-
1625-        // const stdin_fd = posix.STDIN_FILENO;
1626-        // if (!lib_posix.isatty(stdin_fd)) {
1627-        //     var stdin_buf = try std.ArrayList(u8).initCapacity(alloc, 4096);
1628-        //     defer stdin_buf.deinit(alloc);
1629-
1630-        //     while (true) {
1631-        //         var tmp: [4096]u8 = undefined;
1632-        //         const n = posix.read(stdin_fd, &tmp) catch |err| {
1633-        //             if (err == error.WouldBlock) break;
1634-        //             return err;
1635-        //         };
1636-        //         if (n == 0) break;
1637-        //         try stdin_buf.appendSlice(alloc, tmp[0..n]);
1638-        //     }
1639-
1640-        //     if (stdin_buf.items.len > 0) {
1641-        //         // Normalize any trailing newline to CR so readline (raw mode)
1642-        //         // accepts each line.
1643-        //         if (stdin_buf.items[stdin_buf.items.len - 1] == '\n') {
1644-        //             stdin_buf.items[stdin_buf.items.len - 1] = '\r';
1645-        //         } else {
1646-        //             try stdin_buf.append(alloc, '\r');
1647-        //         }
1648-
1649-        //         cmd_to_send = try alloc.dupe(u8, stdin_buf.items);
1650-        //         allocated_cmd = @constCast(cmd_to_send.?);
1651-        //     }
1652-        // }
1653     }
1654 
1655     if (cmd_to_send == null) {
1656@@ -2649,529 +1591,15 @@ fn run(daemon: *Daemon, detached: bool, command_args: [][]const u8) !void {
1657     };
1658     defer lib_posix.close(client_sock);
1659 
1660-    var fds = try std.ArrayList(i32).initCapacity(alloc, 1);
1661-    defer fds.deinit(alloc);
1662-    try fds.append(alloc, client_sock);
1663+    var fds = try std.ArrayList(i32).initCapacity(gpa, 1);
1664+    defer fds.deinit(gpa);
1665+    try fds.append(gpa, client_sock);
1666 
1667     ipc.send(client_sock, .Run, cmd_to_send.?) catch |err| switch (err) {
1668         error.ConnectionResetByPeer, error.BrokenPipe => return,
1669         else => return err,
1670     };
1671 
1672-    const exit_code = try tail(daemon.alloc, fds, detached, true);
1673+    const exit_code = try tail(gpa, fds, detached, true);
1674     lib_posix.exit(exit_code);
1675 }
1676-
1677-const ClientResult = struct {
1678-    kind: enum {
1679-        detach,
1680-        switch_session,
1681-    },
1682-    session_name: ?[]const u8,
1683-};
1684-
1685-/// clientLoop sends ipc commands to its corresponding daemon.  It uses poll() as its non-blocking
1686-/// mechanism. It will send stdin to the daemon and receive stdout from the daemon.
1687-fn clientLoop(client_sock_fd: i32) !ClientResult {
1688-    std.log.info("client loop fd={d}", .{client_sock_fd});
1689-    // use c_allocator to avoid "reached unreachable code" panic in DebugAllocator when forking
1690-    const alloc = std.heap.c_allocator;
1691-    defer lib_posix.close(client_sock_fd);
1692-
1693-    try openSignalPipe();
1694-    installWakeHandler(@intFromEnum(lib_posix.SIG.WINCH));
1695-
1696-    // Make socket non-blocking to avoid blocking on writes
1697-    var sock_flags = try lib_posix.fcntl(client_sock_fd, lib_posix.F.GETFL, 0);
1698-    sock_flags |= O_NONBLOCK;
1699-    _ = try lib_posix.fcntl(client_sock_fd, lib_posix.F.SETFL, sock_flags);
1700-
1701-    // Buffer for outgoing socket writes
1702-    var sock_write_buf = try std.ArrayList(u8).initCapacity(alloc, 4096);
1703-    defer sock_write_buf.deinit(alloc);
1704-
1705-    // Send init message with terminal size (buffered)
1706-    const size = ipc.getTerminalSize(lib_posix.STDOUT_FILENO);
1707-    try ipc.appendMessage(alloc, &sock_write_buf, .Init, std.mem.asBytes(&size));
1708-
1709-    var poll_fds = try std.ArrayList(lib_posix.pollfd).initCapacity(alloc, 4);
1710-    defer poll_fds.deinit(alloc);
1711-
1712-    var read_buf = try ipc.SocketBuffer.init(alloc);
1713-    defer read_buf.deinit();
1714-
1715-    var stdout_buf = try std.ArrayList(u8).initCapacity(alloc, 4096);
1716-    defer stdout_buf.deinit(alloc);
1717-
1718-    const stdin_fd = lib_posix.STDIN_FILENO;
1719-
1720-    // Make stdin non-blocking. O_NONBLOCK is set on the open file description,
1721-    // which is shared with the parent shell; restore on exit to avoid
1722-    // corrupting the parent's stdin.
1723-    const stdin_orig_flags = try lib_posix.fcntl(stdin_fd, lib_posix.F.GETFL, 0);
1724-    _ = try lib_posix.fcntl(stdin_fd, lib_posix.F.SETFL, stdin_orig_flags | O_NONBLOCK);
1725-    defer _ = lib_posix.fcntl(stdin_fd, lib_posix.F.SETFL, stdin_orig_flags) catch {};
1726-
1727-    while (true) {
1728-        poll_fds.clearRetainingCapacity();
1729-
1730-        try poll_fds.append(alloc, .{
1731-            .fd = stdin_fd,
1732-            .events = lib_posix.POLL.IN,
1733-            .revents = 0,
1734-        });
1735-
1736-        // Poll socket for read, and also for write if we have pending data
1737-        var sock_events: i16 = lib_posix.POLL.IN;
1738-        if (sock_write_buf.items.len > 0) {
1739-            sock_events |= lib_posix.POLL.OUT;
1740-        }
1741-        try poll_fds.append(alloc, .{
1742-            .fd = client_sock_fd,
1743-            .events = sock_events,
1744-            .revents = 0,
1745-        });
1746-
1747-        try poll_fds.append(alloc, .{ .fd = sig_pipe[0], .events = lib_posix.POLL.IN, .revents = 0 });
1748-
1749-        if (stdout_buf.items.len > 0) {
1750-            try poll_fds.append(alloc, .{
1751-                .fd = lib_posix.STDOUT_FILENO,
1752-                .events = lib_posix.POLL.OUT,
1753-                .revents = 0,
1754-            });
1755-        }
1756-
1757-        _ = try lib_posix.poll(poll_fds.items, -1);
1758-
1759-        if (poll_fds.items[2].revents & lib_posix.POLL.IN != 0) {
1760-            drainSignalPipe();
1761-            const next_size = ipc.getTerminalSize(lib_posix.STDOUT_FILENO);
1762-            try ipc.appendMessage(alloc, &sock_write_buf, .Resize, std.mem.asBytes(&next_size));
1763-        }
1764-
1765-        // Handle stdin -> socket (Input)
1766-        const inp_flags = (lib_posix.POLL.IN | lib_posix.POLL.HUP | lib_posix.POLL.ERR | lib_posix.POLL.NVAL);
1767-        if (poll_fds.items[0].revents & inp_flags != 0) {
1768-            var buf: [4096]u8 = undefined;
1769-            const n_opt: ?usize = lib_posix.read(stdin_fd, &buf) catch |err| blk: {
1770-                if (err == error.WouldBlock) break :blk null;
1771-                return err;
1772-            };
1773-
1774-            if (n_opt) |n| {
1775-                if (n > 0) {
1776-                    // Check for detach sequences (ctrl+\ as first byte or Kitty escape sequence)
1777-                    if (util.isCtrlBackslash(buf[0..n])) {
1778-                        std.log.info("detach key detected", .{});
1779-                        try ipc.appendMessage(alloc, &sock_write_buf, .Detach, "");
1780-                    } else {
1781-                        try ipc.appendMessage(alloc, &sock_write_buf, .Input, buf[0..n]);
1782-                    }
1783-                } else {
1784-                    std.log.info("eof stdin", .{});
1785-                    // EOF on stdin
1786-                    return ClientResult{ .kind = .detach, .session_name = null };
1787-                }
1788-            }
1789-        }
1790-
1791-        // Handle socket read (incoming Output messages from daemon)
1792-        if (poll_fds.items[1].revents & lib_posix.POLL.IN != 0) {
1793-            const n = read_buf.read(client_sock_fd) catch |err| {
1794-                if (err == error.WouldBlock) continue;
1795-                if (err == error.ConnectionResetByPeer or err == error.BrokenPipe) {
1796-                    return ClientResult{ .kind = .detach, .session_name = null };
1797-                }
1798-                std.log.err("daemon read err={s}", .{@errorName(err)});
1799-                return err;
1800-            };
1801-            if (n == 0) {
1802-                std.log.info("server closed connection", .{});
1803-                // Server closed connection
1804-                return ClientResult{ .kind = .detach, .session_name = null };
1805-            }
1806-
1807-            while (read_buf.next()) |msg| {
1808-                switch (msg.header.tag) {
1809-                    .Output => {
1810-                        if (msg.payload.len > 0) {
1811-                            try stdout_buf.appendSlice(alloc, msg.payload);
1812-                        }
1813-                    },
1814-                    .Resize => {
1815-                        // daemon is asking for the client's window size usually in response
1816-                        // to this client being set as leader.
1817-                        const next_size = ipc.getTerminalSize(lib_posix.STDOUT_FILENO);
1818-                        try ipc.appendMessage(
1819-                            alloc,
1820-                            &sock_write_buf,
1821-                            .Resize,
1822-                            std.mem.asBytes(&next_size),
1823-                        );
1824-                    },
1825-                    .Switch => {
1826-                        std.log.info("switch session", .{});
1827-                        return ClientResult{ .kind = .switch_session, .session_name = try alloc.dupe(u8, msg.payload) };
1828-                    },
1829-                    else => {},
1830-                }
1831-            }
1832-        }
1833-
1834-        // Handle socket write (flush buffered messages to daemon)
1835-        if (poll_fds.items[1].revents & lib_posix.POLL.OUT != 0) {
1836-            if (sock_write_buf.items.len > 0) {
1837-                const n = lib_posix.write(client_sock_fd, sock_write_buf.items) catch |err| blk: {
1838-                    if (err == error.WouldBlock) break :blk 0;
1839-                    if (err == error.ConnectionResetByPeer or err == error.BrokenPipe) {
1840-                        std.log.info("connection reset or broken pipe", .{});
1841-                        return ClientResult{ .kind = .detach, .session_name = null };
1842-                    }
1843-                    return err;
1844-                };
1845-                if (n > 0) {
1846-                    try sock_write_buf.replaceRange(alloc, 0, n, &[_]u8{});
1847-                }
1848-            }
1849-        }
1850-
1851-        if (stdout_buf.items.len > 0) {
1852-            const n = lib_posix.write(lib_posix.STDOUT_FILENO, stdout_buf.items) catch |err| blk: {
1853-                if (err == error.WouldBlock) break :blk 0;
1854-                return err;
1855-            };
1856-            if (n > 0) {
1857-                try stdout_buf.replaceRange(alloc, 0, n, &[_]u8{});
1858-            }
1859-        }
1860-
1861-        if (poll_fds.items[1].revents & (lib_posix.POLL.HUP | lib_posix.POLL.ERR | lib_posix.POLL.NVAL) != 0) {
1862-            std.log.info("poll hup|err|nval", .{});
1863-            return ClientResult{ .kind = .detach, .session_name = null };
1864-        }
1865-    }
1866-}
1867-
1868-/// dameonLoop is what the daemon runs to send and receive ipc commands from its corresponding
1869-/// clients.  It uses poll() as its non-blocking mechanism.
1870-fn daemonLoop(daemon: *Daemon, server_sock_fd: i32, pty_fd: i32) !void {
1871-    std.log.info("daemon started session={s} pty_fd={d}", .{ daemon.session_name, pty_fd });
1872-    daemon.pty_fd = pty_fd;
1873-    try openSignalPipe();
1874-    installWakeHandler(@intFromEnum(lib_posix.SIG.TERM));
1875-    var poll_fds = try std.ArrayList(lib_posix.pollfd).initCapacity(daemon.alloc, 8);
1876-    defer poll_fds.deinit(daemon.alloc);
1877-
1878-    const init_size = ipc.getTerminalSize(pty_fd);
1879-    var term = try ghostty_vt.Terminal.init(daemon.io, daemon.alloc, .{
1880-        .cols = init_size.cols,
1881-        .rows = init_size.rows,
1882-        .max_scrollback = daemon.cfg.max_scrollback,
1883-    });
1884-    defer term.deinit(daemon.alloc);
1885-    var vt_stream = term.vtStream();
1886-    defer vt_stream.deinit();
1887-
1888-    // Carries the tail of the previous PTY read so the task-exit marker
1889-    // search below can see across a read() boundary. Sized to comfortably
1890-    // hold "ZMX_TASK_COMPLETED:" (19 bytes) plus a u8 exit code and CRLF.
1891-    var marker_carry: [32]u8 = undefined;
1892-    var marker_carry_len: usize = 0;
1893-
1894-    daemon_loop: while (daemon.running) {
1895-        poll_fds.clearRetainingCapacity();
1896-
1897-        try poll_fds.append(daemon.alloc, .{
1898-            .fd = server_sock_fd,
1899-            .events = lib_posix.POLL.IN,
1900-            .revents = 0,
1901-        });
1902-
1903-        var pty_events: i16 = lib_posix.POLL.IN;
1904-        if (daemon.pty_write_buf.items.len > 0) {
1905-            pty_events |= lib_posix.POLL.OUT;
1906-        }
1907-        try poll_fds.append(daemon.alloc, .{
1908-            .fd = pty_fd,
1909-            .events = pty_events,
1910-            .revents = 0,
1911-        });
1912-
1913-        try poll_fds.append(daemon.alloc, .{ .fd = sig_pipe[0], .events = lib_posix.POLL.IN, .revents = 0 });
1914-
1915-        for (daemon.clients.items) |client| {
1916-            var events: i16 = lib_posix.POLL.IN;
1917-            if (client.has_pending_output) {
1918-                events |= lib_posix.POLL.OUT;
1919-            }
1920-            try poll_fds.append(daemon.alloc, .{
1921-                .fd = client.socket_fd,
1922-                .events = events,
1923-                .revents = 0,
1924-            });
1925-        }
1926-
1927-        _ = try lib_posix.poll(poll_fds.items, -1);
1928-
1929-        if (poll_fds.items[2].revents & lib_posix.POLL.IN != 0) {
1930-            drainSignalPipe();
1931-            std.log.info(
1932-                "SIGTERM received, shutting down gracefully session={s}",
1933-                .{daemon.session_name},
1934-            );
1935-            break :daemon_loop;
1936-        }
1937-
1938-        if (poll_fds.items[0].revents & (lib_posix.POLL.ERR | lib_posix.POLL.HUP | lib_posix.POLL.NVAL) != 0) {
1939-            std.log.err("server socket error revents={d}", .{poll_fds.items[0].revents});
1940-            break :daemon_loop;
1941-        } else if (poll_fds.items[0].revents & lib_posix.POLL.IN != 0) {
1942-            const client_fd = try lib_posix.accept(
1943-                server_sock_fd,
1944-                null,
1945-                null,
1946-                lib_posix.SOCK.NONBLOCK | lib_posix.SOCK.CLOEXEC,
1947-            );
1948-            const client = try daemon.alloc.create(Client);
1949-            client.* = Client{
1950-                .alloc = daemon.alloc,
1951-                .socket_fd = client_fd,
1952-                .read_buf = try ipc.SocketBuffer.init(daemon.alloc),
1953-                .write_buf = undefined,
1954-            };
1955-            // 64KB initial capacity lets ~15 broadcast cycles (N_TTY_BUF_SIZE reads
1956-            // * header) accumulate before the first ArrayList growth. The write
1957-            // buffer is userspace-only: it drains via POLLOUT to the client socket,
1958-            // which has no corresponding kernel-imposed per-write limit.
1959-            client.write_buf = try std.ArrayList(u8).initCapacity(client.alloc, 65536);
1960-            try daemon.clients.append(daemon.alloc, client);
1961-            std.log.info(
1962-                "client connected fd={d} total={d}",
1963-                .{ client_fd, daemon.clients.items.len },
1964-            );
1965-        }
1966-
1967-        const inp_flags = lib_posix.POLL.IN | lib_posix.POLL.HUP | lib_posix.POLL.ERR | lib_posix.POLL.NVAL;
1968-        if (poll_fds.items[1].revents & inp_flags != 0) {
1969-            // Read from PTY. Buffer is sized to N_TTY_BUF_SIZE (4096): the hard
1970-            // kernel limit for the N_TTY line discipline. A larger buffer doesn't
1971-            // help: each read() from a PTY master returns at most 4096 bytes
1972-            // regardless of the userspace buffer size.
1973-            var buf: [4096]u8 = undefined;
1974-            const n_opt: ?usize = lib_posix.read(pty_fd, &buf) catch |err| blk: {
1975-                if (err == error.WouldBlock) break :blk null;
1976-                break :blk 0;
1977-            };
1978-
1979-            if (n_opt) |n| {
1980-                if (n == 0) {
1981-                    // EOF: Shell exited
1982-                    std.log.info("shell exited pty_fd={d}", .{pty_fd});
1983-                    // Let the rest of this poll iteration complete so client
1984-                    // write buffers are flushed via the normal POLLOUT path.
1985-                    // On the next iteration, daemon.running will be false.
1986-                    daemon.running = false;
1987-                } else {
1988-                    // Feed PTY output to terminal emulator for state tracking
1989-                    vt_stream.nextSlice(buf[0..n]);
1990-                    daemon.has_pty_output = true;
1991-
1992-                    // When no real terminal client has attached yet, respond to
1993-                    // terminal queries (e.g. DA1/DA2) on behalf of the terminal.
1994-                    // This prevents fish from waiting 10s for unanswered queries.
1995-                    // `has_terminal_client` is only set when a client sends .Init
1996-                    // (a real zmx attach), not when a `zmx run` tail-only client
1997-                    // connects.
1998-                    if (!daemon.has_terminal_client and
1999-                        daemon.pty_write_buf.items.len < Daemon.PTY_WRITE_BUF_MAX)
2000-                    {
2001-                        util.respondToDeviceAttributes(daemon.alloc, &daemon.pty_write_buf, buf[0..n]);
2002-                    }
2003-
2004-                    // In run mode, scan output for exit code marker. The marker
2005-                    // can straddle two PTY reads (more likely under a throttled
2006-                    // scheduler, e.g. containers), so prepend the tail carried
2007-                    // over from the previous read before searching.
2008-                    if (daemon.is_task_mode and daemon.task_exit_code == null) {
2009-                        var scan_buf: [marker_carry.len + buf.len]u8 = undefined;
2010-                        @memcpy(scan_buf[0..marker_carry_len], marker_carry[0..marker_carry_len]);
2011-                        @memcpy(scan_buf[marker_carry_len..][0..n], buf[0..n]);
2012-                        const scan_len = marker_carry_len + n;
2013-
2014-                        if (util.findTaskExitMarker(scan_buf[0..scan_len])) |exit_code| {
2015-                            daemon.task_exit_code = exit_code;
2016-                            daemon.task_ended_at = @intCast(std.Io.Timestamp.now(daemon.io, .real).nanoseconds);
2017-
2018-                            std.log.info("task completed exit_code={d}", .{exit_code});
2019-
2020-                            // Notify connected clients
2021-                            for (daemon.clients.items) |c| {
2022-                                ipc.appendMessage(daemon.alloc, &c.write_buf, .TaskComplete, &[_]u8{exit_code}) catch {};
2023-                                c.has_pending_output = true;
2024-                            }
2025-                        }
2026-
2027-                        marker_carry_len = @min(marker_carry.len, scan_len);
2028-                        @memcpy(
2029-                            marker_carry[0..marker_carry_len],
2030-                            scan_buf[scan_len - marker_carry_len .. scan_len],
2031-                        );
2032-                    }
2033-
2034-                    // Broadcast data to all clients.
2035-                    // Rewrite OSC 133;A to include redraw=0 so the outer terminal
2036-                    // does not clear prompt lines on resize (issue #111).
2037-                    const broadcast_data = util.rewritePromptRedraw(daemon.alloc, buf[0..n]) orelse buf[0..n];
2038-                    defer if (broadcast_data.ptr != buf[0..n].ptr) daemon.alloc.free(broadcast_data);
2039-                    for (daemon.clients.items) |client| {
2040-                        ipc.appendMessage(daemon.alloc, &client.write_buf, .Output, broadcast_data) catch |err| {
2041-                            std.log.warn(
2042-                                "failed to buffer output for client err={s}",
2043-                                .{@errorName(err)},
2044-                            );
2045-                            continue;
2046-                        };
2047-                        client.has_pending_output = true;
2048-                    }
2049-                }
2050-            }
2051-        }
2052-
2053-        if (poll_fds.items[1].revents & lib_posix.POLL.OUT != 0) {
2054-            while (daemon.pty_write_buf.items.len > 0) {
2055-                const n = lib_posix.write(pty_fd, daemon.pty_write_buf.items) catch |err| {
2056-                    if (err != error.WouldBlock) {
2057-                        std.log.warn("pty write failed: {s}", .{@errorName(err)});
2058-                        daemon.pty_write_buf.clearRetainingCapacity();
2059-                    }
2060-                    break;
2061-                };
2062-                if (n == 0) break;
2063-                daemon.pty_write_buf.replaceRange(daemon.alloc, 0, n, &[_]u8{}) catch unreachable;
2064-            }
2065-        }
2066-
2067-        var i: usize = daemon.clients.items.len;
2068-        // Only iterate over clients that were present when poll_fds was constructed
2069-        // poll_fds contains [server, pty, sig_pipe, client0, client1, ...]
2070-        // So number of clients in poll_fds is poll_fds.items.len - 3
2071-        const num_polled_clients = poll_fds.items.len - 3;
2072-        if (i > num_polled_clients) {
2073-            // If we have more clients than polled (i.e. we just accepted one), start from the
2074-            // polled ones
2075-            i = num_polled_clients;
2076-        }
2077-
2078-        clients_loop: while (i > 0) {
2079-            i -= 1;
2080-            const client = daemon.clients.items[i];
2081-            const revents = poll_fds.items[i + 3].revents;
2082-
2083-            if (revents & lib_posix.POLL.IN != 0) {
2084-                const n = client.read_buf.read(client.socket_fd) catch |err| {
2085-                    if (err == error.WouldBlock) continue;
2086-                    std.log.debug(
2087-                        "client read err={s} fd={d}",
2088-                        .{ @errorName(err), client.socket_fd },
2089-                    );
2090-                    const last = daemon.closeClient(client, i, false);
2091-                    if (last) break :daemon_loop;
2092-                    continue;
2093-                };
2094-
2095-                if (n == 0) {
2096-                    // Client closed connection
2097-                    const last = daemon.closeClient(client, i, false);
2098-                    if (last) break :daemon_loop;
2099-                    continue;
2100-                }
2101-
2102-                while (client.read_buf.next()) |msg| {
2103-                    switch (msg.header.tag) {
2104-                        .Input => try daemon.handleInput(client, msg.payload),
2105-                        .Send => daemon.handleSend(msg.payload),
2106-                        .Output => try daemon.handleOutput(msg.payload, &vt_stream),
2107-                        .Init => try daemon.handleInit(client, pty_fd, &term, msg.payload),
2108-                        .Switch => try daemon.handleSwitch(msg.payload),
2109-                        .Resize => try daemon.handleResize(client, pty_fd, &term, msg.payload),
2110-                        .Detach => {
2111-                            daemon.handleDetach(client, i);
2112-                            break :clients_loop;
2113-                        },
2114-                        .DetachAll => {
2115-                            daemon.handleDetachAll();
2116-                            break :clients_loop;
2117-                        },
2118-                        .Kill => {
2119-                            break :daemon_loop;
2120-                        },
2121-                        .Info => try daemon.handleInfo(client),
2122-                        .LabelGet => try daemon.handleLabelGet(client),
2123-                        .LabelSet => try daemon.handleLabelSet(client, msg.payload),
2124-                        .LabelClear => try daemon.handleLabelClear(client),
2125-                        .History => try daemon.handleHistory(client, &term, msg.payload),
2126-                        .Run => try daemon.handleRun(client, msg.payload),
2127-                        .Ack, .TaskComplete, .LabelData => {},
2128-                        .Write => try daemon.handleWrite(client, msg.payload),
2129-                        _ => std.log.warn(
2130-                            "ignoring unknown IPC tag={d}",
2131-                            .{@intFromEnum(msg.header.tag)},
2132-                        ),
2133-                    }
2134-                }
2135-            }
2136-
2137-            if (revents & lib_posix.POLL.OUT != 0) {
2138-                // Flush pending output buffers
2139-                const n = lib_posix.write(client.socket_fd, client.write_buf.items) catch |err| blk: {
2140-                    if (err == error.WouldBlock) break :blk 0;
2141-                    // Error on write, close client
2142-                    const last = daemon.closeClient(client, i, false);
2143-                    if (last) break :daemon_loop;
2144-                    continue;
2145-                };
2146-
2147-                if (n > 0) {
2148-                    client.write_buf.replaceRange(daemon.alloc, 0, n, &[_]u8{}) catch unreachable;
2149-                }
2150-
2151-                if (client.write_buf.items.len == 0) {
2152-                    client.has_pending_output = false;
2153-                }
2154-            }
2155-
2156-            if (revents & (lib_posix.POLL.HUP | lib_posix.POLL.ERR | lib_posix.POLL.NVAL) != 0) {
2157-                const last = daemon.closeClient(client, i, false);
2158-                if (last) break :daemon_loop;
2159-            }
2160-        }
2161-    }
2162-}
2163-
2164-fn wakeSignalPipe(_: std.os.linux.SIG, _: *const lib_posix.siginfo_t, _: ?*anyopaque) callconv(.c) void {
2165-    const saved = std.c._errno().*;
2166-    _ = std.c.write(sig_pipe[1], "x", 1);
2167-    std.c._errno().* = saved;
2168-}
2169-
2170-// std.posix.poll retries EINTR internally, so SA_RESTART is moot -- neither
2171-// setting wakes the loop. The handler writes to sig_pipe instead; poll()
2172-// wakes on its read end.
2173-fn installWakeHandler(sig: u6) void {
2174-    const act: lib_posix.Sigaction = .{
2175-        .handler = .{ .sigaction = wakeSignalPipe },
2176-        .mask = lib_posix.sigemptyset(),
2177-        .flags = lib_posix.SA.SIGINFO,
2178-    };
2179-    lib_posix.sigaction(@as(lib_posix.SIG, @enumFromInt(sig)), &act, null);
2180-}
2181-
2182-fn ignoreSigpipe() void {
2183-    const act: lib_posix.Sigaction = .{
2184-        .handler = .{ .handler = lib_posix.SIG.IGN },
2185-        .mask = lib_posix.sigemptyset(),
2186-        .flags = 0,
2187-    };
2188-    lib_posix.sigaction(lib_posix.SIG.PIPE, &act, null);
2189-}
+4, -1
 1@@ -30,8 +30,8 @@ const pid_t = system.pid_t;
 2 const lfs64_abi = native_os == .linux and builtin.link_libc and (builtin.abi.isGnu() or builtin.abi.isAndroid());
 3 const uid_t = system.uid_t;
 4 const mode_t = system.mode_t;
 5-const socket_t = fd_t;
 6 const FD_CLOEXEC = system.FD_CLOEXEC;
 7+pub const socket_t = fd_t;
 8 pub const SA = system.SA;
 9 pub const fd_t = system.fd_t;
10 pub const O = system.O;
11@@ -52,6 +52,9 @@ pub const Sigaction = system.Sigaction;
12 pub const SIG = system.SIG;
13 pub const siginfo_t = system.siginfo_t;
14 
15+// https://github.com/ziglang/zig/blob/738d2be9d6b6ef3ff3559130c05159ef53336224/lib/std/posix.zig#L3505
16+pub const O_NONBLOCK: usize = 1 << @bitOffsetOf(O, "NONBLOCK");
17+
18 pub fn getuid() uid_t {
19     return system.getuid();
20 }
+46, -0
 1@@ -0,0 +1,46 @@
 2+const std = @import("std");
 3+const lib_posix = @import("posix.zig");
 4+
 5+/// Self-pipe woken by signal handlers. std.posix.poll loops on .INTR internally
 6+/// (PollError has no Interrupted member), so a signal that lands during poll()
 7+/// never surfaces; the handler writes a byte here and poll() wakes on POLLIN.
 8+pub var sig_pipe: [2]lib_posix.fd_t = .{ -1, -1 };
 9+
10+pub fn wakeSignalPipe(_: std.os.linux.SIG, _: *const lib_posix.siginfo_t, _: ?*anyopaque) callconv(.c) void {
11+    const saved = std.c._errno().*;
12+    _ = std.c.write(sig_pipe[1], "x", 1);
13+    std.c._errno().* = saved;
14+}
15+
16+// std.posix.poll retries EINTR internally, so SA_RESTART is moot -- neither
17+// setting wakes the loop. The handler writes to sig_pipe instead; poll()
18+// wakes on its read end.
19+pub fn installWakeHandler(sig: u6) void {
20+    const act: lib_posix.Sigaction = .{
21+        .handler = .{ .sigaction = wakeSignalPipe },
22+        .mask = lib_posix.sigemptyset(),
23+        .flags = lib_posix.SA.SIGINFO,
24+    };
25+    lib_posix.sigaction(@as(lib_posix.SIG, @enumFromInt(sig)), &act, null);
26+}
27+
28+pub fn ignoreSigpipe() void {
29+    const act: lib_posix.Sigaction = .{
30+        .handler = .{ .handler = lib_posix.SIG.IGN },
31+        .mask = lib_posix.sigemptyset(),
32+        .flags = 0,
33+    };
34+    lib_posix.sigaction(lib_posix.SIG.PIPE, &act, null);
35+}
36+
37+pub fn openSignalPipe() !void {
38+    sig_pipe = try lib_posix.pipe2(.{ .CLOEXEC = true, .NONBLOCK = true });
39+}
40+
41+pub fn drainSignalPipe() void {
42+    var b: [16]u8 = undefined;
43+    while (true) {
44+        const n = lib_posix.read(sig_pipe[0], &b) catch return;
45+        if (n == 0) return;
46+    }
47+}
+39, -1
 1@@ -28,6 +28,44 @@ pub fn getSeshName(alloc: std.mem.Allocator, sesh: []const u8) ![]const u8 {
 2     return full;
 3 }
 4 
 5+pub fn resolveSessionOrEnv(alloc: std.mem.Allocator, io: std.Io, session_name: ?[]const u8) ![]const u8 {
 6+    const sesh_env = getSeshNameFromEnv();
 7+    const raw = if (session_name) |name|
 8+        if (std.mem.eql(u8, name, ".")) blk: {
 9+            if (sesh_env.len > 0) break :blk sesh_env;
10+            var buf: [4096]u8 = undefined;
11+            var w = std.Io.File.stderr().writer(io, &buf);
12+            w.interface.print("error: \".\" requires ZMX_SESSION (are you inside a zmx session?)\n", .{}) catch {};
13+            w.interface.flush() catch {};
14+            return error.SessionNameRequired;
15+        } else name
16+    else if (sesh_env.len > 0)
17+        sesh_env
18+    else {
19+        return error.SessionNameRequired;
20+    };
21+    return getSeshName(alloc, raw);
22+}
23+
24+pub const SessionMatch = struct {
25+    name: []const u8,
26+    is_prefix: bool,
27+
28+    pub fn matches(self: SessionMatch, session_name: []const u8) bool {
29+        if (self.is_prefix) return std.mem.startsWith(u8, session_name, self.name);
30+        return std.mem.eql(u8, session_name, self.name);
31+    }
32+};
33+
34+pub fn parseSessionArg(alloc: std.mem.Allocator, raw: []const u8) !SessionMatch {
35+    if (raw.len > 0 and raw[raw.len - 1] == '*') {
36+        const name = try getSeshName(alloc, raw[0 .. raw.len - 1]);
37+        return .{ .name = name, .is_prefix = true };
38+    }
39+    const name = try getSeshName(alloc, raw);
40+    return .{ .name = name, .is_prefix = false };
41+}
42+
43 pub fn sessionConnect(sesh: []const u8) !i32 {
44     var unix_addr = try lib_posix.initUnix(sesh);
45     const socket_fd = try lib_posix.socket(lib_posix.AF.UNIX, lib_posix.SOCK.STREAM | lib_posix.SOCK.CLOEXEC, 0);
46@@ -56,7 +94,7 @@ pub fn sessionExists(io: std.Io, dir: std.Io.Dir, name: []const u8) !bool {
47     return true;
48 }
49 
50-pub fn createSocket(sesh: []const u8) !i32 {
51+pub fn createSocket(sesh: []const u8) !lib_posix.socket_t {
52     // AF.UNIX: Unix domain socket for local IPC with client processes
53     // SOCK.STREAM: Reliable, bidirectional communication
54     // SOCK.NONBLOCK: Set socket to non-blocking
+4, -0
1@@ -4,4 +4,8 @@ comptime {
2     _ = @import("socket.zig");
3     _ = @import("ipc.zig");
4     _ = @import("label.zig");
5+    _ = @import("signal.zig");
6+    _ = @import("loop.zig");
7+    _ = @import("cfg.zig");
8+    _ = @import("daemonize.zig");
9 }