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}