main zmx / src / main.zig
Eric Bower  ·  2026-09-19
   1const std = @import("std");
   2const Environ = std.process.Environ;
   3const build_options = @import("build_options");
   4const ghostty_vt = @import("ghostty-vt");
   5const ipc = @import("ipc.zig");
   6const log = @import("log.zig");
   7const completions = @import("completions.zig");
   8const util = @import("util.zig");
   9const cross = @import("cross.zig");
  10const socket = @import("socket.zig");
  11const label = @import("label.zig");
  12const lib_posix = @import("posix.zig");
  13const signal = @import("signal.zig");
  14const Cfg = @import("cfg.zig");
  15const loop = @import("loop.zig");
  16const Client = loop.Client;
  17const Daemon = loop.Daemon;
  18const version = build_options.version;
  19const ghostty_version = build_options.ghostty_version;
  20
  21pub const std_options: std.Options = .{
  22    .logFn = log.zmxLogFn,
  23    .log_level = .debug,
  24};
  25
  26fn detectHelp(arg: []const u8) bool {
  27    return (std.mem.eql(u8, arg, "--help") or std.mem.eql(u8, arg, "-h"));
  28}
  29
  30/// Prints an error message to stderr and exits with status 1.
  31fn printError(io: std.Io, comptime fmt: []const u8, args: anytype) noreturn {
  32    var buf: [4096]u8 = undefined;
  33    var w = std.Io.File.stderr().writer(io, &buf);
  34    w.interface.print("error: " ++ fmt ++ "\n", args) catch {};
  35    w.interface.flush() catch {};
  36    std.process.exit(1);
  37}
  38pub fn main(init: std.process.Init) !void {
  39    const gpa = init.gpa;
  40    const io = init.io;
  41
  42    // Every subcommand may write to a Unix-domain socket; a peer that
  43    // disappears between probe and send would otherwise kill us before
  44    // write() can return BrokenPipe. Inherited across fork, so this also
  45    // covers the daemon.
  46    signal.ignoreSigpipe();
  47
  48    var args = init.minimal.args.iterate();
  49    defer args.deinit();
  50    _ = args.next(); // skip program name
  51
  52    var cfg = try Cfg.init(gpa, io);
  53    defer cfg.deinit(gpa);
  54
  55    const log_path = try std.fs.path.join(gpa, &.{ cfg.log_dir, "zmx.log" });
  56    defer gpa.free(log_path);
  57    const log_mode = std.Io.File.Permissions.fromMode(@intCast(cfg.log_mode));
  58    try log.log_system.init(io, log_path, log_mode);
  59    defer log.log_system.deinit();
  60
  61    const shell_env = init.environ_map.get("SHELL") orelse "/bin/sh";
  62
  63    const cmd = args.next() orelse {
  64        return list(gpa, io, &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(io, &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(io);
  71    } else if (std.mem.eql(u8, cmd, "list") or std.mem.eql(u8, cmd, "l") or std.mem.eql(u8, cmd, "ls")) {
  72        var short = false;
  73        while (args.next()) |arg| {
  74            if (detectHelp(arg)) return help(io);
  75            if (std.mem.eql(u8, arg, "--short")) short = true;
  76        }
  77        return list(gpa, io, &cfg, short);
  78    } else if (std.mem.eql(u8, cmd, "get") or std.mem.eql(u8, cmd, "g")) {
  79        const sesh_name = args.next() orelse {
  80            return printError(io, "session name required (or run inside a zmx session)", .{});
  81        };
  82        if (detectHelp(sesh_name)) return help(io);
  83        const sesh = try socket.resolveSessionOrEnv(gpa, io, sesh_name);
  84        defer gpa.free(sesh);
  85        const single_kv = args.next() orelse "";
  86        return labelGet(gpa, io, &cfg, sesh, single_kv);
  87    } else if (std.mem.eql(u8, cmd, "set")) {
  88        const sesh_name = args.next() orelse {
  89            return printError(io, "session name required (or run inside a zmx session)", .{});
  90        };
  91        if (detectHelp(sesh_name)) return help(io);
  92        const sesh = try socket.resolveSessionOrEnv(gpa, io, sesh_name);
  93        defer gpa.free(sesh);
  94
  95        var kvs = std.ArrayList(u8).empty;
  96        defer kvs.deinit(gpa);
  97        var first = true;
  98        while (args.next()) |arg| {
  99            if (!first) try kvs.append(gpa, ' ');
 100            try kvs.appendSlice(gpa, arg);
 101            first = false;
 102        }
 103        if (kvs.items.len == 0) {
 104            return printError(io, "at least one key=value pair required", .{});
 105        }
 106        return labelSet(gpa, io, &cfg, sesh, kvs.items);
 107    } else if (std.mem.eql(u8, cmd, "clear") or std.mem.eql(u8, cmd, "cl")) {
 108        const sesh_name = args.next() orelse {
 109            return printError(io, "session name required (or run inside a zmx session)", .{});
 110        };
 111        if (detectHelp(sesh_name)) return help(io);
 112        const sesh = try socket.resolveSessionOrEnv(gpa, io, sesh_name);
 113        defer gpa.free(sesh);
 114        return labelClear(gpa, io, &cfg, sesh);
 115    } else if (std.mem.eql(u8, cmd, "completions") or std.mem.eql(u8, cmd, "c")) {
 116        const arg = args.next() orelse {
 117            return printError(io, "completions requires a shell argument (bash, zsh, fish, nu, yash)", .{});
 118        };
 119        if (std.mem.eql(u8, arg, "--help") or std.mem.eql(u8, arg, "-h")) {
 120            return help(io);
 121        }
 122        const shell = completions.Shell.fromString(arg) orelse {
 123            return printError(io, "unknown shell \"{s}\" (valid: bash, zsh, fish, nu, yash)", .{arg});
 124        };
 125        return printCompletions(io, shell);
 126    } else if (std.mem.eql(u8, cmd, "detach") or std.mem.eql(u8, cmd, "d")) {
 127        return detachAll(gpa, io, &cfg);
 128    } else if (std.mem.eql(u8, cmd, "history") or std.mem.eql(u8, cmd, "hi")) {
 129        var session_name: ?[]const u8 = null;
 130        var format: util.HistoryFormat = .plain;
 131        while (args.next()) |arg| {
 132            if (std.mem.eql(u8, arg, "--help") or std.mem.eql(u8, arg, "-h")) {
 133                return help(io);
 134            } else if (std.mem.eql(u8, arg, "--vt")) {
 135                format = .vt;
 136            } else if (std.mem.eql(u8, arg, "--html")) {
 137                format = .html;
 138            } else if (session_name == null) {
 139                session_name = arg;
 140            }
 141        }
 142        const sesh_env = socket.getSeshNameFromEnv();
 143        const raw_name = session_name orelse (if (sesh_env.len > 0) sesh_env else {
 144            return printError(io, "session name required (or run inside a zmx session)", .{});
 145        });
 146        const sesh = try socket.getSeshName(gpa, raw_name);
 147        defer gpa.free(sesh);
 148        return history(gpa, io, &cfg, sesh, format);
 149    } else if (std.mem.eql(u8, cmd, "attach") or std.mem.eql(u8, cmd, "a")) {
 150        var attach_args: std.ArrayList([]const u8) = .empty;
 151        defer attach_args.deinit(gpa);
 152        while (args.next()) |arg| {
 153            try attach_args.append(gpa, arg);
 154        }
 155
 156        const parsed = parseAttachArgs(attach_args.items);
 157        if (parsed.want_help) {
 158            return help(io);
 159        }
 160        if (parsed.missing_labels_value) {
 161            return printError(io, "--labels requires \"key=value ...\"", .{});
 162        }
 163        // Before ensureSession, so a rejected label does not leave a session
 164        // behind that the caller never asked for.
 165        if (parsed.labels) |kvs| assertLabels(io, kvs);
 166
 167        var command: ?[][]const u8 = null;
 168        if (parsed.command_start < attach_args.items.len) {
 169            command = attach_args.items[parsed.command_start..];
 170        }
 171
 172        var cwd_buf: [std.fs.max_path_bytes]u8 = undefined;
 173        const cwd_len = std.process.currentPath(io, &cwd_buf) catch 0;
 174        const cwd = cwd_buf[0..cwd_len];
 175
 176        const sesh = try socket.getSeshName(gpa, parsed.session_name);
 177        defer gpa.free(sesh);
 178        const socket_path = socket.getSocketPath(gpa, cfg.socket_dir, sesh) catch |err| switch (err) {
 179            error.NameTooLong => return socket.printSessionNameTooLong(io, sesh, cfg.socket_dir),
 180            error.OutOfMemory => return err,
 181        };
 182        var daemon = Daemon.init(io, &cfg, sesh, socket_path);
 183        daemon.command = command;
 184        daemon.setCwd(cwd);
 185        daemon.shell = shell_env;
 186        std.log.info("socket path={s}", .{daemon.socket_path});
 187
 188        const env_keys = lib_posix.getenv("ZMX_TRACK_ENV") orelse cfg.tracked_envs;
 189        const env_str = try getTrackedEnvStr(gpa, env_keys, init.environ_map);
 190        defer gpa.free(env_str);
 191
 192        return attach(gpa, io, &daemon, env_str, parsed.labels);
 193    } else if (std.mem.eql(u8, cmd, "run") or std.mem.eql(u8, cmd, "r")) {
 194        const session_name = args.next() orelse "";
 195        if (std.mem.eql(u8, session_name, "--help") or std.mem.eql(u8, session_name, "-h")) {
 196            return help(io);
 197        }
 198
 199        var cmd_args_raw: std.ArrayList([]const u8) = .empty;
 200        defer cmd_args_raw.deinit(gpa);
 201        var detached = false;
 202        while (args.next()) |arg| {
 203            if (std.mem.startsWith(u8, arg, "-d")) {
 204                detached = true;
 205            } else {
 206                try cmd_args_raw.append(gpa, arg);
 207            }
 208        }
 209
 210        var cwd_buf: [std.fs.max_path_bytes]u8 = undefined;
 211        const cwd_len = std.process.currentPath(io, &cwd_buf) catch 0;
 212        const cwd = cwd_buf[0..cwd_len];
 213
 214        const sesh = try socket.getSeshName(gpa, session_name);
 215        defer gpa.free(sesh);
 216        const socket_path = socket.getSocketPath(gpa, cfg.socket_dir, sesh) catch |err| switch (err) {
 217            error.NameTooLong => return socket.printSessionNameTooLong(io, sesh, cfg.socket_dir),
 218            error.OutOfMemory => return err,
 219        };
 220        defer gpa.free(socket_path);
 221        var daemon = Daemon.init(io, &cfg, sesh, socket_path);
 222        daemon.setCwd(cwd);
 223        daemon.is_task_mode = true;
 224        daemon.shell = shell_env;
 225        std.log.info("socket path={s}", .{daemon.socket_path});
 226        return run(gpa, io, &daemon, detached, cmd_args_raw.items);
 227    } else if (std.mem.eql(u8, cmd, "send") or std.mem.eql(u8, cmd, "s")) {
 228        const session_name = args.next() orelse "";
 229        if (std.mem.eql(u8, session_name, "--help") or std.mem.eql(u8, session_name, "-h")) {
 230            return help(io);
 231        }
 232        if (session_name.len == 0) {
 233            return printError(io, "session name required", .{});
 234        }
 235
 236        var text_parts: std.ArrayList([]const u8) = .empty;
 237        defer text_parts.deinit(gpa);
 238        while (args.next()) |arg| {
 239            try text_parts.append(gpa, arg);
 240        }
 241
 242        const sesh = try socket.getSeshName(gpa, session_name);
 243        defer gpa.free(sesh);
 244        const socket_path = socket.getSocketPath(gpa, cfg.socket_dir, sesh) catch |err| switch (err) {
 245            error.NameTooLong => return socket.printSessionNameTooLong(io, sesh, cfg.socket_dir),
 246            error.OutOfMemory => return err,
 247        };
 248        send(gpa, io, &cfg, sesh, socket_path, text_parts.items, .Send) catch |err| {
 249            if (err == error.SessionUnresponsive) std.process.exit(1);
 250            return printError(io, "send failed: {s}", .{@errorName(err)});
 251        };
 252    } else if (std.mem.eql(u8, cmd, "print") or std.mem.eql(u8, cmd, "p")) {
 253        const session_name = args.next() orelse "";
 254        if (std.mem.eql(u8, session_name, "--help") or std.mem.eql(u8, session_name, "-h")) {
 255            return help(io);
 256        }
 257        if (session_name.len == 0) {
 258            return printError(io, "session name required", .{});
 259        }
 260
 261        var text_parts: std.ArrayList([]const u8) = .empty;
 262        defer text_parts.deinit(gpa);
 263        while (args.next()) |arg| {
 264            try text_parts.append(gpa, arg);
 265        }
 266
 267        const sesh = try socket.getSeshName(gpa, session_name);
 268        defer gpa.free(sesh);
 269        const socket_path = socket.getSocketPath(gpa, cfg.socket_dir, sesh) catch |err| switch (err) {
 270            error.NameTooLong => return socket.printSessionNameTooLong(io, sesh, cfg.socket_dir),
 271            error.OutOfMemory => return err,
 272        };
 273        send(gpa, io, &cfg, sesh, socket_path, text_parts.items, .Output) catch |err| {
 274            if (err == error.SessionUnresponsive) std.process.exit(1);
 275            return printError(io, "print failed: {s}", .{@errorName(err)});
 276        };
 277    } else if (std.mem.eql(u8, cmd, "kill") or std.mem.eql(u8, cmd, "k")) {
 278        var stderr_buffer: [1024]u8 = undefined;
 279        var stderr_writer = std.Io.File.stderr().writer(io, &stderr_buffer);
 280        const stderr = &stderr_writer.interface;
 281
 282        var matchers: std.ArrayList(socket.SessionMatch) = .empty;
 283        defer {
 284            for (matchers.items) |m| {
 285                gpa.free(m.name);
 286            }
 287            matchers.deinit(gpa);
 288        }
 289        var force = false;
 290        while (args.next()) |session_name| {
 291            if (std.mem.eql(u8, session_name, "--help") or std.mem.eql(u8, session_name, "-h")) {
 292                return help(io);
 293            }
 294            if (std.mem.eql(u8, session_name, "--force")) {
 295                force = true;
 296                continue;
 297            }
 298            const m = try socket.parseSessionArg(gpa, session_name);
 299            try matchers.append(gpa, m);
 300        }
 301        if (matchers.items.len == 0) {
 302            return printError(io, "session name required", .{});
 303        }
 304        var sessions = try util.get_session_entries(gpa, io, cfg.socket_dir);
 305        defer {
 306            for (sessions.items) |session| {
 307                session.deinit(gpa);
 308            }
 309            sessions.deinit(gpa);
 310        }
 311
 312        var killed_any = false;
 313        for (sessions.items) |session| {
 314            for (matchers.items) |m| {
 315                if (!m.matches(session.name)) continue;
 316                kill(gpa, io, &cfg, session.name, force) catch |err| {
 317                    if (err == error.SessionNotFound and force) {
 318                        killed_any = true;
 319                        continue;
 320                    }
 321                    try stderr.print("failed to kill session={s}: {s}\n", .{ session.name, @errorName(err) });
 322                    try stderr.flush();
 323                };
 324                killed_any = true;
 325                break;
 326            }
 327        }
 328        if (!killed_any) {
 329            for (matchers.items) |m| {
 330                if (m.is_prefix) continue;
 331                kill(gpa, io, &cfg, m.name, force) catch |err| {
 332                    if (err == error.SessionNotFound and force) {
 333                        killed_any = true;
 334                        continue;
 335                    }
 336                    return printError(io, "failed to kill session={s}: {s}", .{ m.name, @errorName(err) });
 337                };
 338                killed_any = true;
 339            }
 340            if (!killed_any) return printError(io, "no matching sessions found", .{});
 341        }
 342    } else if (std.mem.eql(u8, cmd, "wait") or std.mem.eql(u8, cmd, "w")) {
 343        var matchers: std.ArrayList(socket.SessionMatch) = .empty;
 344        defer {
 345            for (matchers.items) |m| {
 346                gpa.free(m.name);
 347            }
 348            matchers.deinit(gpa);
 349        }
 350        while (args.next()) |session_name| {
 351            if (std.mem.eql(u8, session_name, "--help") or std.mem.eql(u8, session_name, "-h")) {
 352                return help(io);
 353            }
 354            const m = try socket.parseSessionArg(gpa, session_name);
 355            try matchers.append(gpa, m);
 356        }
 357        if (matchers.items.len == 0) {
 358            return printError(io, "session name required", .{});
 359        }
 360        return wait(gpa, io, &cfg, matchers);
 361    } else if (std.mem.eql(u8, cmd, "tail") or std.mem.eql(u8, cmd, "t")) {
 362        var matchers: std.ArrayList(socket.SessionMatch) = .empty;
 363        defer {
 364            for (matchers.items) |m| {
 365                gpa.free(m.name);
 366            }
 367            matchers.deinit(gpa);
 368        }
 369        while (args.next()) |session_name| {
 370            if (std.mem.eql(u8, session_name, "--help") or std.mem.eql(u8, session_name, "-h")) {
 371                return help(io);
 372            }
 373            const m = try socket.parseSessionArg(gpa, session_name);
 374            try matchers.append(gpa, m);
 375        }
 376        if (matchers.items.len == 0) {
 377            return printError(io, "session name required", .{});
 378        }
 379
 380        // Resolve matchers against session list to get actual session names.
 381        var resolved_names: std.ArrayList([]const u8) = .empty;
 382        defer {
 383            for (resolved_names.items) |name| {
 384                gpa.free(name);
 385            }
 386            resolved_names.deinit(gpa);
 387        }
 388
 389        var any_prefix = false;
 390        for (matchers.items) |m| {
 391            if (m.is_prefix) {
 392                any_prefix = true;
 393                break;
 394            }
 395        }
 396
 397        if (any_prefix) {
 398            var sessions = try util.get_session_entries(gpa, io, cfg.socket_dir);
 399            defer {
 400                for (sessions.items) |session| {
 401                    session.deinit(gpa);
 402                }
 403                sessions.deinit(gpa);
 404            }
 405            for (sessions.items) |session| {
 406                for (matchers.items) |m| {
 407                    if (m.matches(session.name)) {
 408                        try resolved_names.append(gpa, try gpa.dupe(u8, session.name));
 409                        break;
 410                    }
 411                }
 412            }
 413        }
 414        // Add exact-match names directly.
 415        for (matchers.items) |m| {
 416            if (!m.is_prefix) {
 417                try resolved_names.append(gpa, try gpa.dupe(u8, m.name));
 418            }
 419        }
 420
 421        if (resolved_names.items.len == 0) {
 422            return printError(io, "no matching sessions found", .{});
 423        }
 424
 425        var client_socket_fds = try std.ArrayList(i32).initCapacity(gpa, resolved_names.items.len);
 426        defer {
 427            for (client_socket_fds.items) |client_fd| {
 428                lib_posix.close(client_fd);
 429            }
 430            client_socket_fds.deinit(gpa);
 431        }
 432
 433        for (resolved_names.items) |session_name| {
 434            const socket_path = socket.getSocketPath(gpa, cfg.socket_dir, session_name) catch |err| switch (err) {
 435                error.NameTooLong => return socket.printSessionNameTooLong(init.io, session_name, cfg.socket_dir),
 436                error.OutOfMemory => return err,
 437            };
 438            const client_sock = socket.sessionConnect(socket_path) catch |err| {
 439                return printError(io, "cannot connect to session \"{s}\": {s}", .{ session_name, @errorName(err) });
 440            };
 441            try client_socket_fds.append(gpa, client_sock);
 442        }
 443        _ = try tail(gpa, client_socket_fds, false, false);
 444    } else if (std.mem.eql(u8, cmd, "write") or std.mem.eql(u8, cmd, "wr")) {
 445        const session_name = args.next() orelse "";
 446        if (std.mem.eql(u8, session_name, "--help") or std.mem.eql(u8, session_name, "-h")) {
 447            return help(io);
 448        }
 449        if (session_name.len == 0) {
 450            return printError(io, "session name required", .{});
 451        }
 452        const file_path = args.next() orelse "";
 453        if (std.mem.eql(u8, file_path, "--help") or std.mem.eql(u8, file_path, "-h")) {
 454            return help(io);
 455        }
 456        if (file_path.len == 0) {
 457            return printError(io, "file path required", .{});
 458        }
 459
 460        var cwd_buf: [std.fs.max_path_bytes]u8 = undefined;
 461        const cwd_len = std.process.currentPath(io, &cwd_buf) catch 0;
 462        const cwd = cwd_buf[0..cwd_len];
 463        const sesh = try socket.getSeshName(gpa, session_name);
 464        defer gpa.free(sesh);
 465        const socket_path = socket.getSocketPath(gpa, cfg.socket_dir, sesh) catch |err| switch (err) {
 466            error.NameTooLong => return socket.printSessionNameTooLong(io, sesh, cfg.socket_dir),
 467            error.OutOfMemory => return err,
 468        };
 469        var daemon = Daemon.init(io, &cfg, sesh, socket_path);
 470        daemon.is_task_mode = true;
 471        daemon.setCwd(cwd);
 472        daemon.shell = shell_env;
 473        std.log.info("socket path={s}", .{daemon.socket_path});
 474        writeFile(gpa, io, &daemon, file_path) catch |err| {
 475            if (err == error.SessionUnresponsive) std.process.exit(1);
 476            return printError(io, "write failed: {s}", .{@errorName(err)});
 477        };
 478    } else if (std.mem.eql(u8, cmd, "print-env")) {
 479        var shell_mode = false;
 480        var session_name: ?[]const u8 = null;
 481        var single_kv: []const u8 = "";
 482
 483        while (args.next()) |arg| {
 484            if (detectHelp(arg)) return help(io);
 485            if (std.mem.eql(u8, arg, "-s") or std.mem.eql(u8, arg, "--shell")) {
 486                shell_mode = true;
 487            } else if (session_name == null) {
 488                session_name = arg;
 489            } else if (single_kv.len == 0) {
 490                single_kv = arg;
 491            }
 492        }
 493
 494        const sesh_arg = session_name orelse {
 495            return printError(io, "session name required (or run inside a zmx session)", .{});
 496        };
 497        const sesh = try socket.resolveSessionOrEnv(gpa, io, sesh_arg);
 498        defer gpa.free(sesh);
 499        return envGet(gpa, io, &cfg, sesh, single_kv, shell_mode);
 500    } else {
 501        return printError(io, "unknown command \"{s}\"", .{cmd});
 502    }
 503}
 504
 505fn help(io: std.Io) !void {
 506    const help_text =
 507        \\zmx - session persistence for terminal processes
 508        \\
 509        \\Usage: zmx <command> [args...]
 510        \\
 511        \\Commands:
 512        \\  [a]ttach [--labels kv] <name> [command...]  Attach to session, creating if needed
 513        \\  [r]un <name> [-d] [command...]              Send command without attaching
 514        \\  [s]end <name> <text...>                     Send raw input to session PTY
 515        \\  [p]rint <name> <text...>                    Inject text into session display
 516        \\  [wr]ite <name> <file_path>                  Write stdin to file_path through the session
 517        \\  [d]etach                                    Detach all clients (ctrl+\\ for current client)
 518        \\  [l]ist|ls [--short]                         List active sessions
 519        \\  [g]et <name>                                Get session labels
 520        \\  set <name> k=v ...                          Set session labels (k= to remove)
 521        \\  [cl]ear <name>                              Clear all session labels
 522        \\  print-env [-s] <name> [key]                 Print tracked environment variables
 523        \\  [k]ill <name>... [--force]                  Kill session and all attached clients
 524        \\  [hi]story <name> [--vt|--html]              Output session scrollback
 525        \\  [w]ait <name>...                            Wait for session tasks to complete
 526        \\  [t]ail <name>...                            Follow session output
 527        \\  [c]ompletions <shell>                       Shell completions (bash, zsh, fish, nu, yash)
 528        \\  [v]ersion                                   Show version and metadata (socket dir, log dir)
 529        \\  [h]elp                                      Show this help
 530        \\
 531        \\Attach:
 532        \\  This will spawn a login $SHELL with a PTY.  You can provide a
 533        \\  command instead of creating a shell.
 534        \\
 535        \\  --labels applies labels as the session is created, in the same form
 536        \\  `zmx set` takes. A caller that creates and then labels in two steps
 537        \\  leaves an unlabelled session behind if it dies between them.
 538        \\
 539        \\  Examples:
 540        \\    zmx attach dev
 541        \\    zmx attach dev vim
 542        \\    zmx attach --labels "project=api role=worker" build
 543        \\
 544        \\History:
 545        \\  This should generally be used with `tail` to print the last lines
 546        \\  of the session's scrollback history.
 547        \\
 548        \\  Examples:
 549        \\    zmx history <session> | tail -100
 550        \\
 551        \\Run:
 552        \\  Commands run inside a PTY using bash
 553        \\  Commands are passed as-is: do not wrap in quotes.
 554        \\  Commands run sequentially: do not send multiple in parallel.
 555        \\  Stdin is redirected from /dev/null to prevent interactive programs
 556        \\  (pagers, editors, prompts) from blocking. Use `zmx send` for
 557        \\  commands that need user input, or pipe data directly:
 558        \\    echo "data" | zmx run dev cat
 559        \\
 560        \\  `-d` will detach from the calling terminal. Use `wait` to track
 561        \\  its status.
 562        \\
 563        \\  Examples:
 564        \\    zmx run dev ls
 565        \\    zmx run dev zig build
 566        \\    zmx run dev grep -r TODO src
 567        \\    zmx run dev git log --oneline          # pager won't block
 568        \\    echo "hello" | zmx run dev cat         # piped stdin still works
 569        \\
 570        \\    # heredoc
 571        \\    printf "cat << 'EOF'\r\nHello $USER\r\nToday is $(date).\r\nEOF" | zmx run dev
 572        \\
 573        \\    # non-blocking
 574        \\    zmx run dev -d sleep 10
 575        \\    zmx wait dev
 576        \\
 577        \\Send:
 578        \\  Sends raw text to the session's PTY input (fire-and-forget).
 579        \\  Unlike `run`, no completion marker is appended and no exit code
 580        \\  is tracked.  Useful for TUI applications, interactive prompts,
 581        \\  or any program that reads stdin directly.
 582        \\
 583        \\  Text is sent byte-for-byte with no automatic carriage return.
 584        \\  Append \r yourself when you want the shell to execute a command.
 585        \\
 586        \\  Text can also be piped via stdin:
 587        \\    printf 'ls -la\r' | zmx send dev
 588        \\
 589        \\  Examples:
 590        \\    printf 'echo hello\r' | zmx send dev
 591        \\    zmx send dev $(printf '\x03')
 592        \\    zmx send dev /compact
 593        \\
 594        \\Print:
 595        \\  Injects text directly into the session display and scrollback.
 596        \\  Never touches the PTY input -- the shell sees nothing.
 597        \\  Caller is responsible for newlines (\\r\\n).
 598        \\
 599        \\  Examples:
 600        \\    printf '\\r\\nhello\\r\\n' | zmx print dev
 601        \\    zmx print dev "$(printf '\\r\\nalert\\r\\n')"
 602        \\
 603        \\Write:
 604        \\  Writes stdin to file_path inside the session. Works over SSH.
 605        \\  file_path can be absolute or relative to the session shell's cwd.
 606        \\  Requires base64 and printf in the remote environment.
 607        \\  Large files are chunked automatically (~48KB per chunk).
 608        \\  File path must not contain single quotes.
 609        \\
 610        \\  Examples:
 611        \\    echo "hello" | zmx write dev /tmp/hello.txt
 612        \\    cat main.zig | zmx write dev src/main.zig
 613        \\
 614        \\Wait:
 615        \\  Used with a detached run task to track its status.  Multiple
 616        \\  sessions can be provided.
 617        \\
 618        \\  Examples:
 619        \\    zmx run dev -d sleep 10
 620        \\    zmx wait dev
 621        \\    zmx wait dev other
 622        \\
 623        \\Labels:
 624        \\  Attach key=value labels to live sessions for discovery and
 625        \\  filtering. Labels are in-memory and scoped to session lifetime.
 626        \\
 627        \\  Examples:
 628        \\    zmx set dev project=zmx env=dev
 629        \\    zmx set dev project=            # unset a label
 630        \\    zmx set . status=fail           # "." resolves to current session
 631        \\    zmx get dev
 632        \\    zmx get dev project
 633        \\    zmx set next "$(zmx get prev)"  # set labels from other session
 634        \\    zmx list | grep project=zmx
 635        \\    zmx clear dev
 636        \\
 637        \\Print-env:
 638        \\  Print tracked environment variables for the leader client of a session.
 639        \\
 640        \\  Flags:
 641        \\    -s, --shell       Output POSIX export/unset commands for eval
 642        \\
 643        \\  Examples:
 644        \\    zmx print-env .
 645        \\    zmx print-env -s .
 646        \\    zmx print-env dev
 647        \\    zmx print-env dev DISPLAY
 648        \\    eval "$(zmx print-env -s .)"    # inside shell session precmd hook
 649        \\
 650        \\Environment variables:
 651        \\  SHELL                Default shell for new sessions
 652        \\  ZMX_DIR              Socket directory (priority 1)
 653        \\  XDG_RUNTIME_DIR      Socket directory (priority 2)
 654        \\  TMPDIR               Socket directory (priority 3)
 655        \\  ZMX_SESSION          Session name (injected automatically; makes attach
 656        \\                       switch this session)
 657        \\  ZMX_SESSION_PREFIX   Prefix added to all session names
 658        \\  ZMX_TRACK_ENV        Comma-separated list of environment variables to track
 659        \\                       from attaching clients
 660        \\  ZMX_DIR_MODE         Sets mode for socket and log directories (octal, defaults to 0750)
 661        \\  ZMX_LOG_MODE         Sets mode for log files (octal, defaults to 0640)
 662        \\  ZMX_NO_DETACH_KEY    Disables the ctrl+\ detach shortcut (set to any value)
 663        \\
 664    ;
 665    var buf: [8192]u8 = undefined;
 666    var w = std.Io.File.stdout().writer(io, &buf);
 667    try w.interface.print(help_text, .{});
 668    try w.interface.flush();
 669}
 670
 671fn printVersion(io: std.Io, cfg: *Cfg) !void {
 672    var buf: [256]u8 = undefined;
 673    var w = std.Io.File.stdout().writer(io, &buf);
 674    try w.interface.print(
 675        "zmx\t\t{s}\nghostty_vt\t{s}\nsocket_dir\t{s}\nlog_dir\t\t{s}\n",
 676        .{ version, ghostty_version, cfg.socket_dir, cfg.log_dir },
 677    );
 678    try w.interface.flush();
 679}
 680
 681fn printCompletions(io: std.Io, shell: completions.Shell) !void {
 682    const script = shell.getCompletionScript();
 683    var buf: [8192]u8 = undefined;
 684    var w = std.Io.File.stdout().writer(io, &buf);
 685    try w.interface.print("{s}\n", .{script});
 686    try w.interface.flush();
 687}
 688
 689fn tail(alloc: std.mem.Allocator, client_socket_fds: std.ArrayList(i32), detached: bool, is_run_cmd: bool) !u8 {
 690    var poll_fds = try std.ArrayList(lib_posix.pollfd).initCapacity(alloc, 4);
 691    defer poll_fds.deinit(alloc);
 692
 693    var read_buf = try ipc.SocketBuffer.init(alloc);
 694    defer read_buf.deinit();
 695
 696    var stdout_buf = try std.ArrayList(u8).initCapacity(alloc, 4096);
 697    defer stdout_buf.deinit(alloc);
 698
 699    var is_first_line = true;
 700    var task_complete_code: ?u8 = null;
 701
 702    while (true) {
 703        poll_fds.clearRetainingCapacity();
 704
 705        // Poll socket for read
 706        for (client_socket_fds.items) |client_sock_fd| {
 707            try poll_fds.append(alloc, .{
 708                .fd = client_sock_fd,
 709                .events = lib_posix.POLL.IN,
 710                .revents = 0,
 711            });
 712        }
 713
 714        // Poll for write if we have pending data
 715        if (stdout_buf.items.len > 0) {
 716            try poll_fds.append(alloc, .{
 717                .fd = lib_posix.STDOUT_FILENO,
 718                .events = lib_posix.POLL.OUT,
 719                .revents = 0,
 720            });
 721        }
 722
 723        _ = lib_posix.poll(poll_fds.items, -1) catch |err| {
 724            if (err == error.Interrupted) continue; // EINTR from signal, loop again
 725            return err;
 726        };
 727
 728        // Handle socket read (incoming Output messages from daemon)
 729        for (poll_fds.items) |*poll_fd| {
 730            if (poll_fd.revents & lib_posix.POLL.IN != 0) {
 731                const n = read_buf.read(poll_fd.fd) catch |err| {
 732                    if (err == error.WouldBlock) continue;
 733                    if (err == error.ConnectionResetByPeer or err == error.BrokenPipe) {
 734                        return 1;
 735                    }
 736                    std.log.err("daemon read err={s}", .{@errorName(err)});
 737                    return err;
 738                };
 739                if (n == 0) {
 740                    // Server closed connection. If we got task completion,
 741                    // return the exit code. Otherwise fall back to 0.
 742                    if (task_complete_code) |exit_code| {
 743                        return exit_code;
 744                    }
 745                    return 0;
 746                }
 747
 748                while (read_buf.next()) |msg| {
 749                    switch (msg.header.tag) {
 750                        .Ack => {
 751                            if (detached) {
 752                                _ = lib_posix.write(lib_posix.STDOUT_FILENO, "command sent!\n") catch |err| blk: {
 753                                    if (err == error.WouldBlock) break :blk 0;
 754                                    return err;
 755                                };
 756                                return 0;
 757                            }
 758                        },
 759                        .Output => {
 760                            if (msg.payload.len > 0) {
 761                                //  TODO: figure out how to bring this back
 762                                // Fallback: scan output for task exit marker in case
 763                                // .TaskComplete was lost (e.g. daemon exited before
 764                                // flushing). This ensures we detect completion even
 765                                // when the IPC message doesn't arrive.
 766                                // if (task_complete_code == null and is_run_cmd) {
 767                                //     if (util.findTaskExitMarker(msg.payload)) |ec| {
 768                                //         task_complete_code = ec;
 769                                //     }
 770                                // }
 771
 772                                // Strip the first line (command echo) for run mode.
 773                                var payload = msg.payload;
 774                                if (!detached and is_run_cmd and is_first_line) {
 775                                    if (std.mem.indexOfScalar(u8, payload, '\n')) |nl| {
 776                                        is_first_line = false;
 777                                        payload = payload[nl + 1 ..];
 778                                    } else {
 779                                        is_first_line = false;
 780                                        payload = payload[payload.len..]; // consume entire echo line
 781                                    }
 782                                }
 783
 784                                if (payload.len > 0) {
 785                                    // Strip ANSI escape sequences to produce plain text.
 786                                    // This prevents shell prompts, colors, cursor movements,
 787                                    // and other VT sequences from corrupting the caller's terminal.
 788                                    const plain = util.stripAnsi(alloc, payload) catch |err| {
 789                                        std.log.warn("stripAnsi failed: {s}", .{@errorName(err)});
 790                                        continue;
 791                                    };
 792                                    defer alloc.free(plain);
 793                                    if (plain.len > 0) {
 794                                        try stdout_buf.appendSlice(alloc, plain);
 795                                    }
 796                                }
 797                            }
 798                        },
 799                        .TaskComplete => {
 800                            task_complete_code = if (msg.payload.len > 0) msg.payload[0] else 0;
 801                        },
 802                        else => {},
 803                    }
 804                }
 805            }
 806        }
 807
 808        // Check for task completion after processing socket messages.
 809        // This must be outside the stdout write block because .TaskComplete
 810        // can arrive after all output has already been flushed, leaving
 811        // stdout_buf empty. Without this check, tail() would poll forever.
 812        if (task_complete_code) |exit_code| {
 813            // Flush any remaining output before returning
 814            flush_loop: while (stdout_buf.items.len > 0) {
 815                const n = lib_posix.write(lib_posix.STDOUT_FILENO, stdout_buf.items) catch |err| {
 816                    if (err == error.WouldBlock) break :flush_loop;
 817                    return err;
 818                };
 819                try stdout_buf.replaceRange(alloc, 0, n, &[_]u8{});
 820            }
 821            return exit_code;
 822        }
 823
 824        if (stdout_buf.items.len > 0) {
 825            const n = lib_posix.write(lib_posix.STDOUT_FILENO, stdout_buf.items) catch |err| blk: {
 826                if (err == error.WouldBlock) break :blk 0;
 827                return err;
 828            };
 829            if (n > 0) {
 830                try stdout_buf.replaceRange(alloc, 0, n, &[_]u8{});
 831            }
 832        }
 833
 834        // Check for HUP/ERR on any socket
 835        for (poll_fds.items) |poll_fd| {
 836            if (poll_fd.revents & (lib_posix.POLL.HUP | lib_posix.POLL.ERR | lib_posix.POLL.NVAL) != 0) {
 837                return 0;
 838            }
 839        }
 840    }
 841}
 842
 843fn wait(alloc: std.mem.Allocator, io: std.Io, cfg: *Cfg, matchers: std.ArrayList(socket.SessionMatch)) !void {
 844    var stdout_buffer: [1024]u8 = undefined;
 845    var stdout_writer = std.Io.File.stdout().writer(io, &stdout_buffer);
 846    const stdout = &stdout_writer.interface;
 847
 848    var stderr_buffer: [1024]u8 = undefined;
 849    var stderr_writer = std.Io.File.stderr().writer(io, &stderr_buffer);
 850    const stderr = &stderr_writer.interface;
 851
 852    // Highest match count seen so far. Lets us distinguish "sessions haven't
 853    // appeared yet" (keep polling) from "sessions we were tracking
 854    // disappeared" (fail -- daemon crashed or was killed).
 855    var max_seen: i32 = 0;
 856    var zero_match_iters: u32 = 0;
 857
 858    var agg_exit_code: u8 = 0;
 859    var last_print: std.Io.Timestamp = .zero;
 860    var prev_done: i32 = 0;
 861    while (true) {
 862        agg_exit_code = 0;
 863        var sessions = try util.get_session_entries(alloc, io, cfg.socket_dir);
 864        var total: i32 = 0;
 865        var done: i32 = 0;
 866
 867        for (sessions.items) |session| {
 868            var found = false;
 869            for (matchers.items) |m| {
 870                if (m.matches(session.name)) {
 871                    found = true;
 872                    break;
 873                }
 874            }
 875            if (!found) {
 876                continue;
 877            }
 878
 879            total += 1;
 880            if (session.is_error) {
 881                // Daemon unreachable (probe timed out). On Timeout the socket
 882                // is no longer deleted, so this session would otherwise
 883                // persist as task_ended_at==0 forever → infinite "still
 884                // waiting". Count it as done+failed so wait terminates.
 885                try stderr.print(
 886                    "[{d}] task unreachable: {s} ({s})\n",
 887                    .{
 888                        std.Io.Timestamp.now(io, .real).toSeconds(),
 889                        session.name,
 890                        session.error_name orelse "unknown",
 891                    },
 892                );
 893                try stderr.flush();
 894                agg_exit_code = 1;
 895                done += 1;
 896                continue;
 897            }
 898            if (session.task_ended_at == 0) {
 899                const now = std.Io.Timestamp.now(io, .real);
 900                if (now.toSeconds() - last_print.toSeconds() >= 5) {
 901                    try stdout.print(
 902                        "[{d}] waiting task={s}\n",
 903                        .{ now.toSeconds(), session.name },
 904                    );
 905                    try stdout.flush();
 906                    last_print = now;
 907                }
 908                continue;
 909            }
 910            if (done >= prev_done) {
 911                // Newly completed — print immediately
 912                try stdout.print(
 913                    "[{d}] completed task={s} exit_code={d}\n",
 914                    .{ session.task_ended_at.?, session.name, session.task_exit_code.? },
 915                );
 916                try stdout.flush();
 917            }
 918            if (session.task_exit_code != 0) {
 919                agg_exit_code = session.task_exit_code orelse 0;
 920            }
 921            done += 1;
 922        }
 923
 924        for (sessions.items) |session| {
 925            session.deinit(alloc);
 926        }
 927        sessions.deinit(alloc);
 928
 929        // Check disappearance BEFORE completion: if one of N sessions
 930        // crashed and the remaining N-1 happen to be done, total==done
 931        // would be a false success.
 932        if (total < max_seen) {
 933            try stderr.print(
 934                "error: {d} session(s) disappeared before completing\n",
 935                .{max_seen - total},
 936            );
 937            try stderr.flush();
 938            std.process.exit(1);
 939            return;
 940        }
 941        max_seen = total;
 942
 943        if (total > 0 and total == done) {
 944            break;
 945        }
 946
 947        if (max_seen == 0) {
 948            // `zmx run foo && zmx wait foo` is essentially sequential, so
 949            // matching sessions should be visible from the first poll. If
 950            // nothing appears after a few iterations it's almost certainly a
 951            // typo, not a slow start.
 952            zero_match_iters += 1;
 953            if (zero_match_iters >= 3) {
 954                try stderr.print("error: no matching sessions found\n", .{});
 955                try stderr.flush();
 956                std.process.exit(2);
 957                return;
 958            }
 959        }
 960
 961        prev_done = done;
 962        std.Io.sleep(io, std.Io.Duration.fromMilliseconds(1000), .real) catch unreachable;
 963    }
 964
 965    if (agg_exit_code == 0) {
 966        try stdout.print("task(s) completed!\n", .{});
 967    } else {
 968        try stdout.print("task(s) failed!\n", .{});
 969    }
 970    try stdout.flush();
 971
 972    const sessions = try util.get_session_entries(alloc, io, cfg.socket_dir);
 973    for (sessions.items) |session| {
 974        var found = false;
 975        for (matchers.items) |m| {
 976            if (m.matches(session.name)) {
 977                found = true;
 978                break;
 979            }
 980        }
 981        if (!found) {
 982            continue;
 983        }
 984        if (session.task_exit_code.? > 0) {
 985            try stdout.print("---\n", .{});
 986            try stdout.print("[{d}] failed task={s} exit_status={d}\n", .{
 987                session.task_ended_at.?,
 988                session.name,
 989                session.task_exit_code.?,
 990            });
 991
 992            // Fetch and print the last 20 lines of history for debugging
 993            const history_lines: usize = 20;
 994            const history_text = fetchHistory(alloc, io, cfg, session.name) catch null;
 995            if (history_text) |text| {
 996                defer alloc.free(text);
 997                try stdout.print("\nLast {d} lines of {s} history:\n", .{ history_lines, session.name });
 998
 999                // Count lines and find the start of the last N lines
1000                var total_lines: usize = 0;
1001                var it = std.mem.splitScalar(u8, text, '\n');
1002                while (it.next()) |_| {
1003                    total_lines += 1;
1004                }
1005
1006                const skip = if (total_lines > history_lines) total_lines - history_lines else 0;
1007                var current: usize = 0;
1008                it = std.mem.splitScalar(u8, text, '\n');
1009                while (it.next()) |line| {
1010                    if (current >= skip) {
1011                        try stdout.print("{s}\n", .{line});
1012                    }
1013                    current += 1;
1014                }
1015            }
1016
1017            try stdout.print("\nSee the logs:\nzmx history {s}\nzmx attach {s}\n", .{ session.name, session.name });
1018            try stdout.flush();
1019        }
1020    }
1021
1022    std.process.exit(agg_exit_code);
1023}
1024
1025fn list(alloc: std.mem.Allocator, io: std.Io, cfg: *Cfg, short: bool) !void {
1026    const current_session = socket.getSeshNameFromEnv();
1027    var buf: [4096]u8 = undefined;
1028    var stdout = std.Io.File.stdout().writer(io, &buf);
1029    var sessions = try util.get_session_entries(alloc, io, cfg.socket_dir);
1030    defer {
1031        for (sessions.items) |session| {
1032            session.deinit(alloc);
1033        }
1034        sessions.deinit(alloc);
1035    }
1036
1037    if (sessions.items.len == 0) {
1038        if (short) return;
1039        var errbuf: [4096]u8 = undefined;
1040        var stderr = std.Io.File.stderr().writer(io, &errbuf);
1041        try stderr.interface.print("no sessions found in {s}\n", .{cfg.socket_dir});
1042        try stderr.interface.flush();
1043        return;
1044    }
1045
1046    std.mem.sort(util.SessionEntry, sessions.items, {}, util.SessionEntry.lessThan);
1047
1048    for (sessions.items) |session| {
1049        if (session.is_error) {
1050            try util.writeSessionLine(&stdout.interface, session, short, current_session);
1051            try stdout.interface.flush();
1052            continue;
1053        }
1054
1055        try util.writeSessionLine(&stdout.interface, session, short, current_session);
1056        try stdout.interface.flush();
1057    }
1058}
1059
1060fn detachAll(alloc: std.mem.Allocator, io: std.Io, cfg: *Cfg) !void {
1061    const session_name = socket.getSeshNameFromEnv();
1062    if (session_name.len == 0) {
1063        return printError(io, "not inside a zmx session (ZMX_SESSION not set)", .{});
1064    }
1065    std.log.info("detach all session={s}", .{session_name});
1066
1067    var dir = try std.Io.Dir.openDirAbsolute(io, cfg.socket_dir, .{});
1068    defer dir.close(io);
1069
1070    const socket_path = socket.getSocketPath(alloc, cfg.socket_dir, session_name) catch |err| switch (err) {
1071        error.NameTooLong => return socket.printSessionNameTooLong(io, session_name, cfg.socket_dir),
1072        error.OutOfMemory => return err,
1073    };
1074    defer alloc.free(socket_path);
1075    const fd = ipc.connectSession(socket_path) catch |err| {
1076        std.log.err("session unresponsive: {s}", .{@errorName(err)});
1077        if (err == error.ConnectionRefused) socket.cleanupStaleSocket(io, dir, session_name);
1078        return;
1079    };
1080    defer lib_posix.close(fd);
1081    ipc.send(fd, .DetachAll, "") catch |err| switch (err) {
1082        error.BrokenPipe, error.ConnectionResetByPeer => return,
1083        else => return err,
1084    };
1085}
1086
1087fn kill(alloc: std.mem.Allocator, io: std.Io, cfg: *Cfg, session_name: []const u8, force: bool) !void {
1088    std.log.info("kill session={s}", .{session_name});
1089    const socket_path = socket.getSocketPath(alloc, cfg.socket_dir, session_name) catch |err| switch (err) {
1090        error.NameTooLong => return socket.printSessionNameTooLong(io, session_name, cfg.socket_dir),
1091        error.OutOfMemory => return err,
1092    };
1093    defer alloc.free(socket_path);
1094
1095    var dir = try std.Io.Dir.openDirAbsolute(io, cfg.socket_dir, .{});
1096    defer dir.close(io);
1097
1098    const exists = try socket.sessionExists(io, dir, session_name);
1099    if (!exists) {
1100        return error.SessionNotFound;
1101    }
1102    const fd = ipc.connectSession(socket_path) catch |err| {
1103        std.log.err("session unresponsive: {s}", .{@errorName(err)});
1104        if (force or err == error.ConnectionRefused) {
1105            socket.cleanupStaleSocket(io, dir, session_name);
1106            var ebuf: [4096]u8 = undefined;
1107            var ew = std.Io.File.stderr().writer(io, &ebuf);
1108            ew.interface.print("cleaned up stale session {s}\n", .{session_name}) catch {};
1109            ew.interface.flush() catch {};
1110            return;
1111        } else {
1112            return error.SessionUnresponsive;
1113        }
1114    };
1115
1116    defer lib_posix.close(fd);
1117    ipc.send(fd, .Kill, "") catch |err| switch (err) {
1118        error.BrokenPipe, error.ConnectionResetByPeer => return,
1119        else => return err,
1120    };
1121
1122    // Block until the daemon hangs up. The daemon's shutdown defer closes
1123    // and unlinks the listen socket before it closes client connections,
1124    // so by the time we read EOF here the session name is free for reuse
1125    // and a subsequent `zmx run <name>` can't land in the dying daemon's
1126    // accept backlog.
1127    var drain: [256]u8 = undefined;
1128    while (true) {
1129        const n = lib_posix.read(fd, &drain) catch break;
1130        if (n == 0) break;
1131    }
1132
1133    var buf: [100]u8 = undefined;
1134    var w = std.Io.File.stdout().writer(io, &buf);
1135    try w.interface.print("killed session {s}\n", .{session_name});
1136    try w.interface.flush();
1137}
1138
1139fn printLabelError(io: std.Io, session_name: []const u8, err: anyerror) noreturn {
1140    var buf: [4096]u8 = undefined;
1141    var w = std.Io.File.stderr().writer(io, &buf);
1142    switch (err) {
1143        error.Timeout => w.interface.print(
1144            "error: session \"{s}\" does not support labels (daemon too old?)\n",
1145            .{session_name},
1146        ) catch {},
1147        error.ConnectionRefused, error.Unexpected => w.interface.print(
1148            "error: session \"{s}\" not found or unresponsive\n",
1149            .{session_name},
1150        ) catch {},
1151        else => w.interface.print(
1152            "error: {s}\n",
1153            .{@errorName(err)},
1154        ) catch {},
1155    }
1156    w.interface.flush() catch {};
1157    std.process.exit(1);
1158}
1159
1160fn labelGet(alloc: std.mem.Allocator, io: std.Io, cfg: *Cfg, session_name: []const u8, single_kv: []const u8) !void {
1161    std.log.info("label get session={s}", .{session_name});
1162
1163    const socket_path = socket.getSocketPath(alloc, cfg.socket_dir, session_name) catch |err| switch (err) {
1164        error.NameTooLong => return socket.printSessionNameTooLong(io, session_name, cfg.socket_dir),
1165        error.OutOfMemory => return err,
1166    };
1167    defer alloc.free(socket_path);
1168
1169    const payload = ipc.roundTripForTag(alloc, socket_path, .LabelGet, "", .LabelData) catch |err| {
1170        printLabelError(io, session_name, err);
1171    };
1172    defer alloc.free(payload);
1173
1174    var buf: [4096]u8 = undefined;
1175    var stdout = std.Io.File.stdout().writer(io, &buf);
1176    if (single_kv.len == 0) {
1177        try stdout.interface.print("{s}", .{payload});
1178        try stdout.interface.flush();
1179        return;
1180    }
1181
1182    const val = label.getLabelValueFromPairs(single_kv, payload) catch |err| switch (err) {
1183        error.LabelKeyNotFound => return printError(io, "label key \"{s}\" not found in session \"{s}\"", .{ single_kv, session_name }),
1184    };
1185    try stdout.interface.print("{s}", .{val});
1186    try stdout.interface.flush();
1187}
1188
1189/// Rejects a malformed label set before the caller acts on it. Exits rather
1190/// than returning, so `attach --labels` can check its labels before a session
1191/// exists to be left behind.
1192fn assertLabels(io: std.Io, labels: []const u8) void {
1193    var kvs = label.LabelIterator.init(labels);
1194    while (kvs.next()) |kv| {
1195        label.assertLabel(kv.key, kv.value) catch |err| {
1196            var buf: [4096]u8 = undefined;
1197            var w = std.Io.File.stderr().writer(io, &buf);
1198            const msg = "error: key-value kvs can only contain [a-z, A-Z, 0-9, -_.] characters";
1199            switch (err) {
1200                error.LabelKeyEmpty => {
1201                    w.interface.print("error: label key cannot be empty\n", .{}) catch {};
1202                },
1203                error.LabelKeyReservedName => {
1204                    w.interface.print("error: \"{s}\" is a read-only built-in field\n", .{kv.key}) catch {};
1205                },
1206                error.LabelKeyInvalidChar => {
1207                    w.interface.print("{s}: key=[{s}]\n", .{ msg, kv.key }) catch {};
1208                },
1209                error.LabelValueInvalidChar => {
1210                    w.interface.print("{s}: value=[{s}]\n", .{ msg, kv.value }) catch {};
1211                },
1212            }
1213            w.interface.flush() catch {};
1214            std.process.exit(1);
1215        };
1216    }
1217}
1218
1219fn getTrackedEnvStr(
1220    alloc: std.mem.Allocator,
1221    env_keys: []const u8,
1222    env_map: *const std.process.Environ.Map,
1223) ![]u8 {
1224    var out = std.ArrayList(u8).empty;
1225    errdefer out.deinit(alloc);
1226
1227    var iter = std.mem.splitScalar(u8, env_keys, ',');
1228    while (iter.next()) |entry| {
1229        const env = std.mem.trim(u8, entry, " \t\r\n");
1230        if (env.len == 0) continue;
1231        if (env_map.get(env)) |val| {
1232            try out.appendSlice(alloc, env);
1233            try out.append(alloc, '=');
1234            try out.appendSlice(alloc, val);
1235            try out.append(alloc, '\n');
1236        } else {
1237            try out.append(alloc, '-');
1238            try out.appendSlice(alloc, env);
1239            try out.append(alloc, '\n');
1240        }
1241    }
1242
1243    return out.toOwnedSlice(alloc);
1244}
1245
1246pub const EnvEntry = struct {
1247    key: []const u8,
1248    value: ?[]const u8,
1249};
1250
1251pub const EnvIterator = struct {
1252    it: std.mem.SplitIterator(u8, .scalar),
1253
1254    pub fn init(payload: []const u8) EnvIterator {
1255        return .{ .it = std.mem.splitScalar(u8, payload, '\n') };
1256    }
1257
1258    pub fn next(self: *EnvIterator) ?EnvEntry {
1259        while (self.it.next()) |line| {
1260            if (line.len == 0) continue;
1261            if (line[0] == '-') {
1262                return .{ .key = line[1..], .value = null };
1263            } else if (std.mem.indexOfScalar(u8, line, '=')) |eq_idx| {
1264                return .{ .key = line[0..eq_idx], .value = line[eq_idx + 1 ..] };
1265            }
1266        }
1267        return null;
1268    }
1269};
1270
1271fn getEnvValue(key: []const u8, payload: []const u8) error{EnvVarNotFound}![]const u8 {
1272    var it = EnvIterator.init(payload);
1273    while (it.next()) |entry| {
1274        if (std.mem.eql(u8, entry.key, key)) {
1275            return entry.value orelse return error.EnvVarNotFound;
1276        }
1277    }
1278    return error.EnvVarNotFound;
1279}
1280
1281fn printEnvError(io: std.Io, session_name: []const u8, err: anyerror) noreturn {
1282    var buf: [4096]u8 = undefined;
1283    var w = std.Io.File.stderr().writer(io, &buf);
1284    switch (err) {
1285        error.SessionNotFound => w.interface.print("error: session \"{s}\" not found\n", .{session_name}) catch {},
1286        error.DaemonUnresponsive => w.interface.print("error: session \"{s}\" daemon unresponsive\n", .{session_name}) catch {},
1287        error.EnvVarNotFound => w.interface.print("error: environment variable not found\n", .{}) catch {},
1288        else => w.interface.print(
1289            "error: could not communicate with session \"{s}\" err={s}\n",
1290            .{ session_name, @errorName(err) },
1291        ) catch {},
1292    }
1293    w.interface.flush() catch {};
1294    std.process.exit(1);
1295}
1296
1297fn envGet(alloc: std.mem.Allocator, io: std.Io, cfg: *Cfg, session_name: []const u8, single_kv: []const u8, shell_mode: bool) !void {
1298    std.log.info("env get session={s}", .{session_name});
1299
1300    const socket_path = socket.getSocketPath(alloc, cfg.socket_dir, session_name) catch |err| switch (err) {
1301        error.NameTooLong => return socket.printSessionNameTooLong(io, session_name, cfg.socket_dir),
1302        error.OutOfMemory => return err,
1303    };
1304    defer alloc.free(socket_path);
1305
1306    const payload = ipc.roundTripForTag(alloc, socket_path, .EnvGet, "", .EnvData) catch |err| {
1307        printEnvError(io, session_name, err);
1308    };
1309    defer alloc.free(payload);
1310
1311    var buf: [4096]u8 = undefined;
1312    var stdout = std.Io.File.stdout().writer(io, &buf);
1313    if (single_kv.len > 0) {
1314        const val = getEnvValue(single_kv, payload) catch |err| {
1315            printEnvError(io, session_name, err);
1316        };
1317        try stdout.interface.print("{s}\n", .{val});
1318        try stdout.interface.flush();
1319        return;
1320    }
1321
1322    if (payload.len == 0) return;
1323
1324    if (shell_mode) {
1325        var it = EnvIterator.init(payload);
1326        while (it.next()) |entry| {
1327            if (entry.value) |val| {
1328                try stdout.interface.print("export {s}='", .{entry.key});
1329                for (val) |c| {
1330                    if (c == '\'') {
1331                        try stdout.interface.print("'\\''", .{});
1332                    } else {
1333                        try stdout.interface.print("{c}", .{c});
1334                    }
1335                }
1336                try stdout.interface.print("';\n", .{});
1337            } else {
1338                try stdout.interface.print("unset {s};\n", .{entry.key});
1339            }
1340        }
1341    } else {
1342        try stdout.interface.print("{s}", .{payload});
1343    }
1344    try stdout.interface.flush();
1345}
1346
1347fn labelSet(alloc: std.mem.Allocator, io: std.Io, cfg: *Cfg, session_name: []const u8, labels: []const u8) !void {
1348    std.log.info("label set session={s}", .{session_name});
1349
1350    assertLabels(io, labels);
1351
1352    const socket_path = socket.getSocketPath(alloc, cfg.socket_dir, session_name) catch |err| switch (err) {
1353        error.NameTooLong => return socket.printSessionNameTooLong(io, session_name, cfg.socket_dir),
1354        error.OutOfMemory => return err,
1355    };
1356    defer alloc.free(socket_path);
1357
1358    _ = ipc.roundTripForTag(alloc, socket_path, .LabelSet, labels, .Ack) catch |err| {
1359        printLabelError(io, session_name, err);
1360    };
1361}
1362
1363fn labelClear(alloc: std.mem.Allocator, io: std.Io, cfg: *Cfg, session_name: []const u8) !void {
1364    std.log.info("label clear session={s}", .{session_name});
1365
1366    const socket_path = socket.getSocketPath(alloc, cfg.socket_dir, session_name) catch |err| switch (err) {
1367        error.NameTooLong => return socket.printSessionNameTooLong(io, session_name, cfg.socket_dir),
1368        error.OutOfMemory => return err,
1369    };
1370    defer alloc.free(socket_path);
1371
1372    _ = ipc.roundTripForTag(alloc, socket_path, .LabelClear, "", .Ack) catch |err| {
1373        printLabelError(io, session_name, err);
1374    };
1375}
1376
1377/// Fetch terminal history from a session socket, returning it as an allocated
1378/// string. Caller owns the returned memory and must free it.
1379fn fetchHistory(
1380    alloc: std.mem.Allocator,
1381    io: std.Io,
1382    cfg: *Cfg,
1383    session_name: []const u8,
1384) ![]const u8 {
1385    std.log.info("fetch history session={s}", .{session_name});
1386    const socket_path = socket.getSocketPath(alloc, cfg.socket_dir, session_name) catch |err| switch (err) {
1387        error.NameTooLong => return error.NameTooLong,
1388        error.OutOfMemory => return err,
1389    };
1390    defer alloc.free(socket_path);
1391
1392    var dir = try std.Io.Dir.openDirAbsolute(io, cfg.socket_dir, .{});
1393    defer dir.close(io);
1394
1395    const exists = try socket.sessionExists(io, dir, session_name);
1396    if (!exists) {
1397        return error.SessionNotFound;
1398    }
1399
1400    const fd = ipc.connectSession(socket_path) catch |err| {
1401        if (err == error.ConnectionRefused) socket.cleanupStaleSocket(io, dir, session_name);
1402        return err;
1403    };
1404    defer lib_posix.close(fd);
1405
1406    const format_byte: u8 = @intFromEnum(util.HistoryFormat.plain);
1407    const payload = [_]u8{format_byte};
1408    ipc.send(fd, .History, &payload) catch |err| switch (err) {
1409        error.BrokenPipe, error.ConnectionResetByPeer => return error.SessionUnresponsive,
1410        else => return err,
1411    };
1412
1413    var sb = try ipc.SocketBuffer.init(alloc);
1414    defer sb.deinit();
1415
1416    var result = std.ArrayList(u8).initCapacity(alloc, 4096) catch return error.OutOfMemory;
1417    errdefer result.deinit(alloc);
1418
1419    while (true) {
1420        var poll_fds = [_]lib_posix.pollfd{.{ .fd = fd, .events = lib_posix.POLL.IN, .revents = 0 }};
1421        const poll_result = lib_posix.poll(&poll_fds, 5000) catch return error.Timeout;
1422        if (poll_result == 0) {
1423            return error.Timeout;
1424        }
1425
1426        const n = sb.read(fd) catch return error.ReadFailed;
1427        if (n == 0) break;
1428
1429        while (sb.next()) |msg| {
1430            if (msg.header.tag == .History) {
1431                try result.appendSlice(alloc, msg.payload);
1432                return result.toOwnedSlice(alloc);
1433            }
1434        }
1435    }
1436
1437    return error.NoHistoryResponse;
1438}
1439
1440fn history(alloc: std.mem.Allocator, io: std.Io, cfg: *Cfg, session_name: []const u8, format: util.HistoryFormat) !void {
1441    std.log.info("history session={s}", .{session_name});
1442
1443    const socket_path = socket.getSocketPath(alloc, cfg.socket_dir, session_name) catch |err| switch (err) {
1444        error.NameTooLong => return socket.printSessionNameTooLong(io, session_name, cfg.socket_dir),
1445        error.OutOfMemory => return err,
1446    };
1447    defer alloc.free(socket_path);
1448
1449    var dir = try std.Io.Dir.openDirAbsolute(io, cfg.socket_dir, .{});
1450    defer dir.close(io);
1451
1452    const exists = try socket.sessionExists(io, dir, session_name);
1453    if (!exists) {
1454        return printError(io, "session \"{s}\" does not exist", .{session_name});
1455    }
1456    const fd = ipc.connectSession(socket_path) catch |err| {
1457        std.log.err("session unresponsive: {s}", .{@errorName(err)});
1458        if (err == error.ConnectionRefused) socket.cleanupStaleSocket(io, dir, session_name);
1459        return printError(io, "session \"{s}\" is unresponsive ({s})", .{ session_name, @errorName(err) });
1460    };
1461    defer lib_posix.close(fd);
1462
1463    const format_byte = [_]u8{@intFromEnum(format)};
1464    ipc.send(fd, .History, &format_byte) catch |err| switch (err) {
1465        error.BrokenPipe, error.ConnectionResetByPeer => return,
1466        else => return err,
1467    };
1468
1469    var sb = try ipc.SocketBuffer.init(alloc);
1470    defer sb.deinit();
1471
1472    while (true) {
1473        var poll_fds = [_]lib_posix.pollfd{.{ .fd = fd, .events = lib_posix.POLL.IN, .revents = 0 }};
1474        const poll_result = lib_posix.poll(&poll_fds, 5000) catch return;
1475        if (poll_result == 0) {
1476            std.log.err("timeout waiting for history response", .{});
1477            return;
1478        }
1479
1480        const n = sb.read(fd) catch return;
1481        if (n == 0) return;
1482
1483        while (sb.next()) |msg| {
1484            if (msg.header.tag == .History) {
1485                _ = lib_posix.write(lib_posix.STDOUT_FILENO, msg.payload) catch return;
1486                return;
1487            }
1488        }
1489    }
1490}
1491
1492fn switchSesh(gpa: std.mem.Allocator, io: std.Io, daemon: *Daemon, current_sesh: []const u8) !void {
1493    // we want daemon.session_name because that's the session name the user provided during zmx attach
1494    // instead of the name of the session they are currently inside of.
1495    const next_session = daemon.session_name;
1496    std.log.info("switch session cur={s} next={s}", .{ current_sesh, next_session });
1497
1498    const socket_path = socket.getSocketPath(gpa, daemon.cfg.socket_dir, current_sesh) catch |err| switch (err) {
1499        error.NameTooLong => return socket.printSessionNameTooLong(io, current_sesh, daemon.cfg.socket_dir),
1500        error.OutOfMemory => return err,
1501    };
1502    defer gpa.free(socket_path);
1503
1504    var dir = try std.Io.Dir.openDirAbsolute(io, daemon.cfg.socket_dir, .{});
1505    defer dir.close(io);
1506
1507    const exists = try socket.sessionExists(io, dir, current_sesh);
1508    if (!exists) {
1509        return printError(io, "session \"{s}\" does not exist", .{current_sesh});
1510    }
1511    const fd = ipc.connectSession(socket_path) catch |err| {
1512        std.log.err("session unresponsive: {s}", .{@errorName(err)});
1513        if (err == error.ConnectionRefused) socket.cleanupStaleSocket(io, dir, current_sesh);
1514        return printError(io, "session \"{s}\" is unresponsive ({s})", .{ current_sesh, @errorName(err) });
1515    };
1516    defer lib_posix.close(fd);
1517
1518    ipc.send(fd, .Switch, next_session) catch |err| switch (err) {
1519        error.BrokenPipe, error.ConnectionResetByPeer => return,
1520        else => return err,
1521    };
1522}
1523
1524const AttachArgs = struct {
1525    /// Session name, or "" when the caller did not name one.
1526    session_name: []const u8 = "",
1527    /// Index of the first word of the session command.
1528    command_start: usize = 0,
1529    /// `--labels "k=v ..."`: labels to apply once the session exists, in the
1530    /// same space-separated form `zmx set` takes.
1531    labels: ?[]const u8 = null,
1532    want_help: bool = false,
1533    /// `--labels` was given with nothing to apply.
1534    missing_labels_value: bool = false,
1535};
1536
1537/// Parses the arguments that follow `zmx attach`. Flags are only recognized
1538/// before the session name, so everything after it stays part of the command
1539/// handed to the session.
1540fn parseAttachArgs(argv: []const []const u8) AttachArgs {
1541    const labels_flag = "--labels";
1542    var parsed: AttachArgs = .{};
1543    var i: usize = 0;
1544    while (i < argv.len) : (i += 1) {
1545        const arg = argv[i];
1546        if (std.mem.eql(u8, arg, "--help") or std.mem.eql(u8, arg, "-h")) {
1547            parsed.want_help = true;
1548            return parsed;
1549        }
1550        if (std.mem.startsWith(u8, arg, labels_flag ++ "=")) {
1551            parsed.labels = arg[labels_flag.len + 1 ..];
1552            continue;
1553        }
1554        if (std.mem.eql(u8, arg, labels_flag)) {
1555            if (i + 1 >= argv.len) {
1556                parsed.missing_labels_value = true;
1557                parsed.command_start = argv.len;
1558                return parsed;
1559            }
1560            i += 1;
1561            parsed.labels = argv[i];
1562            continue;
1563        }
1564        parsed.session_name = arg;
1565        i += 1;
1566        break;
1567    }
1568    parsed.command_start = i;
1569    return parsed;
1570}
1571
1572fn attach(gpa: std.mem.Allocator, io: std.Io, daemon: *Daemon, env_str: []const u8, labels: ?[]const u8) !void {
1573    const sesh = socket.getSeshNameFromEnv();
1574    if (sesh.len > 0) {
1575        return switchSesh(gpa, io, daemon, sesh);
1576    }
1577
1578    const is_daemon_proc = try daemon.ensureSession(io);
1579    if (is_daemon_proc) return;
1580
1581    // The session exists now, so labels land before the client takes over the
1582    // terminal. Doing it here rather than in a follow-up `zmx set` keeps a
1583    // supervisor from leaving an unlabelled session behind if it dies in
1584    // between the two calls.
1585    if (labels) |kvs| {
1586        try labelSet(gpa, io, daemon.cfg, daemon.session_name, kvs);
1587    }
1588
1589    const client_sock = socket.sessionConnect(daemon.socket_path) catch |err| {
1590        return printError(io, "cannot connect to session \"{s}\": {s}", .{ daemon.session_name, @errorName(err) });
1591    };
1592    std.log.info("attached session={s}", .{daemon.session_name});
1593    //  This is typically used with tcsetattr() to modify terminal settings.
1594    //      - you first get the current settings with tcgetattr()
1595    //      - modify the desired attributes in the termios structure
1596    //      - then apply the changes with tcsetattr().
1597    //  This prevents unintended side effects by preserving other settings.
1598    // restore stdin fd to its original state after exiting.
1599    // Use TCSAFLUSH to discard any unread input, preventing stale input after detach.
1600    //
1601    // tcgetattr fails when stdin is not a TTY (e.g. piped). In that case,
1602    // skip terminal setup entirely rather than applying undefined stack bytes
1603    // via tcsetattr.
1604    var orig_termios: cross.c.termios = undefined;
1605    const stdin_is_tty = cross.c.tcgetattr(lib_posix.STDIN_FILENO, &orig_termios) == 0;
1606
1607    // RIS, OSC 10/11/12
1608    const restore_seq = "\x1bc\x1b]110\x1b\\\x1b]111\x1b\\\x1b]112\x1b\\";
1609
1610    defer {
1611        if (stdin_is_tty) {
1612            _ = cross.c.tcsetattr(lib_posix.STDIN_FILENO, cross.c.TCSAFLUSH, &orig_termios);
1613        }
1614        // Reset terminal modes on detach
1615        _ = lib_posix.write(lib_posix.STDOUT_FILENO, restore_seq) catch {};
1616    }
1617
1618    if (stdin_is_tty) {
1619        var raw_termios = orig_termios;
1620        //  set raw mode after successful connection.
1621        //      disables canonical mode (line buffering), input echoing, signal generation from
1622        //      control characters (like Ctrl+C), and flow control.
1623        cross.c.cfmakeraw(&raw_termios);
1624
1625        // Additional granular raw mode settings for precise control
1626        // (matches what abduco and shpool do)
1627        raw_termios.c_cc[cross.c.VLNEXT] = cross.c._POSIX_VDISABLE; // Disable literal-next (Ctrl-V)
1628        // We want to intercept Ctrl+\ (SIGQUIT) so we can use it as a detach key
1629        raw_termios.c_cc[cross.c.VQUIT] = cross.c._POSIX_VDISABLE; // Disable SIGQUIT (Ctrl+\)
1630        raw_termios.c_cc[cross.c.VMIN] = 1; // Minimum chars to read: return after 1 byte
1631        raw_termios.c_cc[cross.c.VTIME] = 0; // Read timeout: no timeout, return immediately
1632
1633        _ = cross.c.tcsetattr(lib_posix.STDIN_FILENO, cross.c.TCSANOW, &raw_termios);
1634    }
1635
1636    // Clear screen before attaching. This provides a clean slate before
1637    // the session restore.
1638    const clear_seq = "\x1b[2J\x1b[H";
1639    _ = try lib_posix.write(lib_posix.STDOUT_FILENO, clear_seq);
1640
1641    const looper = try loop.clientLoop(client_sock, env_str);
1642    switch (looper.kind) {
1643        .detach => return,
1644        .switch_session => {
1645            if (looper.session_name) |session_name| {
1646                // Reset terminal modes when switching sessions
1647                _ = lib_posix.write(lib_posix.STDOUT_FILENO, restore_seq) catch {};
1648
1649                const target_path = socket.getSocketPath(
1650                    gpa,
1651                    daemon.cfg.socket_dir,
1652                    session_name,
1653                ) catch |err| switch (err) {
1654                    error.NameTooLong => return socket.printSessionNameTooLong(
1655                        io,
1656                        session_name,
1657                        daemon.cfg.socket_dir,
1658                    ),
1659                    error.OutOfMemory => return err,
1660                };
1661
1662                var target_daemon = Daemon.init(io, daemon.cfg, session_name, target_path);
1663                // Use the cwd from the previous daemon if available (sent by the daemon),
1664                // otherwise fall back to the client's original cwd
1665                const switch_cwd = looper.cwd orelse daemon.cwd;
1666                std.log.info("switching to new session cwd={s}", .{switch_cwd});
1667                target_daemon.setCwd(switch_cwd);
1668                target_daemon.shell = daemon.shell;
1669                return attach(gpa, io, &target_daemon, env_str, null);
1670            }
1671        },
1672    }
1673}
1674
1675fn writeFile(gpa: std.mem.Allocator, io: std.Io, daemon: *Daemon, file_path: []const u8) !void {
1676    const is_daemon_proc = try daemon.ensureSession(io);
1677    if (is_daemon_proc) return;
1678
1679    var buf: [4096]u8 = undefined;
1680    var w = std.Io.File.stdout().writer(io, &buf);
1681
1682    const stdin_fd = lib_posix.STDIN_FILENO;
1683    var stdin_buf = try std.ArrayList(u8).initCapacity(gpa, 4096);
1684    defer stdin_buf.deinit(gpa);
1685
1686    while (true) {
1687        var tmp: [4096]u8 = undefined;
1688        const n = lib_posix.read(stdin_fd, &tmp) catch |err| {
1689            if (err == error.WouldBlock) break;
1690            return err;
1691        };
1692        if (n == 0) break;
1693        try stdin_buf.appendSlice(gpa, tmp[0..n]);
1694    }
1695
1696    const socket_path = socket.getSocketPath(
1697        gpa,
1698        daemon.cfg.socket_dir,
1699        daemon.session_name,
1700    ) catch |err| switch (err) {
1701        error.NameTooLong => return socket.printSessionNameTooLong(
1702            io,
1703            daemon.session_name,
1704            daemon.cfg.socket_dir,
1705        ),
1706        error.OutOfMemory => return err,
1707    };
1708    var dir = try std.Io.Dir.openDirAbsolute(io, daemon.cfg.socket_dir, .{});
1709    defer dir.close(io);
1710
1711    const result = ipc.probeSession(gpa, socket_path) catch |err| {
1712        var errbuf: [4096]u8 = undefined;
1713        var ew = std.Io.File.stderr().writer(io, &errbuf);
1714        if (err == error.ConnectionRefused) {
1715            socket.cleanupStaleSocket(io, dir, daemon.session_name);
1716            ew.interface.print("cleaned up stale session {s}\n", .{daemon.session_name}) catch {};
1717        } else {
1718            ew.interface.print(
1719                "session {s} is unresponsive ({s})\ndaemon may be busy: try again\n",
1720                .{ daemon.session_name, @errorName(err) },
1721            ) catch {};
1722        }
1723        ew.interface.flush() catch {};
1724        return error.SessionUnresponsive;
1725    };
1726
1727    defer result.deinit();
1728
1729    // Build wire payload: [u32 path len][path bytes][file content]
1730    var wire_buf = try std.ArrayList(u8).initCapacity(
1731        gpa,
1732        @sizeOf(u32) + file_path.len + stdin_buf.items.len,
1733    );
1734    defer wire_buf.deinit(gpa);
1735    const path_len: u32 = @intCast(file_path.len);
1736    try wire_buf.appendSlice(gpa, std.mem.asBytes(&path_len));
1737    try wire_buf.appendSlice(gpa, file_path);
1738    try wire_buf.appendSlice(gpa, stdin_buf.items);
1739
1740    ipc.send(result.fd, .Write, wire_buf.items) catch |err| switch (err) {
1741        error.BrokenPipe, error.ConnectionResetByPeer => return,
1742        else => return err,
1743    };
1744
1745    var sb = try ipc.SocketBuffer.init(gpa);
1746    defer sb.deinit();
1747
1748    const n = sb.read(result.fd) catch return error.ReadFailed;
1749    if (n == 0) return error.ConnectionClosed;
1750
1751    while (sb.next()) |msg| {
1752        if (msg.header.tag == .Ack) {
1753            try w.interface.print("file created {s}\n", .{file_path});
1754            try w.interface.flush();
1755            return;
1756        }
1757    }
1758
1759    return error.NoAckReceived;
1760}
1761
1762fn send(alloc: std.mem.Allocator, io: std.Io, cfg: *Cfg, session_name: []const u8, socket_path: []const u8, text_parts: [][]const u8, tag: ipc.Tag) !void {
1763    std.log.info("send session={s}", .{session_name});
1764
1765    var payload = std.ArrayList(u8).empty;
1766    defer payload.deinit(alloc);
1767
1768    if (text_parts.len > 0) {
1769        for (text_parts, 0..) |part, i| {
1770            if (i > 0) try payload.append(alloc, ' ');
1771            try payload.appendSlice(alloc, part);
1772        }
1773    } else {
1774        // Read from stdin when no text arguments provided.
1775        const stdin_file = std.Io.File.stdin();
1776        defer stdin_file.close(io);
1777        var stdin_buf: [4096]u8 = undefined;
1778        var reader = stdin_file.reader(io, &stdin_buf);
1779        if (!try stdin_file.isTty(io)) {
1780            while (true) {
1781                var dest: [1024]u8 = undefined;
1782                const n = try reader.interface.readSliceShort(&dest);
1783                if (n == 0) break; // EOF
1784                try payload.appendSlice(alloc, dest[0..n]);
1785            }
1786            // Strip trailing newline from piped input; the caller is
1787            // responsible for including \r when submission is desired.
1788            // For .Output the caller controls exact bytes, so don't strip.
1789            if (tag != .Output and payload.items.len > 0 and payload.items[payload.items.len - 1] == '\n') {
1790                _ = payload.pop();
1791            }
1792        }
1793    }
1794
1795    if (payload.items.len == 0) {
1796        return printError(io, "text argument required (or pipe input via stdin)", .{});
1797    }
1798
1799    var dir = try std.Io.Dir.openDirAbsolute(io, cfg.socket_dir, .{});
1800    defer dir.close(io);
1801
1802    const probe_result = ipc.probeSession(alloc, socket_path) catch |err| {
1803        std.log.err("session unresponsive: {s}", .{@errorName(err)});
1804        var errbuf: [4096]u8 = undefined;
1805        var ew = std.Io.File.stderr().writer(io, &errbuf);
1806        if (err == error.ConnectionRefused) {
1807            socket.cleanupStaleSocket(io, dir, session_name);
1808            ew.interface.print("cleaned up stale session {s}\n", .{session_name}) catch {};
1809        } else {
1810            ew.interface.print(
1811                "session {s} is unresponsive ({s})\ndaemon may be busy: try again\n",
1812                .{ session_name, @errorName(err) },
1813            ) catch {};
1814        }
1815        ew.interface.flush() catch {};
1816        return error.SessionUnresponsive;
1817    };
1818    defer probe_result.deinit();
1819
1820    ipc.send(probe_result.fd, tag, payload.items) catch |err| switch (err) {
1821        error.ConnectionResetByPeer, error.BrokenPipe => return,
1822        else => return err,
1823    };
1824}
1825
1826fn run(gpa: std.mem.Allocator, io: std.Io, daemon: *Daemon, detached: bool, command_args: [][]const u8) !void {
1827    var cmd_to_send: ?[]const u8 = null;
1828    var allocated_cmd: ?[]u8 = null;
1829    defer if (allocated_cmd) |cmd| gpa.free(cmd);
1830
1831    const is_daemon_proc = try daemon.ensureSession(io);
1832    if (is_daemon_proc) return;
1833
1834    if (command_args.len > 0) {
1835        var cmd_list = std.ArrayList(u8).empty;
1836        defer cmd_list.deinit(gpa);
1837
1838        for (command_args, 0..) |arg, i| {
1839            if (i > 0) try cmd_list.append(gpa, ' ');
1840            if (util.shellNeedsQuoting(arg)) {
1841                const quoted = try util.shellQuote(gpa, arg);
1842                defer gpa.free(quoted);
1843                try cmd_list.appendSlice(gpa, quoted);
1844            } else {
1845                try cmd_list.appendSlice(gpa, arg);
1846            }
1847        }
1848
1849        // \r, not \n: once the shell is at the readline prompt the PTY is in
1850        // raw mode; readline's accept-line binds to CR. The first-ever run
1851        // works with \n only because it arrives during shell startup while
1852        // the line discipline is still canonical.
1853        try cmd_list.append(gpa, '\r');
1854
1855        cmd_to_send = try cmd_list.toOwnedSlice(gpa);
1856        allocated_cmd = @constCast(cmd_to_send.?);
1857    } else {
1858        // Read from stdin when no text arguments provided.
1859        const stdin_file = std.Io.File.stdin();
1860        defer stdin_file.close(io);
1861        var stdin_buf = try std.ArrayList(u8).initCapacity(gpa, 4096);
1862        defer stdin_buf.deinit(gpa);
1863        var stdbuf: [4096]u8 = undefined;
1864        var reader = stdin_file.reader(io, &stdbuf);
1865        if (!try stdin_file.isTty(io)) {
1866            while (true) {
1867                var dest: [1024]u8 = undefined;
1868                const n = try reader.interface.readSliceShort(&dest);
1869                if (n == 0) break; // EOF
1870                try stdin_buf.appendSlice(gpa, dest[0..n]);
1871            }
1872
1873            if (stdin_buf.items.len > 0) {
1874                // Normalize any trailing newline to CR so readline (raw mode)
1875                // accepts each line.
1876                if (stdin_buf.items[stdin_buf.items.len - 1] == '\n') {
1877                    stdin_buf.items[stdin_buf.items.len - 1] = '\r';
1878                } else {
1879                    try stdin_buf.append(gpa, '\r');
1880                }
1881
1882                cmd_to_send = try gpa.dupe(u8, stdin_buf.items);
1883                allocated_cmd = @constCast(cmd_to_send.?);
1884            }
1885        }
1886    }
1887
1888    if (cmd_to_send == null) {
1889        return printError(io, "run requires a command (or pipe input via stdin)", .{});
1890    }
1891
1892    const client_sock = ipc.connectSession(daemon.socket_path) catch |err| {
1893        std.log.err("session not ready: {s}", .{@errorName(err)});
1894        return printError(io, "session not ready: {s}", .{@errorName(err)});
1895    };
1896    defer lib_posix.close(client_sock);
1897
1898    const term_size = ipc.getTerminalSize(lib_posix.STDOUT_FILENO);
1899    ipc.send(client_sock, .Resize, std.mem.asBytes(&term_size)) catch {};
1900
1901    var fds = try std.ArrayList(i32).initCapacity(gpa, 1);
1902    defer fds.deinit(gpa);
1903    try fds.append(gpa, client_sock);
1904
1905    ipc.send(client_sock, .Run, cmd_to_send.?) catch |err| switch (err) {
1906        error.ConnectionResetByPeer, error.BrokenPipe => return,
1907        else => return err,
1908    };
1909
1910    const exit_code = try tail(gpa, fds, detached, true);
1911    lib_posix.exit(exit_code);
1912}
1913
1914test "parseAttachArgs reads a bare session name" {
1915    const parsed = parseAttachArgs(&.{"dev"});
1916    try std.testing.expectEqualStrings("dev", parsed.session_name);
1917    try std.testing.expect(!parsed.want_help);
1918    try std.testing.expectEqual(@as(usize, 1), parsed.command_start);
1919}
1920
1921test "parseAttachArgs keeps the session command intact" {
1922    const argv: []const []const u8 = &.{ "dev", "vim", "-n" };
1923    const parsed = parseAttachArgs(argv);
1924    try std.testing.expectEqualStrings("dev", parsed.session_name);
1925    // -n after the session name belongs to the command, not to zmx.
1926    try std.testing.expectEqualSlices([]const u8, argv[1..], argv[parsed.command_start..]);
1927}
1928
1929test "parseAttachArgs reports help before the session name" {
1930    try std.testing.expect(parseAttachArgs(&.{"--help"}).want_help);
1931    try std.testing.expect(parseAttachArgs(&.{"-h"}).want_help);
1932    try std.testing.expect(parseAttachArgs(&.{ "--labels", "a=1", "--help" }).want_help);
1933    // Once a session name is read, -h belongs to the command.
1934    try std.testing.expect(!parseAttachArgs(&.{ "dev", "-h" }).want_help);
1935}
1936
1937test "parseAttachArgs reads --labels in both forms" {
1938    for ([_][]const []const u8{
1939        &.{ "--labels", "a=1 b=2", "build" },
1940        &.{ "--labels=a=1 b=2", "build" },
1941    }) |argv| {
1942        const parsed = parseAttachArgs(argv);
1943        try std.testing.expectEqualStrings("a=1 b=2", parsed.labels.?);
1944        try std.testing.expectEqualStrings("build", parsed.session_name);
1945        try std.testing.expect(!parsed.missing_labels_value);
1946        try std.testing.expectEqual(argv.len, parsed.command_start);
1947    }
1948}
1949
1950test "parseAttachArgs combines --labels with a session command" {
1951    const argv: []const []const u8 = &.{ "--labels", "a=1", "build", "make", "--labels" };
1952    const parsed = parseAttachArgs(argv);
1953    try std.testing.expectEqualStrings("a=1", parsed.labels.?);
1954    try std.testing.expectEqualStrings("build", parsed.session_name);
1955    // --labels after the session name belongs to the command.
1956    try std.testing.expectEqualSlices([]const u8, argv[3..], argv[parsed.command_start..]);
1957}
1958
1959test "parseAttachArgs reports --labels with no value" {
1960    const parsed = parseAttachArgs(&.{"--labels"});
1961    try std.testing.expect(parsed.missing_labels_value);
1962    try std.testing.expectEqual(@as(?[]const u8, null), parsed.labels);
1963}
1964
1965test "parseAttachArgs leaves labels unset when the flag is absent" {
1966    const parsed = parseAttachArgs(&.{"dev"});
1967    try std.testing.expectEqual(@as(?[]const u8, null), parsed.labels);
1968    try std.testing.expect(!parsed.missing_labels_value);
1969}
1970
1971test "getTrackedEnvStr includes set and unset entries" {
1972    const alloc = std.testing.allocator;
1973    var env_map = std.process.Environ.Map.init(alloc);
1974    defer env_map.deinit();
1975
1976    try env_map.put("DISPLAY", ":1");
1977    try env_map.put("SSH_AUTH_SOCK", "/tmp/ssh.sock");
1978
1979    const str = try getTrackedEnvStr(alloc, "DISPLAY, KITTY_WINDOW_ID, SSH_AUTH_SOCK", &env_map);
1980    defer alloc.free(str);
1981
1982    try std.testing.expectEqualStrings("DISPLAY=:1\n-KITTY_WINDOW_ID\nSSH_AUTH_SOCK=/tmp/ssh.sock\n", str);
1983}
1984
1985test "getEnvValue extracts set value or returns EnvVarNotFound for unset" {
1986    const payload = "DISPLAY=:1\n-KITTY_WINDOW_ID\nSSH_AUTH_SOCK=/tmp/ssh.sock\n";
1987
1988    try std.testing.expectEqualStrings(":1", try getEnvValue("DISPLAY", payload));
1989    try std.testing.expectEqualStrings("/tmp/ssh.sock", try getEnvValue("SSH_AUTH_SOCK", payload));
1990    try std.testing.expectError(error.EnvVarNotFound, getEnvValue("KITTY_WINDOW_ID", payload));
1991    try std.testing.expectError(error.EnvVarNotFound, getEnvValue("NONEXISTENT", payload));
1992}
1993
1994test "EnvIterator parses set and unset entries correctly" {
1995    const payload = "DISPLAY=:1\n-KITTY_WINDOW_ID\nSSH_AUTH_SOCK=/tmp/ssh.sock\n";
1996    var it = EnvIterator.init(payload);
1997
1998    const e1 = it.next().?;
1999    try std.testing.expectEqualStrings("DISPLAY", e1.key);
2000    try std.testing.expectEqualStrings(":1", e1.value.?);
2001
2002    const e2 = it.next().?;
2003    try std.testing.expectEqualStrings("KITTY_WINDOW_ID", e2.key);
2004    try std.testing.expect(e2.value == null);
2005
2006    const e3 = it.next().?;
2007    try std.testing.expectEqualStrings("SSH_AUTH_SOCK", e3.key);
2008    try std.testing.expectEqualStrings("/tmp/ssh.sock", e3.value.?);
2009
2010    try std.testing.expect(it.next() == null);
2011}