repos / zmx

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

zmx / src
Eric Bower  ·  2025-12-20

main.zig

   1const std = @import("std");
   2const posix = std.posix;
   3const builtin = @import("builtin");
   4const build_options = @import("build_options");
   5const ghostty_vt = @import("ghostty-vt");
   6const ipc = @import("ipc.zig");
   7const log = @import("log.zig");
   8
   9pub const version = build_options.version;
  10pub const ghostty_version = build_options.ghostty_version;
  11
  12var log_system = log.LogSystem{};
  13
  14pub const std_options: std.Options = .{
  15    .logFn = zmxLogFn,
  16    .log_level = .debug,
  17};
  18
  19fn zmxLogFn(
  20    comptime level: std.log.Level,
  21    comptime scope: @Type(.enum_literal),
  22    comptime format: []const u8,
  23    args: anytype,
  24) void {
  25    log_system.log(level, scope, format, args);
  26}
  27
  28const c = switch (builtin.os.tag) {
  29    .macos => @cImport({
  30        @cInclude("sys/ioctl.h"); // ioctl and constants
  31        @cInclude("termios.h");
  32        @cInclude("stdlib.h");
  33        @cInclude("unistd.h");
  34    }),
  35    .freebsd => @cImport({
  36        @cInclude("termios.h"); // ioctl and constants
  37        @cInclude("libutil.h"); // openpty()
  38        @cInclude("stdlib.h");
  39        @cInclude("unistd.h");
  40    }),
  41    else => @cImport({
  42        @cInclude("sys/ioctl.h"); // ioctl and constants
  43        @cInclude("pty.h");
  44        @cInclude("stdlib.h");
  45        @cInclude("unistd.h");
  46    }),
  47};
  48
  49// Manually declare forkpty for macOS since util.h is not available during cross-compilation
  50const forkpty = if (builtin.os.tag == .macos)
  51    struct {
  52        extern "c" fn forkpty(master_fd: *c_int, name: ?[*:0]u8, termp: ?*const c.struct_termios, winp: ?*const c.struct_winsize) c_int;
  53    }.forkpty
  54else
  55    c.forkpty;
  56
  57var sigwinch_received: std.atomic.Value(bool) = std.atomic.Value(bool).init(false);
  58
  59const Client = struct {
  60    alloc: std.mem.Allocator,
  61    socket_fd: i32,
  62    has_pending_output: bool = false,
  63    read_buf: ipc.SocketBuffer,
  64    write_buf: std.ArrayList(u8),
  65
  66    pub fn deinit(self: *Client) void {
  67        posix.close(self.socket_fd);
  68        self.read_buf.deinit();
  69        self.write_buf.deinit(self.alloc);
  70    }
  71};
  72
  73const Cfg = struct {
  74    socket_dir: []const u8,
  75    log_dir: []const u8,
  76    max_scrollback: usize = 10_000_000,
  77
  78    pub fn init(alloc: std.mem.Allocator) !Cfg {
  79        const tmpdir = posix.getenv("TMPDIR") orelse "/tmp";
  80        const uid = posix.getuid();
  81
  82        const socket_dir: []const u8 = if (posix.getenv("ZMX_DIR")) |zmxdir|
  83            try alloc.dupe(u8, zmxdir)
  84        else if (posix.getenv("XDG_RUNTIME_DIR")) |xdg_runtime|
  85            try std.fmt.allocPrint(alloc, "{s}/zmx", .{xdg_runtime})
  86        else
  87            try std.fmt.allocPrint(alloc, "{s}/zmx-{d}", .{ tmpdir, uid });
  88        errdefer alloc.free(socket_dir);
  89
  90        const log_dir = try std.fmt.allocPrint(alloc, "{s}/logs", .{socket_dir});
  91        errdefer alloc.free(log_dir);
  92
  93        var cfg = Cfg{
  94            .socket_dir = socket_dir,
  95            .log_dir = log_dir,
  96        };
  97
  98        try cfg.mkdir();
  99
 100        return cfg;
 101    }
 102
 103    pub fn deinit(self: *Cfg, alloc: std.mem.Allocator) void {
 104        if (self.socket_dir.len > 0) alloc.free(self.socket_dir);
 105        if (self.log_dir.len > 0) alloc.free(self.log_dir);
 106    }
 107
 108    pub fn mkdir(self: *Cfg) !void {
 109        posix.mkdirat(posix.AT.FDCWD, self.socket_dir, 0o750) catch |err| switch (err) {
 110            error.PathAlreadyExists => {},
 111            else => return err,
 112        };
 113
 114        posix.mkdirat(posix.AT.FDCWD, self.log_dir, 0o750) catch |err| switch (err) {
 115            error.PathAlreadyExists => {},
 116            else => return err,
 117        };
 118    }
 119};
 120
 121const Daemon = struct {
 122    cfg: *Cfg,
 123    alloc: std.mem.Allocator,
 124    clients: std.ArrayList(*Client),
 125    session_name: []const u8,
 126    socket_path: []const u8,
 127    running: bool,
 128    pid: i32,
 129    command: ?[]const []const u8 = null,
 130    has_pty_output: bool = false,
 131    has_had_client: bool = false,
 132
 133    pub fn deinit(self: *Daemon) void {
 134        self.clients.deinit(self.alloc);
 135        self.alloc.free(self.socket_path);
 136    }
 137
 138    pub fn shutdown(self: *Daemon) void {
 139        std.log.info("shutting down daemon session_name={s}", .{self.session_name});
 140        self.running = false;
 141
 142        for (self.clients.items) |client| {
 143            client.deinit();
 144            self.alloc.destroy(client);
 145        }
 146        self.clients.clearRetainingCapacity();
 147    }
 148
 149    pub fn closeClient(self: *Daemon, client: *Client, i: usize, shutdown_on_last: bool) bool {
 150        const fd = client.socket_fd;
 151        client.deinit();
 152        self.alloc.destroy(client);
 153        _ = self.clients.orderedRemove(i);
 154        std.log.info("client disconnected fd={d} remaining={d}", .{ fd, self.clients.items.len });
 155        if (shutdown_on_last and self.clients.items.len == 0) {
 156            self.shutdown();
 157            return true;
 158        }
 159        return false;
 160    }
 161
 162    pub fn handleInput(self: *Daemon, pty_fd: i32, payload: []const u8) !void {
 163        _ = self;
 164        if (payload.len > 0) {
 165            _ = try posix.write(pty_fd, payload);
 166        }
 167    }
 168
 169    pub fn handleInit(
 170        self: *Daemon,
 171        client: *Client,
 172        pty_fd: i32,
 173        term: *ghostty_vt.Terminal,
 174        payload: []const u8,
 175    ) !void {
 176        if (payload.len != @sizeOf(ipc.Resize)) return;
 177
 178        const resize = std.mem.bytesToValue(ipc.Resize, payload);
 179
 180        // Serialize terminal state BEFORE resize to capture correct cursor position.
 181        // Resizing triggers reflow which can move the cursor, and the shell's
 182        // SIGWINCH-triggered redraw will run after our snapshot is sent.
 183        // Only serialize on re-attach (has_had_client), not first attach, to avoid
 184        // interfering with shell initialization (DA1 queries, etc.)
 185        if (self.has_pty_output and self.has_had_client) {
 186            const cursor = &term.screens.active.cursor;
 187            std.log.debug("cursor before serialize: x={d} y={d} pending_wrap={}", .{ cursor.x, cursor.y, cursor.pending_wrap });
 188            if (serializeTerminalState(self.alloc, term)) |term_output| {
 189                std.log.debug("serialize terminal state", .{});
 190                defer self.alloc.free(term_output);
 191                ipc.appendMessage(self.alloc, &client.write_buf, .Output, term_output) catch |err| {
 192                    std.log.warn("failed to buffer terminal state for client err={s}", .{@errorName(err)});
 193                };
 194                client.has_pending_output = true;
 195            }
 196        }
 197
 198        var ws: c.struct_winsize = .{
 199            .ws_row = resize.rows,
 200            .ws_col = resize.cols,
 201            .ws_xpixel = 0,
 202            .ws_ypixel = 0,
 203        };
 204        _ = c.ioctl(pty_fd, c.TIOCSWINSZ, &ws);
 205        try term.resize(self.alloc, resize.cols, resize.rows);
 206
 207        // Mark that we've had a client init, so subsequent clients get terminal state
 208        self.has_had_client = true;
 209
 210        std.log.debug("init resize rows={d} cols={d}", .{ resize.rows, resize.cols });
 211    }
 212
 213    pub fn handleResize(self: *Daemon, pty_fd: i32, term: *ghostty_vt.Terminal, payload: []const u8) !void {
 214        if (payload.len != @sizeOf(ipc.Resize)) return;
 215
 216        const resize = std.mem.bytesToValue(ipc.Resize, payload);
 217        var ws: c.struct_winsize = .{
 218            .ws_row = resize.rows,
 219            .ws_col = resize.cols,
 220            .ws_xpixel = 0,
 221            .ws_ypixel = 0,
 222        };
 223        _ = c.ioctl(pty_fd, c.TIOCSWINSZ, &ws);
 224        try term.resize(self.alloc, resize.cols, resize.rows);
 225        std.log.debug("resize rows={d} cols={d}", .{ resize.rows, resize.cols });
 226    }
 227
 228    pub fn handleDetach(self: *Daemon, client: *Client, i: usize) void {
 229        std.log.info("client detach fd={d}", .{client.socket_fd});
 230        _ = self.closeClient(client, i, false);
 231    }
 232
 233    pub fn handleDetachAll(self: *Daemon) void {
 234        std.log.info("detach all clients={d}", .{self.clients.items.len});
 235        for (self.clients.items) |client_to_close| {
 236            client_to_close.deinit();
 237            self.alloc.destroy(client_to_close);
 238        }
 239        self.clients.clearRetainingCapacity();
 240    }
 241
 242    pub fn handleKill(self: *Daemon) void {
 243        std.log.info("kill received session={s}", .{self.session_name});
 244        posix.kill(self.pid, posix.SIG.TERM) catch |err| {
 245            std.log.warn("failed to send SIGTERM to pty child err={s}", .{@errorName(err)});
 246        };
 247        self.shutdown();
 248    }
 249
 250    pub fn handleInfo(self: *Daemon, client: *Client) !void {
 251        const clients_len = self.clients.items.len - 1;
 252        const info = ipc.Info{
 253            .clients_len = clients_len,
 254            .pid = self.pid,
 255        };
 256        try ipc.appendMessage(self.alloc, &client.write_buf, .Info, std.mem.asBytes(&info));
 257        client.has_pending_output = true;
 258    }
 259};
 260
 261pub fn main() !void {
 262    // use c_allocator to avoid "reached unreachable code" panic in DebugAllocator when forking
 263    const alloc = std.heap.c_allocator;
 264
 265    var args = try std.process.argsWithAllocator(alloc);
 266    defer args.deinit();
 267    _ = args.skip(); // skip program name
 268
 269    var cfg = try Cfg.init(alloc);
 270    defer cfg.deinit(alloc);
 271
 272    const log_path = try std.fs.path.join(alloc, &.{ cfg.log_dir, "zmx.log" });
 273    defer alloc.free(log_path);
 274    try log_system.init(alloc, log_path);
 275    defer log_system.deinit();
 276
 277    const cmd = args.next() orelse {
 278        return list(&cfg);
 279    };
 280
 281    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")) {
 282        return printVersion();
 283    } else if (std.mem.eql(u8, cmd, "help") or std.mem.eql(u8, cmd, "h") or std.mem.eql(u8, cmd, "-h")) {
 284        return help();
 285    } else if (std.mem.eql(u8, cmd, "list") or std.mem.eql(u8, cmd, "l")) {
 286        return list(&cfg);
 287    } else if (std.mem.eql(u8, cmd, "detach") or std.mem.eql(u8, cmd, "d")) {
 288        return detachAll(&cfg);
 289    } else if (std.mem.eql(u8, cmd, "kill") or std.mem.eql(u8, cmd, "k")) {
 290        const session_name = args.next() orelse {
 291            return error.SessionNameRequired;
 292        };
 293        return kill(&cfg, session_name);
 294    } else if (std.mem.eql(u8, cmd, "attach") or std.mem.eql(u8, cmd, "a")) {
 295        const session_name = args.next() orelse {
 296            return error.SessionNameRequired;
 297        };
 298
 299        var command_args: std.ArrayList([]const u8) = .empty;
 300        defer command_args.deinit(alloc);
 301        while (args.next()) |arg| {
 302            try command_args.append(alloc, arg);
 303        }
 304
 305        const clients = try std.ArrayList(*Client).initCapacity(alloc, 10);
 306        var command: ?[][]const u8 = null;
 307        if (command_args.items.len > 0) {
 308            command = command_args.items;
 309        }
 310        var daemon = Daemon{
 311            .running = true,
 312            .cfg = &cfg,
 313            .alloc = alloc,
 314            .clients = clients,
 315            .session_name = session_name,
 316            .socket_path = undefined,
 317            .pid = undefined,
 318            .command = command,
 319        };
 320        daemon.socket_path = try getSocketPath(alloc, cfg.socket_dir, session_name);
 321        std.log.info("socket path={s}", .{daemon.socket_path});
 322        return attach(&daemon);
 323    } else {
 324        return help();
 325    }
 326}
 327
 328fn printVersion() !void {
 329    var buf: [256]u8 = undefined;
 330    var w = std.fs.File.stdout().writer(&buf);
 331    try w.interface.print("zmx {s}\nghostty-vt {s}\n", .{ version, ghostty_version });
 332    try w.interface.flush();
 333}
 334
 335fn help() !void {
 336    const help_text =
 337        \\zmx - session persistence for terminal processes
 338        \\
 339        \\Usage: zmx <command> [args]
 340        \\
 341        \\Commands:
 342        \\  [a]ttach <name> [command...]  Create or attach to a session
 343        \\  [d]etach                      Detach all clients from current session (ctrl+b + d for current client)
 344        \\  [l]ist                        List active sessions
 345        \\  [k]ill <name>                 Kill a session and all attached clients
 346        \\  [v]ersion                     Show version information
 347        \\  [h]elp                        Show this help message
 348        \\
 349    ;
 350    var buf: [4096]u8 = undefined;
 351    var w = std.fs.File.stdout().writer(&buf);
 352    try w.interface.print(help_text, .{});
 353    try w.interface.flush();
 354}
 355
 356const SessionEntry = struct {
 357    name: []const u8,
 358    pid: ?i32,
 359    clients_len: ?usize,
 360    is_error: bool,
 361    error_name: ?[]const u8,
 362
 363    fn lessThan(_: void, a: SessionEntry, b: SessionEntry) bool {
 364        return std.mem.order(u8, a.name, b.name) == .lt;
 365    }
 366};
 367
 368fn list(cfg: *Cfg) !void {
 369    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
 370    defer _ = gpa.deinit();
 371    const alloc = gpa.allocator();
 372
 373    var dir = try std.fs.openDirAbsolute(cfg.socket_dir, .{ .iterate = true });
 374    defer dir.close();
 375    var iter = dir.iterate();
 376    var buf: [4096]u8 = undefined;
 377    var w = std.fs.File.stdout().writer(&buf);
 378
 379    var sessions = try std.ArrayList(SessionEntry).initCapacity(alloc, 16);
 380    defer {
 381        for (sessions.items) |session| {
 382            alloc.free(session.name);
 383        }
 384        sessions.deinit(alloc);
 385    }
 386
 387    while (try iter.next()) |entry| {
 388        const exists = sessionExists(dir, entry.name) catch continue;
 389        if (exists) {
 390            const name = try alloc.dupe(u8, entry.name);
 391            errdefer alloc.free(name);
 392
 393            const socket_path = try getSocketPath(alloc, cfg.socket_dir, entry.name);
 394            defer alloc.free(socket_path);
 395
 396            const result = probeSession(alloc, socket_path) catch |err| {
 397                try sessions.append(alloc, .{
 398                    .name = name,
 399                    .pid = null,
 400                    .clients_len = null,
 401                    .is_error = true,
 402                    .error_name = @errorName(err),
 403                });
 404                cleanupStaleSocket(dir, entry.name);
 405                continue;
 406            };
 407            posix.close(result.fd);
 408
 409            try sessions.append(alloc, .{
 410                .name = name,
 411                .pid = result.info.pid,
 412                .clients_len = result.info.clients_len,
 413                .is_error = false,
 414                .error_name = null,
 415            });
 416        }
 417    }
 418
 419    if (sessions.items.len == 0) {
 420        try w.interface.print("no sessions found in {s}\n", .{cfg.socket_dir});
 421        try w.interface.flush();
 422        return;
 423    }
 424
 425    std.mem.sort(SessionEntry, sessions.items, {}, SessionEntry.lessThan);
 426
 427    for (sessions.items) |session| {
 428        if (session.is_error) {
 429            try w.interface.print("session_name={s}\tstatus={s}\t(cleaning up)\n", .{ session.name, session.error_name.? });
 430        } else {
 431            try w.interface.print("session_name={s}\tpid={d}\tclients={d}\n", .{ session.name, session.pid.?, session.clients_len.? });
 432        }
 433        try w.interface.flush();
 434    }
 435}
 436
 437fn detachAll(cfg: *Cfg) !void {
 438    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
 439    defer _ = gpa.deinit();
 440    const alloc = gpa.allocator();
 441    const session_name = std.process.getEnvVarOwned(alloc, "ZMX_SESSION") catch |err| switch (err) {
 442        error.EnvironmentVariableNotFound => {
 443            std.log.err("ZMX_SESSION env var not found: are you inside a zmx session?", .{});
 444            return;
 445        },
 446        else => return err,
 447    };
 448    defer alloc.free(session_name);
 449
 450    var dir = try std.fs.openDirAbsolute(cfg.socket_dir, .{});
 451    defer dir.close();
 452
 453    const socket_path = try getSocketPath(alloc, cfg.socket_dir, session_name);
 454    defer alloc.free(socket_path);
 455    const result = probeSession(alloc, socket_path) catch |err| {
 456        std.log.err("session unresponsive: {s}", .{@errorName(err)});
 457        cleanupStaleSocket(dir, session_name);
 458        return;
 459    };
 460    defer posix.close(result.fd);
 461    ipc.send(result.fd, .DetachAll, "") catch |err| switch (err) {
 462        error.BrokenPipe, error.ConnectionResetByPeer => return,
 463        else => return err,
 464    };
 465}
 466
 467fn kill(cfg: *Cfg, session_name: []const u8) !void {
 468    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
 469    defer _ = gpa.deinit();
 470    const alloc = gpa.allocator();
 471
 472    var dir = try std.fs.openDirAbsolute(cfg.socket_dir, .{});
 473    defer dir.close();
 474
 475    const exists = try sessionExists(dir, session_name);
 476    if (!exists) {
 477        std.log.err("cannot kill session because it does not exist session_name={s}", .{session_name});
 478        return;
 479    }
 480
 481    const socket_path = try getSocketPath(alloc, cfg.socket_dir, session_name);
 482    defer alloc.free(socket_path);
 483    const result = probeSession(alloc, socket_path) catch |err| {
 484        std.log.err("session unresponsive: {s}", .{@errorName(err)});
 485        cleanupStaleSocket(dir, session_name);
 486        var buf: [4096]u8 = undefined;
 487        var w = std.fs.File.stdout().writer(&buf);
 488        w.interface.print("cleaned up stale session {s}\n", .{session_name}) catch {};
 489        w.interface.flush() catch {};
 490        return;
 491    };
 492    defer posix.close(result.fd);
 493    ipc.send(result.fd, .Kill, "") catch |err| switch (err) {
 494        error.BrokenPipe, error.ConnectionResetByPeer => return,
 495        else => return err,
 496    };
 497
 498    var buf: [4096]u8 = undefined;
 499    var w = std.fs.File.stdout().writer(&buf);
 500    try w.interface.print("killed session {s}\n", .{session_name});
 501    try w.interface.flush();
 502}
 503
 504fn attach(daemon: *Daemon) !void {
 505    if (std.posix.getenv("ZMX_SESSION")) |_| {
 506        return error.CannotAttachToSessionInSession;
 507    }
 508
 509    var dir = try std.fs.openDirAbsolute(daemon.cfg.socket_dir, .{});
 510    defer dir.close();
 511
 512    const exists = try sessionExists(dir, daemon.session_name);
 513    var should_create = !exists;
 514
 515    if (exists) {
 516        if (probeSession(daemon.alloc, daemon.socket_path)) |result| {
 517            posix.close(result.fd);
 518            if (daemon.command != null) {
 519                std.log.warn("session already exists, ignoring command session={s}", .{daemon.session_name});
 520            }
 521        } else |_| {
 522            cleanupStaleSocket(dir, daemon.session_name);
 523            should_create = true;
 524        }
 525    }
 526
 527    if (should_create) {
 528        std.log.info("creating session={s}", .{daemon.session_name});
 529        const server_sock_fd = try createSocket(daemon.socket_path);
 530
 531        const pid = try posix.fork();
 532        if (pid == 0) { // child
 533            _ = try posix.setsid();
 534
 535            log_system.deinit();
 536            const session_log_name = try std.fmt.allocPrint(daemon.alloc, "{s}.log", .{daemon.session_name});
 537            defer daemon.alloc.free(session_log_name);
 538            const session_log_path = try std.fs.path.join(daemon.alloc, &.{ daemon.cfg.log_dir, session_log_name });
 539            defer daemon.alloc.free(session_log_path);
 540            try log_system.init(daemon.alloc, session_log_path);
 541
 542            errdefer {
 543                posix.close(server_sock_fd);
 544                dir.deleteFile(daemon.session_name) catch {};
 545            }
 546            const pty_fd = try spawnPty(daemon);
 547            defer {
 548                posix.close(pty_fd);
 549                posix.close(server_sock_fd);
 550                std.log.info("deleting socket file session_name={s}", .{daemon.session_name});
 551                dir.deleteFile(daemon.session_name) catch |err| {
 552                    std.log.warn("failed to delete socket file err={s}", .{@errorName(err)});
 553                };
 554            }
 555            try daemonLoop(daemon, server_sock_fd, pty_fd);
 556            // Reap PTY child to prevent zombie
 557            _ = posix.waitpid(daemon.pid, 0);
 558            daemon.deinit();
 559            return;
 560        }
 561        posix.close(server_sock_fd);
 562        std.Thread.sleep(10 * std.time.ns_per_ms);
 563    }
 564
 565    const client_sock = try sessionConnect(daemon.socket_path);
 566    std.log.info("attached session={s}", .{daemon.session_name});
 567    //  this is typically used with tcsetattr() to modify terminal settings.
 568    //      - you first get the current settings with tcgetattr()
 569    //      - modify the desired attributes in the termios structure
 570    //      - then apply the changes with tcsetattr().
 571    //  This prevents unintended side effects by preserving other settings.
 572    var orig_termios: c.termios = undefined;
 573    _ = c.tcgetattr(posix.STDIN_FILENO, &orig_termios);
 574
 575    // restore stdin fd to its original state after exiting.
 576    // Use TCSAFLUSH to discard any unread input, preventing stale input after detach.
 577    defer {
 578        _ = c.tcsetattr(posix.STDIN_FILENO, c.TCSAFLUSH, &orig_termios);
 579        // Reset terminal modes on detach:
 580        // - Mouse: 1000=basic, 1002=button-event, 1003=any-event, 1006=SGR extended
 581        // - 2004=bracketed paste, 1004=focus events, 1049=alt screen
 582        // - 25h=show cursor, 2J=clear screen, H=cursor home
 583        const restore_seq = "\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l" ++
 584            "\x1b[?2004l\x1b[?1004l\x1b[?1049l" ++
 585            "\x1b[?25h\x1b[2J\x1b[H";
 586        _ = posix.write(posix.STDOUT_FILENO, restore_seq) catch {};
 587    }
 588
 589    var raw_termios = orig_termios;
 590    //  set raw mode after successful connection.
 591    //      disables canonical mode (line buffering), input echoing, signal generation from
 592    //      control characters (like Ctrl+C), and flow control.
 593    c.cfmakeraw(&raw_termios);
 594
 595    // Additional granular raw mode settings for precise control
 596    // (matches what abduco and shpool do)
 597    raw_termios.c_cc[c.VLNEXT] = c._POSIX_VDISABLE; // Disable literal-next (Ctrl-V)
 598    // We want to intercept Ctrl+\ (SIGQUIT) so we can use it as a detach key
 599    raw_termios.c_cc[c.VQUIT] = c._POSIX_VDISABLE; // Disable SIGQUIT (Ctrl+\)
 600    raw_termios.c_cc[c.VMIN] = 1; // Minimum chars to read: return after 1 byte
 601    raw_termios.c_cc[c.VTIME] = 0; // Read timeout: no timeout, return immediately
 602
 603    _ = c.tcsetattr(posix.STDIN_FILENO, c.TCSANOW, &raw_termios);
 604
 605    // Clear screen and move cursor to home before attaching
 606    const clear_seq = "\x1b[2J\x1b[H";
 607    _ = try posix.write(posix.STDOUT_FILENO, clear_seq);
 608
 609    try clientLoop(daemon.cfg, client_sock);
 610}
 611
 612fn clientLoop(_: *Cfg, client_sock_fd: i32) !void {
 613    // use c_allocator to avoid "reached unreachable code" panic in DebugAllocator when forking
 614    const alloc = std.heap.c_allocator;
 615    defer posix.close(client_sock_fd);
 616
 617    setupSigwinchHandler();
 618
 619    // Send init message with terminal size
 620    const size = getTerminalSize(posix.STDOUT_FILENO);
 621    ipc.send(client_sock_fd, .Init, std.mem.asBytes(&size)) catch {};
 622
 623    var poll_fds = try std.ArrayList(posix.pollfd).initCapacity(alloc, 2);
 624    defer poll_fds.deinit(alloc);
 625
 626    var read_buf = try ipc.SocketBuffer.init(alloc);
 627    defer read_buf.deinit();
 628
 629    var stdout_buf = try std.ArrayList(u8).initCapacity(alloc, 4096);
 630    defer stdout_buf.deinit(alloc);
 631
 632    const stdin_fd = posix.STDIN_FILENO;
 633
 634    // Prefix key state for ctrl+b + <key> bindings
 635    var prefix_active = false;
 636
 637    // Make stdin non-blocking
 638    const flags = try posix.fcntl(stdin_fd, posix.F.GETFL, 0);
 639    _ = try posix.fcntl(stdin_fd, posix.F.SETFL, flags | posix.SOCK.NONBLOCK);
 640
 641    while (true) {
 642        // Check for pending SIGWINCH
 643        if (sigwinch_received.swap(false, .acq_rel)) {
 644            const next_size = getTerminalSize(posix.STDOUT_FILENO);
 645            ipc.send(client_sock_fd, .Resize, std.mem.asBytes(&next_size)) catch |err| switch (err) {
 646                error.BrokenPipe, error.ConnectionResetByPeer => return,
 647                else => return err,
 648            };
 649        }
 650
 651        poll_fds.clearRetainingCapacity();
 652
 653        try poll_fds.append(alloc, .{
 654            .fd = stdin_fd,
 655            .events = posix.POLL.IN,
 656            .revents = 0,
 657        });
 658
 659        try poll_fds.append(alloc, .{
 660            .fd = client_sock_fd,
 661            .events = posix.POLL.IN,
 662            .revents = 0,
 663        });
 664
 665        if (stdout_buf.items.len > 0) {
 666            try poll_fds.append(alloc, .{
 667                .fd = posix.STDOUT_FILENO,
 668                .events = posix.POLL.OUT,
 669                .revents = 0,
 670            });
 671        }
 672
 673        _ = posix.poll(poll_fds.items, -1) catch |err| {
 674            if (err == error.Interrupted) continue; // EINTR from signal, loop again
 675            return err;
 676        };
 677
 678        // Handle stdin -> socket (Input)
 679        if (poll_fds.items[0].revents & (posix.POLL.IN | posix.POLL.HUP | posix.POLL.ERR | posix.POLL.NVAL) != 0) {
 680            var buf: [4096]u8 = undefined;
 681            const n_opt: ?usize = posix.read(stdin_fd, &buf) catch |err| blk: {
 682                if (err == error.WouldBlock) break :blk null;
 683                return err;
 684            };
 685
 686            if (n_opt) |n| {
 687                if (n > 0) {
 688                    // Check for Kitty keyboard protocol sequences first
 689                    switch (parseStdinInput(buf[0..n], &prefix_active)) {
 690                        .detach => {
 691                            ipc.send(client_sock_fd, .Detach, "") catch |err| switch (err) {
 692                                error.BrokenPipe, error.ConnectionResetByPeer => return,
 693                                else => return err,
 694                            };
 695                            continue;
 696                        },
 697                        .send => |data| {
 698                            ipc.send(client_sock_fd, .Input, data) catch |err| switch (err) {
 699                                error.BrokenPipe, error.ConnectionResetByPeer => return,
 700                                else => return err,
 701                            };
 702                            continue;
 703                        },
 704                        .activate_prefix => continue,
 705                        .none => {},
 706                    }
 707
 708                    // Process byte-by-byte for non-Kitty input
 709                    var i: usize = 0;
 710                    while (i < n) : (i += 1) {
 711                        const action = parseStdinByte(buf[i], prefix_active);
 712                        switch (action) {
 713                            .detach => {
 714                                ipc.send(client_sock_fd, .Detach, "") catch |err| switch (err) {
 715                                    error.BrokenPipe, error.ConnectionResetByPeer => return,
 716                                    else => return err,
 717                                };
 718                                prefix_active = false;
 719                            },
 720                            .send => |data| {
 721                                ipc.send(client_sock_fd, .Input, data) catch |err| switch (err) {
 722                                    error.BrokenPipe, error.ConnectionResetByPeer => return,
 723                                    else => return err,
 724                                };
 725                                prefix_active = false;
 726                            },
 727                            .activate_prefix => {
 728                                prefix_active = true;
 729                            },
 730                            .none => {
 731                                if (prefix_active) {
 732                                    // Unknown prefix command, forward both ctrl+b and this key
 733                                    ipc.send(client_sock_fd, .Input, &[_]u8{0x02}) catch |err| switch (err) {
 734                                        error.BrokenPipe, error.ConnectionResetByPeer => return,
 735                                        else => return err,
 736                                    };
 737                                    ipc.send(client_sock_fd, .Input, buf[i .. i + 1]) catch |err| switch (err) {
 738                                        error.BrokenPipe, error.ConnectionResetByPeer => return,
 739                                        else => return err,
 740                                    };
 741                                    prefix_active = false;
 742                                } else {
 743                                    ipc.send(client_sock_fd, .Input, buf[i .. i + 1]) catch |err| switch (err) {
 744                                        error.BrokenPipe, error.ConnectionResetByPeer => return,
 745                                        else => return err,
 746                                    };
 747                                }
 748                            },
 749                        }
 750                    }
 751                } else {
 752                    // EOF on stdin
 753                    return;
 754                }
 755            }
 756        }
 757
 758        // Handle socket -> stdout (Output)
 759        if (poll_fds.items[1].revents & posix.POLL.IN != 0) {
 760            const n = read_buf.read(client_sock_fd) catch |err| {
 761                if (err == error.WouldBlock) continue;
 762                if (err == error.ConnectionResetByPeer or err == error.BrokenPipe) {
 763                    return;
 764                }
 765                std.log.err("daemon read err={s}", .{@errorName(err)});
 766                return err;
 767            };
 768            if (n == 0) {
 769                return; // Server closed connection
 770            }
 771
 772            while (read_buf.next()) |msg| {
 773                switch (msg.header.tag) {
 774                    .Output => {
 775                        if (msg.payload.len > 0) {
 776                            try stdout_buf.appendSlice(alloc, msg.payload);
 777                        }
 778                    },
 779                    else => {},
 780                }
 781            }
 782        }
 783
 784        if (stdout_buf.items.len > 0) {
 785            const n = posix.write(posix.STDOUT_FILENO, stdout_buf.items) catch |err| blk: {
 786                if (err == error.WouldBlock) break :blk 0;
 787                return err;
 788            };
 789            if (n > 0) {
 790                try stdout_buf.replaceRange(alloc, 0, n, &[_]u8{});
 791            }
 792        }
 793
 794        if (poll_fds.items[1].revents & (posix.POLL.HUP | posix.POLL.ERR | posix.POLL.NVAL) != 0) {
 795            return;
 796        }
 797    }
 798}
 799
 800fn daemonLoop(daemon: *Daemon, server_sock_fd: i32, pty_fd: i32) !void {
 801    std.log.info("daemon started session={s} pty_fd={d}", .{ daemon.session_name, pty_fd });
 802    var should_exit = false;
 803    var poll_fds = try std.ArrayList(posix.pollfd).initCapacity(daemon.alloc, 8);
 804    defer poll_fds.deinit(daemon.alloc);
 805
 806    const init_size = getTerminalSize(pty_fd);
 807    var term = try ghostty_vt.Terminal.init(daemon.alloc, .{
 808        .cols = init_size.cols,
 809        .rows = init_size.rows,
 810        .max_scrollback = daemon.cfg.max_scrollback,
 811    });
 812    defer term.deinit(daemon.alloc);
 813    var vt_stream = term.vtStream();
 814    defer vt_stream.deinit();
 815
 816    while (!should_exit and daemon.running) {
 817        poll_fds.clearRetainingCapacity();
 818
 819        try poll_fds.append(daemon.alloc, .{
 820            .fd = server_sock_fd,
 821            .events = posix.POLL.IN,
 822            .revents = 0,
 823        });
 824
 825        try poll_fds.append(daemon.alloc, .{
 826            .fd = pty_fd,
 827            .events = posix.POLL.IN,
 828            .revents = 0,
 829        });
 830
 831        for (daemon.clients.items) |client| {
 832            var events: i16 = posix.POLL.IN;
 833            if (client.has_pending_output) {
 834                events |= posix.POLL.OUT;
 835            }
 836            try poll_fds.append(daemon.alloc, .{
 837                .fd = client.socket_fd,
 838                .events = events,
 839                .revents = 0,
 840            });
 841        }
 842
 843        _ = posix.poll(poll_fds.items, -1) catch |err| {
 844            return err;
 845        };
 846
 847        if (poll_fds.items[0].revents & (posix.POLL.ERR | posix.POLL.HUP | posix.POLL.NVAL) != 0) {
 848            std.log.err("server socket error revents={d}", .{poll_fds.items[0].revents});
 849            should_exit = true;
 850        } else if (poll_fds.items[0].revents & posix.POLL.IN != 0) {
 851            const client_fd = try posix.accept(server_sock_fd, null, null, posix.SOCK.NONBLOCK | posix.SOCK.CLOEXEC);
 852            const client = try daemon.alloc.create(Client);
 853            client.* = Client{
 854                .alloc = daemon.alloc,
 855                .socket_fd = client_fd,
 856                .read_buf = try ipc.SocketBuffer.init(daemon.alloc),
 857                .write_buf = undefined,
 858            };
 859            client.write_buf = try std.ArrayList(u8).initCapacity(client.alloc, 4096);
 860            try daemon.clients.append(daemon.alloc, client);
 861            std.log.info("client connected fd={d} total={d}", .{ client_fd, daemon.clients.items.len });
 862        }
 863
 864        if (poll_fds.items[1].revents & (posix.POLL.IN | posix.POLL.HUP | posix.POLL.ERR | posix.POLL.NVAL) != 0) {
 865            // Read from PTY
 866            var buf: [4096]u8 = undefined;
 867            const n_opt: ?usize = posix.read(pty_fd, &buf) catch |err| blk: {
 868                if (err == error.WouldBlock) break :blk null;
 869                break :blk 0;
 870            };
 871
 872            if (n_opt) |n| {
 873                if (n == 0) {
 874                    // EOF: Shell exited
 875                    std.log.info("shell exited pty_fd={d}", .{pty_fd});
 876                    should_exit = true;
 877                } else {
 878                    // Feed PTY output to terminal emulator for state tracking
 879                    try vt_stream.nextSlice(buf[0..n]);
 880                    daemon.has_pty_output = true;
 881
 882                    // Broadcast data to all clients
 883                    for (daemon.clients.items) |client| {
 884                        ipc.appendMessage(daemon.alloc, &client.write_buf, .Output, buf[0..n]) catch |err| {
 885                            std.log.warn("failed to buffer output for client err={s}", .{@errorName(err)});
 886                            continue;
 887                        };
 888                        client.has_pending_output = true;
 889                    }
 890                }
 891            }
 892        }
 893
 894        var i: usize = daemon.clients.items.len;
 895        // Only iterate over clients that were present when poll_fds was constructed
 896        // poll_fds contains [server, pty, client0, client1, ...]
 897        // So number of clients in poll_fds is poll_fds.items.len - 2
 898        const num_polled_clients = poll_fds.items.len - 2;
 899        if (i > num_polled_clients) {
 900            // If we have more clients than polled (i.e. we just accepted one), start from the polled ones
 901            i = num_polled_clients;
 902        }
 903
 904        clients_loop: while (i > 0) {
 905            i -= 1;
 906            const client = daemon.clients.items[i];
 907            const revents = poll_fds.items[i + 2].revents;
 908
 909            if (revents & posix.POLL.IN != 0) {
 910                const n = client.read_buf.read(client.socket_fd) catch |err| {
 911                    if (err == error.WouldBlock) continue;
 912                    std.log.debug("client read err={s} fd={d}", .{ @errorName(err), client.socket_fd });
 913                    const last = daemon.closeClient(client, i, false);
 914                    if (last) should_exit = true;
 915                    continue;
 916                };
 917
 918                if (n == 0) {
 919                    // Client closed connection
 920                    const last = daemon.closeClient(client, i, false);
 921                    if (last) should_exit = true;
 922                    continue;
 923                }
 924
 925                while (client.read_buf.next()) |msg| {
 926                    switch (msg.header.tag) {
 927                        .Input => try daemon.handleInput(pty_fd, msg.payload),
 928                        .Init => try daemon.handleInit(client, pty_fd, &term, msg.payload),
 929                        .Resize => try daemon.handleResize(pty_fd, &term, msg.payload),
 930                        .Detach => {
 931                            daemon.handleDetach(client, i);
 932                            break :clients_loop;
 933                        },
 934                        .DetachAll => {
 935                            daemon.handleDetachAll();
 936                            break :clients_loop;
 937                        },
 938                        .Kill => {
 939                            daemon.handleKill();
 940                            should_exit = true;
 941                            break :clients_loop;
 942                        },
 943                        .Info => try daemon.handleInfo(client),
 944                        .Output => {},
 945                    }
 946                }
 947            }
 948
 949            if (revents & posix.POLL.OUT != 0) {
 950                // Flush pending output buffers
 951                const n = posix.write(client.socket_fd, client.write_buf.items) catch |err| blk: {
 952                    if (err == error.WouldBlock) break :blk 0;
 953                    // Error on write, close client
 954                    const last = daemon.closeClient(client, i, false);
 955                    if (last) should_exit = true;
 956                    continue;
 957                };
 958
 959                if (n > 0) {
 960                    client.write_buf.replaceRange(daemon.alloc, 0, n, &[_]u8{}) catch unreachable;
 961                }
 962
 963                if (client.write_buf.items.len == 0) {
 964                    client.has_pending_output = false;
 965                }
 966            }
 967
 968            if (revents & (posix.POLL.HUP | posix.POLL.ERR | posix.POLL.NVAL) != 0) {
 969                const last = daemon.closeClient(client, i, false);
 970                if (last) should_exit = true;
 971            }
 972        }
 973    }
 974}
 975
 976fn spawnPty(daemon: *Daemon) !c_int {
 977    const size = getTerminalSize(posix.STDOUT_FILENO);
 978    var ws: c.struct_winsize = .{
 979        .ws_row = size.rows,
 980        .ws_col = size.cols,
 981        .ws_xpixel = 0,
 982        .ws_ypixel = 0,
 983    };
 984
 985    var master_fd: c_int = undefined;
 986    const pid = forkpty(&master_fd, null, null, &ws);
 987    if (pid < 0) {
 988        return error.ForkPtyFailed;
 989    }
 990
 991    if (pid == 0) { // child pid code path
 992        const session_env = try std.fmt.allocPrint(daemon.alloc, "ZMX_SESSION={s}\x00", .{daemon.session_name});
 993        _ = c.putenv(@ptrCast(session_env.ptr));
 994
 995        if (daemon.command) |cmd_args| {
 996            const alloc = std.heap.c_allocator;
 997            var argv_buf: [64:null]?[*:0]const u8 = undefined;
 998            for (cmd_args, 0..) |arg, i| {
 999                argv_buf[i] = alloc.dupeZ(u8, arg) catch {
1000                    std.posix.exit(1);
1001                };
1002            }
1003            argv_buf[cmd_args.len] = null;
1004            const argv: [*:null]const ?[*:0]const u8 = &argv_buf;
1005            const err = std.posix.execvpeZ(argv_buf[0].?, argv, std.c.environ);
1006            std.log.err("execvpe failed: cmd={s} err={s}", .{ cmd_args[0], @errorName(err) });
1007            std.posix.exit(1);
1008        } else {
1009            const shell = std.posix.getenv("SHELL") orelse "/bin/sh";
1010            const argv = [_:null]?[*:0]const u8{ shell, null };
1011            const err = std.posix.execveZ(shell, &argv, std.c.environ);
1012            std.log.err("execve failed: err={s}", .{@errorName(err)});
1013            std.posix.exit(1);
1014        }
1015    }
1016    // master pid code path
1017    daemon.pid = pid;
1018    std.log.info("pty spawned session={s} pid={d}", .{ daemon.session_name, pid });
1019
1020    // make pty non-blocking
1021    const flags = try posix.fcntl(master_fd, posix.F.GETFL, 0);
1022    _ = try posix.fcntl(master_fd, posix.F.SETFL, flags | @as(u32, 0o4000));
1023    return master_fd;
1024}
1025
1026fn sessionConnect(fname: []const u8) !i32 {
1027    var unix_addr = try std.net.Address.initUnix(fname);
1028    const socket_fd = try posix.socket(posix.AF.UNIX, posix.SOCK.STREAM | posix.SOCK.CLOEXEC, 0);
1029    errdefer posix.close(socket_fd);
1030    try posix.connect(socket_fd, &unix_addr.any, unix_addr.getOsSockLen());
1031    return socket_fd;
1032}
1033
1034const SessionProbeError = error{
1035    Timeout,
1036    ConnectionRefused,
1037    Unexpected,
1038};
1039
1040const SessionProbeResult = struct {
1041    fd: i32,
1042    info: ipc.Info,
1043};
1044
1045fn probeSession(alloc: std.mem.Allocator, socket_path: []const u8) SessionProbeError!SessionProbeResult {
1046    const timeout_ms = 1000;
1047    const fd = sessionConnect(socket_path) catch |err| switch (err) {
1048        error.ConnectionRefused => return error.ConnectionRefused,
1049        else => return error.Unexpected,
1050    };
1051    errdefer posix.close(fd);
1052
1053    ipc.send(fd, .Info, "") catch return error.Unexpected;
1054
1055    var poll_fds = [_]posix.pollfd{.{ .fd = fd, .events = posix.POLL.IN, .revents = 0 }};
1056    const poll_result = posix.poll(&poll_fds, timeout_ms) catch return error.Unexpected;
1057    if (poll_result == 0) {
1058        return error.Timeout;
1059    }
1060
1061    var sb = ipc.SocketBuffer.init(alloc) catch return error.Unexpected;
1062    defer sb.deinit();
1063
1064    const n = sb.read(fd) catch return error.Unexpected;
1065    if (n == 0) return error.Unexpected;
1066
1067    while (sb.next()) |msg| {
1068        if (msg.header.tag == .Info) {
1069            if (msg.payload.len == @sizeOf(ipc.Info)) {
1070                return .{
1071                    .fd = fd,
1072                    .info = std.mem.bytesToValue(ipc.Info, msg.payload[0..@sizeOf(ipc.Info)]),
1073                };
1074            }
1075        }
1076    }
1077    return error.Unexpected;
1078}
1079
1080const InputAction = union(enum) {
1081    send: []const u8,
1082    detach,
1083    activate_prefix,
1084    none,
1085};
1086
1087fn parseStdinByte(byte: u8, prefix_active: bool) InputAction {
1088    if (byte == 0x1C) { // Ctrl+\ (File Separator)
1089        return .detach;
1090    } else if (byte == 0x02) { // Ctrl+B
1091        if (prefix_active) {
1092            return .{ .send = &[_]u8{0x02} };
1093        } else {
1094            return .activate_prefix;
1095        }
1096    } else if (prefix_active) {
1097        if (byte == 'd') {
1098            return .detach;
1099        } else {
1100            return .none; // Unknown prefix command - caller handles forwarding
1101        }
1102    }
1103    return .none; // Regular byte - caller handles forwarding
1104}
1105
1106fn parseStdinInput(buf: []const u8, prefix_active: *bool) InputAction {
1107    // Check for Kitty keyboard protocol escape sequences first
1108    if (isKittyCtrlBackslash(buf)) {
1109        prefix_active.* = false;
1110        return .detach;
1111    }
1112
1113    if (isKittyCtrlB(buf)) {
1114        prefix_active.* = true;
1115        return .activate_prefix;
1116    }
1117
1118    // Handle prefix mode for Kitty 'd' key
1119    if (prefix_active.* and isKittyKey(buf, 'd')) {
1120        prefix_active.* = false;
1121        return .detach;
1122    }
1123
1124    return .none; // Not a special sequence
1125}
1126
1127fn cleanupStaleSocket(dir: std.fs.Dir, session_name: []const u8) void {
1128    std.log.warn("stale socket found, cleaning up session={s}", .{session_name});
1129    dir.deleteFile(session_name) catch |err| {
1130        std.log.warn("failed to delete stale socket err={s}", .{@errorName(err)});
1131    };
1132}
1133
1134fn sessionExists(dir: std.fs.Dir, name: []const u8) !bool {
1135    const stat = dir.statFile(name) catch |err| switch (err) {
1136        error.FileNotFound => return false,
1137        else => return err,
1138    };
1139    if (stat.kind != .unix_domain_socket) {
1140        return error.FileNotUnixSocket;
1141    }
1142    return true;
1143}
1144
1145fn createSocket(fname: []const u8) !i32 {
1146    // AF.UNIX: Unix domain socket for local IPC with client processes
1147    // SOCK.STREAM: Reliable, bidirectional communication
1148    // SOCK.NONBLOCK: Set socket to non-blocking
1149    const fd = try posix.socket(posix.AF.UNIX, posix.SOCK.STREAM | posix.SOCK.NONBLOCK | posix.SOCK.CLOEXEC, 0);
1150    errdefer posix.close(fd);
1151
1152    var unix_addr = try std.net.Address.initUnix(fname);
1153    try posix.bind(fd, &unix_addr.any, unix_addr.getOsSockLen());
1154    try posix.listen(fd, 128);
1155    return fd;
1156}
1157
1158pub fn getSocketPath(alloc: std.mem.Allocator, socket_dir: []const u8, session_name: []const u8) ![]const u8 {
1159    const dir = socket_dir;
1160    const fname = try alloc.alloc(u8, dir.len + session_name.len + 1);
1161    @memcpy(fname[0..dir.len], dir);
1162    @memcpy(fname[dir.len .. dir.len + 1], "/");
1163    @memcpy(fname[dir.len + 1 ..], session_name);
1164    return fname;
1165}
1166
1167fn handleSigwinch(_: i32, _: *const posix.siginfo_t, _: ?*anyopaque) callconv(.c) void {
1168    sigwinch_received.store(true, .release);
1169}
1170
1171fn setupSigwinchHandler() void {
1172    const act: posix.Sigaction = .{
1173        .handler = .{ .sigaction = handleSigwinch },
1174        .mask = posix.sigemptyset(),
1175        .flags = posix.SA.SIGINFO,
1176    };
1177    posix.sigaction(posix.SIG.WINCH, &act, null);
1178}
1179
1180fn getTerminalSize(fd: i32) ipc.Resize {
1181    var ws: c.struct_winsize = undefined;
1182    if (c.ioctl(fd, c.TIOCGWINSZ, &ws) == 0 and ws.ws_row > 0 and ws.ws_col > 0) {
1183        return .{ .rows = ws.ws_row, .cols = ws.ws_col };
1184    }
1185    return .{ .rows = 24, .cols = 80 };
1186}
1187
1188/// Detects Kitty keyboard protocol escape sequence for Ctrl+\
1189/// Common sequences: \e[92;5u (basic), \e[92;133u (with event flags)
1190fn isKittyCtrlBackslash(buf: []const u8) bool {
1191    return std.mem.indexOf(u8, buf, "\x1b[92;5u") != null or
1192        std.mem.indexOf(u8, buf, "\x1b[92;133u") != null;
1193}
1194
1195test "isKittyCtrlBackslash" {
1196    try std.testing.expect(isKittyCtrlBackslash("\x1b[92;5u"));
1197    try std.testing.expect(isKittyCtrlBackslash("\x1b[92;133u"));
1198    try std.testing.expect(!isKittyCtrlBackslash("\x1b[92;1u"));
1199    try std.testing.expect(!isKittyCtrlBackslash("\x1b[93;5u"));
1200    try std.testing.expect(!isKittyCtrlBackslash("garbage"));
1201}
1202
1203fn serializeTerminalState(alloc: std.mem.Allocator, term: *ghostty_vt.Terminal) ?[]const u8 {
1204    var builder: std.Io.Writer.Allocating = .init(alloc);
1205    defer builder.deinit();
1206
1207    var term_formatter = ghostty_vt.formatter.TerminalFormatter.init(term, .vt);
1208    term_formatter.content = .{ .selection = null };
1209    term_formatter.extra = .{
1210        .palette = false,
1211        .modes = true,
1212        .scrolling_region = true,
1213        .tabstops = false, // tabstop restoration moves cursor after CUP, corrupting position
1214        .pwd = true,
1215        .keyboard = true,
1216        .screen = .all,
1217    };
1218
1219    term_formatter.format(&builder.writer) catch |err| {
1220        std.log.warn("failed to format terminal state err={s}", .{@errorName(err)});
1221        return null;
1222    };
1223
1224    const output = builder.writer.buffered();
1225    if (output.len == 0) return null;
1226
1227    return alloc.dupe(u8, output) catch |err| {
1228        std.log.warn("failed to allocate terminal state err={s}", .{@errorName(err)});
1229        return null;
1230    };
1231}
1232
1233fn isKittyCtrlB(buf: []const u8) bool {
1234    return std.mem.indexOf(u8, buf, "\x1b[98;5u") != null or
1235        std.mem.indexOf(u8, buf, "\x1b[98;133u") != null;
1236}
1237
1238test "isKittyCtrlB" {
1239    try std.testing.expect(isKittyCtrlB("\x1b[98;5u"));
1240    try std.testing.expect(isKittyCtrlB("\x1b[98;133u"));
1241    try std.testing.expect(!isKittyCtrlB("\x1b[98;1u"));
1242    try std.testing.expect(!isKittyCtrlB("\x1b[99;5u"));
1243    try std.testing.expect(!isKittyCtrlB("garbage"));
1244}
1245
1246fn isKittyKey(buf: []const u8, key: u8) bool {
1247    var expected: [16]u8 = undefined;
1248    const seq = std.fmt.bufPrint(&expected, "\x1b[{d}u", .{key}) catch return false;
1249
1250    return std.mem.indexOf(u8, buf, seq) != null;
1251}
1252
1253test "isKittyKey" {
1254    try std.testing.expect(isKittyKey("\x1b[100u", 'd'));
1255    try std.testing.expect(!isKittyKey("\x1b[100;5u", 'd'));
1256    try std.testing.expect(!isKittyKey("\x1b[101u", 'd'));
1257    try std.testing.expect(!isKittyKey("d", 'd'));
1258}