Commit 0222428

Eric Bower  ·  2026-03-04 21:55:55 -0500 EST
parent fe2c4ce
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
......@@ -0,0 +1,30 @@
1+const builtin = @import("builtin");
2+
3+pub const c = switch (builtin.os.tag) {
4+ .macos => @cImport({
5+ @cInclude("sys/ioctl.h"); // ioctl and constants
6+ @cInclude("termios.h");
7+ @cInclude("stdlib.h");
8+ @cInclude("unistd.h");
9+ }),
10+ .freebsd => @cImport({
11+ @cInclude("termios.h"); // ioctl and constants
12+ @cInclude("libutil.h"); // openpty()
13+ @cInclude("stdlib.h");
14+ @cInclude("unistd.h");
15+ }),
16+ else => @cImport({
17+ @cInclude("sys/ioctl.h"); // ioctl and constants
18+ @cInclude("pty.h");
19+ @cInclude("stdlib.h");
20+ @cInclude("unistd.h");
21+ }),
22+};
23+
24+// Manually declare forkpty for macOS since util.h is not available during cross-compilation
25+pub const forkpty = if (builtin.os.tag == .macos)
26+ struct {
27+ extern "c" fn forkpty(master_fd: *c_int, name: ?[*:0]u8, termp: ?*const c.struct_termios, winp: ?*const c.struct_winsize) c_int;
28+ }.forkpty
29+else
30+ c.forkpty;
+56, -0
......@@ -1,5 +1,7 @@
11 const std = @import("std");
22 const posix = std.posix;
3+const cross = @import("cross.zig");
4+const socket = @import("socket.zig");
35
46 pub const Tag = enum(u8) {
57 Input = 0,
......@@ -25,6 +27,14 @@ pub const Resize = packed struct {
2527 cols: u16,
2628 };
2729
30+pub fn getTerminalSize(fd: i32) Resize {
31+ var ws: cross.c.struct_winsize = undefined;
32+ if (cross.c.ioctl(fd, cross.c.TIOCGWINSZ, &ws) == 0 and ws.ws_row > 0 and ws.ws_col > 0) {
33+ return .{ .rows = ws.ws_row, .cols = ws.ws_col };
34+ }
35+ return .{ .rows = 24, .cols = 80 };
36+}
37+
2838 pub const MAX_CMD_LEN = 256;
2939 pub const MAX_CWD_LEN = 256;
3040
......@@ -150,3 +160,49 @@ pub const SocketBuffer = struct {
150160 return .{ .header = hdr, .payload = pay };
151161 }
152162 };
163+
164+const SessionProbeError = error{
165+ Timeout,
166+ ConnectionRefused,
167+ Unexpected,
168+};
169+
170+const SessionProbeResult = struct {
171+ fd: i32,
172+ info: Info,
173+};
174+
175+pub fn probeSession(alloc: std.mem.Allocator, socket_path: []const u8) SessionProbeError!SessionProbeResult {
176+ const timeout_ms = 1000;
177+ const fd = socket.sessionConnect(socket_path) catch |err| switch (err) {
178+ error.ConnectionRefused => return error.ConnectionRefused,
179+ else => return error.Unexpected,
180+ };
181+ errdefer posix.close(fd);
182+
183+ send(fd, .Info, "") catch return error.Unexpected;
184+
185+ var poll_fds = [_]posix.pollfd{.{ .fd = fd, .events = posix.POLL.IN, .revents = 0 }};
186+ const poll_result = posix.poll(&poll_fds, timeout_ms) catch return error.Unexpected;
187+ if (poll_result == 0) {
188+ return error.Timeout;
189+ }
190+
191+ var sb = SocketBuffer.init(alloc) catch return error.Unexpected;
192+ defer sb.deinit();
193+
194+ const n = sb.read(fd) catch return error.Unexpected;
195+ if (n == 0) return error.Unexpected;
196+
197+ while (sb.next()) |msg| {
198+ if (msg.header.tag == .Info) {
199+ if (msg.payload.len == @sizeOf(Info)) {
200+ return .{
201+ .fd = fd,
202+ .info = std.mem.bytesToValue(Info, msg.payload[0..@sizeOf(Info)]),
203+ };
204+ }
205+ }
206+ }
207+ return error.Unexpected;
208+}
+317, -966
......@@ -6,6 +6,9 @@ const ghostty_vt = @import("ghostty-vt");
66 const ipc = @import("ipc.zig");
77 const log = @import("log.zig");
88 const completions = @import("completions.zig");
9+const util = @import("util.zig");
10+const cross = @import("cross.zig");
11+const socket = @import("socket.zig");
912
1013 pub const version = build_options.version;
1114 pub const git_sha = build_options.git_sha;
......@@ -27,38 +30,147 @@ fn zmxLogFn(
2730 log_system.log(level, scope, format, args);
2831 }
2932
30-const c = switch (builtin.os.tag) {
31- .macos => @cImport({
32- @cInclude("sys/ioctl.h"); // ioctl and constants
33- @cInclude("termios.h");
34- @cInclude("stdlib.h");
35- @cInclude("unistd.h");
36- }),
37- .freebsd => @cImport({
38- @cInclude("termios.h"); // ioctl and constants
39- @cInclude("libutil.h"); // openpty()
40- @cInclude("stdlib.h");
41- @cInclude("unistd.h");
42- }),
43- else => @cImport({
44- @cInclude("sys/ioctl.h"); // ioctl and constants
45- @cInclude("pty.h");
46- @cInclude("stdlib.h");
47- @cInclude("unistd.h");
48- }),
49-};
50-
51-// Manually declare forkpty for macOS since util.h is not available during cross-compilation
52-const forkpty = if (builtin.os.tag == .macos)
53- struct {
54- extern "c" fn forkpty(master_fd: *c_int, name: ?[*:0]u8, termp: ?*const c.struct_termios, winp: ?*const c.struct_winsize) c_int;
55- }.forkpty
56-else
57- c.forkpty;
58-
5933 var sigwinch_received: std.atomic.Value(bool) = std.atomic.Value(bool).init(false);
6034 var sigterm_received: std.atomic.Value(bool) = std.atomic.Value(bool).init(false);
6135
36+pub fn main() !void {
37+ // use c_allocator to avoid "reached unreachable code" panic in DebugAllocator when forking
38+ const alloc = std.heap.c_allocator;
39+
40+ var args = try std.process.argsWithAllocator(alloc);
41+ defer args.deinit();
42+ _ = args.skip(); // skip program name
43+
44+ var cfg = try Cfg.init(alloc);
45+ defer cfg.deinit(alloc);
46+
47+ const log_path = try std.fs.path.join(alloc, &.{ cfg.log_dir, "zmx.log" });
48+ defer alloc.free(log_path);
49+ try log_system.init(alloc, log_path);
50+ defer log_system.deinit();
51+
52+ const cmd = args.next() orelse {
53+ return list(&cfg, false);
54+ };
55+
56+ 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")) {
57+ return printVersion(&cfg);
58+ } else if (std.mem.eql(u8, cmd, "help") or std.mem.eql(u8, cmd, "h") or std.mem.eql(u8, cmd, "-h")) {
59+ return help();
60+ } else if (std.mem.eql(u8, cmd, "list") or std.mem.eql(u8, cmd, "l")) {
61+ const short = if (args.next()) |arg| std.mem.eql(u8, arg, "--short") else false;
62+ return list(&cfg, short);
63+ } else if (std.mem.eql(u8, cmd, "completions") or std.mem.eql(u8, cmd, "c")) {
64+ const arg = args.next() orelse return;
65+ const shell = completions.Shell.fromString(arg) orelse return;
66+ return printCompletions(shell);
67+ } else if (std.mem.eql(u8, cmd, "detach") or std.mem.eql(u8, cmd, "d")) {
68+ return detachAll(&cfg);
69+ } else if (std.mem.eql(u8, cmd, "kill") or std.mem.eql(u8, cmd, "k")) {
70+ const session_name = args.next() orelse "";
71+ const sesh = try socket.getSeshName(alloc, session_name);
72+ defer alloc.free(sesh);
73+ return kill(&cfg, sesh);
74+ } else if (std.mem.eql(u8, cmd, "history") or std.mem.eql(u8, cmd, "hi")) {
75+ var session_name: ?[]const u8 = null;
76+ var format: util.HistoryFormat = .plain;
77+ while (args.next()) |arg| {
78+ if (std.mem.eql(u8, arg, "--vt")) {
79+ format = .vt;
80+ } else if (std.mem.eql(u8, arg, "--html")) {
81+ format = .html;
82+ } else if (session_name == null) {
83+ session_name = arg;
84+ }
85+ }
86+ const sesh = try socket.getSeshName(alloc, session_name.?);
87+ defer alloc.free(sesh);
88+ return history(&cfg, sesh, format);
89+ } else if (std.mem.eql(u8, cmd, "attach") or std.mem.eql(u8, cmd, "a")) {
90+ const session_name = args.next() orelse "";
91+
92+ var command_args: std.ArrayList([]const u8) = .empty;
93+ defer command_args.deinit(alloc);
94+ while (args.next()) |arg| {
95+ try command_args.append(alloc, arg);
96+ }
97+
98+ const clients = try std.ArrayList(*Client).initCapacity(alloc, 10);
99+ var command: ?[][]const u8 = null;
100+ if (command_args.items.len > 0) {
101+ command = command_args.items;
102+ }
103+
104+ var cwd_buf: [std.fs.max_path_bytes]u8 = undefined;
105+ const cwd = std.posix.getcwd(&cwd_buf) catch "";
106+
107+ const sesh = try socket.getSeshName(alloc, session_name);
108+ defer alloc.free(sesh);
109+ var daemon = Daemon{
110+ .running = true,
111+ .cfg = &cfg,
112+ .alloc = alloc,
113+ .clients = clients,
114+ .session_name = sesh,
115+ .socket_path = undefined,
116+ .pid = undefined,
117+ .command = command,
118+ .cwd = cwd,
119+ .created_at = @intCast(std.time.timestamp()),
120+ };
121+ daemon.socket_path = try socket.getSocketPath(alloc, cfg.socket_dir, sesh);
122+ std.log.info("socket path={s}", .{daemon.socket_path});
123+ return attach(&daemon);
124+ } else if (std.mem.eql(u8, cmd, "run") or std.mem.eql(u8, cmd, "r")) {
125+ const session_name = args.next() orelse "";
126+
127+ var cmd_args_raw: std.ArrayList([]const u8) = .empty;
128+ defer cmd_args_raw.deinit(alloc);
129+ while (args.next()) |arg| {
130+ try cmd_args_raw.append(alloc, arg);
131+ }
132+ const clients = try std.ArrayList(*Client).initCapacity(alloc, 10);
133+
134+ var cwd_buf: [std.fs.max_path_bytes]u8 = undefined;
135+ const cwd = std.posix.getcwd(&cwd_buf) catch "";
136+
137+ const sesh = try socket.getSeshName(alloc, session_name);
138+ defer alloc.free(sesh);
139+ var daemon = Daemon{
140+ .running = true,
141+ .cfg = &cfg,
142+ .alloc = alloc,
143+ .clients = clients,
144+ .session_name = sesh,
145+ .socket_path = undefined,
146+ .pid = undefined,
147+ .command = null,
148+ .cwd = cwd,
149+ .created_at = @intCast(std.time.timestamp()),
150+ .is_task_mode = true,
151+ .task_command = cmd_args_raw.items,
152+ };
153+ daemon.socket_path = try socket.getSocketPath(alloc, cfg.socket_dir, sesh);
154+ std.log.info("socket path={s}", .{daemon.socket_path});
155+ return run(&daemon, cmd_args_raw.items);
156+ } else if (std.mem.eql(u8, cmd, "wait") or std.mem.eql(u8, cmd, "w")) {
157+ var args_raw: std.ArrayList([]const u8) = .empty;
158+ defer {
159+ for (args_raw.items) |sesh| {
160+ alloc.free(sesh);
161+ }
162+ args_raw.deinit(alloc);
163+ }
164+ while (args.next()) |session_name| {
165+ const sesh = try socket.getSeshName(alloc, session_name);
166+ try args_raw.append(alloc, sesh);
167+ }
168+ return wait(&cfg, args_raw);
169+ } else {
170+ return help();
171+ }
172+}
173+
62174 const Client = struct {
63175 alloc: std.mem.Allocator,
64176 socket_fd: i32,
......@@ -121,11 +233,9 @@ const Cfg = struct {
121233 }
122234 };
123235
124-const SessionMetadata = struct {
125- created_at: u64, // unix timestamp (ns) - all sessions
126- task_exit_code: ?i32 = null, // null = running, set when task completes
127- task_end_time: ?u64 = null, // timestamp when task exited
128- task_command: []const u8 = "", // original task command string
236+const EnsureSessionResult = struct {
237+ created: bool,
238+ is_daemon: bool,
129239 };
130240
131241 const Daemon = struct {
......@@ -175,6 +285,120 @@ const Daemon = struct {
175285 return false;
176286 }
177287
288+ fn spawnPty(self: *Daemon) !c_int {
289+ const size = ipc.getTerminalSize(posix.STDOUT_FILENO);
290+ var ws: cross.c.struct_winsize = .{
291+ .ws_row = size.rows,
292+ .ws_col = size.cols,
293+ .ws_xpixel = 0,
294+ .ws_ypixel = 0,
295+ };
296+
297+ var master_fd: c_int = undefined;
298+ const pid = cross.forkpty(&master_fd, null, null, &ws);
299+ if (pid < 0) {
300+ return error.ForkPtyFailed;
301+ }
302+
303+ if (pid == 0) { // child pid code path
304+ const session_env = try std.fmt.allocPrint(self.alloc, "ZMX_SESSION={s}\x00", .{self.session_name});
305+ _ = cross.c.putenv(@ptrCast(session_env.ptr));
306+
307+ if (self.command) |cmd_args| {
308+ const alloc = std.heap.c_allocator;
309+ var argv_buf: [64:null]?[*:0]const u8 = undefined;
310+ for (cmd_args, 0..) |arg, i| {
311+ argv_buf[i] = alloc.dupeZ(u8, arg) catch {
312+ std.posix.exit(1);
313+ };
314+ }
315+ argv_buf[cmd_args.len] = null;
316+ const argv: [*:null]const ?[*:0]const u8 = &argv_buf;
317+ const err = std.posix.execvpeZ(argv_buf[0].?, argv, std.c.environ);
318+ std.log.err("execvpe failed: cmd={s} err={s}", .{ cmd_args[0], @errorName(err) });
319+ std.posix.exit(1);
320+ } else {
321+ const shell = util.detectShell();
322+ // Use "-shellname" as argv[0] to signal login shell (traditional method)
323+ var buf: [64]u8 = undefined;
324+ const login_shell = try std.fmt.bufPrintZ(&buf, "-{s}", .{std.fs.path.basename(shell)});
325+ const argv = [_:null]?[*:0]const u8{ login_shell, null };
326+ const err = std.posix.execveZ(shell, &argv, std.c.environ);
327+ std.log.err("execve failed: err={s}", .{@errorName(err)});
328+ std.posix.exit(1);
329+ }
330+ }
331+ // master pid code path
332+ self.pid = pid;
333+ std.log.info("pty spawned session={s} pid={d}", .{ self.session_name, pid });
334+
335+ // make pty non-blocking
336+ const flags = try posix.fcntl(master_fd, posix.F.GETFL, 0);
337+ _ = try posix.fcntl(master_fd, posix.F.SETFL, flags | @as(u32, 0o4000));
338+ return master_fd;
339+ }
340+
341+ fn ensureSession(self: *Daemon) !EnsureSessionResult {
342+ var dir = try std.fs.openDirAbsolute(self.cfg.socket_dir, .{});
343+ defer dir.close();
344+
345+ const exists = try socket.sessionExists(dir, self.session_name);
346+ var should_create = !exists;
347+
348+ if (exists) {
349+ if (ipc.probeSession(self.alloc, self.socket_path)) |result| {
350+ posix.close(result.fd);
351+ if (self.command != null) {
352+ std.log.warn("session already exists, ignoring command session={s}", .{self.session_name});
353+ }
354+ } else |_| {
355+ socket.cleanupStaleSocket(dir, self.session_name);
356+ should_create = true;
357+ }
358+ }
359+
360+ if (should_create) {
361+ std.log.info("creating session={s}", .{self.session_name});
362+ const server_sock_fd = try socket.createSocket(self.socket_path);
363+
364+ const pid = try posix.fork();
365+ if (pid == 0) { // child (daemon)
366+ _ = try posix.setsid();
367+
368+ log_system.deinit();
369+ const session_log_name = try std.fmt.allocPrint(self.alloc, "{s}.log", .{self.session_name});
370+ defer self.alloc.free(session_log_name);
371+ const session_log_path = try std.fs.path.join(self.alloc, &.{ self.cfg.log_dir, session_log_name });
372+ defer self.alloc.free(session_log_path);
373+ try log_system.init(self.alloc, session_log_path);
374+
375+ errdefer {
376+ posix.close(server_sock_fd);
377+ dir.deleteFile(self.session_name) catch {};
378+ }
379+ const pty_fd = try self.spawnPty();
380+ defer {
381+ posix.close(pty_fd);
382+ posix.close(server_sock_fd);
383+ std.log.info("deleting socket file session_name={s}", .{self.session_name});
384+ dir.deleteFile(self.session_name) catch |err| {
385+ std.log.warn("failed to delete socket file err={s}", .{@errorName(err)});
386+ };
387+ }
388+ try daemonLoop(self, server_sock_fd, pty_fd);
389+ self.handleKill();
390+ _ = posix.waitpid(self.pid, 0);
391+ self.deinit();
392+ return .{ .created = true, .is_daemon = true };
393+ }
394+ posix.close(server_sock_fd);
395+ std.Thread.sleep(10 * std.time.ns_per_ms);
396+ return .{ .created = true, .is_daemon = false };
397+ }
398+
399+ return .{ .created = false, .is_daemon = false };
400+ }
401+
178402 pub fn handleInput(self: *Daemon, pty_fd: i32, payload: []const u8) !void {
179403 _ = self;
180404 if (payload.len > 0) {
......@@ -193,13 +417,13 @@ const Daemon = struct {
193417
194418 const resize = std.mem.bytesToValue(ipc.Resize, payload);
195419
196- var ws: c.struct_winsize = .{
420+ var ws: cross.c.struct_winsize = .{
197421 .ws_row = resize.rows,
198422 .ws_col = resize.cols,
199423 .ws_xpixel = 0,
200424 .ws_ypixel = 0,
201425 };
202- _ = c.ioctl(pty_fd, c.TIOCSWINSZ, &ws);
426+ _ = cross.c.ioctl(pty_fd, cross.c.TIOCSWINSZ, &ws);
203427 try term.resize(self.alloc, resize.cols, resize.rows);
204428
205429 // Serialize terminal state BEFORE resize to capture correct cursor position.
......@@ -210,7 +434,7 @@ const Daemon = struct {
210434 if (self.has_pty_output and self.has_had_client) {
211435 const cursor = &term.screens.active.cursor;
212436 std.log.debug("cursor before serialize: x={d} y={d} pending_wrap={}", .{ cursor.x, cursor.y, cursor.pending_wrap });
213- if (serializeTerminalState(self.alloc, term)) |term_output| {
437+ if (util.serializeTerminalState(self.alloc, term)) |term_output| {
214438 std.log.debug("serialize terminal state", .{});
215439 defer self.alloc.free(term_output);
216440 ipc.appendMessage(self.alloc, &client.write_buf, .Output, term_output) catch |err| {
......@@ -230,13 +454,13 @@ const Daemon = struct {
230454 if (payload.len != @sizeOf(ipc.Resize)) return;
231455
232456 const resize = std.mem.bytesToValue(ipc.Resize, payload);
233- var ws: c.struct_winsize = .{
457+ var ws: cross.c.struct_winsize = .{
234458 .ws_row = resize.rows,
235459 .ws_col = resize.cols,
236460 .ws_xpixel = 0,
237461 .ws_ypixel = 0,
238462 };
239- _ = c.ioctl(pty_fd, c.TIOCSWINSZ, &ws);
463+ _ = cross.c.ioctl(pty_fd, cross.c.TIOCSWINSZ, &ws);
240464 try term.resize(self.alloc, resize.cols, resize.rows);
241465 std.log.debug("resize rows={d} cols={d}", .{ resize.rows, resize.cols });
242466 }
......@@ -281,8 +505,8 @@ const Daemon = struct {
281505 const cur_cmd = self.command orelse self.task_command;
282506 if (cur_cmd) |args| {
283507 for (args, 0..) |arg, i| {
284- const quoted = if (shellNeedsQuoting(arg))
285- shellQuote(self.alloc, arg) catch null
508+ const quoted = if (util.shellNeedsQuoting(arg))
509+ util.shellQuote(self.alloc, arg) catch null
286510 else
287511 null;
288512 defer if (quoted) |q| self.alloc.free(q);
......@@ -328,11 +552,11 @@ const Daemon = struct {
328552 }
329553
330554 pub fn handleHistory(self: *Daemon, client: *Client, term: *ghostty_vt.Terminal, payload: []const u8) !void {
331- const format: HistoryFormat = if (payload.len > 0)
555+ const format: util.HistoryFormat = if (payload.len > 0)
332556 @enumFromInt(payload[0])
333557 else
334558 .plain;
335- if (serializeTerminal(self.alloc, term, format)) |output| {
559+ if (util.serializeTerminal(self.alloc, term, format)) |output| {
336560 defer self.alloc.free(output);
337561 try ipc.appendMessage(self.alloc, &client.write_buf, .History, output);
338562 client.has_pending_output = true;
......@@ -353,144 +577,6 @@ const Daemon = struct {
353577 }
354578 };
355579
356-pub fn main() !void {
357- // use c_allocator to avoid "reached unreachable code" panic in DebugAllocator when forking
358- const alloc = std.heap.c_allocator;
359-
360- var args = try std.process.argsWithAllocator(alloc);
361- defer args.deinit();
362- _ = args.skip(); // skip program name
363-
364- var cfg = try Cfg.init(alloc);
365- defer cfg.deinit(alloc);
366-
367- const log_path = try std.fs.path.join(alloc, &.{ cfg.log_dir, "zmx.log" });
368- defer alloc.free(log_path);
369- try log_system.init(alloc, log_path);
370- defer log_system.deinit();
371-
372- const cmd = args.next() orelse {
373- return list(&cfg, false);
374- };
375-
376- 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")) {
377- return printVersion(&cfg);
378- } else if (std.mem.eql(u8, cmd, "help") or std.mem.eql(u8, cmd, "h") or std.mem.eql(u8, cmd, "-h")) {
379- return help();
380- } else if (std.mem.eql(u8, cmd, "list") or std.mem.eql(u8, cmd, "l")) {
381- const short = if (args.next()) |arg| std.mem.eql(u8, arg, "--short") else false;
382- return list(&cfg, short);
383- } else if (std.mem.eql(u8, cmd, "completions") or std.mem.eql(u8, cmd, "c")) {
384- const arg = args.next() orelse return;
385- const shell = completions.Shell.fromString(arg) orelse return;
386- return printCompletions(shell);
387- } else if (std.mem.eql(u8, cmd, "detach") or std.mem.eql(u8, cmd, "d")) {
388- return detachAll(&cfg);
389- } else if (std.mem.eql(u8, cmd, "kill") or std.mem.eql(u8, cmd, "k")) {
390- const session_name = args.next() orelse "";
391- const sesh = try getSeshName(alloc, session_name);
392- defer alloc.free(sesh);
393- return kill(&cfg, sesh);
394- } else if (std.mem.eql(u8, cmd, "history") or std.mem.eql(u8, cmd, "hi")) {
395- var session_name: ?[]const u8 = null;
396- var format: HistoryFormat = .plain;
397- while (args.next()) |arg| {
398- if (std.mem.eql(u8, arg, "--vt")) {
399- format = .vt;
400- } else if (std.mem.eql(u8, arg, "--html")) {
401- format = .html;
402- } else if (session_name == null) {
403- session_name = arg;
404- }
405- }
406- const sesh = try getSeshName(alloc, session_name.?);
407- defer alloc.free(sesh);
408- return history(&cfg, sesh, format);
409- } else if (std.mem.eql(u8, cmd, "attach") or std.mem.eql(u8, cmd, "a")) {
410- const session_name = args.next() orelse "";
411-
412- var command_args: std.ArrayList([]const u8) = .empty;
413- defer command_args.deinit(alloc);
414- while (args.next()) |arg| {
415- try command_args.append(alloc, arg);
416- }
417-
418- const clients = try std.ArrayList(*Client).initCapacity(alloc, 10);
419- var command: ?[][]const u8 = null;
420- if (command_args.items.len > 0) {
421- command = command_args.items;
422- }
423-
424- var cwd_buf: [std.fs.max_path_bytes]u8 = undefined;
425- const cwd = std.posix.getcwd(&cwd_buf) catch "";
426-
427- const sesh = try getSeshName(alloc, session_name);
428- defer alloc.free(sesh);
429- var daemon = Daemon{
430- .running = true,
431- .cfg = &cfg,
432- .alloc = alloc,
433- .clients = clients,
434- .session_name = sesh,
435- .socket_path = undefined,
436- .pid = undefined,
437- .command = command,
438- .cwd = cwd,
439- .created_at = @intCast(std.time.timestamp()),
440- };
441- daemon.socket_path = try getSocketPath(alloc, cfg.socket_dir, sesh);
442- std.log.info("socket path={s}", .{daemon.socket_path});
443- return attach(&daemon);
444- } else if (std.mem.eql(u8, cmd, "run") or std.mem.eql(u8, cmd, "r")) {
445- const session_name = args.next() orelse "";
446-
447- var cmd_args_raw: std.ArrayList([]const u8) = .empty;
448- defer cmd_args_raw.deinit(alloc);
449- while (args.next()) |arg| {
450- try cmd_args_raw.append(alloc, arg);
451- }
452- const clients = try std.ArrayList(*Client).initCapacity(alloc, 10);
453-
454- var cwd_buf: [std.fs.max_path_bytes]u8 = undefined;
455- const cwd = std.posix.getcwd(&cwd_buf) catch "";
456-
457- const sesh = try getSeshName(alloc, session_name);
458- defer alloc.free(sesh);
459- var daemon = Daemon{
460- .running = true,
461- .cfg = &cfg,
462- .alloc = alloc,
463- .clients = clients,
464- .session_name = sesh,
465- .socket_path = undefined,
466- .pid = undefined,
467- .command = null,
468- .cwd = cwd,
469- .created_at = @intCast(std.time.timestamp()),
470- .is_task_mode = true,
471- .task_command = cmd_args_raw.items,
472- };
473- daemon.socket_path = try getSocketPath(alloc, cfg.socket_dir, sesh);
474- std.log.info("socket path={s}", .{daemon.socket_path});
475- return run(&daemon, cmd_args_raw.items);
476- } else if (std.mem.eql(u8, cmd, "wait") or std.mem.eql(u8, cmd, "w")) {
477- var args_raw: std.ArrayList([]const u8) = .empty;
478- defer {
479- for (args_raw.items) |sesh| {
480- alloc.free(sesh);
481- }
482- args_raw.deinit(alloc);
483- }
484- while (args.next()) |session_name| {
485- const sesh = try getSeshName(alloc, session_name);
486- try args_raw.append(alloc, sesh);
487- }
488- return wait(&cfg, args_raw);
489- } else {
490- return help();
491- }
492-}
493-
494580 fn printVersion(cfg: *Cfg) !void {
495581 var buf: [256]u8 = undefined;
496582 var w = std.fs.File.stdout().writer(&buf);
......@@ -546,23 +632,6 @@ fn help() !void {
546632 try w.interface.flush();
547633 }
548634
549-const SessionEntry = struct {
550- name: []const u8,
551- pid: ?i32,
552- clients_len: ?usize,
553- is_error: bool,
554- error_name: ?[]const u8,
555- cmd: ?[]const u8 = null,
556- cwd: ?[]const u8 = null,
557- created_at: u64,
558- task_ended_at: ?u64,
559- task_exit_code: ?u8,
560-
561- fn lessThan(_: void, a: SessionEntry, b: SessionEntry) bool {
562- return std.mem.order(u8, a.name, b.name) == .lt;
563- }
564-};
565-
566635 fn wait(cfg: *Cfg, session_names: std.ArrayList([]const u8)) !void {
567636 var gpa = std.heap.GeneralPurposeAllocator(.{}){};
568637 defer _ = gpa.deinit();
......@@ -573,7 +642,7 @@ fn wait(cfg: *Cfg, session_names: std.ArrayList([]const u8)) !void {
573642 const stdout = &stdout_writer.interface;
574643
575644 while (true) {
576- var sessions = try get_session_entries(alloc, cfg);
645+ var sessions = try util.get_session_entries(alloc, cfg.socket_dir);
577646 var total: i32 = 0;
578647 var done: i32 = 0;
579648 var agg_exit_code: u8 = 0;
......@@ -602,7 +671,10 @@ fn wait(cfg: *Cfg, session_names: std.ArrayList([]const u8)) !void {
602671 done += 1;
603672 }
604673
605- session_entries_deinit(alloc, &sessions);
674+ for (sessions.items) |session| {
675+ session.deinit(alloc);
676+ }
677+ sessions.deinit(alloc);
606678
607679 if (total == done) {
608680 try stdout.print("tasks completed!\n", .{});
......@@ -615,77 +687,6 @@ fn wait(cfg: *Cfg, session_names: std.ArrayList([]const u8)) !void {
615687 }
616688 }
617689
618-fn session_entries_deinit(alloc: std.mem.Allocator, sessions: *std.ArrayList(SessionEntry)) void {
619- for (sessions.items) |session| {
620- alloc.free(session.name);
621- if (session.cmd) |cmd| alloc.free(cmd);
622- if (session.cwd) |cwd| alloc.free(cwd);
623- }
624- sessions.deinit(alloc);
625-}
626-
627-fn get_session_entries(alloc: std.mem.Allocator, cfg: *Cfg) !std.ArrayList(SessionEntry) {
628- var dir = try std.fs.openDirAbsolute(cfg.socket_dir, .{ .iterate = true });
629- defer dir.close();
630- var iter = dir.iterate();
631-
632- var sessions = try std.ArrayList(SessionEntry).initCapacity(alloc, 30);
633-
634- while (try iter.next()) |entry| {
635- const exists = sessionExists(dir, entry.name) catch continue;
636- if (exists) {
637- const name = try alloc.dupe(u8, entry.name);
638- errdefer alloc.free(name);
639-
640- const socket_path = try getSocketPath(alloc, cfg.socket_dir, entry.name);
641- defer alloc.free(socket_path);
642-
643- const result = probeSession(alloc, socket_path) catch |err| {
644- try sessions.append(alloc, .{
645- .name = name,
646- .pid = null,
647- .clients_len = null,
648- .is_error = true,
649- .error_name = @errorName(err),
650- .created_at = 0,
651- .task_exit_code = 1,
652- .task_ended_at = 0,
653- });
654- cleanupStaleSocket(dir, entry.name);
655- continue;
656- };
657- posix.close(result.fd);
658-
659- // Extract cmd and cwd from the fixed-size arrays
660- const cmd: ?[]const u8 = if (result.info.cmd_len > 0)
661- alloc.dupe(u8, result.info.cmd[0..result.info.cmd_len]) catch null
662- else
663- null;
664- const cwd: ?[]const u8 = if (result.info.cwd_len > 0)
665- alloc.dupe(u8, result.info.cwd[0..result.info.cwd_len]) catch null
666- else
667- null;
668-
669- try sessions.append(alloc, .{
670- .name = name,
671- .pid = result.info.pid,
672- .clients_len = result.info.clients_len,
673- .is_error = false,
674- .error_name = null,
675- .cmd = cmd,
676- .cwd = cwd,
677- .created_at = result.info.created_at,
678- .task_ended_at = result.info.task_ended_at,
679- .task_exit_code = result.info.task_exit_code,
680- });
681- }
682- }
683-
684- return sessions;
685-}
686-
687-const current_arrow = "→";
688-
689690 fn list(cfg: *Cfg, short: bool) !void {
690691 var gpa = std.heap.GeneralPurposeAllocator(.{}){};
691692 defer _ = gpa.deinit();
......@@ -699,8 +700,13 @@ fn list(cfg: *Cfg, short: bool) !void {
699700 var buf: [4096]u8 = undefined;
700701 var w = std.fs.File.stdout().writer(&buf);
701702
702- var sessions = try get_session_entries(alloc, cfg);
703- defer session_entries_deinit(alloc, &sessions);
703+ var sessions = try util.get_session_entries(alloc, cfg.socket_dir);
704+ defer {
705+ for (sessions.items) |session| {
706+ session.deinit(alloc);
707+ }
708+ sessions.deinit(alloc);
709+ }
704710
705711 if (sessions.items.len == 0) {
706712 if (short) return;
......@@ -709,10 +715,10 @@ fn list(cfg: *Cfg, short: bool) !void {
709715 return;
710716 }
711717
712- std.mem.sort(SessionEntry, sessions.items, {}, SessionEntry.lessThan);
718+ std.mem.sort(util.SessionEntry, sessions.items, {}, util.SessionEntry.lessThan);
713719
714720 for (sessions.items) |session| {
715- try writeSessionLine(&w.interface, session, short, current_session);
721+ try util.writeSessionLine(&w.interface, session, short, current_session);
716722 try w.interface.flush();
717723 }
718724 }
......@@ -733,11 +739,11 @@ fn detachAll(cfg: *Cfg) !void {
733739 var dir = try std.fs.openDirAbsolute(cfg.socket_dir, .{});
734740 defer dir.close();
735741
736- const socket_path = try getSocketPath(alloc, cfg.socket_dir, session_name);
742+ const socket_path = try socket.getSocketPath(alloc, cfg.socket_dir, session_name);
737743 defer alloc.free(socket_path);
738- const result = probeSession(alloc, socket_path) catch |err| {
744+ const result = ipc.probeSession(alloc, socket_path) catch |err| {
739745 std.log.err("session unresponsive: {s}", .{@errorName(err)});
740- cleanupStaleSocket(dir, session_name);
746+ socket.cleanupStaleSocket(dir, session_name);
741747 return;
742748 };
743749 defer posix.close(result.fd);
......@@ -755,17 +761,17 @@ fn kill(cfg: *Cfg, session_name: []const u8) !void {
755761 var dir = try std.fs.openDirAbsolute(cfg.socket_dir, .{});
756762 defer dir.close();
757763
758- const exists = try sessionExists(dir, session_name);
764+ const exists = try socket.sessionExists(dir, session_name);
759765 if (!exists) {
760766 std.log.err("cannot kill session because it does not exist session_name={s}", .{session_name});
761767 return;
762768 }
763769
764- const socket_path = try getSocketPath(alloc, cfg.socket_dir, session_name);
770+ const socket_path = try socket.getSocketPath(alloc, cfg.socket_dir, session_name);
765771 defer alloc.free(socket_path);
766- const result = probeSession(alloc, socket_path) catch |err| {
772+ const result = ipc.probeSession(alloc, socket_path) catch |err| {
767773 std.log.err("session unresponsive: {s}", .{@errorName(err)});
768- cleanupStaleSocket(dir, session_name);
774+ socket.cleanupStaleSocket(dir, session_name);
769775 var buf: [4096]u8 = undefined;
770776 var w = std.fs.File.stdout().writer(&buf);
771777 w.interface.print("cleaned up stale session {s}\n", .{session_name}) catch {};
......@@ -784,13 +790,7 @@ fn kill(cfg: *Cfg, session_name: []const u8) !void {
784790 try w.interface.flush();
785791 }
786792
787-const HistoryFormat = enum(u8) {
788- plain = 0,
789- vt = 1,
790- html = 2,
791-};
792-
793-fn history(cfg: *Cfg, session_name: []const u8, format: HistoryFormat) !void {
793+fn history(cfg: *Cfg, session_name: []const u8, format: util.HistoryFormat) !void {
794794 var gpa = std.heap.GeneralPurposeAllocator(.{}){};
795795 defer _ = gpa.deinit();
796796 const alloc = gpa.allocator();
......@@ -798,17 +798,17 @@ fn history(cfg: *Cfg, session_name: []const u8, format: HistoryFormat) !void {
798798 var dir = try std.fs.openDirAbsolute(cfg.socket_dir, .{});
799799 defer dir.close();
800800
801- const exists = try sessionExists(dir, session_name);
801+ const exists = try socket.sessionExists(dir, session_name);
802802 if (!exists) {
803803 std.log.err("session does not exist session_name={s}", .{session_name});
804804 return;
805805 }
806806
807- const socket_path = try getSocketPath(alloc, cfg.socket_dir, session_name);
807+ const socket_path = try socket.getSocketPath(alloc, cfg.socket_dir, session_name);
808808 defer alloc.free(socket_path);
809- const result = probeSession(alloc, socket_path) catch |err| {
809+ const result = ipc.probeSession(alloc, socket_path) catch |err| {
810810 std.log.err("session unresponsive: {s}", .{@errorName(err)});
811- cleanupStaleSocket(dir, session_name);
811+ socket.cleanupStaleSocket(dir, session_name);
812812 return;
813813 };
814814 defer posix.close(result.fd);
......@@ -842,94 +842,28 @@ fn history(cfg: *Cfg, session_name: []const u8, format: HistoryFormat) !void {
842842 }
843843 }
844844
845-const EnsureSessionResult = struct {
846- created: bool,
847- is_daemon: bool,
848-};
849-
850-fn ensureSession(daemon: *Daemon) !EnsureSessionResult {
851- var dir = try std.fs.openDirAbsolute(daemon.cfg.socket_dir, .{});
852- defer dir.close();
853-
854- const exists = try sessionExists(dir, daemon.session_name);
855- var should_create = !exists;
856-
857- if (exists) {
858- if (probeSession(daemon.alloc, daemon.socket_path)) |result| {
859- posix.close(result.fd);
860- if (daemon.command != null) {
861- std.log.warn("session already exists, ignoring command session={s}", .{daemon.session_name});
862- }
863- } else |_| {
864- cleanupStaleSocket(dir, daemon.session_name);
865- should_create = true;
866- }
867- }
868-
869- if (should_create) {
870- std.log.info("creating session={s}", .{daemon.session_name});
871- const server_sock_fd = try createSocket(daemon.socket_path);
872-
873- const pid = try posix.fork();
874- if (pid == 0) { // child (daemon)
875- _ = try posix.setsid();
876-
877- log_system.deinit();
878- const session_log_name = try std.fmt.allocPrint(daemon.alloc, "{s}.log", .{daemon.session_name});
879- defer daemon.alloc.free(session_log_name);
880- const session_log_path = try std.fs.path.join(daemon.alloc, &.{ daemon.cfg.log_dir, session_log_name });
881- defer daemon.alloc.free(session_log_path);
882- try log_system.init(daemon.alloc, session_log_path);
883-
884- errdefer {
885- posix.close(server_sock_fd);
886- dir.deleteFile(daemon.session_name) catch {};
887- }
888- const pty_fd = try spawnPty(daemon);
889- defer {
890- posix.close(pty_fd);
891- posix.close(server_sock_fd);
892- std.log.info("deleting socket file session_name={s}", .{daemon.session_name});
893- dir.deleteFile(daemon.session_name) catch |err| {
894- std.log.warn("failed to delete socket file err={s}", .{@errorName(err)});
895- };
896- }
897- try daemonLoop(daemon, server_sock_fd, pty_fd);
898- daemon.handleKill();
899- _ = posix.waitpid(daemon.pid, 0);
900- daemon.deinit();
901- return .{ .created = true, .is_daemon = true };
902- }
903- posix.close(server_sock_fd);
904- std.Thread.sleep(10 * std.time.ns_per_ms);
905- return .{ .created = true, .is_daemon = false };
906- }
907-
908- return .{ .created = false, .is_daemon = false };
909-}
910-
911845 fn attach(daemon: *Daemon) !void {
912846 if (std.posix.getenv("ZMX_SESSION")) |_| {
913847 return error.CannotAttachToSessionInSession;
914848 }
915849
916- const result = try ensureSession(daemon);
850+ const result = try daemon.ensureSession();
917851 if (result.is_daemon) return;
918852
919- const client_sock = try sessionConnect(daemon.socket_path);
853+ const client_sock = try socket.sessionConnect(daemon.socket_path);
920854 std.log.info("attached session={s}", .{daemon.session_name});
921855 // this is typically used with tcsetattr() to modify terminal settings.
922856 // - you first get the current settings with tcgetattr()
923857 // - modify the desired attributes in the termios structure
924858 // - then apply the changes with tcsetattr().
925859 // This prevents unintended side effects by preserving other settings.
926- var orig_termios: c.termios = undefined;
927- _ = c.tcgetattr(posix.STDIN_FILENO, &orig_termios);
860+ var orig_termios: cross.c.termios = undefined;
861+ _ = cross.c.tcgetattr(posix.STDIN_FILENO, &orig_termios);
928862
929863 // restore stdin fd to its original state after exiting.
930864 // Use TCSAFLUSH to discard any unread input, preventing stale input after detach.
931865 defer {
932- _ = c.tcsetattr(posix.STDIN_FILENO, c.TCSAFLUSH, &orig_termios);
866+ _ = cross.c.tcsetattr(posix.STDIN_FILENO, cross.c.TCSAFLUSH, &orig_termios);
933867 // Reset terminal modes on detach:
934868 // - Mouse: 1000=basic, 1002=button-event, 1003=any-event, 1006=SGR extended
935869 // - 2004=bracketed paste, 1004=focus events, 1049=alt screen
......@@ -949,60 +883,24 @@ fn attach(daemon: *Daemon) !void {
949883 // set raw mode after successful connection.
950884 // disables canonical mode (line buffering), input echoing, signal generation from
951885 // control characters (like Ctrl+C), and flow control.
952- c.cfmakeraw(&raw_termios);
886+ cross.c.cfmakeraw(&raw_termios);
953887
954888 // Additional granular raw mode settings for precise control
955889 // (matches what abduco and shpool do)
956- raw_termios.c_cc[c.VLNEXT] = c._POSIX_VDISABLE; // Disable literal-next (Ctrl-V)
890+ raw_termios.c_cc[cross.c.VLNEXT] = cross.c._POSIX_VDISABLE; // Disable literal-next (Ctrl-V)
957891 // We want to intercept Ctrl+\ (SIGQUIT) so we can use it as a detach key
958- raw_termios.c_cc[c.VQUIT] = c._POSIX_VDISABLE; // Disable SIGQUIT (Ctrl+\)
959- raw_termios.c_cc[c.VMIN] = 1; // Minimum chars to read: return after 1 byte
960- raw_termios.c_cc[c.VTIME] = 0; // Read timeout: no timeout, return immediately
892+ raw_termios.c_cc[cross.c.VQUIT] = cross.c._POSIX_VDISABLE; // Disable SIGQUIT (Ctrl+\)
893+ raw_termios.c_cc[cross.c.VMIN] = 1; // Minimum chars to read: return after 1 byte
894+ raw_termios.c_cc[cross.c.VTIME] = 0; // Read timeout: no timeout, return immediately
961895
962- _ = c.tcsetattr(posix.STDIN_FILENO, c.TCSANOW, &raw_termios);
896+ _ = cross.c.tcsetattr(posix.STDIN_FILENO, cross.c.TCSANOW, &raw_termios);
963897
964898 // Clear screen before attaching. This provides a clean slate before
965899 // the session restore.
966900 const clear_seq = "\x1b[2J\x1b[H";
967901 _ = try posix.write(posix.STDOUT_FILENO, clear_seq);
968902
969- try clientLoop(daemon.cfg, client_sock);
970-}
971-
972-fn shellNeedsQuoting(arg: []const u8) bool {
973- if (arg.len == 0) return true;
974- for (arg) |ch| {
975- switch (ch) {
976- ' ', '\t', '"', '\'', '\\', '$', '`', '!', '(', ')', '{', '}', '[', ']', '|', '&', ';', '<', '>', '?', '*', '~', '#', '\n' => return true,
977- else => {},
978- }
979- }
980- return false;
981-}
982-
983-fn shellQuote(alloc: std.mem.Allocator, arg: []const u8) ![]u8 {
984- // Always use single quotes (like Python's shlex.quote). Inside single
985- // quotes nothing is special except ' itself, which we handle with the
986- // '\'' trick (end quote, escaped literal quote, reopen quote).
987- var len: usize = 2;
988- for (arg) |ch| {
989- len += if (ch == '\'') 4 else 1;
990- }
991- const buf = try alloc.alloc(u8, len);
992- var i: usize = 0;
993- buf[i] = '\'';
994- i += 1;
995- for (arg) |ch| {
996- if (ch == '\'') {
997- @memcpy(buf[i..][0..4], "'\\''");
998- i += 4;
999- } else {
1000- buf[i] = ch;
1001- i += 1;
1002- }
1003- }
1004- buf[i] = '\'';
1005- return buf;
903+ try clientLoop(client_sock);
1006904 }
1007905
1008906 fn run(daemon: *Daemon, command_args: [][]const u8) !void {
......@@ -1014,7 +912,7 @@ fn run(daemon: *Daemon, command_args: [][]const u8) !void {
1014912 var allocated_cmd: ?[]u8 = null;
1015913 defer if (allocated_cmd) |cmd| alloc.free(cmd);
1016914
1017- const result = try ensureSession(daemon);
915+ const result = try daemon.ensureSession();
1018916 if (result.is_daemon) return;
1019917
1020918 if (result.created) {
......@@ -1022,7 +920,7 @@ fn run(daemon: *Daemon, command_args: [][]const u8) !void {
1022920 try w.interface.flush();
1023921 }
1024922
1025- const shell = detectShell();
923+ const shell = util.detectShell();
1026924 const shell_basename = std.fs.path.basename(shell);
1027925 const inline_task_marker = if (std.mem.eql(u8, shell_basename, "fish"))
1028926 "; echo ZMX_TASK_COMPLETED:$status"
......@@ -1039,8 +937,8 @@ fn run(daemon: *Daemon, command_args: [][]const u8) !void {
1039937
1040938 for (command_args, 0..) |arg, i| {
1041939 if (i > 0) try cmd_list.append(alloc, ' ');
1042- if (shellNeedsQuoting(arg)) {
1043- const quoted = try shellQuote(alloc, arg);
940+ if (util.shellNeedsQuoting(arg)) {
941+ const quoted = try util.shellQuote(alloc, arg);
1044942 defer alloc.free(quoted);
1045943 try cmd_list.appendSlice(alloc, quoted);
1046944 } else {
......@@ -1088,7 +986,7 @@ fn run(daemon: *Daemon, command_args: [][]const u8) !void {
1088986 return error.CommandRequired;
1089987 }
1090988
1091- const probe_result = probeSession(alloc, daemon.socket_path) catch |err| {
989+ const probe_result = ipc.probeSession(alloc, daemon.socket_path) catch |err| {
1092990 std.log.err("session not ready: {s}", .{@errorName(err)});
1093991 return error.SessionNotReady;
1094992 };
......@@ -1120,7 +1018,7 @@ fn run(daemon: *Daemon, command_args: [][]const u8) !void {
11201018 return error.NoAckReceived;
11211019 }
11221020
1123-fn clientLoop(_: *Cfg, client_sock_fd: i32) !void {
1021+fn clientLoop(client_sock_fd: i32) !void {
11241022 // use c_allocator to avoid "reached unreachable code" panic in DebugAllocator when forking
11251023 const alloc = std.heap.c_allocator;
11261024 defer posix.close(client_sock_fd);
......@@ -1136,7 +1034,7 @@ fn clientLoop(_: *Cfg, client_sock_fd: i32) !void {
11361034 defer sock_write_buf.deinit(alloc);
11371035
11381036 // Send init message with terminal size (buffered)
1139- const size = getTerminalSize(posix.STDOUT_FILENO);
1037+ const size = ipc.getTerminalSize(posix.STDOUT_FILENO);
11401038 try ipc.appendMessage(alloc, &sock_write_buf, .Init, std.mem.asBytes(&size));
11411039
11421040 var poll_fds = try std.ArrayList(posix.pollfd).initCapacity(alloc, 4);
......@@ -1157,7 +1055,7 @@ fn clientLoop(_: *Cfg, client_sock_fd: i32) !void {
11571055 while (true) {
11581056 // Check for pending SIGWINCH
11591057 if (sigwinch_received.swap(false, .acq_rel)) {
1160- const next_size = getTerminalSize(posix.STDOUT_FILENO);
1058+ const next_size = ipc.getTerminalSize(posix.STDOUT_FILENO);
11611059 try ipc.appendMessage(alloc, &sock_write_buf, .Resize, std.mem.asBytes(&next_size));
11621060 }
11631061
......@@ -1204,7 +1102,7 @@ fn clientLoop(_: *Cfg, client_sock_fd: i32) !void {
12041102 if (n_opt) |n| {
12051103 if (n > 0) {
12061104 // Check for detach sequences (ctrl+\ as first byte or Kitty escape sequence)
1207- if (buf[0] == 0x1C or isKittyCtrlBackslash(buf[0..n])) {
1105+ if (buf[0] == 0x1C or util.isKittyCtrlBackslash(buf[0..n])) {
12081106 try ipc.appendMessage(alloc, &sock_write_buf, .Detach, "");
12091107 } else {
12101108 try ipc.appendMessage(alloc, &sock_write_buf, .Input, buf[0..n]);
......@@ -1274,80 +1172,13 @@ fn clientLoop(_: *Cfg, client_sock_fd: i32) !void {
12741172 }
12751173 }
12761174
1277-const DA1_QUERY = "\x1b[c";
1278-const DA1_QUERY_EXPLICIT = "\x1b[0c";
1279-const DA2_QUERY = "\x1b[>c";
1280-const DA2_QUERY_EXPLICIT = "\x1b[>0c";
1281-const DA1_RESPONSE = "\x1b[?62;22c";
1282-const DA2_RESPONSE = "\x1b[>1;10;0c";
1283-
1284-fn respondToDeviceAttributes(pty_fd: i32, data: []const u8) void {
1285- // Scan for DA queries in PTY output and respond on behalf of the terminal.
1286- // This handles the case where no client is attached (e.g. zmx run)
1287- // and the shell (e.g. fish) sends a DA query that would otherwise go unanswered.
1288- //
1289- // DA1 query: ESC [ c or ESC [ 0 c
1290- // DA2 query: ESC [ > c or ESC [ > 0 c
1291- // DA1 response (from terminal): ESC [ ? ... c (has '?' after '[')
1292- //
1293- // We must NOT match DA responses (which contain '?') as queries.
1294- var i: usize = 0;
1295- while (i < data.len) {
1296- if (data[i] == '\x1b' and i + 1 < data.len and data[i + 1] == '[') {
1297- // Skip DA responses which have '?' after CSI
1298- if (i + 2 < data.len and data[i + 2] == '?') {
1299- i += 3;
1300- continue;
1301- }
1302- if (matchSeq(data[i..], DA2_QUERY) or matchSeq(data[i..], DA2_QUERY_EXPLICIT)) {
1303- _ = posix.write(pty_fd, DA2_RESPONSE) catch {};
1304- } else if (matchSeq(data[i..], DA1_QUERY) or matchSeq(data[i..], DA1_QUERY_EXPLICIT)) {
1305- _ = posix.write(pty_fd, DA1_RESPONSE) catch {};
1306- }
1307- }
1308- i += 1;
1309- }
1310-}
1311-
1312-fn matchSeq(data: []const u8, seq: []const u8) bool {
1313- if (data.len < seq.len) return false;
1314- return std.mem.eql(u8, data[0..seq.len], seq);
1315-}
1316-
1317-fn findTaskExitMarker(output: []const u8) ?u8 {
1318- const marker = "ZMX_TASK_COMPLETED:";
1319-
1320- // Search for marker in output
1321- if (std.mem.indexOf(u8, output, marker)) |idx| {
1322- const after_marker = output[idx + marker.len ..];
1323-
1324- // Find the exit code number and newline
1325- var end_idx: usize = 0;
1326- while (end_idx < after_marker.len and after_marker[end_idx] != '\n' and after_marker[end_idx] != '\r') {
1327- end_idx += 1;
1328- }
1329-
1330- const exit_code_str = after_marker[0..end_idx];
1331-
1332- // Parse exit code
1333- if (std.fmt.parseInt(u8, exit_code_str, 10)) |exit_code| {
1334- return exit_code;
1335- } else |_| {
1336- std.log.warn("failed to parse task exit code from: {s}", .{exit_code_str});
1337- return null;
1338- }
1339- }
1340-
1341- return null;
1342-}
1343-
13441175 fn daemonLoop(daemon: *Daemon, server_sock_fd: i32, pty_fd: i32) !void {
13451176 std.log.info("daemon started session={s} pty_fd={d}", .{ daemon.session_name, pty_fd });
13461177 setupSigtermHandler();
13471178 var poll_fds = try std.ArrayList(posix.pollfd).initCapacity(daemon.alloc, 8);
13481179 defer poll_fds.deinit(daemon.alloc);
13491180
1350- const init_size = getTerminalSize(pty_fd);
1181+ const init_size = ipc.getTerminalSize(pty_fd);
13511182 var term = try ghostty_vt.Terminal.init(daemon.alloc, .{
13521183 .cols = init_size.cols,
13531184 .rows = init_size.rows,
......@@ -1434,12 +1265,12 @@ fn daemonLoop(daemon: *Daemon, server_sock_fd: i32, pty_fd: i32) !void {
14341265 // and then sending a no DA query response warning because
14351266 // there's no client terminal to respond to the query.
14361267 if (daemon.clients.items.len == 0) {
1437- respondToDeviceAttributes(pty_fd, buf[0..n]);
1268+ util.respondToDeviceAttributes(pty_fd, buf[0..n]);
14381269 }
14391270
14401271 // In run mode, scan output for exit code marker
14411272 if (daemon.is_task_mode and daemon.task_exit_code == null) {
1442- if (findTaskExitMarker(buf[0..n])) |exit_code| {
1273+ if (util.findTaskExitMarker(buf[0..n])) |exit_code| {
14431274 daemon.task_exit_code = exit_code;
14441275 daemon.task_ended_at = @intCast(std.time.timestamp());
14451276
......@@ -1542,169 +1373,6 @@ fn daemonLoop(daemon: *Daemon, server_sock_fd: i32, pty_fd: i32) !void {
15421373 }
15431374 }
15441375
1545-fn spawnPty(daemon: *Daemon) !c_int {
1546- const size = getTerminalSize(posix.STDOUT_FILENO);
1547- var ws: c.struct_winsize = .{
1548- .ws_row = size.rows,
1549- .ws_col = size.cols,
1550- .ws_xpixel = 0,
1551- .ws_ypixel = 0,
1552- };
1553-
1554- var master_fd: c_int = undefined;
1555- const pid = forkpty(&master_fd, null, null, &ws);
1556- if (pid < 0) {
1557- return error.ForkPtyFailed;
1558- }
1559-
1560- if (pid == 0) { // child pid code path
1561- const session_env = try std.fmt.allocPrint(daemon.alloc, "ZMX_SESSION={s}\x00", .{daemon.session_name});
1562- _ = c.putenv(@ptrCast(session_env.ptr));
1563-
1564- if (daemon.command) |cmd_args| {
1565- const alloc = std.heap.c_allocator;
1566- var argv_buf: [64:null]?[*:0]const u8 = undefined;
1567- for (cmd_args, 0..) |arg, i| {
1568- argv_buf[i] = alloc.dupeZ(u8, arg) catch {
1569- std.posix.exit(1);
1570- };
1571- }
1572- argv_buf[cmd_args.len] = null;
1573- const argv: [*:null]const ?[*:0]const u8 = &argv_buf;
1574- const err = std.posix.execvpeZ(argv_buf[0].?, argv, std.c.environ);
1575- std.log.err("execvpe failed: cmd={s} err={s}", .{ cmd_args[0], @errorName(err) });
1576- std.posix.exit(1);
1577- } else {
1578- const shell = detectShell();
1579- // Use "-shellname" as argv[0] to signal login shell (traditional method)
1580- var buf: [64]u8 = undefined;
1581- const login_shell = try std.fmt.bufPrintZ(&buf, "-{s}", .{std.fs.path.basename(shell)});
1582- const argv = [_:null]?[*:0]const u8{ login_shell, null };
1583- const err = std.posix.execveZ(shell, &argv, std.c.environ);
1584- std.log.err("execve failed: err={s}", .{@errorName(err)});
1585- std.posix.exit(1);
1586- }
1587- }
1588- // master pid code path
1589- daemon.pid = pid;
1590- std.log.info("pty spawned session={s} pid={d}", .{ daemon.session_name, pid });
1591-
1592- // make pty non-blocking
1593- const flags = try posix.fcntl(master_fd, posix.F.GETFL, 0);
1594- _ = try posix.fcntl(master_fd, posix.F.SETFL, flags | @as(u32, 0o4000));
1595- return master_fd;
1596-}
1597-
1598-fn detectShell() [:0]const u8 {
1599- return std.posix.getenv("SHELL") orelse "/bin/sh";
1600-}
1601-
1602-fn seshPrefix() []const u8 {
1603- return std.posix.getenv("ZMX_SESSION_PREFIX") orelse "";
1604-}
1605-
1606-fn getSeshName(alloc: std.mem.Allocator, sesh: []const u8) ![]const u8 {
1607- const prefix = seshPrefix();
1608- if (std.mem.eql(u8, prefix, "") and std.mem.eql(u8, sesh, "")) {
1609- return error.SessionNameRequired;
1610- }
1611- return std.fmt.allocPrint(alloc, "{s}{s}", .{ seshPrefix(), sesh });
1612-}
1613-
1614-fn sessionConnect(sesh: []const u8) !i32 {
1615- var unix_addr = try std.net.Address.initUnix(sesh);
1616- const socket_fd = try posix.socket(posix.AF.UNIX, posix.SOCK.STREAM | posix.SOCK.CLOEXEC, 0);
1617- errdefer posix.close(socket_fd);
1618- try posix.connect(socket_fd, &unix_addr.any, unix_addr.getOsSockLen());
1619- return socket_fd;
1620-}
1621-
1622-const SessionProbeError = error{
1623- Timeout,
1624- ConnectionRefused,
1625- Unexpected,
1626-};
1627-
1628-const SessionProbeResult = struct {
1629- fd: i32,
1630- info: ipc.Info,
1631-};
1632-
1633-fn probeSession(alloc: std.mem.Allocator, socket_path: []const u8) SessionProbeError!SessionProbeResult {
1634- const timeout_ms = 1000;
1635- const fd = sessionConnect(socket_path) catch |err| switch (err) {
1636- error.ConnectionRefused => return error.ConnectionRefused,
1637- else => return error.Unexpected,
1638- };
1639- errdefer posix.close(fd);
1640-
1641- ipc.send(fd, .Info, "") catch return error.Unexpected;
1642-
1643- var poll_fds = [_]posix.pollfd{.{ .fd = fd, .events = posix.POLL.IN, .revents = 0 }};
1644- const poll_result = posix.poll(&poll_fds, timeout_ms) catch return error.Unexpected;
1645- if (poll_result == 0) {
1646- return error.Timeout;
1647- }
1648-
1649- var sb = ipc.SocketBuffer.init(alloc) catch return error.Unexpected;
1650- defer sb.deinit();
1651-
1652- const n = sb.read(fd) catch return error.Unexpected;
1653- if (n == 0) return error.Unexpected;
1654-
1655- while (sb.next()) |msg| {
1656- if (msg.header.tag == .Info) {
1657- if (msg.payload.len == @sizeOf(ipc.Info)) {
1658- return .{
1659- .fd = fd,
1660- .info = std.mem.bytesToValue(ipc.Info, msg.payload[0..@sizeOf(ipc.Info)]),
1661- };
1662- }
1663- }
1664- }
1665- return error.Unexpected;
1666-}
1667-
1668-fn cleanupStaleSocket(dir: std.fs.Dir, session_name: []const u8) void {
1669- std.log.warn("stale socket found, cleaning up session={s}", .{session_name});
1670- dir.deleteFile(session_name) catch |err| {
1671- std.log.warn("failed to delete stale socket err={s}", .{@errorName(err)});
1672- };
1673-}
1674-
1675-fn sessionExists(dir: std.fs.Dir, name: []const u8) !bool {
1676- const stat = dir.statFile(name) catch |err| switch (err) {
1677- error.FileNotFound => return false,
1678- else => return err,
1679- };
1680- if (stat.kind != .unix_domain_socket) {
1681- return error.FileNotUnixSocket;
1682- }
1683- return true;
1684-}
1685-
1686-fn createSocket(fname: []const u8) !i32 {
1687- // AF.UNIX: Unix domain socket for local IPC with client processes
1688- // SOCK.STREAM: Reliable, bidirectional communication
1689- // SOCK.NONBLOCK: Set socket to non-blocking
1690- const fd = try posix.socket(posix.AF.UNIX, posix.SOCK.STREAM | posix.SOCK.NONBLOCK | posix.SOCK.CLOEXEC, 0);
1691- errdefer posix.close(fd);
1692-
1693- var unix_addr = try std.net.Address.initUnix(fname);
1694- try posix.bind(fd, &unix_addr.any, unix_addr.getOsSockLen());
1695- try posix.listen(fd, 128);
1696- return fd;
1697-}
1698-
1699-pub fn getSocketPath(alloc: std.mem.Allocator, socket_dir: []const u8, session_name: []const u8) ![]const u8 {
1700- const dir = socket_dir;
1701- const fname = try alloc.alloc(u8, dir.len + session_name.len + 1);
1702- @memcpy(fname[0..dir.len], dir);
1703- @memcpy(fname[dir.len .. dir.len + 1], "/");
1704- @memcpy(fname[dir.len + 1 ..], session_name);
1705- return fname;
1706-}
1707-
17081376 fn handleSigwinch(_: i32, _: *const posix.siginfo_t, _: ?*anyopaque) callconv(.c) void {
17091377 sigwinch_received.store(true, .release);
17101378 }
......@@ -1730,320 +1398,3 @@ fn setupSigtermHandler() void {
17301398 };
17311399 posix.sigaction(posix.SIG.TERM, &act, null);
17321400 }
1733-
1734-fn getTerminalSize(fd: i32) ipc.Resize {
1735- var ws: c.struct_winsize = undefined;
1736- if (c.ioctl(fd, c.TIOCGWINSZ, &ws) == 0 and ws.ws_row > 0 and ws.ws_col > 0) {
1737- return .{ .rows = ws.ws_row, .cols = ws.ws_col };
1738- }
1739- return .{ .rows = 24, .cols = 80 };
1740-}
1741-
1742-/// Formats a session entry for list output (only the name when `short` is
1743-/// true), adding a prefix to indicate the current session, if there is one.
1744-fn writeSessionLine(writer: *std.Io.Writer, session: SessionEntry, short: bool, current_session: ?[]const u8) !void {
1745- const prefix = if (current_session) |current|
1746- if (std.mem.eql(u8, current, session.name)) current_arrow ++ " " else " "
1747- else
1748- "";
1749-
1750- if (short) {
1751- if (session.is_error) return;
1752- try writer.print("{s}\n", .{session.name});
1753- return;
1754- }
1755-
1756- if (session.is_error) {
1757- try writer.print("{s}name={s}\terr={s}\tstatus=cleaning up\n", .{
1758- prefix,
1759- session.name,
1760- session.error_name.?,
1761- });
1762- return;
1763- }
1764-
1765- try writer.print("{s}name={s}\tpid={d}\tclients={d}\tcreated={d}", .{
1766- prefix,
1767- session.name,
1768- session.pid.?,
1769- session.clients_len.?,
1770- session.created_at,
1771- });
1772- if (session.cwd) |cwd| {
1773- try writer.print("\tstart_dir={s}", .{cwd});
1774- }
1775- if (session.cmd) |cmd| {
1776- try writer.print("\tcmd={s}", .{cmd});
1777- }
1778- if (session.task_ended_at) |ended_at| {
1779- if (ended_at > 0) {
1780- try writer.print("\tended={d}", .{ended_at});
1781-
1782- if (session.task_exit_code) |exit_code| {
1783- try writer.print("\texit_code={d}", .{exit_code});
1784- }
1785- }
1786- }
1787- try writer.print("\n", .{});
1788-}
1789-
1790-/// Detects Kitty keyboard protocol escape sequence for Ctrl+\
1791-/// 92 = backslash, 5 = ctrl modifier, :1 = key press event
1792-fn isKittyCtrlBackslash(buf: []const u8) bool {
1793- return std.mem.indexOf(u8, buf, "\x1b[92;5u") != null or
1794- std.mem.indexOf(u8, buf, "\x1b[92;5:1u") != null;
1795-}
1796-
1797-fn serializeTerminalState(alloc: std.mem.Allocator, term: *ghostty_vt.Terminal) ?[]const u8 {
1798- var builder: std.Io.Writer.Allocating = .init(alloc);
1799- defer builder.deinit();
1800-
1801- var term_formatter = ghostty_vt.formatter.TerminalFormatter.init(term, .vt);
1802- term_formatter.content = .{ .selection = null };
1803- term_formatter.extra = .{
1804- .palette = false,
1805- .modes = true,
1806- .scrolling_region = true,
1807- .tabstops = false, // tabstop restoration moves cursor after CUP, corrupting position
1808- .pwd = true,
1809- .keyboard = true,
1810- .screen = .all,
1811- };
1812-
1813- term_formatter.format(&builder.writer) catch |err| {
1814- std.log.warn("failed to format terminal state err={s}", .{@errorName(err)});
1815- return null;
1816- };
1817-
1818- const output = builder.writer.buffered();
1819- if (output.len == 0) return null;
1820-
1821- return alloc.dupe(u8, output) catch |err| {
1822- std.log.warn("failed to allocate terminal state err={s}", .{@errorName(err)});
1823- return null;
1824- };
1825-}
1826-
1827-fn serializeTerminal(alloc: std.mem.Allocator, term: *ghostty_vt.Terminal, format: HistoryFormat) ?[]const u8 {
1828- var builder: std.Io.Writer.Allocating = .init(alloc);
1829- defer builder.deinit();
1830-
1831- const opts: ghostty_vt.formatter.Options = switch (format) {
1832- .plain => .plain,
1833- .vt => .vt,
1834- .html => .html,
1835- };
1836- var term_formatter = ghostty_vt.formatter.TerminalFormatter.init(term, opts);
1837- term_formatter.content = .{ .selection = null };
1838- term_formatter.extra = switch (format) {
1839- .plain => .none,
1840- .vt => .{
1841- .palette = false,
1842- .modes = true,
1843- .scrolling_region = true,
1844- .tabstops = false,
1845- .pwd = true,
1846- .keyboard = true,
1847- .screen = .all,
1848- },
1849- .html => .styles,
1850- };
1851-
1852- term_formatter.format(&builder.writer) catch |err| {
1853- std.log.warn("failed to format terminal err={s}", .{@errorName(err)});
1854- return null;
1855- };
1856-
1857- const output = builder.writer.buffered();
1858- if (output.len == 0) return null;
1859-
1860- return alloc.dupe(u8, output) catch |err| {
1861- std.log.warn("failed to allocate terminal output err={s}", .{@errorName(err)});
1862- return null;
1863- };
1864-}
1865-
1866-test "shellNeedsQuoting" {
1867- try std.testing.expect(shellNeedsQuoting(""));
1868- try std.testing.expect(shellNeedsQuoting("hello world"));
1869- try std.testing.expect(shellNeedsQuoting("hello!"));
1870- try std.testing.expect(shellNeedsQuoting("$PATH"));
1871- try std.testing.expect(shellNeedsQuoting("it's"));
1872- try std.testing.expect(shellNeedsQuoting("a|b"));
1873- try std.testing.expect(shellNeedsQuoting("a;b"));
1874- try std.testing.expect(!shellNeedsQuoting("hello"));
1875- try std.testing.expect(!shellNeedsQuoting("bash"));
1876- try std.testing.expect(!shellNeedsQuoting("-c"));
1877- try std.testing.expect(!shellNeedsQuoting("/usr/bin/env"));
1878-}
1879-
1880-test "shellQuote" {
1881- const alloc = std.testing.allocator;
1882-
1883- const empty = try shellQuote(alloc, "");
1884- defer alloc.free(empty);
1885- try std.testing.expectEqualStrings("''", empty);
1886-
1887- const space = try shellQuote(alloc, "hello world");
1888- defer alloc.free(space);
1889- try std.testing.expectEqualStrings("'hello world'", space);
1890-
1891- const bang = try shellQuote(alloc, "hello!");
1892- defer alloc.free(bang);
1893- try std.testing.expectEqualStrings("'hello!'", bang);
1894-
1895- const dollar = try shellQuote(alloc, "$PATH");
1896- defer alloc.free(dollar);
1897- try std.testing.expectEqualStrings("'$PATH'", dollar);
1898-
1899- const sq = try shellQuote(alloc, "it's");
1900- defer alloc.free(sq);
1901- try std.testing.expectEqualStrings("'it'\\''s'", sq);
1902-
1903- const dq = try shellQuote(alloc, "say \"hi\"");
1904- defer alloc.free(dq);
1905- try std.testing.expectEqualStrings("'say \"hi\"'", dq);
1906-
1907- const both = try shellQuote(alloc, "it's \"cool\"");
1908- defer alloc.free(both);
1909- try std.testing.expectEqualStrings("'it'\\''s \"cool\"'", both);
1910-
1911- // just a single quote
1912- const lone_sq = try shellQuote(alloc, "'");
1913- defer alloc.free(lone_sq);
1914- try std.testing.expectEqualStrings("''\\'''", lone_sq);
1915-
1916- // multiple consecutive single quotes
1917- const triple_sq = try shellQuote(alloc, "'''");
1918- defer alloc.free(triple_sq);
1919- try std.testing.expectEqualStrings("''\\'''\\'''\\'''", triple_sq);
1920-
1921- // backtick command substitution
1922- const backtick = try shellQuote(alloc, "`whoami`");
1923- defer alloc.free(backtick);
1924- try std.testing.expectEqualStrings("'`whoami`'", backtick);
1925-
1926- // dollar command substitution
1927- const dollar_cmd = try shellQuote(alloc, "$(whoami)");
1928- defer alloc.free(dollar_cmd);
1929- try std.testing.expectEqualStrings("'$(whoami)'", dollar_cmd);
1930-
1931- // glob
1932- const glob = try shellQuote(alloc, "*.txt");
1933- defer alloc.free(glob);
1934- try std.testing.expectEqualStrings("'*.txt'", glob);
1935-
1936- // tilde
1937- const tilde = try shellQuote(alloc, "~/file");
1938- defer alloc.free(tilde);
1939- try std.testing.expectEqualStrings("'~/file'", tilde);
1940-
1941- // trailing backslash
1942- const trailing_bs = try shellQuote(alloc, "path\\");
1943- defer alloc.free(trailing_bs);
1944- try std.testing.expectEqualStrings("'path\\'", trailing_bs);
1945-
1946- // semicolon (command injection)
1947- const semi = try shellQuote(alloc, "; rm -rf /");
1948- defer alloc.free(semi);
1949- try std.testing.expectEqualStrings("'; rm -rf /'", semi);
1950-
1951- // embedded newline
1952- const newline = try shellQuote(alloc, "line1\nline2");
1953- defer alloc.free(newline);
1954- try std.testing.expectEqualStrings("'line1\nline2'", newline);
1955-
1956- // parentheses (subshell)
1957- const parens = try shellQuote(alloc, "(echo hi)");
1958- defer alloc.free(parens);
1959- try std.testing.expectEqualStrings("'(echo hi)'", parens);
1960-
1961- // heredoc marker
1962- const heredoc = try shellQuote(alloc, "<<EOF");
1963- defer alloc.free(heredoc);
1964- try std.testing.expectEqualStrings("'<<EOF'", heredoc);
1965-
1966- // no quoting needed -- plain word should still be quoted
1967- // (shellQuote is only called when shellNeedsQuoting returns true,
1968- // but verify it produces valid output anyway)
1969- const plain = try shellQuote(alloc, "hello");
1970- defer alloc.free(plain);
1971- try std.testing.expectEqualStrings("'hello'", plain);
1972-}
1973-
1974-test "isKittyCtrlBackslash" {
1975- try std.testing.expect(isKittyCtrlBackslash("\x1b[92;5u"));
1976- try std.testing.expect(isKittyCtrlBackslash("\x1b[92;5:1u"));
1977- try std.testing.expect(!isKittyCtrlBackslash("\x1b[92;5:3u"));
1978- try std.testing.expect(!isKittyCtrlBackslash("\x1b[92;1u"));
1979- try std.testing.expect(!isKittyCtrlBackslash("garbage"));
1980-}
1981-
1982-test "writeSessionLine formats output for current session and short output" {
1983- const Case = struct {
1984- session: SessionEntry,
1985- short: bool,
1986- current_session: ?[]const u8,
1987- expected: []const u8,
1988- };
1989-
1990- const session = SessionEntry{
1991- .name = "dev",
1992- .pid = 123,
1993- .clients_len = 2,
1994- .is_error = false,
1995- .error_name = null,
1996- .cmd = null,
1997- .cwd = null,
1998- .created_at = 0,
1999- .task_ended_at = null,
2000- .task_exit_code = null,
2001- };
2002-
2003- const cases = [_]Case{
2004- .{
2005- .session = session,
2006- .short = false,
2007- .current_session = "dev",
2008- .expected = "→ name=dev\tpid=123\tclients=2\tcreated=0\n",
2009- },
2010- .{
2011- .session = session,
2012- .short = false,
2013- .current_session = "other",
2014- .expected = " name=dev\tpid=123\tclients=2\tcreated=0\n",
2015- },
2016- .{
2017- .session = session,
2018- .short = false,
2019- .current_session = null,
2020- .expected = "name=dev\tpid=123\tclients=2\tcreated=0\n",
2021- },
2022- .{
2023- .session = session,
2024- .short = true,
2025- .current_session = "dev",
2026- .expected = "dev\n",
2027- },
2028- .{
2029- .session = session,
2030- .short = true,
2031- .current_session = "other",
2032- .expected = "dev\n",
2033- },
2034- .{
2035- .session = session,
2036- .short = true,
2037- .current_session = null,
2038- .expected = "dev\n",
2039- },
2040- };
2041-
2042- for (cases) |case| {
2043- var builder: std.Io.Writer.Allocating = .init(std.testing.allocator);
2044- defer builder.deinit();
2045-
2046- try writeSessionLine(&builder.writer, case.session, case.short, case.current_session);
2047- try std.testing.expectEqualStrings(case.expected, builder.writer.buffered());
2048- }
2049-}
+62, -0
......@@ -0,0 +1,62 @@
1+const std = @import("std");
2+const posix = std.posix;
3+
4+pub fn seshPrefix() []const u8 {
5+ return std.posix.getenv("ZMX_SESSION_PREFIX") orelse "";
6+}
7+
8+pub fn getSeshName(alloc: std.mem.Allocator, sesh: []const u8) ![]const u8 {
9+ const prefix = seshPrefix();
10+ if (std.mem.eql(u8, prefix, "") and std.mem.eql(u8, sesh, "")) {
11+ return error.SessionNameRequired;
12+ }
13+ return std.fmt.allocPrint(alloc, "{s}{s}", .{ seshPrefix(), sesh });
14+}
15+
16+pub fn sessionConnect(sesh: []const u8) !i32 {
17+ var unix_addr = try std.net.Address.initUnix(sesh);
18+ const socket_fd = try posix.socket(posix.AF.UNIX, posix.SOCK.STREAM | posix.SOCK.CLOEXEC, 0);
19+ errdefer posix.close(socket_fd);
20+ try posix.connect(socket_fd, &unix_addr.any, unix_addr.getOsSockLen());
21+ return socket_fd;
22+}
23+
24+pub fn cleanupStaleSocket(dir: std.fs.Dir, session_name: []const u8) void {
25+ std.log.warn("stale socket found, cleaning up session={s}", .{session_name});
26+ dir.deleteFile(session_name) catch |err| {
27+ std.log.warn("failed to delete stale socket err={s}", .{@errorName(err)});
28+ };
29+}
30+
31+pub fn sessionExists(dir: std.fs.Dir, name: []const u8) !bool {
32+ const stat = dir.statFile(name) catch |err| switch (err) {
33+ error.FileNotFound => return false,
34+ else => return err,
35+ };
36+ if (stat.kind != .unix_domain_socket) {
37+ return error.FileNotUnixSocket;
38+ }
39+ return true;
40+}
41+
42+pub fn createSocket(fname: []const u8) !i32 {
43+ // AF.UNIX: Unix domain socket for local IPC with client processes
44+ // SOCK.STREAM: Reliable, bidirectional communication
45+ // SOCK.NONBLOCK: Set socket to non-blocking
46+ const fd = try posix.socket(posix.AF.UNIX, posix.SOCK.STREAM | posix.SOCK.NONBLOCK | posix.SOCK.CLOEXEC, 0);
47+ errdefer posix.close(fd);
48+
49+ var unix_addr = try std.net.Address.initUnix(fname);
50+ try posix.bind(fd, &unix_addr.any, unix_addr.getOsSockLen());
51+ try posix.listen(fd, 128);
52+ return fd;
53+}
54+
55+pub fn getSocketPath(alloc: std.mem.Allocator, socket_dir: []const u8, session_name: []const u8) ![]const u8 {
56+ const dir = socket_dir;
57+ const fname = try alloc.alloc(u8, dir.len + session_name.len + 1);
58+ @memcpy(fname[0..dir.len], dir);
59+ @memcpy(fname[dir.len .. dir.len + 1], "/");
60+ @memcpy(fname[dir.len + 1 ..], session_name);
61+ return fname;
62+}
+511, -0
......@@ -0,0 +1,511 @@
1+const std = @import("std");
2+const posix = std.posix;
3+const ghostty_vt = @import("ghostty-vt");
4+const ipc = @import("ipc.zig");
5+const socket = @import("socket.zig");
6+
7+pub const SessionEntry = struct {
8+ name: []const u8,
9+ pid: ?i32,
10+ clients_len: ?usize,
11+ is_error: bool,
12+ error_name: ?[]const u8,
13+ cmd: ?[]const u8 = null,
14+ cwd: ?[]const u8 = null,
15+ created_at: u64,
16+ task_ended_at: ?u64,
17+ task_exit_code: ?u8,
18+
19+ pub fn deinit(self: SessionEntry, alloc: std.mem.Allocator) void {
20+ alloc.free(self.name);
21+ if (self.cmd) |cmd| alloc.free(cmd);
22+ if (self.cwd) |cwd| alloc.free(cwd);
23+ }
24+
25+ pub fn lessThan(_: void, a: SessionEntry, b: SessionEntry) bool {
26+ return std.mem.order(u8, a.name, b.name) == .lt;
27+ }
28+};
29+
30+pub fn get_session_entries(alloc: std.mem.Allocator, socket_dir: []const u8) !std.ArrayList(SessionEntry) {
31+ var dir = try std.fs.openDirAbsolute(socket_dir, .{ .iterate = true });
32+ defer dir.close();
33+ var iter = dir.iterate();
34+
35+ var sessions = try std.ArrayList(SessionEntry).initCapacity(alloc, 30);
36+
37+ while (try iter.next()) |entry| {
38+ const exists = socket.sessionExists(dir, entry.name) catch continue;
39+ if (exists) {
40+ const name = try alloc.dupe(u8, entry.name);
41+ errdefer alloc.free(name);
42+
43+ const socket_path = try socket.getSocketPath(alloc, socket_dir, entry.name);
44+ defer alloc.free(socket_path);
45+
46+ const result = ipc.probeSession(alloc, socket_path) catch |err| {
47+ try sessions.append(alloc, .{
48+ .name = name,
49+ .pid = null,
50+ .clients_len = null,
51+ .is_error = true,
52+ .error_name = @errorName(err),
53+ .created_at = 0,
54+ .task_exit_code = 1,
55+ .task_ended_at = 0,
56+ });
57+ socket.cleanupStaleSocket(dir, entry.name);
58+ continue;
59+ };
60+ posix.close(result.fd);
61+
62+ // Extract cmd and cwd from the fixed-size arrays
63+ const cmd: ?[]const u8 = if (result.info.cmd_len > 0)
64+ alloc.dupe(u8, result.info.cmd[0..result.info.cmd_len]) catch null
65+ else
66+ null;
67+ const cwd: ?[]const u8 = if (result.info.cwd_len > 0)
68+ alloc.dupe(u8, result.info.cwd[0..result.info.cwd_len]) catch null
69+ else
70+ null;
71+
72+ try sessions.append(alloc, .{
73+ .name = name,
74+ .pid = result.info.pid,
75+ .clients_len = result.info.clients_len,
76+ .is_error = false,
77+ .error_name = null,
78+ .cmd = cmd,
79+ .cwd = cwd,
80+ .created_at = result.info.created_at,
81+ .task_ended_at = result.info.task_ended_at,
82+ .task_exit_code = result.info.task_exit_code,
83+ });
84+ }
85+ }
86+
87+ return sessions;
88+}
89+
90+pub fn shellNeedsQuoting(arg: []const u8) bool {
91+ if (arg.len == 0) return true;
92+ for (arg) |ch| {
93+ switch (ch) {
94+ ' ', '\t', '"', '\'', '\\', '$', '`', '!', '(', ')', '{', '}', '[', ']', '|', '&', ';', '<', '>', '?', '*', '~', '#', '\n' => return true,
95+ else => {},
96+ }
97+ }
98+ return false;
99+}
100+
101+pub fn shellQuote(alloc: std.mem.Allocator, arg: []const u8) ![]u8 {
102+ // Always use single quotes (like Python's shlex.quote). Inside single
103+ // quotes nothing is special except ' itself, which we handle with the
104+ // '\'' trick (end quote, escaped literal quote, reopen quote).
105+ var len: usize = 2;
106+ for (arg) |ch| {
107+ len += if (ch == '\'') 4 else 1;
108+ }
109+ const buf = try alloc.alloc(u8, len);
110+ var i: usize = 0;
111+ buf[i] = '\'';
112+ i += 1;
113+ for (arg) |ch| {
114+ if (ch == '\'') {
115+ @memcpy(buf[i..][0..4], "'\\''");
116+ i += 4;
117+ } else {
118+ buf[i] = ch;
119+ i += 1;
120+ }
121+ }
122+ buf[i] = '\'';
123+ return buf;
124+}
125+
126+const DA1_QUERY = "\x1b[c";
127+const DA1_QUERY_EXPLICIT = "\x1b[0c";
128+const DA2_QUERY = "\x1b[>c";
129+const DA2_QUERY_EXPLICIT = "\x1b[>0c";
130+const DA1_RESPONSE = "\x1b[?62;22c";
131+const DA2_RESPONSE = "\x1b[>1;10;0c";
132+
133+pub fn respondToDeviceAttributes(pty_fd: i32, data: []const u8) void {
134+ // Scan for DA queries in PTY output and respond on behalf of the terminal.
135+ // This handles the case where no client is attached (e.g. zmx run)
136+ // and the shell (e.g. fish) sends a DA query that would otherwise go unanswered.
137+ //
138+ // DA1 query: ESC [ c or ESC [ 0 c
139+ // DA2 query: ESC [ > c or ESC [ > 0 c
140+ // DA1 response (from terminal): ESC [ ? ... c (has '?' after '[')
141+ //
142+ // We must NOT match DA responses (which contain '?') as queries.
143+ var i: usize = 0;
144+ while (i < data.len) {
145+ if (data[i] == '\x1b' and i + 1 < data.len and data[i + 1] == '[') {
146+ // Skip DA responses which have '?' after CSI
147+ if (i + 2 < data.len and data[i + 2] == '?') {
148+ i += 3;
149+ continue;
150+ }
151+ if (matchSeq(data[i..], DA2_QUERY) or matchSeq(data[i..], DA2_QUERY_EXPLICIT)) {
152+ _ = posix.write(pty_fd, DA2_RESPONSE) catch {};
153+ } else if (matchSeq(data[i..], DA1_QUERY) or matchSeq(data[i..], DA1_QUERY_EXPLICIT)) {
154+ _ = posix.write(pty_fd, DA1_RESPONSE) catch {};
155+ }
156+ }
157+ i += 1;
158+ }
159+}
160+
161+fn matchSeq(data: []const u8, seq: []const u8) bool {
162+ if (data.len < seq.len) return false;
163+ return std.mem.eql(u8, data[0..seq.len], seq);
164+}
165+
166+pub fn findTaskExitMarker(output: []const u8) ?u8 {
167+ const marker = "ZMX_TASK_COMPLETED:";
168+
169+ // Search for marker in output
170+ if (std.mem.indexOf(u8, output, marker)) |idx| {
171+ const after_marker = output[idx + marker.len ..];
172+
173+ // Find the exit code number and newline
174+ var end_idx: usize = 0;
175+ while (end_idx < after_marker.len and after_marker[end_idx] != '\n' and after_marker[end_idx] != '\r') {
176+ end_idx += 1;
177+ }
178+
179+ const exit_code_str = after_marker[0..end_idx];
180+
181+ // Parse exit code
182+ if (std.fmt.parseInt(u8, exit_code_str, 10)) |exit_code| {
183+ return exit_code;
184+ } else |_| {
185+ std.log.warn("failed to parse task exit code from: {s}", .{exit_code_str});
186+ return null;
187+ }
188+ }
189+
190+ return null;
191+}
192+
193+/// Detects Kitty keyboard protocol escape sequence for Ctrl+\
194+/// 92 = backslash, 5 = ctrl modifier, :1 = key press event
195+pub fn isKittyCtrlBackslash(buf: []const u8) bool {
196+ return std.mem.indexOf(u8, buf, "\x1b[92;5u") != null or
197+ std.mem.indexOf(u8, buf, "\x1b[92;5:1u") != null;
198+}
199+
200+pub fn serializeTerminalState(alloc: std.mem.Allocator, term: *ghostty_vt.Terminal) ?[]const u8 {
201+ var builder: std.Io.Writer.Allocating = .init(alloc);
202+ defer builder.deinit();
203+
204+ var term_formatter = ghostty_vt.formatter.TerminalFormatter.init(term, .vt);
205+ term_formatter.content = .{ .selection = null };
206+ term_formatter.extra = .{
207+ .palette = false,
208+ .modes = true,
209+ .scrolling_region = true,
210+ .tabstops = false, // tabstop restoration moves cursor after CUP, corrupting position
211+ .pwd = true,
212+ .keyboard = true,
213+ .screen = .all,
214+ };
215+
216+ term_formatter.format(&builder.writer) catch |err| {
217+ std.log.warn("failed to format terminal state err={s}", .{@errorName(err)});
218+ return null;
219+ };
220+
221+ const output = builder.writer.buffered();
222+ if (output.len == 0) return null;
223+
224+ return alloc.dupe(u8, output) catch |err| {
225+ std.log.warn("failed to allocate terminal state err={s}", .{@errorName(err)});
226+ return null;
227+ };
228+}
229+
230+pub const HistoryFormat = enum(u8) {
231+ plain = 0,
232+ vt = 1,
233+ html = 2,
234+};
235+
236+pub fn serializeTerminal(alloc: std.mem.Allocator, term: *ghostty_vt.Terminal, format: HistoryFormat) ?[]const u8 {
237+ var builder: std.Io.Writer.Allocating = .init(alloc);
238+ defer builder.deinit();
239+
240+ const opts: ghostty_vt.formatter.Options = switch (format) {
241+ .plain => .plain,
242+ .vt => .vt,
243+ .html => .html,
244+ };
245+ var term_formatter = ghostty_vt.formatter.TerminalFormatter.init(term, opts);
246+ term_formatter.content = .{ .selection = null };
247+ term_formatter.extra = switch (format) {
248+ .plain => .none,
249+ .vt => .{
250+ .palette = false,
251+ .modes = true,
252+ .scrolling_region = true,
253+ .tabstops = false,
254+ .pwd = true,
255+ .keyboard = true,
256+ .screen = .all,
257+ },
258+ .html => .styles,
259+ };
260+
261+ term_formatter.format(&builder.writer) catch |err| {
262+ std.log.warn("failed to format terminal err={s}", .{@errorName(err)});
263+ return null;
264+ };
265+
266+ const output = builder.writer.buffered();
267+ if (output.len == 0) return null;
268+
269+ return alloc.dupe(u8, output) catch |err| {
270+ std.log.warn("failed to allocate terminal output err={s}", .{@errorName(err)});
271+ return null;
272+ };
273+}
274+
275+pub fn detectShell() [:0]const u8 {
276+ return std.posix.getenv("SHELL") orelse "/bin/sh";
277+}
278+
279+/// Formats a session entry for list output (only the name when `short` is
280+/// true), adding a prefix to indicate the current session, if there is one.
281+pub fn writeSessionLine(writer: *std.Io.Writer, session: SessionEntry, short: bool, current_session: ?[]const u8) !void {
282+ const current_arrow = "→";
283+ const prefix = if (current_session) |current|
284+ if (std.mem.eql(u8, current, session.name)) current_arrow ++ " " else " "
285+ else
286+ "";
287+
288+ if (short) {
289+ if (session.is_error) return;
290+ try writer.print("{s}\n", .{session.name});
291+ return;
292+ }
293+
294+ if (session.is_error) {
295+ try writer.print("{s}name={s}\terr={s}\tstatus=cleaning up\n", .{
296+ prefix,
297+ session.name,
298+ session.error_name.?,
299+ });
300+ return;
301+ }
302+
303+ try writer.print("{s}name={s}\tpid={d}\tclients={d}\tcreated={d}", .{
304+ prefix,
305+ session.name,
306+ session.pid.?,
307+ session.clients_len.?,
308+ session.created_at,
309+ });
310+ if (session.cwd) |cwd| {
311+ try writer.print("\tstart_dir={s}", .{cwd});
312+ }
313+ if (session.cmd) |cmd| {
314+ try writer.print("\tcmd={s}", .{cmd});
315+ }
316+ if (session.task_ended_at) |ended_at| {
317+ if (ended_at > 0) {
318+ try writer.print("\tended={d}", .{ended_at});
319+
320+ if (session.task_exit_code) |exit_code| {
321+ try writer.print("\texit_code={d}", .{exit_code});
322+ }
323+ }
324+ }
325+ try writer.print("\n", .{});
326+}
327+
328+test "writeSessionLine formats output for current session and short output" {
329+ const Case = struct {
330+ session: SessionEntry,
331+ short: bool,
332+ current_session: ?[]const u8,
333+ expected: []const u8,
334+ };
335+
336+ const session = SessionEntry{
337+ .name = "dev",
338+ .pid = 123,
339+ .clients_len = 2,
340+ .is_error = false,
341+ .error_name = null,
342+ .cmd = null,
343+ .cwd = null,
344+ .created_at = 0,
345+ .task_ended_at = null,
346+ .task_exit_code = null,
347+ };
348+
349+ const cases = [_]Case{
350+ .{
351+ .session = session,
352+ .short = false,
353+ .current_session = "dev",
354+ .expected = "→ name=dev\tpid=123\tclients=2\tcreated=0\n",
355+ },
356+ .{
357+ .session = session,
358+ .short = false,
359+ .current_session = "other",
360+ .expected = " name=dev\tpid=123\tclients=2\tcreated=0\n",
361+ },
362+ .{
363+ .session = session,
364+ .short = false,
365+ .current_session = null,
366+ .expected = "name=dev\tpid=123\tclients=2\tcreated=0\n",
367+ },
368+ .{
369+ .session = session,
370+ .short = true,
371+ .current_session = "dev",
372+ .expected = "dev\n",
373+ },
374+ .{
375+ .session = session,
376+ .short = true,
377+ .current_session = "other",
378+ .expected = "dev\n",
379+ },
380+ .{
381+ .session = session,
382+ .short = true,
383+ .current_session = null,
384+ .expected = "dev\n",
385+ },
386+ };
387+
388+ for (cases) |case| {
389+ var builder: std.Io.Writer.Allocating = .init(std.testing.allocator);
390+ defer builder.deinit();
391+
392+ try writeSessionLine(&builder.writer, case.session, case.short, case.current_session);
393+ try std.testing.expectEqualStrings(case.expected, builder.writer.buffered());
394+ }
395+}
396+
397+test "shellNeedsQuoting" {
398+ try std.testing.expect(shellNeedsQuoting(""));
399+ try std.testing.expect(shellNeedsQuoting("hello world"));
400+ try std.testing.expect(shellNeedsQuoting("hello!"));
401+ try std.testing.expect(shellNeedsQuoting("$PATH"));
402+ try std.testing.expect(shellNeedsQuoting("it's"));
403+ try std.testing.expect(shellNeedsQuoting("a|b"));
404+ try std.testing.expect(shellNeedsQuoting("a;b"));
405+ try std.testing.expect(!shellNeedsQuoting("hello"));
406+ try std.testing.expect(!shellNeedsQuoting("bash"));
407+ try std.testing.expect(!shellNeedsQuoting("-c"));
408+ try std.testing.expect(!shellNeedsQuoting("/usr/bin/env"));
409+}
410+
411+test "shellQuote" {
412+ const alloc = std.testing.allocator;
413+
414+ const empty = try shellQuote(alloc, "");
415+ defer alloc.free(empty);
416+ try std.testing.expectEqualStrings("''", empty);
417+
418+ const space = try shellQuote(alloc, "hello world");
419+ defer alloc.free(space);
420+ try std.testing.expectEqualStrings("'hello world'", space);
421+
422+ const bang = try shellQuote(alloc, "hello!");
423+ defer alloc.free(bang);
424+ try std.testing.expectEqualStrings("'hello!'", bang);
425+
426+ const dollar = try shellQuote(alloc, "$PATH");
427+ defer alloc.free(dollar);
428+ try std.testing.expectEqualStrings("'$PATH'", dollar);
429+
430+ const sq = try shellQuote(alloc, "it's");
431+ defer alloc.free(sq);
432+ try std.testing.expectEqualStrings("'it'\\''s'", sq);
433+
434+ const dq = try shellQuote(alloc, "say \"hi\"");
435+ defer alloc.free(dq);
436+ try std.testing.expectEqualStrings("'say \"hi\"'", dq);
437+
438+ const both = try shellQuote(alloc, "it's \"cool\"");
439+ defer alloc.free(both);
440+ try std.testing.expectEqualStrings("'it'\\''s \"cool\"'", both);
441+
442+ // just a single quote
443+ const lone_sq = try shellQuote(alloc, "'");
444+ defer alloc.free(lone_sq);
445+ try std.testing.expectEqualStrings("''\\'''", lone_sq);
446+
447+ // multiple consecutive single quotes
448+ const triple_sq = try shellQuote(alloc, "'''");
449+ defer alloc.free(triple_sq);
450+ try std.testing.expectEqualStrings("''\\'''\\'''\\'''", triple_sq);
451+
452+ // backtick command substitution
453+ const backtick = try shellQuote(alloc, "`whoami`");
454+ defer alloc.free(backtick);
455+ try std.testing.expectEqualStrings("'`whoami`'", backtick);
456+
457+ // dollar command substitution
458+ const dollar_cmd = try shellQuote(alloc, "$(whoami)");
459+ defer alloc.free(dollar_cmd);
460+ try std.testing.expectEqualStrings("'$(whoami)'", dollar_cmd);
461+
462+ // glob
463+ const glob = try shellQuote(alloc, "*.txt");
464+ defer alloc.free(glob);
465+ try std.testing.expectEqualStrings("'*.txt'", glob);
466+
467+ // tilde
468+ const tilde = try shellQuote(alloc, "~/file");
469+ defer alloc.free(tilde);
470+ try std.testing.expectEqualStrings("'~/file'", tilde);
471+
472+ // trailing backslash
473+ const trailing_bs = try shellQuote(alloc, "path\\");
474+ defer alloc.free(trailing_bs);
475+ try std.testing.expectEqualStrings("'path\\'", trailing_bs);
476+
477+ // semicolon (command injection)
478+ const semi = try shellQuote(alloc, "; rm -rf /");
479+ defer alloc.free(semi);
480+ try std.testing.expectEqualStrings("'; rm -rf /'", semi);
481+
482+ // embedded newline
483+ const newline = try shellQuote(alloc, "line1\nline2");
484+ defer alloc.free(newline);
485+ try std.testing.expectEqualStrings("'line1\nline2'", newline);
486+
487+ // parentheses (subshell)
488+ const parens = try shellQuote(alloc, "(echo hi)");
489+ defer alloc.free(parens);
490+ try std.testing.expectEqualStrings("'(echo hi)'", parens);
491+
492+ // heredoc marker
493+ const heredoc = try shellQuote(alloc, "<<EOF");
494+ defer alloc.free(heredoc);
495+ try std.testing.expectEqualStrings("'<<EOF'", heredoc);
496+
497+ // no quoting needed -- plain word should still be quoted
498+ // (shellQuote is only called when shellNeedsQuoting returns true,
499+ // but verify it produces valid output anyway)
500+ const plain = try shellQuote(alloc, "hello");
501+ defer alloc.free(plain);
502+ try std.testing.expectEqualStrings("'hello'", plain);
503+}
504+
505+test "isKittyCtrlBackslash" {
506+ try std.testing.expect(isKittyCtrlBackslash("\x1b[92;5u"));
507+ try std.testing.expect(isKittyCtrlBackslash("\x1b[92;5:1u"));
508+ try std.testing.expect(!isKittyCtrlBackslash("\x1b[92;5:3u"));
509+ try std.testing.expect(!isKittyCtrlBackslash("\x1b[92;1u"));
510+ try std.testing.expect(!isKittyCtrlBackslash("garbage"));
511+}