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