repos / zmx

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

commit
0222428
parent
fe2c4ce
author
Eric Bower
date
2026-03-04 21:55:55 -0500 EST
refactor: break up main.zig

This is just some code cleanup to make it a little easier to navigate the codebase.  I don't want to break everything up into separate files but I want the main business logic to live inside of main.zig.
5 files changed,  +976, -966
A src/cross.zig
+30, -0
 1@@ -0,0 +1,30 @@
 2+const builtin = @import("builtin");
 3+
 4+pub const c = switch (builtin.os.tag) {
 5+    .macos => @cImport({
 6+        @cInclude("sys/ioctl.h"); // ioctl and constants
 7+        @cInclude("termios.h");
 8+        @cInclude("stdlib.h");
 9+        @cInclude("unistd.h");
10+    }),
11+    .freebsd => @cImport({
12+        @cInclude("termios.h"); // ioctl and constants
13+        @cInclude("libutil.h"); // openpty()
14+        @cInclude("stdlib.h");
15+        @cInclude("unistd.h");
16+    }),
17+    else => @cImport({
18+        @cInclude("sys/ioctl.h"); // ioctl and constants
19+        @cInclude("pty.h");
20+        @cInclude("stdlib.h");
21+        @cInclude("unistd.h");
22+    }),
23+};
24+
25+// Manually declare forkpty for macOS since util.h is not available during cross-compilation
26+pub const forkpty = if (builtin.os.tag == .macos)
27+    struct {
28+        extern "c" fn forkpty(master_fd: *c_int, name: ?[*:0]u8, termp: ?*const c.struct_termios, winp: ?*const c.struct_winsize) c_int;
29+    }.forkpty
30+else
31+    c.forkpty;
M src/ipc.zig
+56, -0
 1@@ -1,5 +1,7 @@
 2 const std = @import("std");
 3 const posix = std.posix;
 4+const cross = @import("cross.zig");
 5+const socket = @import("socket.zig");
 6 
 7 pub const Tag = enum(u8) {
 8     Input = 0,
 9@@ -25,6 +27,14 @@ pub const Resize = packed struct {
10     cols: u16,
11 };
12 
13+pub fn getTerminalSize(fd: i32) Resize {
14+    var ws: cross.c.struct_winsize = undefined;
15+    if (cross.c.ioctl(fd, cross.c.TIOCGWINSZ, &ws) == 0 and ws.ws_row > 0 and ws.ws_col > 0) {
16+        return .{ .rows = ws.ws_row, .cols = ws.ws_col };
17+    }
18+    return .{ .rows = 24, .cols = 80 };
19+}
20+
21 pub const MAX_CMD_LEN = 256;
22 pub const MAX_CWD_LEN = 256;
23 
24@@ -150,3 +160,49 @@ pub const SocketBuffer = struct {
25         return .{ .header = hdr, .payload = pay };
26     }
27 };
28+
29+const SessionProbeError = error{
30+    Timeout,
31+    ConnectionRefused,
32+    Unexpected,
33+};
34+
35+const SessionProbeResult = struct {
36+    fd: i32,
37+    info: Info,
38+};
39+
40+pub fn probeSession(alloc: std.mem.Allocator, socket_path: []const u8) SessionProbeError!SessionProbeResult {
41+    const timeout_ms = 1000;
42+    const fd = socket.sessionConnect(socket_path) catch |err| switch (err) {
43+        error.ConnectionRefused => return error.ConnectionRefused,
44+        else => return error.Unexpected,
45+    };
46+    errdefer posix.close(fd);
47+
48+    send(fd, .Info, "") catch return error.Unexpected;
49+
50+    var poll_fds = [_]posix.pollfd{.{ .fd = fd, .events = posix.POLL.IN, .revents = 0 }};
51+    const poll_result = posix.poll(&poll_fds, timeout_ms) catch return error.Unexpected;
52+    if (poll_result == 0) {
53+        return error.Timeout;
54+    }
55+
56+    var sb = SocketBuffer.init(alloc) catch return error.Unexpected;
57+    defer sb.deinit();
58+
59+    const n = sb.read(fd) catch return error.Unexpected;
60+    if (n == 0) return error.Unexpected;
61+
62+    while (sb.next()) |msg| {
63+        if (msg.header.tag == .Info) {
64+            if (msg.payload.len == @sizeOf(Info)) {
65+                return .{
66+                    .fd = fd,
67+                    .info = std.mem.bytesToValue(Info, msg.payload[0..@sizeOf(Info)]),
68+                };
69+            }
70+        }
71+    }
72+    return error.Unexpected;
73+}
M src/main.zig
+317, -966
   1@@ -6,6 +6,9 @@ const ghostty_vt = @import("ghostty-vt");
   2 const ipc = @import("ipc.zig");
   3 const log = @import("log.zig");
   4 const completions = @import("completions.zig");
   5+const util = @import("util.zig");
   6+const cross = @import("cross.zig");
   7+const socket = @import("socket.zig");
   8 
   9 pub const version = build_options.version;
  10 pub const git_sha = build_options.git_sha;
  11@@ -27,38 +30,147 @@ fn zmxLogFn(
  12     log_system.log(level, scope, format, args);
  13 }
  14 
  15-const c = switch (builtin.os.tag) {
  16-    .macos => @cImport({
  17-        @cInclude("sys/ioctl.h"); // ioctl and constants
  18-        @cInclude("termios.h");
  19-        @cInclude("stdlib.h");
  20-        @cInclude("unistd.h");
  21-    }),
  22-    .freebsd => @cImport({
  23-        @cInclude("termios.h"); // ioctl and constants
  24-        @cInclude("libutil.h"); // openpty()
  25-        @cInclude("stdlib.h");
  26-        @cInclude("unistd.h");
  27-    }),
  28-    else => @cImport({
  29-        @cInclude("sys/ioctl.h"); // ioctl and constants
  30-        @cInclude("pty.h");
  31-        @cInclude("stdlib.h");
  32-        @cInclude("unistd.h");
  33-    }),
  34-};
  35-
  36-// Manually declare forkpty for macOS since util.h is not available during cross-compilation
  37-const forkpty = if (builtin.os.tag == .macos)
  38-    struct {
  39-        extern "c" fn forkpty(master_fd: *c_int, name: ?[*:0]u8, termp: ?*const c.struct_termios, winp: ?*const c.struct_winsize) c_int;
  40-    }.forkpty
  41-else
  42-    c.forkpty;
  43-
  44 var sigwinch_received: std.atomic.Value(bool) = std.atomic.Value(bool).init(false);
  45 var sigterm_received: std.atomic.Value(bool) = std.atomic.Value(bool).init(false);
  46 
  47+pub fn main() !void {
  48+    // use c_allocator to avoid "reached unreachable code" panic in DebugAllocator when forking
  49+    const alloc = std.heap.c_allocator;
  50+
  51+    var args = try std.process.argsWithAllocator(alloc);
  52+    defer args.deinit();
  53+    _ = args.skip(); // skip program name
  54+
  55+    var cfg = try Cfg.init(alloc);
  56+    defer cfg.deinit(alloc);
  57+
  58+    const log_path = try std.fs.path.join(alloc, &.{ cfg.log_dir, "zmx.log" });
  59+    defer alloc.free(log_path);
  60+    try log_system.init(alloc, log_path);
  61+    defer log_system.deinit();
  62+
  63+    const cmd = args.next() orelse {
  64+        return list(&cfg, false);
  65+    };
  66+
  67+    if (std.mem.eql(u8, cmd, "version") or std.mem.eql(u8, cmd, "v") or std.mem.eql(u8, cmd, "-v") or std.mem.eql(u8, cmd, "--version")) {
  68+        return printVersion(&cfg);
  69+    } else if (std.mem.eql(u8, cmd, "help") or std.mem.eql(u8, cmd, "h") or std.mem.eql(u8, cmd, "-h")) {
  70+        return help();
  71+    } else if (std.mem.eql(u8, cmd, "list") or std.mem.eql(u8, cmd, "l")) {
  72+        const short = if (args.next()) |arg| std.mem.eql(u8, arg, "--short") else false;
  73+        return list(&cfg, short);
  74+    } else if (std.mem.eql(u8, cmd, "completions") or std.mem.eql(u8, cmd, "c")) {
  75+        const arg = args.next() orelse return;
  76+        const shell = completions.Shell.fromString(arg) orelse return;
  77+        return printCompletions(shell);
  78+    } else if (std.mem.eql(u8, cmd, "detach") or std.mem.eql(u8, cmd, "d")) {
  79+        return detachAll(&cfg);
  80+    } else if (std.mem.eql(u8, cmd, "kill") or std.mem.eql(u8, cmd, "k")) {
  81+        const session_name = args.next() orelse "";
  82+        const sesh = try socket.getSeshName(alloc, session_name);
  83+        defer alloc.free(sesh);
  84+        return kill(&cfg, sesh);
  85+    } else if (std.mem.eql(u8, cmd, "history") or std.mem.eql(u8, cmd, "hi")) {
  86+        var session_name: ?[]const u8 = null;
  87+        var format: util.HistoryFormat = .plain;
  88+        while (args.next()) |arg| {
  89+            if (std.mem.eql(u8, arg, "--vt")) {
  90+                format = .vt;
  91+            } else if (std.mem.eql(u8, arg, "--html")) {
  92+                format = .html;
  93+            } else if (session_name == null) {
  94+                session_name = arg;
  95+            }
  96+        }
  97+        const sesh = try socket.getSeshName(alloc, session_name.?);
  98+        defer alloc.free(sesh);
  99+        return history(&cfg, sesh, format);
 100+    } else if (std.mem.eql(u8, cmd, "attach") or std.mem.eql(u8, cmd, "a")) {
 101+        const session_name = args.next() orelse "";
 102+
 103+        var command_args: std.ArrayList([]const u8) = .empty;
 104+        defer command_args.deinit(alloc);
 105+        while (args.next()) |arg| {
 106+            try command_args.append(alloc, arg);
 107+        }
 108+
 109+        const clients = try std.ArrayList(*Client).initCapacity(alloc, 10);
 110+        var command: ?[][]const u8 = null;
 111+        if (command_args.items.len > 0) {
 112+            command = command_args.items;
 113+        }
 114+
 115+        var cwd_buf: [std.fs.max_path_bytes]u8 = undefined;
 116+        const cwd = std.posix.getcwd(&cwd_buf) catch "";
 117+
 118+        const sesh = try socket.getSeshName(alloc, session_name);
 119+        defer alloc.free(sesh);
 120+        var daemon = Daemon{
 121+            .running = true,
 122+            .cfg = &cfg,
 123+            .alloc = alloc,
 124+            .clients = clients,
 125+            .session_name = sesh,
 126+            .socket_path = undefined,
 127+            .pid = undefined,
 128+            .command = command,
 129+            .cwd = cwd,
 130+            .created_at = @intCast(std.time.timestamp()),
 131+        };
 132+        daemon.socket_path = try socket.getSocketPath(alloc, cfg.socket_dir, sesh);
 133+        std.log.info("socket path={s}", .{daemon.socket_path});
 134+        return attach(&daemon);
 135+    } else if (std.mem.eql(u8, cmd, "run") or std.mem.eql(u8, cmd, "r")) {
 136+        const session_name = args.next() orelse "";
 137+
 138+        var cmd_args_raw: std.ArrayList([]const u8) = .empty;
 139+        defer cmd_args_raw.deinit(alloc);
 140+        while (args.next()) |arg| {
 141+            try cmd_args_raw.append(alloc, arg);
 142+        }
 143+        const clients = try std.ArrayList(*Client).initCapacity(alloc, 10);
 144+
 145+        var cwd_buf: [std.fs.max_path_bytes]u8 = undefined;
 146+        const cwd = std.posix.getcwd(&cwd_buf) catch "";
 147+
 148+        const sesh = try socket.getSeshName(alloc, session_name);
 149+        defer alloc.free(sesh);
 150+        var daemon = Daemon{
 151+            .running = true,
 152+            .cfg = &cfg,
 153+            .alloc = alloc,
 154+            .clients = clients,
 155+            .session_name = sesh,
 156+            .socket_path = undefined,
 157+            .pid = undefined,
 158+            .command = null,
 159+            .cwd = cwd,
 160+            .created_at = @intCast(std.time.timestamp()),
 161+            .is_task_mode = true,
 162+            .task_command = cmd_args_raw.items,
 163+        };
 164+        daemon.socket_path = try socket.getSocketPath(alloc, cfg.socket_dir, sesh);
 165+        std.log.info("socket path={s}", .{daemon.socket_path});
 166+        return run(&daemon, cmd_args_raw.items);
 167+    } else if (std.mem.eql(u8, cmd, "wait") or std.mem.eql(u8, cmd, "w")) {
 168+        var args_raw: std.ArrayList([]const u8) = .empty;
 169+        defer {
 170+            for (args_raw.items) |sesh| {
 171+                alloc.free(sesh);
 172+            }
 173+            args_raw.deinit(alloc);
 174+        }
 175+        while (args.next()) |session_name| {
 176+            const sesh = try socket.getSeshName(alloc, session_name);
 177+            try args_raw.append(alloc, sesh);
 178+        }
 179+        return wait(&cfg, args_raw);
 180+    } else {
 181+        return help();
 182+    }
 183+}
 184+
 185 const Client = struct {
 186     alloc: std.mem.Allocator,
 187     socket_fd: i32,
 188@@ -121,11 +233,9 @@ const Cfg = struct {
 189     }
 190 };
 191 
 192-const SessionMetadata = struct {
 193-    created_at: u64, // unix timestamp (ns) - all sessions
 194-    task_exit_code: ?i32 = null, // null = running, set when task completes
 195-    task_end_time: ?u64 = null, // timestamp when task exited
 196-    task_command: []const u8 = "", // original task command string
 197+const EnsureSessionResult = struct {
 198+    created: bool,
 199+    is_daemon: bool,
 200 };
 201 
 202 const Daemon = struct {
 203@@ -175,6 +285,120 @@ const Daemon = struct {
 204         return false;
 205     }
 206 
 207+    fn spawnPty(self: *Daemon) !c_int {
 208+        const size = ipc.getTerminalSize(posix.STDOUT_FILENO);
 209+        var ws: cross.c.struct_winsize = .{
 210+            .ws_row = size.rows,
 211+            .ws_col = size.cols,
 212+            .ws_xpixel = 0,
 213+            .ws_ypixel = 0,
 214+        };
 215+
 216+        var master_fd: c_int = undefined;
 217+        const pid = cross.forkpty(&master_fd, null, null, &ws);
 218+        if (pid < 0) {
 219+            return error.ForkPtyFailed;
 220+        }
 221+
 222+        if (pid == 0) { // child pid code path
 223+            const session_env = try std.fmt.allocPrint(self.alloc, "ZMX_SESSION={s}\x00", .{self.session_name});
 224+            _ = cross.c.putenv(@ptrCast(session_env.ptr));
 225+
 226+            if (self.command) |cmd_args| {
 227+                const alloc = std.heap.c_allocator;
 228+                var argv_buf: [64:null]?[*:0]const u8 = undefined;
 229+                for (cmd_args, 0..) |arg, i| {
 230+                    argv_buf[i] = alloc.dupeZ(u8, arg) catch {
 231+                        std.posix.exit(1);
 232+                    };
 233+                }
 234+                argv_buf[cmd_args.len] = null;
 235+                const argv: [*:null]const ?[*:0]const u8 = &argv_buf;
 236+                const err = std.posix.execvpeZ(argv_buf[0].?, argv, std.c.environ);
 237+                std.log.err("execvpe failed: cmd={s} err={s}", .{ cmd_args[0], @errorName(err) });
 238+                std.posix.exit(1);
 239+            } else {
 240+                const shell = util.detectShell();
 241+                // Use "-shellname" as argv[0] to signal login shell (traditional method)
 242+                var buf: [64]u8 = undefined;
 243+                const login_shell = try std.fmt.bufPrintZ(&buf, "-{s}", .{std.fs.path.basename(shell)});
 244+                const argv = [_:null]?[*:0]const u8{ login_shell, null };
 245+                const err = std.posix.execveZ(shell, &argv, std.c.environ);
 246+                std.log.err("execve failed: err={s}", .{@errorName(err)});
 247+                std.posix.exit(1);
 248+            }
 249+        }
 250+        // master pid code path
 251+        self.pid = pid;
 252+        std.log.info("pty spawned session={s} pid={d}", .{ self.session_name, pid });
 253+
 254+        // make pty non-blocking
 255+        const flags = try posix.fcntl(master_fd, posix.F.GETFL, 0);
 256+        _ = try posix.fcntl(master_fd, posix.F.SETFL, flags | @as(u32, 0o4000));
 257+        return master_fd;
 258+    }
 259+
 260+    fn ensureSession(self: *Daemon) !EnsureSessionResult {
 261+        var dir = try std.fs.openDirAbsolute(self.cfg.socket_dir, .{});
 262+        defer dir.close();
 263+
 264+        const exists = try socket.sessionExists(dir, self.session_name);
 265+        var should_create = !exists;
 266+
 267+        if (exists) {
 268+            if (ipc.probeSession(self.alloc, self.socket_path)) |result| {
 269+                posix.close(result.fd);
 270+                if (self.command != null) {
 271+                    std.log.warn("session already exists, ignoring command session={s}", .{self.session_name});
 272+                }
 273+            } else |_| {
 274+                socket.cleanupStaleSocket(dir, self.session_name);
 275+                should_create = true;
 276+            }
 277+        }
 278+
 279+        if (should_create) {
 280+            std.log.info("creating session={s}", .{self.session_name});
 281+            const server_sock_fd = try socket.createSocket(self.socket_path);
 282+
 283+            const pid = try posix.fork();
 284+            if (pid == 0) { // child (daemon)
 285+                _ = try posix.setsid();
 286+
 287+                log_system.deinit();
 288+                const session_log_name = try std.fmt.allocPrint(self.alloc, "{s}.log", .{self.session_name});
 289+                defer self.alloc.free(session_log_name);
 290+                const session_log_path = try std.fs.path.join(self.alloc, &.{ self.cfg.log_dir, session_log_name });
 291+                defer self.alloc.free(session_log_path);
 292+                try log_system.init(self.alloc, session_log_path);
 293+
 294+                errdefer {
 295+                    posix.close(server_sock_fd);
 296+                    dir.deleteFile(self.session_name) catch {};
 297+                }
 298+                const pty_fd = try self.spawnPty();
 299+                defer {
 300+                    posix.close(pty_fd);
 301+                    posix.close(server_sock_fd);
 302+                    std.log.info("deleting socket file session_name={s}", .{self.session_name});
 303+                    dir.deleteFile(self.session_name) catch |err| {
 304+                        std.log.warn("failed to delete socket file err={s}", .{@errorName(err)});
 305+                    };
 306+                }
 307+                try daemonLoop(self, server_sock_fd, pty_fd);
 308+                self.handleKill();
 309+                _ = posix.waitpid(self.pid, 0);
 310+                self.deinit();
 311+                return .{ .created = true, .is_daemon = true };
 312+            }
 313+            posix.close(server_sock_fd);
 314+            std.Thread.sleep(10 * std.time.ns_per_ms);
 315+            return .{ .created = true, .is_daemon = false };
 316+        }
 317+
 318+        return .{ .created = false, .is_daemon = false };
 319+    }
 320+
 321     pub fn handleInput(self: *Daemon, pty_fd: i32, payload: []const u8) !void {
 322         _ = self;
 323         if (payload.len > 0) {
 324@@ -193,13 +417,13 @@ const Daemon = struct {
 325 
 326         const resize = std.mem.bytesToValue(ipc.Resize, payload);
 327 
 328-        var ws: c.struct_winsize = .{
 329+        var ws: cross.c.struct_winsize = .{
 330             .ws_row = resize.rows,
 331             .ws_col = resize.cols,
 332             .ws_xpixel = 0,
 333             .ws_ypixel = 0,
 334         };
 335-        _ = c.ioctl(pty_fd, c.TIOCSWINSZ, &ws);
 336+        _ = cross.c.ioctl(pty_fd, cross.c.TIOCSWINSZ, &ws);
 337         try term.resize(self.alloc, resize.cols, resize.rows);
 338 
 339         // Serialize terminal state BEFORE resize to capture correct cursor position.
 340@@ -210,7 +434,7 @@ const Daemon = struct {
 341         if (self.has_pty_output and self.has_had_client) {
 342             const cursor = &term.screens.active.cursor;
 343             std.log.debug("cursor before serialize: x={d} y={d} pending_wrap={}", .{ cursor.x, cursor.y, cursor.pending_wrap });
 344-            if (serializeTerminalState(self.alloc, term)) |term_output| {
 345+            if (util.serializeTerminalState(self.alloc, term)) |term_output| {
 346                 std.log.debug("serialize terminal state", .{});
 347                 defer self.alloc.free(term_output);
 348                 ipc.appendMessage(self.alloc, &client.write_buf, .Output, term_output) catch |err| {
 349@@ -230,13 +454,13 @@ const Daemon = struct {
 350         if (payload.len != @sizeOf(ipc.Resize)) return;
 351 
 352         const resize = std.mem.bytesToValue(ipc.Resize, payload);
 353-        var ws: c.struct_winsize = .{
 354+        var ws: cross.c.struct_winsize = .{
 355             .ws_row = resize.rows,
 356             .ws_col = resize.cols,
 357             .ws_xpixel = 0,
 358             .ws_ypixel = 0,
 359         };
 360-        _ = c.ioctl(pty_fd, c.TIOCSWINSZ, &ws);
 361+        _ = cross.c.ioctl(pty_fd, cross.c.TIOCSWINSZ, &ws);
 362         try term.resize(self.alloc, resize.cols, resize.rows);
 363         std.log.debug("resize rows={d} cols={d}", .{ resize.rows, resize.cols });
 364     }
 365@@ -281,8 +505,8 @@ const Daemon = struct {
 366         const cur_cmd = self.command orelse self.task_command;
 367         if (cur_cmd) |args| {
 368             for (args, 0..) |arg, i| {
 369-                const quoted = if (shellNeedsQuoting(arg))
 370-                    shellQuote(self.alloc, arg) catch null
 371+                const quoted = if (util.shellNeedsQuoting(arg))
 372+                    util.shellQuote(self.alloc, arg) catch null
 373                 else
 374                     null;
 375                 defer if (quoted) |q| self.alloc.free(q);
 376@@ -328,11 +552,11 @@ const Daemon = struct {
 377     }
 378 
 379     pub fn handleHistory(self: *Daemon, client: *Client, term: *ghostty_vt.Terminal, payload: []const u8) !void {
 380-        const format: HistoryFormat = if (payload.len > 0)
 381+        const format: util.HistoryFormat = if (payload.len > 0)
 382             @enumFromInt(payload[0])
 383         else
 384             .plain;
 385-        if (serializeTerminal(self.alloc, term, format)) |output| {
 386+        if (util.serializeTerminal(self.alloc, term, format)) |output| {
 387             defer self.alloc.free(output);
 388             try ipc.appendMessage(self.alloc, &client.write_buf, .History, output);
 389             client.has_pending_output = true;
 390@@ -353,144 +577,6 @@ const Daemon = struct {
 391     }
 392 };
 393 
 394-pub fn main() !void {
 395-    // use c_allocator to avoid "reached unreachable code" panic in DebugAllocator when forking
 396-    const alloc = std.heap.c_allocator;
 397-
 398-    var args = try std.process.argsWithAllocator(alloc);
 399-    defer args.deinit();
 400-    _ = args.skip(); // skip program name
 401-
 402-    var cfg = try Cfg.init(alloc);
 403-    defer cfg.deinit(alloc);
 404-
 405-    const log_path = try std.fs.path.join(alloc, &.{ cfg.log_dir, "zmx.log" });
 406-    defer alloc.free(log_path);
 407-    try log_system.init(alloc, log_path);
 408-    defer log_system.deinit();
 409-
 410-    const cmd = args.next() orelse {
 411-        return list(&cfg, false);
 412-    };
 413-
 414-    if (std.mem.eql(u8, cmd, "version") or std.mem.eql(u8, cmd, "v") or std.mem.eql(u8, cmd, "-v") or std.mem.eql(u8, cmd, "--version")) {
 415-        return printVersion(&cfg);
 416-    } else if (std.mem.eql(u8, cmd, "help") or std.mem.eql(u8, cmd, "h") or std.mem.eql(u8, cmd, "-h")) {
 417-        return help();
 418-    } else if (std.mem.eql(u8, cmd, "list") or std.mem.eql(u8, cmd, "l")) {
 419-        const short = if (args.next()) |arg| std.mem.eql(u8, arg, "--short") else false;
 420-        return list(&cfg, short);
 421-    } else if (std.mem.eql(u8, cmd, "completions") or std.mem.eql(u8, cmd, "c")) {
 422-        const arg = args.next() orelse return;
 423-        const shell = completions.Shell.fromString(arg) orelse return;
 424-        return printCompletions(shell);
 425-    } else if (std.mem.eql(u8, cmd, "detach") or std.mem.eql(u8, cmd, "d")) {
 426-        return detachAll(&cfg);
 427-    } else if (std.mem.eql(u8, cmd, "kill") or std.mem.eql(u8, cmd, "k")) {
 428-        const session_name = args.next() orelse "";
 429-        const sesh = try getSeshName(alloc, session_name);
 430-        defer alloc.free(sesh);
 431-        return kill(&cfg, sesh);
 432-    } else if (std.mem.eql(u8, cmd, "history") or std.mem.eql(u8, cmd, "hi")) {
 433-        var session_name: ?[]const u8 = null;
 434-        var format: HistoryFormat = .plain;
 435-        while (args.next()) |arg| {
 436-            if (std.mem.eql(u8, arg, "--vt")) {
 437-                format = .vt;
 438-            } else if (std.mem.eql(u8, arg, "--html")) {
 439-                format = .html;
 440-            } else if (session_name == null) {
 441-                session_name = arg;
 442-            }
 443-        }
 444-        const sesh = try getSeshName(alloc, session_name.?);
 445-        defer alloc.free(sesh);
 446-        return history(&cfg, sesh, format);
 447-    } else if (std.mem.eql(u8, cmd, "attach") or std.mem.eql(u8, cmd, "a")) {
 448-        const session_name = args.next() orelse "";
 449-
 450-        var command_args: std.ArrayList([]const u8) = .empty;
 451-        defer command_args.deinit(alloc);
 452-        while (args.next()) |arg| {
 453-            try command_args.append(alloc, arg);
 454-        }
 455-
 456-        const clients = try std.ArrayList(*Client).initCapacity(alloc, 10);
 457-        var command: ?[][]const u8 = null;
 458-        if (command_args.items.len > 0) {
 459-            command = command_args.items;
 460-        }
 461-
 462-        var cwd_buf: [std.fs.max_path_bytes]u8 = undefined;
 463-        const cwd = std.posix.getcwd(&cwd_buf) catch "";
 464-
 465-        const sesh = try getSeshName(alloc, session_name);
 466-        defer alloc.free(sesh);
 467-        var daemon = Daemon{
 468-            .running = true,
 469-            .cfg = &cfg,
 470-            .alloc = alloc,
 471-            .clients = clients,
 472-            .session_name = sesh,
 473-            .socket_path = undefined,
 474-            .pid = undefined,
 475-            .command = command,
 476-            .cwd = cwd,
 477-            .created_at = @intCast(std.time.timestamp()),
 478-        };
 479-        daemon.socket_path = try getSocketPath(alloc, cfg.socket_dir, sesh);
 480-        std.log.info("socket path={s}", .{daemon.socket_path});
 481-        return attach(&daemon);
 482-    } else if (std.mem.eql(u8, cmd, "run") or std.mem.eql(u8, cmd, "r")) {
 483-        const session_name = args.next() orelse "";
 484-
 485-        var cmd_args_raw: std.ArrayList([]const u8) = .empty;
 486-        defer cmd_args_raw.deinit(alloc);
 487-        while (args.next()) |arg| {
 488-            try cmd_args_raw.append(alloc, arg);
 489-        }
 490-        const clients = try std.ArrayList(*Client).initCapacity(alloc, 10);
 491-
 492-        var cwd_buf: [std.fs.max_path_bytes]u8 = undefined;
 493-        const cwd = std.posix.getcwd(&cwd_buf) catch "";
 494-
 495-        const sesh = try getSeshName(alloc, session_name);
 496-        defer alloc.free(sesh);
 497-        var daemon = Daemon{
 498-            .running = true,
 499-            .cfg = &cfg,
 500-            .alloc = alloc,
 501-            .clients = clients,
 502-            .session_name = sesh,
 503-            .socket_path = undefined,
 504-            .pid = undefined,
 505-            .command = null,
 506-            .cwd = cwd,
 507-            .created_at = @intCast(std.time.timestamp()),
 508-            .is_task_mode = true,
 509-            .task_command = cmd_args_raw.items,
 510-        };
 511-        daemon.socket_path = try getSocketPath(alloc, cfg.socket_dir, sesh);
 512-        std.log.info("socket path={s}", .{daemon.socket_path});
 513-        return run(&daemon, cmd_args_raw.items);
 514-    } else if (std.mem.eql(u8, cmd, "wait") or std.mem.eql(u8, cmd, "w")) {
 515-        var args_raw: std.ArrayList([]const u8) = .empty;
 516-        defer {
 517-            for (args_raw.items) |sesh| {
 518-                alloc.free(sesh);
 519-            }
 520-            args_raw.deinit(alloc);
 521-        }
 522-        while (args.next()) |session_name| {
 523-            const sesh = try getSeshName(alloc, session_name);
 524-            try args_raw.append(alloc, sesh);
 525-        }
 526-        return wait(&cfg, args_raw);
 527-    } else {
 528-        return help();
 529-    }
 530-}
 531-
 532 fn printVersion(cfg: *Cfg) !void {
 533     var buf: [256]u8 = undefined;
 534     var w = std.fs.File.stdout().writer(&buf);
 535@@ -546,23 +632,6 @@ fn help() !void {
 536     try w.interface.flush();
 537 }
 538 
 539-const SessionEntry = struct {
 540-    name: []const u8,
 541-    pid: ?i32,
 542-    clients_len: ?usize,
 543-    is_error: bool,
 544-    error_name: ?[]const u8,
 545-    cmd: ?[]const u8 = null,
 546-    cwd: ?[]const u8 = null,
 547-    created_at: u64,
 548-    task_ended_at: ?u64,
 549-    task_exit_code: ?u8,
 550-
 551-    fn lessThan(_: void, a: SessionEntry, b: SessionEntry) bool {
 552-        return std.mem.order(u8, a.name, b.name) == .lt;
 553-    }
 554-};
 555-
 556 fn wait(cfg: *Cfg, session_names: std.ArrayList([]const u8)) !void {
 557     var gpa = std.heap.GeneralPurposeAllocator(.{}){};
 558     defer _ = gpa.deinit();
 559@@ -573,7 +642,7 @@ fn wait(cfg: *Cfg, session_names: std.ArrayList([]const u8)) !void {
 560     const stdout = &stdout_writer.interface;
 561 
 562     while (true) {
 563-        var sessions = try get_session_entries(alloc, cfg);
 564+        var sessions = try util.get_session_entries(alloc, cfg.socket_dir);
 565         var total: i32 = 0;
 566         var done: i32 = 0;
 567         var agg_exit_code: u8 = 0;
 568@@ -602,7 +671,10 @@ fn wait(cfg: *Cfg, session_names: std.ArrayList([]const u8)) !void {
 569             done += 1;
 570         }
 571 
 572-        session_entries_deinit(alloc, &sessions);
 573+        for (sessions.items) |session| {
 574+            session.deinit(alloc);
 575+        }
 576+        sessions.deinit(alloc);
 577 
 578         if (total == done) {
 579             try stdout.print("tasks completed!\n", .{});
 580@@ -615,77 +687,6 @@ fn wait(cfg: *Cfg, session_names: std.ArrayList([]const u8)) !void {
 581     }
 582 }
 583 
 584-fn session_entries_deinit(alloc: std.mem.Allocator, sessions: *std.ArrayList(SessionEntry)) void {
 585-    for (sessions.items) |session| {
 586-        alloc.free(session.name);
 587-        if (session.cmd) |cmd| alloc.free(cmd);
 588-        if (session.cwd) |cwd| alloc.free(cwd);
 589-    }
 590-    sessions.deinit(alloc);
 591-}
 592-
 593-fn get_session_entries(alloc: std.mem.Allocator, cfg: *Cfg) !std.ArrayList(SessionEntry) {
 594-    var dir = try std.fs.openDirAbsolute(cfg.socket_dir, .{ .iterate = true });
 595-    defer dir.close();
 596-    var iter = dir.iterate();
 597-
 598-    var sessions = try std.ArrayList(SessionEntry).initCapacity(alloc, 30);
 599-
 600-    while (try iter.next()) |entry| {
 601-        const exists = sessionExists(dir, entry.name) catch continue;
 602-        if (exists) {
 603-            const name = try alloc.dupe(u8, entry.name);
 604-            errdefer alloc.free(name);
 605-
 606-            const socket_path = try getSocketPath(alloc, cfg.socket_dir, entry.name);
 607-            defer alloc.free(socket_path);
 608-
 609-            const result = probeSession(alloc, socket_path) catch |err| {
 610-                try sessions.append(alloc, .{
 611-                    .name = name,
 612-                    .pid = null,
 613-                    .clients_len = null,
 614-                    .is_error = true,
 615-                    .error_name = @errorName(err),
 616-                    .created_at = 0,
 617-                    .task_exit_code = 1,
 618-                    .task_ended_at = 0,
 619-                });
 620-                cleanupStaleSocket(dir, entry.name);
 621-                continue;
 622-            };
 623-            posix.close(result.fd);
 624-
 625-            // Extract cmd and cwd from the fixed-size arrays
 626-            const cmd: ?[]const u8 = if (result.info.cmd_len > 0)
 627-                alloc.dupe(u8, result.info.cmd[0..result.info.cmd_len]) catch null
 628-            else
 629-                null;
 630-            const cwd: ?[]const u8 = if (result.info.cwd_len > 0)
 631-                alloc.dupe(u8, result.info.cwd[0..result.info.cwd_len]) catch null
 632-            else
 633-                null;
 634-
 635-            try sessions.append(alloc, .{
 636-                .name = name,
 637-                .pid = result.info.pid,
 638-                .clients_len = result.info.clients_len,
 639-                .is_error = false,
 640-                .error_name = null,
 641-                .cmd = cmd,
 642-                .cwd = cwd,
 643-                .created_at = result.info.created_at,
 644-                .task_ended_at = result.info.task_ended_at,
 645-                .task_exit_code = result.info.task_exit_code,
 646-            });
 647-        }
 648-    }
 649-
 650-    return sessions;
 651-}
 652-
 653-const current_arrow = "→";
 654-
 655 fn list(cfg: *Cfg, short: bool) !void {
 656     var gpa = std.heap.GeneralPurposeAllocator(.{}){};
 657     defer _ = gpa.deinit();
 658@@ -699,8 +700,13 @@ fn list(cfg: *Cfg, short: bool) !void {
 659     var buf: [4096]u8 = undefined;
 660     var w = std.fs.File.stdout().writer(&buf);
 661 
 662-    var sessions = try get_session_entries(alloc, cfg);
 663-    defer session_entries_deinit(alloc, &sessions);
 664+    var sessions = try util.get_session_entries(alloc, cfg.socket_dir);
 665+    defer {
 666+        for (sessions.items) |session| {
 667+            session.deinit(alloc);
 668+        }
 669+        sessions.deinit(alloc);
 670+    }
 671 
 672     if (sessions.items.len == 0) {
 673         if (short) return;
 674@@ -709,10 +715,10 @@ fn list(cfg: *Cfg, short: bool) !void {
 675         return;
 676     }
 677 
 678-    std.mem.sort(SessionEntry, sessions.items, {}, SessionEntry.lessThan);
 679+    std.mem.sort(util.SessionEntry, sessions.items, {}, util.SessionEntry.lessThan);
 680 
 681     for (sessions.items) |session| {
 682-        try writeSessionLine(&w.interface, session, short, current_session);
 683+        try util.writeSessionLine(&w.interface, session, short, current_session);
 684         try w.interface.flush();
 685     }
 686 }
 687@@ -733,11 +739,11 @@ fn detachAll(cfg: *Cfg) !void {
 688     var dir = try std.fs.openDirAbsolute(cfg.socket_dir, .{});
 689     defer dir.close();
 690 
 691-    const socket_path = try getSocketPath(alloc, cfg.socket_dir, session_name);
 692+    const socket_path = try socket.getSocketPath(alloc, cfg.socket_dir, session_name);
 693     defer alloc.free(socket_path);
 694-    const result = probeSession(alloc, socket_path) catch |err| {
 695+    const result = ipc.probeSession(alloc, socket_path) catch |err| {
 696         std.log.err("session unresponsive: {s}", .{@errorName(err)});
 697-        cleanupStaleSocket(dir, session_name);
 698+        socket.cleanupStaleSocket(dir, session_name);
 699         return;
 700     };
 701     defer posix.close(result.fd);
 702@@ -755,17 +761,17 @@ fn kill(cfg: *Cfg, session_name: []const u8) !void {
 703     var dir = try std.fs.openDirAbsolute(cfg.socket_dir, .{});
 704     defer dir.close();
 705 
 706-    const exists = try sessionExists(dir, session_name);
 707+    const exists = try socket.sessionExists(dir, session_name);
 708     if (!exists) {
 709         std.log.err("cannot kill session because it does not exist session_name={s}", .{session_name});
 710         return;
 711     }
 712 
 713-    const socket_path = try getSocketPath(alloc, cfg.socket_dir, session_name);
 714+    const socket_path = try socket.getSocketPath(alloc, cfg.socket_dir, session_name);
 715     defer alloc.free(socket_path);
 716-    const result = probeSession(alloc, socket_path) catch |err| {
 717+    const result = ipc.probeSession(alloc, socket_path) catch |err| {
 718         std.log.err("session unresponsive: {s}", .{@errorName(err)});
 719-        cleanupStaleSocket(dir, session_name);
 720+        socket.cleanupStaleSocket(dir, session_name);
 721         var buf: [4096]u8 = undefined;
 722         var w = std.fs.File.stdout().writer(&buf);
 723         w.interface.print("cleaned up stale session {s}\n", .{session_name}) catch {};
 724@@ -784,13 +790,7 @@ fn kill(cfg: *Cfg, session_name: []const u8) !void {
 725     try w.interface.flush();
 726 }
 727 
 728-const HistoryFormat = enum(u8) {
 729-    plain = 0,
 730-    vt = 1,
 731-    html = 2,
 732-};
 733-
 734-fn history(cfg: *Cfg, session_name: []const u8, format: HistoryFormat) !void {
 735+fn history(cfg: *Cfg, session_name: []const u8, format: util.HistoryFormat) !void {
 736     var gpa = std.heap.GeneralPurposeAllocator(.{}){};
 737     defer _ = gpa.deinit();
 738     const alloc = gpa.allocator();
 739@@ -798,17 +798,17 @@ fn history(cfg: *Cfg, session_name: []const u8, format: HistoryFormat) !void {
 740     var dir = try std.fs.openDirAbsolute(cfg.socket_dir, .{});
 741     defer dir.close();
 742 
 743-    const exists = try sessionExists(dir, session_name);
 744+    const exists = try socket.sessionExists(dir, session_name);
 745     if (!exists) {
 746         std.log.err("session does not exist session_name={s}", .{session_name});
 747         return;
 748     }
 749 
 750-    const socket_path = try getSocketPath(alloc, cfg.socket_dir, session_name);
 751+    const socket_path = try socket.getSocketPath(alloc, cfg.socket_dir, session_name);
 752     defer alloc.free(socket_path);
 753-    const result = probeSession(alloc, socket_path) catch |err| {
 754+    const result = ipc.probeSession(alloc, socket_path) catch |err| {
 755         std.log.err("session unresponsive: {s}", .{@errorName(err)});
 756-        cleanupStaleSocket(dir, session_name);
 757+        socket.cleanupStaleSocket(dir, session_name);
 758         return;
 759     };
 760     defer posix.close(result.fd);
 761@@ -842,94 +842,28 @@ fn history(cfg: *Cfg, session_name: []const u8, format: HistoryFormat) !void {
 762     }
 763 }
 764 
 765-const EnsureSessionResult = struct {
 766-    created: bool,
 767-    is_daemon: bool,
 768-};
 769-
 770-fn ensureSession(daemon: *Daemon) !EnsureSessionResult {
 771-    var dir = try std.fs.openDirAbsolute(daemon.cfg.socket_dir, .{});
 772-    defer dir.close();
 773-
 774-    const exists = try sessionExists(dir, daemon.session_name);
 775-    var should_create = !exists;
 776-
 777-    if (exists) {
 778-        if (probeSession(daemon.alloc, daemon.socket_path)) |result| {
 779-            posix.close(result.fd);
 780-            if (daemon.command != null) {
 781-                std.log.warn("session already exists, ignoring command session={s}", .{daemon.session_name});
 782-            }
 783-        } else |_| {
 784-            cleanupStaleSocket(dir, daemon.session_name);
 785-            should_create = true;
 786-        }
 787-    }
 788-
 789-    if (should_create) {
 790-        std.log.info("creating session={s}", .{daemon.session_name});
 791-        const server_sock_fd = try createSocket(daemon.socket_path);
 792-
 793-        const pid = try posix.fork();
 794-        if (pid == 0) { // child (daemon)
 795-            _ = try posix.setsid();
 796-
 797-            log_system.deinit();
 798-            const session_log_name = try std.fmt.allocPrint(daemon.alloc, "{s}.log", .{daemon.session_name});
 799-            defer daemon.alloc.free(session_log_name);
 800-            const session_log_path = try std.fs.path.join(daemon.alloc, &.{ daemon.cfg.log_dir, session_log_name });
 801-            defer daemon.alloc.free(session_log_path);
 802-            try log_system.init(daemon.alloc, session_log_path);
 803-
 804-            errdefer {
 805-                posix.close(server_sock_fd);
 806-                dir.deleteFile(daemon.session_name) catch {};
 807-            }
 808-            const pty_fd = try spawnPty(daemon);
 809-            defer {
 810-                posix.close(pty_fd);
 811-                posix.close(server_sock_fd);
 812-                std.log.info("deleting socket file session_name={s}", .{daemon.session_name});
 813-                dir.deleteFile(daemon.session_name) catch |err| {
 814-                    std.log.warn("failed to delete socket file err={s}", .{@errorName(err)});
 815-                };
 816-            }
 817-            try daemonLoop(daemon, server_sock_fd, pty_fd);
 818-            daemon.handleKill();
 819-            _ = posix.waitpid(daemon.pid, 0);
 820-            daemon.deinit();
 821-            return .{ .created = true, .is_daemon = true };
 822-        }
 823-        posix.close(server_sock_fd);
 824-        std.Thread.sleep(10 * std.time.ns_per_ms);
 825-        return .{ .created = true, .is_daemon = false };
 826-    }
 827-
 828-    return .{ .created = false, .is_daemon = false };
 829-}
 830-
 831 fn attach(daemon: *Daemon) !void {
 832     if (std.posix.getenv("ZMX_SESSION")) |_| {
 833         return error.CannotAttachToSessionInSession;
 834     }
 835 
 836-    const result = try ensureSession(daemon);
 837+    const result = try daemon.ensureSession();
 838     if (result.is_daemon) return;
 839 
 840-    const client_sock = try sessionConnect(daemon.socket_path);
 841+    const client_sock = try socket.sessionConnect(daemon.socket_path);
 842     std.log.info("attached session={s}", .{daemon.session_name});
 843     //  this is typically used with tcsetattr() to modify terminal settings.
 844     //      - you first get the current settings with tcgetattr()
 845     //      - modify the desired attributes in the termios structure
 846     //      - then apply the changes with tcsetattr().
 847     //  This prevents unintended side effects by preserving other settings.
 848-    var orig_termios: c.termios = undefined;
 849-    _ = c.tcgetattr(posix.STDIN_FILENO, &orig_termios);
 850+    var orig_termios: cross.c.termios = undefined;
 851+    _ = cross.c.tcgetattr(posix.STDIN_FILENO, &orig_termios);
 852 
 853     // restore stdin fd to its original state after exiting.
 854     // Use TCSAFLUSH to discard any unread input, preventing stale input after detach.
 855     defer {
 856-        _ = c.tcsetattr(posix.STDIN_FILENO, c.TCSAFLUSH, &orig_termios);
 857+        _ = cross.c.tcsetattr(posix.STDIN_FILENO, cross.c.TCSAFLUSH, &orig_termios);
 858         // Reset terminal modes on detach:
 859         // - Mouse: 1000=basic, 1002=button-event, 1003=any-event, 1006=SGR extended
 860         // - 2004=bracketed paste, 1004=focus events, 1049=alt screen
 861@@ -949,60 +883,24 @@ fn attach(daemon: *Daemon) !void {
 862     //  set raw mode after successful connection.
 863     //      disables canonical mode (line buffering), input echoing, signal generation from
 864     //      control characters (like Ctrl+C), and flow control.
 865-    c.cfmakeraw(&raw_termios);
 866+    cross.c.cfmakeraw(&raw_termios);
 867 
 868     // Additional granular raw mode settings for precise control
 869     // (matches what abduco and shpool do)
 870-    raw_termios.c_cc[c.VLNEXT] = c._POSIX_VDISABLE; // Disable literal-next (Ctrl-V)
 871+    raw_termios.c_cc[cross.c.VLNEXT] = cross.c._POSIX_VDISABLE; // Disable literal-next (Ctrl-V)
 872     // We want to intercept Ctrl+\ (SIGQUIT) so we can use it as a detach key
 873-    raw_termios.c_cc[c.VQUIT] = c._POSIX_VDISABLE; // Disable SIGQUIT (Ctrl+\)
 874-    raw_termios.c_cc[c.VMIN] = 1; // Minimum chars to read: return after 1 byte
 875-    raw_termios.c_cc[c.VTIME] = 0; // Read timeout: no timeout, return immediately
 876+    raw_termios.c_cc[cross.c.VQUIT] = cross.c._POSIX_VDISABLE; // Disable SIGQUIT (Ctrl+\)
 877+    raw_termios.c_cc[cross.c.VMIN] = 1; // Minimum chars to read: return after 1 byte
 878+    raw_termios.c_cc[cross.c.VTIME] = 0; // Read timeout: no timeout, return immediately
 879 
 880-    _ = c.tcsetattr(posix.STDIN_FILENO, c.TCSANOW, &raw_termios);
 881+    _ = cross.c.tcsetattr(posix.STDIN_FILENO, cross.c.TCSANOW, &raw_termios);
 882 
 883     // Clear screen before attaching. This provides a clean slate before
 884     // the session restore.
 885     const clear_seq = "\x1b[2J\x1b[H";
 886     _ = try posix.write(posix.STDOUT_FILENO, clear_seq);
 887 
 888-    try clientLoop(daemon.cfg, client_sock);
 889-}
 890-
 891-fn shellNeedsQuoting(arg: []const u8) bool {
 892-    if (arg.len == 0) return true;
 893-    for (arg) |ch| {
 894-        switch (ch) {
 895-            ' ', '\t', '"', '\'', '\\', '$', '`', '!', '(', ')', '{', '}', '[', ']', '|', '&', ';', '<', '>', '?', '*', '~', '#', '\n' => return true,
 896-            else => {},
 897-        }
 898-    }
 899-    return false;
 900-}
 901-
 902-fn shellQuote(alloc: std.mem.Allocator, arg: []const u8) ![]u8 {
 903-    // Always use single quotes (like Python's shlex.quote). Inside single
 904-    // quotes nothing is special except ' itself, which we handle with the
 905-    // '\'' trick (end quote, escaped literal quote, reopen quote).
 906-    var len: usize = 2;
 907-    for (arg) |ch| {
 908-        len += if (ch == '\'') 4 else 1;
 909-    }
 910-    const buf = try alloc.alloc(u8, len);
 911-    var i: usize = 0;
 912-    buf[i] = '\'';
 913-    i += 1;
 914-    for (arg) |ch| {
 915-        if (ch == '\'') {
 916-            @memcpy(buf[i..][0..4], "'\\''");
 917-            i += 4;
 918-        } else {
 919-            buf[i] = ch;
 920-            i += 1;
 921-        }
 922-    }
 923-    buf[i] = '\'';
 924-    return buf;
 925+    try clientLoop(client_sock);
 926 }
 927 
 928 fn run(daemon: *Daemon, command_args: [][]const u8) !void {
 929@@ -1014,7 +912,7 @@ fn run(daemon: *Daemon, command_args: [][]const u8) !void {
 930     var allocated_cmd: ?[]u8 = null;
 931     defer if (allocated_cmd) |cmd| alloc.free(cmd);
 932 
 933-    const result = try ensureSession(daemon);
 934+    const result = try daemon.ensureSession();
 935     if (result.is_daemon) return;
 936 
 937     if (result.created) {
 938@@ -1022,7 +920,7 @@ fn run(daemon: *Daemon, command_args: [][]const u8) !void {
 939         try w.interface.flush();
 940     }
 941 
 942-    const shell = detectShell();
 943+    const shell = util.detectShell();
 944     const shell_basename = std.fs.path.basename(shell);
 945     const inline_task_marker = if (std.mem.eql(u8, shell_basename, "fish"))
 946         "; echo ZMX_TASK_COMPLETED:$status"
 947@@ -1039,8 +937,8 @@ fn run(daemon: *Daemon, command_args: [][]const u8) !void {
 948 
 949         for (command_args, 0..) |arg, i| {
 950             if (i > 0) try cmd_list.append(alloc, ' ');
 951-            if (shellNeedsQuoting(arg)) {
 952-                const quoted = try shellQuote(alloc, arg);
 953+            if (util.shellNeedsQuoting(arg)) {
 954+                const quoted = try util.shellQuote(alloc, arg);
 955                 defer alloc.free(quoted);
 956                 try cmd_list.appendSlice(alloc, quoted);
 957             } else {
 958@@ -1088,7 +986,7 @@ fn run(daemon: *Daemon, command_args: [][]const u8) !void {
 959         return error.CommandRequired;
 960     }
 961 
 962-    const probe_result = probeSession(alloc, daemon.socket_path) catch |err| {
 963+    const probe_result = ipc.probeSession(alloc, daemon.socket_path) catch |err| {
 964         std.log.err("session not ready: {s}", .{@errorName(err)});
 965         return error.SessionNotReady;
 966     };
 967@@ -1120,7 +1018,7 @@ fn run(daemon: *Daemon, command_args: [][]const u8) !void {
 968     return error.NoAckReceived;
 969 }
 970 
 971-fn clientLoop(_: *Cfg, client_sock_fd: i32) !void {
 972+fn clientLoop(client_sock_fd: i32) !void {
 973     // use c_allocator to avoid "reached unreachable code" panic in DebugAllocator when forking
 974     const alloc = std.heap.c_allocator;
 975     defer posix.close(client_sock_fd);
 976@@ -1136,7 +1034,7 @@ fn clientLoop(_: *Cfg, client_sock_fd: i32) !void {
 977     defer sock_write_buf.deinit(alloc);
 978 
 979     // Send init message with terminal size (buffered)
 980-    const size = getTerminalSize(posix.STDOUT_FILENO);
 981+    const size = ipc.getTerminalSize(posix.STDOUT_FILENO);
 982     try ipc.appendMessage(alloc, &sock_write_buf, .Init, std.mem.asBytes(&size));
 983 
 984     var poll_fds = try std.ArrayList(posix.pollfd).initCapacity(alloc, 4);
 985@@ -1157,7 +1055,7 @@ fn clientLoop(_: *Cfg, client_sock_fd: i32) !void {
 986     while (true) {
 987         // Check for pending SIGWINCH
 988         if (sigwinch_received.swap(false, .acq_rel)) {
 989-            const next_size = getTerminalSize(posix.STDOUT_FILENO);
 990+            const next_size = ipc.getTerminalSize(posix.STDOUT_FILENO);
 991             try ipc.appendMessage(alloc, &sock_write_buf, .Resize, std.mem.asBytes(&next_size));
 992         }
 993 
 994@@ -1204,7 +1102,7 @@ fn clientLoop(_: *Cfg, client_sock_fd: i32) !void {
 995             if (n_opt) |n| {
 996                 if (n > 0) {
 997                     // Check for detach sequences (ctrl+\ as first byte or Kitty escape sequence)
 998-                    if (buf[0] == 0x1C or isKittyCtrlBackslash(buf[0..n])) {
 999+                    if (buf[0] == 0x1C or util.isKittyCtrlBackslash(buf[0..n])) {
1000                         try ipc.appendMessage(alloc, &sock_write_buf, .Detach, "");
1001                     } else {
1002                         try ipc.appendMessage(alloc, &sock_write_buf, .Input, buf[0..n]);
1003@@ -1274,80 +1172,13 @@ fn clientLoop(_: *Cfg, client_sock_fd: i32) !void {
1004     }
1005 }
1006 
1007-const DA1_QUERY = "\x1b[c";
1008-const DA1_QUERY_EXPLICIT = "\x1b[0c";
1009-const DA2_QUERY = "\x1b[>c";
1010-const DA2_QUERY_EXPLICIT = "\x1b[>0c";
1011-const DA1_RESPONSE = "\x1b[?62;22c";
1012-const DA2_RESPONSE = "\x1b[>1;10;0c";
1013-
1014-fn respondToDeviceAttributes(pty_fd: i32, data: []const u8) void {
1015-    // Scan for DA queries in PTY output and respond on behalf of the terminal.
1016-    // This handles the case where no client is attached (e.g. zmx run)
1017-    // and the shell (e.g. fish) sends a DA query that would otherwise go unanswered.
1018-    //
1019-    // DA1 query: ESC [ c  or  ESC [ 0 c
1020-    // DA2 query: ESC [ > c  or  ESC [ > 0 c
1021-    // DA1 response (from terminal): ESC [ ? ... c  (has '?' after '[')
1022-    //
1023-    // We must NOT match DA responses (which contain '?') as queries.
1024-    var i: usize = 0;
1025-    while (i < data.len) {
1026-        if (data[i] == '\x1b' and i + 1 < data.len and data[i + 1] == '[') {
1027-            // Skip DA responses which have '?' after CSI
1028-            if (i + 2 < data.len and data[i + 2] == '?') {
1029-                i += 3;
1030-                continue;
1031-            }
1032-            if (matchSeq(data[i..], DA2_QUERY) or matchSeq(data[i..], DA2_QUERY_EXPLICIT)) {
1033-                _ = posix.write(pty_fd, DA2_RESPONSE) catch {};
1034-            } else if (matchSeq(data[i..], DA1_QUERY) or matchSeq(data[i..], DA1_QUERY_EXPLICIT)) {
1035-                _ = posix.write(pty_fd, DA1_RESPONSE) catch {};
1036-            }
1037-        }
1038-        i += 1;
1039-    }
1040-}
1041-
1042-fn matchSeq(data: []const u8, seq: []const u8) bool {
1043-    if (data.len < seq.len) return false;
1044-    return std.mem.eql(u8, data[0..seq.len], seq);
1045-}
1046-
1047-fn findTaskExitMarker(output: []const u8) ?u8 {
1048-    const marker = "ZMX_TASK_COMPLETED:";
1049-
1050-    // Search for marker in output
1051-    if (std.mem.indexOf(u8, output, marker)) |idx| {
1052-        const after_marker = output[idx + marker.len ..];
1053-
1054-        // Find the exit code number and newline
1055-        var end_idx: usize = 0;
1056-        while (end_idx < after_marker.len and after_marker[end_idx] != '\n' and after_marker[end_idx] != '\r') {
1057-            end_idx += 1;
1058-        }
1059-
1060-        const exit_code_str = after_marker[0..end_idx];
1061-
1062-        // Parse exit code
1063-        if (std.fmt.parseInt(u8, exit_code_str, 10)) |exit_code| {
1064-            return exit_code;
1065-        } else |_| {
1066-            std.log.warn("failed to parse task exit code from: {s}", .{exit_code_str});
1067-            return null;
1068-        }
1069-    }
1070-
1071-    return null;
1072-}
1073-
1074 fn daemonLoop(daemon: *Daemon, server_sock_fd: i32, pty_fd: i32) !void {
1075     std.log.info("daemon started session={s} pty_fd={d}", .{ daemon.session_name, pty_fd });
1076     setupSigtermHandler();
1077     var poll_fds = try std.ArrayList(posix.pollfd).initCapacity(daemon.alloc, 8);
1078     defer poll_fds.deinit(daemon.alloc);
1079 
1080-    const init_size = getTerminalSize(pty_fd);
1081+    const init_size = ipc.getTerminalSize(pty_fd);
1082     var term = try ghostty_vt.Terminal.init(daemon.alloc, .{
1083         .cols = init_size.cols,
1084         .rows = init_size.rows,
1085@@ -1434,12 +1265,12 @@ fn daemonLoop(daemon: *Daemon, server_sock_fd: i32, pty_fd: i32) !void {
1086                     // and then sending a no DA query response warning because
1087                     // there's no client terminal to respond to the query.
1088                     if (daemon.clients.items.len == 0) {
1089-                        respondToDeviceAttributes(pty_fd, buf[0..n]);
1090+                        util.respondToDeviceAttributes(pty_fd, buf[0..n]);
1091                     }
1092 
1093                     // In run mode, scan output for exit code marker
1094                     if (daemon.is_task_mode and daemon.task_exit_code == null) {
1095-                        if (findTaskExitMarker(buf[0..n])) |exit_code| {
1096+                        if (util.findTaskExitMarker(buf[0..n])) |exit_code| {
1097                             daemon.task_exit_code = exit_code;
1098                             daemon.task_ended_at = @intCast(std.time.timestamp());
1099 
1100@@ -1542,169 +1373,6 @@ fn daemonLoop(daemon: *Daemon, server_sock_fd: i32, pty_fd: i32) !void {
1101     }
1102 }
1103 
1104-fn spawnPty(daemon: *Daemon) !c_int {
1105-    const size = getTerminalSize(posix.STDOUT_FILENO);
1106-    var ws: c.struct_winsize = .{
1107-        .ws_row = size.rows,
1108-        .ws_col = size.cols,
1109-        .ws_xpixel = 0,
1110-        .ws_ypixel = 0,
1111-    };
1112-
1113-    var master_fd: c_int = undefined;
1114-    const pid = forkpty(&master_fd, null, null, &ws);
1115-    if (pid < 0) {
1116-        return error.ForkPtyFailed;
1117-    }
1118-
1119-    if (pid == 0) { // child pid code path
1120-        const session_env = try std.fmt.allocPrint(daemon.alloc, "ZMX_SESSION={s}\x00", .{daemon.session_name});
1121-        _ = c.putenv(@ptrCast(session_env.ptr));
1122-
1123-        if (daemon.command) |cmd_args| {
1124-            const alloc = std.heap.c_allocator;
1125-            var argv_buf: [64:null]?[*:0]const u8 = undefined;
1126-            for (cmd_args, 0..) |arg, i| {
1127-                argv_buf[i] = alloc.dupeZ(u8, arg) catch {
1128-                    std.posix.exit(1);
1129-                };
1130-            }
1131-            argv_buf[cmd_args.len] = null;
1132-            const argv: [*:null]const ?[*:0]const u8 = &argv_buf;
1133-            const err = std.posix.execvpeZ(argv_buf[0].?, argv, std.c.environ);
1134-            std.log.err("execvpe failed: cmd={s} err={s}", .{ cmd_args[0], @errorName(err) });
1135-            std.posix.exit(1);
1136-        } else {
1137-            const shell = detectShell();
1138-            // Use "-shellname" as argv[0] to signal login shell (traditional method)
1139-            var buf: [64]u8 = undefined;
1140-            const login_shell = try std.fmt.bufPrintZ(&buf, "-{s}", .{std.fs.path.basename(shell)});
1141-            const argv = [_:null]?[*:0]const u8{ login_shell, null };
1142-            const err = std.posix.execveZ(shell, &argv, std.c.environ);
1143-            std.log.err("execve failed: err={s}", .{@errorName(err)});
1144-            std.posix.exit(1);
1145-        }
1146-    }
1147-    // master pid code path
1148-    daemon.pid = pid;
1149-    std.log.info("pty spawned session={s} pid={d}", .{ daemon.session_name, pid });
1150-
1151-    // make pty non-blocking
1152-    const flags = try posix.fcntl(master_fd, posix.F.GETFL, 0);
1153-    _ = try posix.fcntl(master_fd, posix.F.SETFL, flags | @as(u32, 0o4000));
1154-    return master_fd;
1155-}
1156-
1157-fn detectShell() [:0]const u8 {
1158-    return std.posix.getenv("SHELL") orelse "/bin/sh";
1159-}
1160-
1161-fn seshPrefix() []const u8 {
1162-    return std.posix.getenv("ZMX_SESSION_PREFIX") orelse "";
1163-}
1164-
1165-fn getSeshName(alloc: std.mem.Allocator, sesh: []const u8) ![]const u8 {
1166-    const prefix = seshPrefix();
1167-    if (std.mem.eql(u8, prefix, "") and std.mem.eql(u8, sesh, "")) {
1168-        return error.SessionNameRequired;
1169-    }
1170-    return std.fmt.allocPrint(alloc, "{s}{s}", .{ seshPrefix(), sesh });
1171-}
1172-
1173-fn sessionConnect(sesh: []const u8) !i32 {
1174-    var unix_addr = try std.net.Address.initUnix(sesh);
1175-    const socket_fd = try posix.socket(posix.AF.UNIX, posix.SOCK.STREAM | posix.SOCK.CLOEXEC, 0);
1176-    errdefer posix.close(socket_fd);
1177-    try posix.connect(socket_fd, &unix_addr.any, unix_addr.getOsSockLen());
1178-    return socket_fd;
1179-}
1180-
1181-const SessionProbeError = error{
1182-    Timeout,
1183-    ConnectionRefused,
1184-    Unexpected,
1185-};
1186-
1187-const SessionProbeResult = struct {
1188-    fd: i32,
1189-    info: ipc.Info,
1190-};
1191-
1192-fn probeSession(alloc: std.mem.Allocator, socket_path: []const u8) SessionProbeError!SessionProbeResult {
1193-    const timeout_ms = 1000;
1194-    const fd = sessionConnect(socket_path) catch |err| switch (err) {
1195-        error.ConnectionRefused => return error.ConnectionRefused,
1196-        else => return error.Unexpected,
1197-    };
1198-    errdefer posix.close(fd);
1199-
1200-    ipc.send(fd, .Info, "") catch return error.Unexpected;
1201-
1202-    var poll_fds = [_]posix.pollfd{.{ .fd = fd, .events = posix.POLL.IN, .revents = 0 }};
1203-    const poll_result = posix.poll(&poll_fds, timeout_ms) catch return error.Unexpected;
1204-    if (poll_result == 0) {
1205-        return error.Timeout;
1206-    }
1207-
1208-    var sb = ipc.SocketBuffer.init(alloc) catch return error.Unexpected;
1209-    defer sb.deinit();
1210-
1211-    const n = sb.read(fd) catch return error.Unexpected;
1212-    if (n == 0) return error.Unexpected;
1213-
1214-    while (sb.next()) |msg| {
1215-        if (msg.header.tag == .Info) {
1216-            if (msg.payload.len == @sizeOf(ipc.Info)) {
1217-                return .{
1218-                    .fd = fd,
1219-                    .info = std.mem.bytesToValue(ipc.Info, msg.payload[0..@sizeOf(ipc.Info)]),
1220-                };
1221-            }
1222-        }
1223-    }
1224-    return error.Unexpected;
1225-}
1226-
1227-fn cleanupStaleSocket(dir: std.fs.Dir, session_name: []const u8) void {
1228-    std.log.warn("stale socket found, cleaning up session={s}", .{session_name});
1229-    dir.deleteFile(session_name) catch |err| {
1230-        std.log.warn("failed to delete stale socket err={s}", .{@errorName(err)});
1231-    };
1232-}
1233-
1234-fn sessionExists(dir: std.fs.Dir, name: []const u8) !bool {
1235-    const stat = dir.statFile(name) catch |err| switch (err) {
1236-        error.FileNotFound => return false,
1237-        else => return err,
1238-    };
1239-    if (stat.kind != .unix_domain_socket) {
1240-        return error.FileNotUnixSocket;
1241-    }
1242-    return true;
1243-}
1244-
1245-fn createSocket(fname: []const u8) !i32 {
1246-    // AF.UNIX: Unix domain socket for local IPC with client processes
1247-    // SOCK.STREAM: Reliable, bidirectional communication
1248-    // SOCK.NONBLOCK: Set socket to non-blocking
1249-    const fd = try posix.socket(posix.AF.UNIX, posix.SOCK.STREAM | posix.SOCK.NONBLOCK | posix.SOCK.CLOEXEC, 0);
1250-    errdefer posix.close(fd);
1251-
1252-    var unix_addr = try std.net.Address.initUnix(fname);
1253-    try posix.bind(fd, &unix_addr.any, unix_addr.getOsSockLen());
1254-    try posix.listen(fd, 128);
1255-    return fd;
1256-}
1257-
1258-pub fn getSocketPath(alloc: std.mem.Allocator, socket_dir: []const u8, session_name: []const u8) ![]const u8 {
1259-    const dir = socket_dir;
1260-    const fname = try alloc.alloc(u8, dir.len + session_name.len + 1);
1261-    @memcpy(fname[0..dir.len], dir);
1262-    @memcpy(fname[dir.len .. dir.len + 1], "/");
1263-    @memcpy(fname[dir.len + 1 ..], session_name);
1264-    return fname;
1265-}
1266-
1267 fn handleSigwinch(_: i32, _: *const posix.siginfo_t, _: ?*anyopaque) callconv(.c) void {
1268     sigwinch_received.store(true, .release);
1269 }
1270@@ -1730,320 +1398,3 @@ fn setupSigtermHandler() void {
1271     };
1272     posix.sigaction(posix.SIG.TERM, &act, null);
1273 }
1274-
1275-fn getTerminalSize(fd: i32) ipc.Resize {
1276-    var ws: c.struct_winsize = undefined;
1277-    if (c.ioctl(fd, c.TIOCGWINSZ, &ws) == 0 and ws.ws_row > 0 and ws.ws_col > 0) {
1278-        return .{ .rows = ws.ws_row, .cols = ws.ws_col };
1279-    }
1280-    return .{ .rows = 24, .cols = 80 };
1281-}
1282-
1283-/// Formats a session entry for list output (only the name when `short` is
1284-/// true), adding a prefix to indicate the current session, if there is one.
1285-fn writeSessionLine(writer: *std.Io.Writer, session: SessionEntry, short: bool, current_session: ?[]const u8) !void {
1286-    const prefix = if (current_session) |current|
1287-        if (std.mem.eql(u8, current, session.name)) current_arrow ++ " " else "  "
1288-    else
1289-        "";
1290-
1291-    if (short) {
1292-        if (session.is_error) return;
1293-        try writer.print("{s}\n", .{session.name});
1294-        return;
1295-    }
1296-
1297-    if (session.is_error) {
1298-        try writer.print("{s}name={s}\terr={s}\tstatus=cleaning up\n", .{
1299-            prefix,
1300-            session.name,
1301-            session.error_name.?,
1302-        });
1303-        return;
1304-    }
1305-
1306-    try writer.print("{s}name={s}\tpid={d}\tclients={d}\tcreated={d}", .{
1307-        prefix,
1308-        session.name,
1309-        session.pid.?,
1310-        session.clients_len.?,
1311-        session.created_at,
1312-    });
1313-    if (session.cwd) |cwd| {
1314-        try writer.print("\tstart_dir={s}", .{cwd});
1315-    }
1316-    if (session.cmd) |cmd| {
1317-        try writer.print("\tcmd={s}", .{cmd});
1318-    }
1319-    if (session.task_ended_at) |ended_at| {
1320-        if (ended_at > 0) {
1321-            try writer.print("\tended={d}", .{ended_at});
1322-
1323-            if (session.task_exit_code) |exit_code| {
1324-                try writer.print("\texit_code={d}", .{exit_code});
1325-            }
1326-        }
1327-    }
1328-    try writer.print("\n", .{});
1329-}
1330-
1331-/// Detects Kitty keyboard protocol escape sequence for Ctrl+\
1332-/// 92 = backslash, 5 = ctrl modifier, :1 = key press event
1333-fn isKittyCtrlBackslash(buf: []const u8) bool {
1334-    return std.mem.indexOf(u8, buf, "\x1b[92;5u") != null or
1335-        std.mem.indexOf(u8, buf, "\x1b[92;5:1u") != null;
1336-}
1337-
1338-fn serializeTerminalState(alloc: std.mem.Allocator, term: *ghostty_vt.Terminal) ?[]const u8 {
1339-    var builder: std.Io.Writer.Allocating = .init(alloc);
1340-    defer builder.deinit();
1341-
1342-    var term_formatter = ghostty_vt.formatter.TerminalFormatter.init(term, .vt);
1343-    term_formatter.content = .{ .selection = null };
1344-    term_formatter.extra = .{
1345-        .palette = false,
1346-        .modes = true,
1347-        .scrolling_region = true,
1348-        .tabstops = false, // tabstop restoration moves cursor after CUP, corrupting position
1349-        .pwd = true,
1350-        .keyboard = true,
1351-        .screen = .all,
1352-    };
1353-
1354-    term_formatter.format(&builder.writer) catch |err| {
1355-        std.log.warn("failed to format terminal state err={s}", .{@errorName(err)});
1356-        return null;
1357-    };
1358-
1359-    const output = builder.writer.buffered();
1360-    if (output.len == 0) return null;
1361-
1362-    return alloc.dupe(u8, output) catch |err| {
1363-        std.log.warn("failed to allocate terminal state err={s}", .{@errorName(err)});
1364-        return null;
1365-    };
1366-}
1367-
1368-fn serializeTerminal(alloc: std.mem.Allocator, term: *ghostty_vt.Terminal, format: HistoryFormat) ?[]const u8 {
1369-    var builder: std.Io.Writer.Allocating = .init(alloc);
1370-    defer builder.deinit();
1371-
1372-    const opts: ghostty_vt.formatter.Options = switch (format) {
1373-        .plain => .plain,
1374-        .vt => .vt,
1375-        .html => .html,
1376-    };
1377-    var term_formatter = ghostty_vt.formatter.TerminalFormatter.init(term, opts);
1378-    term_formatter.content = .{ .selection = null };
1379-    term_formatter.extra = switch (format) {
1380-        .plain => .none,
1381-        .vt => .{
1382-            .palette = false,
1383-            .modes = true,
1384-            .scrolling_region = true,
1385-            .tabstops = false,
1386-            .pwd = true,
1387-            .keyboard = true,
1388-            .screen = .all,
1389-        },
1390-        .html => .styles,
1391-    };
1392-
1393-    term_formatter.format(&builder.writer) catch |err| {
1394-        std.log.warn("failed to format terminal err={s}", .{@errorName(err)});
1395-        return null;
1396-    };
1397-
1398-    const output = builder.writer.buffered();
1399-    if (output.len == 0) return null;
1400-
1401-    return alloc.dupe(u8, output) catch |err| {
1402-        std.log.warn("failed to allocate terminal output err={s}", .{@errorName(err)});
1403-        return null;
1404-    };
1405-}
1406-
1407-test "shellNeedsQuoting" {
1408-    try std.testing.expect(shellNeedsQuoting(""));
1409-    try std.testing.expect(shellNeedsQuoting("hello world"));
1410-    try std.testing.expect(shellNeedsQuoting("hello!"));
1411-    try std.testing.expect(shellNeedsQuoting("$PATH"));
1412-    try std.testing.expect(shellNeedsQuoting("it's"));
1413-    try std.testing.expect(shellNeedsQuoting("a|b"));
1414-    try std.testing.expect(shellNeedsQuoting("a;b"));
1415-    try std.testing.expect(!shellNeedsQuoting("hello"));
1416-    try std.testing.expect(!shellNeedsQuoting("bash"));
1417-    try std.testing.expect(!shellNeedsQuoting("-c"));
1418-    try std.testing.expect(!shellNeedsQuoting("/usr/bin/env"));
1419-}
1420-
1421-test "shellQuote" {
1422-    const alloc = std.testing.allocator;
1423-
1424-    const empty = try shellQuote(alloc, "");
1425-    defer alloc.free(empty);
1426-    try std.testing.expectEqualStrings("''", empty);
1427-
1428-    const space = try shellQuote(alloc, "hello world");
1429-    defer alloc.free(space);
1430-    try std.testing.expectEqualStrings("'hello world'", space);
1431-
1432-    const bang = try shellQuote(alloc, "hello!");
1433-    defer alloc.free(bang);
1434-    try std.testing.expectEqualStrings("'hello!'", bang);
1435-
1436-    const dollar = try shellQuote(alloc, "$PATH");
1437-    defer alloc.free(dollar);
1438-    try std.testing.expectEqualStrings("'$PATH'", dollar);
1439-
1440-    const sq = try shellQuote(alloc, "it's");
1441-    defer alloc.free(sq);
1442-    try std.testing.expectEqualStrings("'it'\\''s'", sq);
1443-
1444-    const dq = try shellQuote(alloc, "say \"hi\"");
1445-    defer alloc.free(dq);
1446-    try std.testing.expectEqualStrings("'say \"hi\"'", dq);
1447-
1448-    const both = try shellQuote(alloc, "it's \"cool\"");
1449-    defer alloc.free(both);
1450-    try std.testing.expectEqualStrings("'it'\\''s \"cool\"'", both);
1451-
1452-    // just a single quote
1453-    const lone_sq = try shellQuote(alloc, "'");
1454-    defer alloc.free(lone_sq);
1455-    try std.testing.expectEqualStrings("''\\'''", lone_sq);
1456-
1457-    // multiple consecutive single quotes
1458-    const triple_sq = try shellQuote(alloc, "'''");
1459-    defer alloc.free(triple_sq);
1460-    try std.testing.expectEqualStrings("''\\'''\\'''\\'''", triple_sq);
1461-
1462-    // backtick command substitution
1463-    const backtick = try shellQuote(alloc, "`whoami`");
1464-    defer alloc.free(backtick);
1465-    try std.testing.expectEqualStrings("'`whoami`'", backtick);
1466-
1467-    // dollar command substitution
1468-    const dollar_cmd = try shellQuote(alloc, "$(whoami)");
1469-    defer alloc.free(dollar_cmd);
1470-    try std.testing.expectEqualStrings("'$(whoami)'", dollar_cmd);
1471-
1472-    // glob
1473-    const glob = try shellQuote(alloc, "*.txt");
1474-    defer alloc.free(glob);
1475-    try std.testing.expectEqualStrings("'*.txt'", glob);
1476-
1477-    // tilde
1478-    const tilde = try shellQuote(alloc, "~/file");
1479-    defer alloc.free(tilde);
1480-    try std.testing.expectEqualStrings("'~/file'", tilde);
1481-
1482-    // trailing backslash
1483-    const trailing_bs = try shellQuote(alloc, "path\\");
1484-    defer alloc.free(trailing_bs);
1485-    try std.testing.expectEqualStrings("'path\\'", trailing_bs);
1486-
1487-    // semicolon (command injection)
1488-    const semi = try shellQuote(alloc, "; rm -rf /");
1489-    defer alloc.free(semi);
1490-    try std.testing.expectEqualStrings("'; rm -rf /'", semi);
1491-
1492-    // embedded newline
1493-    const newline = try shellQuote(alloc, "line1\nline2");
1494-    defer alloc.free(newline);
1495-    try std.testing.expectEqualStrings("'line1\nline2'", newline);
1496-
1497-    // parentheses (subshell)
1498-    const parens = try shellQuote(alloc, "(echo hi)");
1499-    defer alloc.free(parens);
1500-    try std.testing.expectEqualStrings("'(echo hi)'", parens);
1501-
1502-    // heredoc marker
1503-    const heredoc = try shellQuote(alloc, "<<EOF");
1504-    defer alloc.free(heredoc);
1505-    try std.testing.expectEqualStrings("'<<EOF'", heredoc);
1506-
1507-    // no quoting needed -- plain word should still be quoted
1508-    // (shellQuote is only called when shellNeedsQuoting returns true,
1509-    // but verify it produces valid output anyway)
1510-    const plain = try shellQuote(alloc, "hello");
1511-    defer alloc.free(plain);
1512-    try std.testing.expectEqualStrings("'hello'", plain);
1513-}
1514-
1515-test "isKittyCtrlBackslash" {
1516-    try std.testing.expect(isKittyCtrlBackslash("\x1b[92;5u"));
1517-    try std.testing.expect(isKittyCtrlBackslash("\x1b[92;5:1u"));
1518-    try std.testing.expect(!isKittyCtrlBackslash("\x1b[92;5:3u"));
1519-    try std.testing.expect(!isKittyCtrlBackslash("\x1b[92;1u"));
1520-    try std.testing.expect(!isKittyCtrlBackslash("garbage"));
1521-}
1522-
1523-test "writeSessionLine formats output for current session and short output" {
1524-    const Case = struct {
1525-        session: SessionEntry,
1526-        short: bool,
1527-        current_session: ?[]const u8,
1528-        expected: []const u8,
1529-    };
1530-
1531-    const session = SessionEntry{
1532-        .name = "dev",
1533-        .pid = 123,
1534-        .clients_len = 2,
1535-        .is_error = false,
1536-        .error_name = null,
1537-        .cmd = null,
1538-        .cwd = null,
1539-        .created_at = 0,
1540-        .task_ended_at = null,
1541-        .task_exit_code = null,
1542-    };
1543-
1544-    const cases = [_]Case{
1545-        .{
1546-            .session = session,
1547-            .short = false,
1548-            .current_session = "dev",
1549-            .expected = "→ name=dev\tpid=123\tclients=2\tcreated=0\n",
1550-        },
1551-        .{
1552-            .session = session,
1553-            .short = false,
1554-            .current_session = "other",
1555-            .expected = "  name=dev\tpid=123\tclients=2\tcreated=0\n",
1556-        },
1557-        .{
1558-            .session = session,
1559-            .short = false,
1560-            .current_session = null,
1561-            .expected = "name=dev\tpid=123\tclients=2\tcreated=0\n",
1562-        },
1563-        .{
1564-            .session = session,
1565-            .short = true,
1566-            .current_session = "dev",
1567-            .expected = "dev\n",
1568-        },
1569-        .{
1570-            .session = session,
1571-            .short = true,
1572-            .current_session = "other",
1573-            .expected = "dev\n",
1574-        },
1575-        .{
1576-            .session = session,
1577-            .short = true,
1578-            .current_session = null,
1579-            .expected = "dev\n",
1580-        },
1581-    };
1582-
1583-    for (cases) |case| {
1584-        var builder: std.Io.Writer.Allocating = .init(std.testing.allocator);
1585-        defer builder.deinit();
1586-
1587-        try writeSessionLine(&builder.writer, case.session, case.short, case.current_session);
1588-        try std.testing.expectEqualStrings(case.expected, builder.writer.buffered());
1589-    }
1590-}
A src/socket.zig
+62, -0
 1@@ -0,0 +1,62 @@
 2+const std = @import("std");
 3+const posix = std.posix;
 4+
 5+pub fn seshPrefix() []const u8 {
 6+    return std.posix.getenv("ZMX_SESSION_PREFIX") orelse "";
 7+}
 8+
 9+pub fn getSeshName(alloc: std.mem.Allocator, sesh: []const u8) ![]const u8 {
10+    const prefix = seshPrefix();
11+    if (std.mem.eql(u8, prefix, "") and std.mem.eql(u8, sesh, "")) {
12+        return error.SessionNameRequired;
13+    }
14+    return std.fmt.allocPrint(alloc, "{s}{s}", .{ seshPrefix(), sesh });
15+}
16+
17+pub fn sessionConnect(sesh: []const u8) !i32 {
18+    var unix_addr = try std.net.Address.initUnix(sesh);
19+    const socket_fd = try posix.socket(posix.AF.UNIX, posix.SOCK.STREAM | posix.SOCK.CLOEXEC, 0);
20+    errdefer posix.close(socket_fd);
21+    try posix.connect(socket_fd, &unix_addr.any, unix_addr.getOsSockLen());
22+    return socket_fd;
23+}
24+
25+pub fn cleanupStaleSocket(dir: std.fs.Dir, session_name: []const u8) void {
26+    std.log.warn("stale socket found, cleaning up session={s}", .{session_name});
27+    dir.deleteFile(session_name) catch |err| {
28+        std.log.warn("failed to delete stale socket err={s}", .{@errorName(err)});
29+    };
30+}
31+
32+pub fn sessionExists(dir: std.fs.Dir, name: []const u8) !bool {
33+    const stat = dir.statFile(name) catch |err| switch (err) {
34+        error.FileNotFound => return false,
35+        else => return err,
36+    };
37+    if (stat.kind != .unix_domain_socket) {
38+        return error.FileNotUnixSocket;
39+    }
40+    return true;
41+}
42+
43+pub fn createSocket(fname: []const u8) !i32 {
44+    // AF.UNIX: Unix domain socket for local IPC with client processes
45+    // SOCK.STREAM: Reliable, bidirectional communication
46+    // SOCK.NONBLOCK: Set socket to non-blocking
47+    const fd = try posix.socket(posix.AF.UNIX, posix.SOCK.STREAM | posix.SOCK.NONBLOCK | posix.SOCK.CLOEXEC, 0);
48+    errdefer posix.close(fd);
49+
50+    var unix_addr = try std.net.Address.initUnix(fname);
51+    try posix.bind(fd, &unix_addr.any, unix_addr.getOsSockLen());
52+    try posix.listen(fd, 128);
53+    return fd;
54+}
55+
56+pub fn getSocketPath(alloc: std.mem.Allocator, socket_dir: []const u8, session_name: []const u8) ![]const u8 {
57+    const dir = socket_dir;
58+    const fname = try alloc.alloc(u8, dir.len + session_name.len + 1);
59+    @memcpy(fname[0..dir.len], dir);
60+    @memcpy(fname[dir.len .. dir.len + 1], "/");
61+    @memcpy(fname[dir.len + 1 ..], session_name);
62+    return fname;
63+}
A src/util.zig
+511, -0
  1@@ -0,0 +1,511 @@
  2+const std = @import("std");
  3+const posix = std.posix;
  4+const ghostty_vt = @import("ghostty-vt");
  5+const ipc = @import("ipc.zig");
  6+const socket = @import("socket.zig");
  7+
  8+pub const SessionEntry = struct {
  9+    name: []const u8,
 10+    pid: ?i32,
 11+    clients_len: ?usize,
 12+    is_error: bool,
 13+    error_name: ?[]const u8,
 14+    cmd: ?[]const u8 = null,
 15+    cwd: ?[]const u8 = null,
 16+    created_at: u64,
 17+    task_ended_at: ?u64,
 18+    task_exit_code: ?u8,
 19+
 20+    pub fn deinit(self: SessionEntry, alloc: std.mem.Allocator) void {
 21+        alloc.free(self.name);
 22+        if (self.cmd) |cmd| alloc.free(cmd);
 23+        if (self.cwd) |cwd| alloc.free(cwd);
 24+    }
 25+
 26+    pub fn lessThan(_: void, a: SessionEntry, b: SessionEntry) bool {
 27+        return std.mem.order(u8, a.name, b.name) == .lt;
 28+    }
 29+};
 30+
 31+pub fn get_session_entries(alloc: std.mem.Allocator, socket_dir: []const u8) !std.ArrayList(SessionEntry) {
 32+    var dir = try std.fs.openDirAbsolute(socket_dir, .{ .iterate = true });
 33+    defer dir.close();
 34+    var iter = dir.iterate();
 35+
 36+    var sessions = try std.ArrayList(SessionEntry).initCapacity(alloc, 30);
 37+
 38+    while (try iter.next()) |entry| {
 39+        const exists = socket.sessionExists(dir, entry.name) catch continue;
 40+        if (exists) {
 41+            const name = try alloc.dupe(u8, entry.name);
 42+            errdefer alloc.free(name);
 43+
 44+            const socket_path = try socket.getSocketPath(alloc, socket_dir, entry.name);
 45+            defer alloc.free(socket_path);
 46+
 47+            const result = ipc.probeSession(alloc, socket_path) catch |err| {
 48+                try sessions.append(alloc, .{
 49+                    .name = name,
 50+                    .pid = null,
 51+                    .clients_len = null,
 52+                    .is_error = true,
 53+                    .error_name = @errorName(err),
 54+                    .created_at = 0,
 55+                    .task_exit_code = 1,
 56+                    .task_ended_at = 0,
 57+                });
 58+                socket.cleanupStaleSocket(dir, entry.name);
 59+                continue;
 60+            };
 61+            posix.close(result.fd);
 62+
 63+            // Extract cmd and cwd from the fixed-size arrays
 64+            const cmd: ?[]const u8 = if (result.info.cmd_len > 0)
 65+                alloc.dupe(u8, result.info.cmd[0..result.info.cmd_len]) catch null
 66+            else
 67+                null;
 68+            const cwd: ?[]const u8 = if (result.info.cwd_len > 0)
 69+                alloc.dupe(u8, result.info.cwd[0..result.info.cwd_len]) catch null
 70+            else
 71+                null;
 72+
 73+            try sessions.append(alloc, .{
 74+                .name = name,
 75+                .pid = result.info.pid,
 76+                .clients_len = result.info.clients_len,
 77+                .is_error = false,
 78+                .error_name = null,
 79+                .cmd = cmd,
 80+                .cwd = cwd,
 81+                .created_at = result.info.created_at,
 82+                .task_ended_at = result.info.task_ended_at,
 83+                .task_exit_code = result.info.task_exit_code,
 84+            });
 85+        }
 86+    }
 87+
 88+    return sessions;
 89+}
 90+
 91+pub fn shellNeedsQuoting(arg: []const u8) bool {
 92+    if (arg.len == 0) return true;
 93+    for (arg) |ch| {
 94+        switch (ch) {
 95+            ' ', '\t', '"', '\'', '\\', '$', '`', '!', '(', ')', '{', '}', '[', ']', '|', '&', ';', '<', '>', '?', '*', '~', '#', '\n' => return true,
 96+            else => {},
 97+        }
 98+    }
 99+    return false;
100+}
101+
102+pub fn shellQuote(alloc: std.mem.Allocator, arg: []const u8) ![]u8 {
103+    // Always use single quotes (like Python's shlex.quote). Inside single
104+    // quotes nothing is special except ' itself, which we handle with the
105+    // '\'' trick (end quote, escaped literal quote, reopen quote).
106+    var len: usize = 2;
107+    for (arg) |ch| {
108+        len += if (ch == '\'') 4 else 1;
109+    }
110+    const buf = try alloc.alloc(u8, len);
111+    var i: usize = 0;
112+    buf[i] = '\'';
113+    i += 1;
114+    for (arg) |ch| {
115+        if (ch == '\'') {
116+            @memcpy(buf[i..][0..4], "'\\''");
117+            i += 4;
118+        } else {
119+            buf[i] = ch;
120+            i += 1;
121+        }
122+    }
123+    buf[i] = '\'';
124+    return buf;
125+}
126+
127+const DA1_QUERY = "\x1b[c";
128+const DA1_QUERY_EXPLICIT = "\x1b[0c";
129+const DA2_QUERY = "\x1b[>c";
130+const DA2_QUERY_EXPLICIT = "\x1b[>0c";
131+const DA1_RESPONSE = "\x1b[?62;22c";
132+const DA2_RESPONSE = "\x1b[>1;10;0c";
133+
134+pub fn respondToDeviceAttributes(pty_fd: i32, data: []const u8) void {
135+    // Scan for DA queries in PTY output and respond on behalf of the terminal.
136+    // This handles the case where no client is attached (e.g. zmx run)
137+    // and the shell (e.g. fish) sends a DA query that would otherwise go unanswered.
138+    //
139+    // DA1 query: ESC [ c  or  ESC [ 0 c
140+    // DA2 query: ESC [ > c  or  ESC [ > 0 c
141+    // DA1 response (from terminal): ESC [ ? ... c  (has '?' after '[')
142+    //
143+    // We must NOT match DA responses (which contain '?') as queries.
144+    var i: usize = 0;
145+    while (i < data.len) {
146+        if (data[i] == '\x1b' and i + 1 < data.len and data[i + 1] == '[') {
147+            // Skip DA responses which have '?' after CSI
148+            if (i + 2 < data.len and data[i + 2] == '?') {
149+                i += 3;
150+                continue;
151+            }
152+            if (matchSeq(data[i..], DA2_QUERY) or matchSeq(data[i..], DA2_QUERY_EXPLICIT)) {
153+                _ = posix.write(pty_fd, DA2_RESPONSE) catch {};
154+            } else if (matchSeq(data[i..], DA1_QUERY) or matchSeq(data[i..], DA1_QUERY_EXPLICIT)) {
155+                _ = posix.write(pty_fd, DA1_RESPONSE) catch {};
156+            }
157+        }
158+        i += 1;
159+    }
160+}
161+
162+fn matchSeq(data: []const u8, seq: []const u8) bool {
163+    if (data.len < seq.len) return false;
164+    return std.mem.eql(u8, data[0..seq.len], seq);
165+}
166+
167+pub fn findTaskExitMarker(output: []const u8) ?u8 {
168+    const marker = "ZMX_TASK_COMPLETED:";
169+
170+    // Search for marker in output
171+    if (std.mem.indexOf(u8, output, marker)) |idx| {
172+        const after_marker = output[idx + marker.len ..];
173+
174+        // Find the exit code number and newline
175+        var end_idx: usize = 0;
176+        while (end_idx < after_marker.len and after_marker[end_idx] != '\n' and after_marker[end_idx] != '\r') {
177+            end_idx += 1;
178+        }
179+
180+        const exit_code_str = after_marker[0..end_idx];
181+
182+        // Parse exit code
183+        if (std.fmt.parseInt(u8, exit_code_str, 10)) |exit_code| {
184+            return exit_code;
185+        } else |_| {
186+            std.log.warn("failed to parse task exit code from: {s}", .{exit_code_str});
187+            return null;
188+        }
189+    }
190+
191+    return null;
192+}
193+
194+/// Detects Kitty keyboard protocol escape sequence for Ctrl+\
195+/// 92 = backslash, 5 = ctrl modifier, :1 = key press event
196+pub fn isKittyCtrlBackslash(buf: []const u8) bool {
197+    return std.mem.indexOf(u8, buf, "\x1b[92;5u") != null or
198+        std.mem.indexOf(u8, buf, "\x1b[92;5:1u") != null;
199+}
200+
201+pub fn serializeTerminalState(alloc: std.mem.Allocator, term: *ghostty_vt.Terminal) ?[]const u8 {
202+    var builder: std.Io.Writer.Allocating = .init(alloc);
203+    defer builder.deinit();
204+
205+    var term_formatter = ghostty_vt.formatter.TerminalFormatter.init(term, .vt);
206+    term_formatter.content = .{ .selection = null };
207+    term_formatter.extra = .{
208+        .palette = false,
209+        .modes = true,
210+        .scrolling_region = true,
211+        .tabstops = false, // tabstop restoration moves cursor after CUP, corrupting position
212+        .pwd = true,
213+        .keyboard = true,
214+        .screen = .all,
215+    };
216+
217+    term_formatter.format(&builder.writer) catch |err| {
218+        std.log.warn("failed to format terminal state err={s}", .{@errorName(err)});
219+        return null;
220+    };
221+
222+    const output = builder.writer.buffered();
223+    if (output.len == 0) return null;
224+
225+    return alloc.dupe(u8, output) catch |err| {
226+        std.log.warn("failed to allocate terminal state err={s}", .{@errorName(err)});
227+        return null;
228+    };
229+}
230+
231+pub const HistoryFormat = enum(u8) {
232+    plain = 0,
233+    vt = 1,
234+    html = 2,
235+};
236+
237+pub fn serializeTerminal(alloc: std.mem.Allocator, term: *ghostty_vt.Terminal, format: HistoryFormat) ?[]const u8 {
238+    var builder: std.Io.Writer.Allocating = .init(alloc);
239+    defer builder.deinit();
240+
241+    const opts: ghostty_vt.formatter.Options = switch (format) {
242+        .plain => .plain,
243+        .vt => .vt,
244+        .html => .html,
245+    };
246+    var term_formatter = ghostty_vt.formatter.TerminalFormatter.init(term, opts);
247+    term_formatter.content = .{ .selection = null };
248+    term_formatter.extra = switch (format) {
249+        .plain => .none,
250+        .vt => .{
251+            .palette = false,
252+            .modes = true,
253+            .scrolling_region = true,
254+            .tabstops = false,
255+            .pwd = true,
256+            .keyboard = true,
257+            .screen = .all,
258+        },
259+        .html => .styles,
260+    };
261+
262+    term_formatter.format(&builder.writer) catch |err| {
263+        std.log.warn("failed to format terminal err={s}", .{@errorName(err)});
264+        return null;
265+    };
266+
267+    const output = builder.writer.buffered();
268+    if (output.len == 0) return null;
269+
270+    return alloc.dupe(u8, output) catch |err| {
271+        std.log.warn("failed to allocate terminal output err={s}", .{@errorName(err)});
272+        return null;
273+    };
274+}
275+
276+pub fn detectShell() [:0]const u8 {
277+    return std.posix.getenv("SHELL") orelse "/bin/sh";
278+}
279+
280+/// Formats a session entry for list output (only the name when `short` is
281+/// true), adding a prefix to indicate the current session, if there is one.
282+pub fn writeSessionLine(writer: *std.Io.Writer, session: SessionEntry, short: bool, current_session: ?[]const u8) !void {
283+    const current_arrow = "→";
284+    const prefix = if (current_session) |current|
285+        if (std.mem.eql(u8, current, session.name)) current_arrow ++ " " else "  "
286+    else
287+        "";
288+
289+    if (short) {
290+        if (session.is_error) return;
291+        try writer.print("{s}\n", .{session.name});
292+        return;
293+    }
294+
295+    if (session.is_error) {
296+        try writer.print("{s}name={s}\terr={s}\tstatus=cleaning up\n", .{
297+            prefix,
298+            session.name,
299+            session.error_name.?,
300+        });
301+        return;
302+    }
303+
304+    try writer.print("{s}name={s}\tpid={d}\tclients={d}\tcreated={d}", .{
305+        prefix,
306+        session.name,
307+        session.pid.?,
308+        session.clients_len.?,
309+        session.created_at,
310+    });
311+    if (session.cwd) |cwd| {
312+        try writer.print("\tstart_dir={s}", .{cwd});
313+    }
314+    if (session.cmd) |cmd| {
315+        try writer.print("\tcmd={s}", .{cmd});
316+    }
317+    if (session.task_ended_at) |ended_at| {
318+        if (ended_at > 0) {
319+            try writer.print("\tended={d}", .{ended_at});
320+
321+            if (session.task_exit_code) |exit_code| {
322+                try writer.print("\texit_code={d}", .{exit_code});
323+            }
324+        }
325+    }
326+    try writer.print("\n", .{});
327+}
328+
329+test "writeSessionLine formats output for current session and short output" {
330+    const Case = struct {
331+        session: SessionEntry,
332+        short: bool,
333+        current_session: ?[]const u8,
334+        expected: []const u8,
335+    };
336+
337+    const session = SessionEntry{
338+        .name = "dev",
339+        .pid = 123,
340+        .clients_len = 2,
341+        .is_error = false,
342+        .error_name = null,
343+        .cmd = null,
344+        .cwd = null,
345+        .created_at = 0,
346+        .task_ended_at = null,
347+        .task_exit_code = null,
348+    };
349+
350+    const cases = [_]Case{
351+        .{
352+            .session = session,
353+            .short = false,
354+            .current_session = "dev",
355+            .expected = "→ name=dev\tpid=123\tclients=2\tcreated=0\n",
356+        },
357+        .{
358+            .session = session,
359+            .short = false,
360+            .current_session = "other",
361+            .expected = "  name=dev\tpid=123\tclients=2\tcreated=0\n",
362+        },
363+        .{
364+            .session = session,
365+            .short = false,
366+            .current_session = null,
367+            .expected = "name=dev\tpid=123\tclients=2\tcreated=0\n",
368+        },
369+        .{
370+            .session = session,
371+            .short = true,
372+            .current_session = "dev",
373+            .expected = "dev\n",
374+        },
375+        .{
376+            .session = session,
377+            .short = true,
378+            .current_session = "other",
379+            .expected = "dev\n",
380+        },
381+        .{
382+            .session = session,
383+            .short = true,
384+            .current_session = null,
385+            .expected = "dev\n",
386+        },
387+    };
388+
389+    for (cases) |case| {
390+        var builder: std.Io.Writer.Allocating = .init(std.testing.allocator);
391+        defer builder.deinit();
392+
393+        try writeSessionLine(&builder.writer, case.session, case.short, case.current_session);
394+        try std.testing.expectEqualStrings(case.expected, builder.writer.buffered());
395+    }
396+}
397+
398+test "shellNeedsQuoting" {
399+    try std.testing.expect(shellNeedsQuoting(""));
400+    try std.testing.expect(shellNeedsQuoting("hello world"));
401+    try std.testing.expect(shellNeedsQuoting("hello!"));
402+    try std.testing.expect(shellNeedsQuoting("$PATH"));
403+    try std.testing.expect(shellNeedsQuoting("it's"));
404+    try std.testing.expect(shellNeedsQuoting("a|b"));
405+    try std.testing.expect(shellNeedsQuoting("a;b"));
406+    try std.testing.expect(!shellNeedsQuoting("hello"));
407+    try std.testing.expect(!shellNeedsQuoting("bash"));
408+    try std.testing.expect(!shellNeedsQuoting("-c"));
409+    try std.testing.expect(!shellNeedsQuoting("/usr/bin/env"));
410+}
411+
412+test "shellQuote" {
413+    const alloc = std.testing.allocator;
414+
415+    const empty = try shellQuote(alloc, "");
416+    defer alloc.free(empty);
417+    try std.testing.expectEqualStrings("''", empty);
418+
419+    const space = try shellQuote(alloc, "hello world");
420+    defer alloc.free(space);
421+    try std.testing.expectEqualStrings("'hello world'", space);
422+
423+    const bang = try shellQuote(alloc, "hello!");
424+    defer alloc.free(bang);
425+    try std.testing.expectEqualStrings("'hello!'", bang);
426+
427+    const dollar = try shellQuote(alloc, "$PATH");
428+    defer alloc.free(dollar);
429+    try std.testing.expectEqualStrings("'$PATH'", dollar);
430+
431+    const sq = try shellQuote(alloc, "it's");
432+    defer alloc.free(sq);
433+    try std.testing.expectEqualStrings("'it'\\''s'", sq);
434+
435+    const dq = try shellQuote(alloc, "say \"hi\"");
436+    defer alloc.free(dq);
437+    try std.testing.expectEqualStrings("'say \"hi\"'", dq);
438+
439+    const both = try shellQuote(alloc, "it's \"cool\"");
440+    defer alloc.free(both);
441+    try std.testing.expectEqualStrings("'it'\\''s \"cool\"'", both);
442+
443+    // just a single quote
444+    const lone_sq = try shellQuote(alloc, "'");
445+    defer alloc.free(lone_sq);
446+    try std.testing.expectEqualStrings("''\\'''", lone_sq);
447+
448+    // multiple consecutive single quotes
449+    const triple_sq = try shellQuote(alloc, "'''");
450+    defer alloc.free(triple_sq);
451+    try std.testing.expectEqualStrings("''\\'''\\'''\\'''", triple_sq);
452+
453+    // backtick command substitution
454+    const backtick = try shellQuote(alloc, "`whoami`");
455+    defer alloc.free(backtick);
456+    try std.testing.expectEqualStrings("'`whoami`'", backtick);
457+
458+    // dollar command substitution
459+    const dollar_cmd = try shellQuote(alloc, "$(whoami)");
460+    defer alloc.free(dollar_cmd);
461+    try std.testing.expectEqualStrings("'$(whoami)'", dollar_cmd);
462+
463+    // glob
464+    const glob = try shellQuote(alloc, "*.txt");
465+    defer alloc.free(glob);
466+    try std.testing.expectEqualStrings("'*.txt'", glob);
467+
468+    // tilde
469+    const tilde = try shellQuote(alloc, "~/file");
470+    defer alloc.free(tilde);
471+    try std.testing.expectEqualStrings("'~/file'", tilde);
472+
473+    // trailing backslash
474+    const trailing_bs = try shellQuote(alloc, "path\\");
475+    defer alloc.free(trailing_bs);
476+    try std.testing.expectEqualStrings("'path\\'", trailing_bs);
477+
478+    // semicolon (command injection)
479+    const semi = try shellQuote(alloc, "; rm -rf /");
480+    defer alloc.free(semi);
481+    try std.testing.expectEqualStrings("'; rm -rf /'", semi);
482+
483+    // embedded newline
484+    const newline = try shellQuote(alloc, "line1\nline2");
485+    defer alloc.free(newline);
486+    try std.testing.expectEqualStrings("'line1\nline2'", newline);
487+
488+    // parentheses (subshell)
489+    const parens = try shellQuote(alloc, "(echo hi)");
490+    defer alloc.free(parens);
491+    try std.testing.expectEqualStrings("'(echo hi)'", parens);
492+
493+    // heredoc marker
494+    const heredoc = try shellQuote(alloc, "<<EOF");
495+    defer alloc.free(heredoc);
496+    try std.testing.expectEqualStrings("'<<EOF'", heredoc);
497+
498+    // no quoting needed -- plain word should still be quoted
499+    // (shellQuote is only called when shellNeedsQuoting returns true,
500+    // but verify it produces valid output anyway)
501+    const plain = try shellQuote(alloc, "hello");
502+    defer alloc.free(plain);
503+    try std.testing.expectEqualStrings("'hello'", plain);
504+}
505+
506+test "isKittyCtrlBackslash" {
507+    try std.testing.expect(isKittyCtrlBackslash("\x1b[92;5u"));
508+    try std.testing.expect(isKittyCtrlBackslash("\x1b[92;5:1u"));
509+    try std.testing.expect(!isKittyCtrlBackslash("\x1b[92;5:3u"));
510+    try std.testing.expect(!isKittyCtrlBackslash("\x1b[92;1u"));
511+    try std.testing.expect(!isKittyCtrlBackslash("garbage"));
512+}