repos / zmx

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

commit
79f25b5
parent
9a25a8a
author
Eric Bower
date
2025-10-11 16:52:22 -0400 EDT
refactor: use new protocol module
6 files changed,  +272, -369
M src/attach.zig
+126, -98
  1@@ -4,6 +4,7 @@ const xevg = @import("xev");
  2 const xev = xevg.Dynamic;
  3 const clap = @import("clap");
  4 const config_mod = @import("config.zig");
  5+const protocol = @import("protocol.zig");
  6 
  7 const c = @cImport({
  8     @cInclude("termios.h");
  9@@ -85,13 +86,6 @@ pub fn main(config: config_mod.Config, iter: *std.process.ArgIterator) !void {
 10     c.cfmakeraw(&raw_termios);
 11     _ = c.tcsetattr(posix.STDIN_FILENO, c.TCSANOW, &raw_termios);
 12 
 13-    const request = try std.fmt.allocPrint(
 14-        allocator,
 15-        "{{\"type\":\"attach_session_request\",\"payload\":{{\"session_name\":\"{s}\"}}}}\n",
 16-        .{session_name},
 17-    );
 18-    defer allocator.free(request);
 19-
 20     const ctx = try allocator.create(Context);
 21     ctx.* = .{
 22         .stream = xev.Stream.initFd(socket_fd),
 23@@ -101,6 +95,22 @@ pub fn main(config: config_mod.Config, iter: *std.process.ArgIterator) !void {
 24         .session_name = session_name,
 25     };
 26 
 27+    const request_payload = protocol.AttachSessionRequest{ .session_name = session_name };
 28+    var out: std.io.Writer.Allocating = .init(allocator);
 29+    defer out.deinit();
 30+
 31+    const msg = protocol.Message(@TypeOf(request_payload)){
 32+        .type = protocol.MessageType.attach_session_request.toString(),
 33+        .payload = request_payload,
 34+    };
 35+
 36+    var stringify: std.json.Stringify = .{ .writer = &out.writer };
 37+    try stringify.write(msg);
 38+    try out.writer.writeByte('\n');
 39+
 40+    const request = try allocator.dupe(u8, out.written());
 41+    defer allocator.free(request);
 42+
 43     const write_completion = try allocator.create(xev.Completion);
 44     ctx.stream.write(&loop, write_completion, .{ .slice = request }, Context, ctx, writeCallback);
 45 
 46@@ -175,77 +185,99 @@ fn readCallback(
 47         const msg_line = data[0..newline_idx];
 48         // std.debug.print("Parsing message ({d} bytes): {s}\n", .{msg_line.len, msg_line});
 49 
 50-        const parsed = std.json.parseFromSlice(
 51-            std.json.Value,
 52-            ctx.allocator,
 53-            msg_line,
 54-            .{},
 55-        ) catch |err| {
 56+        const msg_type_parsed = protocol.parseMessageType(ctx.allocator, msg_line) catch |err| {
 57             std.debug.print("JSON parse error: {s}\n", .{@errorName(err)});
 58             return .rearm;
 59         };
 60-        defer parsed.deinit();
 61-
 62-        const root = parsed.value.object;
 63-        const msg_type = root.get("type").?.string;
 64-        const payload = root.get("payload").?.object;
 65-
 66-        if (std.mem.eql(u8, msg_type, "attach_session_response")) {
 67-            const status = payload.get("status").?.string;
 68-            if (std.mem.eql(u8, status, "ok")) {
 69-                // Get client_fd from response
 70-                const client_fd = payload.get("client_fd").?.integer;
 71-
 72-                // Write client_fd to a file so shell commands can read it
 73-                const home_dir = posix.getenv("HOME") orelse "/tmp";
 74-                const client_fd_path = std.fmt.allocPrint(
 75-                    ctx.allocator,
 76-                    "{s}/.zmx_client_fd_{s}",
 77-                    .{ home_dir, ctx.session_name },
 78-                ) catch |err| {
 79-                    std.debug.print("Failed to create client_fd path: {s}\n", .{@errorName(err)});
 80-                    return .rearm;
 81-                };
 82-                defer ctx.allocator.free(client_fd_path);
 83+        defer msg_type_parsed.deinit();
 84 
 85-                const file = std.fs.cwd().createFile(client_fd_path, .{ .truncate = true }) catch |err| {
 86-                    std.debug.print("Failed to create client_fd file: {s}\n", .{@errorName(err)});
 87+        const msg_type = protocol.MessageType.fromString(msg_type_parsed.value.type) orelse {
 88+            std.debug.print("Unknown message type: {s}\n", .{msg_type_parsed.value.type});
 89+            return .rearm;
 90+        };
 91+
 92+        switch (msg_type) {
 93+            .attach_session_response => {
 94+                const parsed = protocol.parseMessage(protocol.AttachSessionResponse, ctx.allocator, msg_line) catch |err| {
 95+                    std.debug.print("Failed to parse attach response: {s}\n", .{@errorName(err)});
 96                     return .rearm;
 97                 };
 98-                defer file.close();
 99-
100-                const fd_str = std.fmt.allocPrint(ctx.allocator, "{d}", .{client_fd}) catch return .rearm;
101-                defer ctx.allocator.free(fd_str);
102-
103-                file.writeAll(fd_str) catch |err| {
104-                    std.debug.print("Failed to write client_fd: {s}\n", .{@errorName(err)});
105+                defer parsed.deinit();
106+
107+                if (std.mem.eql(u8, parsed.value.payload.status, "ok")) {
108+                    const client_fd = parsed.value.payload.client_fd orelse {
109+                        std.debug.print("Missing client_fd in response\n", .{});
110+                        return .rearm;
111+                    };
112+
113+                    // Write client_fd to a file so shell commands can read it
114+                    const home_dir = posix.getenv("HOME") orelse "/tmp";
115+                    const client_fd_path = std.fmt.allocPrint(
116+                        ctx.allocator,
117+                        "{s}/.zmx_client_fd_{s}",
118+                        .{ home_dir, ctx.session_name },
119+                    ) catch |err| {
120+                        std.debug.print("Failed to create client_fd path: {s}\n", .{@errorName(err)});
121+                        return .rearm;
122+                    };
123+                    defer ctx.allocator.free(client_fd_path);
124+
125+                    const file = std.fs.cwd().createFile(client_fd_path, .{ .truncate = true }) catch |err| {
126+                        std.debug.print("Failed to create client_fd file: {s}\n", .{@errorName(err)});
127+                        return .rearm;
128+                    };
129+                    defer file.close();
130+
131+                    const fd_str = std.fmt.allocPrint(ctx.allocator, "{d}", .{client_fd}) catch return .rearm;
132+                    defer ctx.allocator.free(fd_str);
133+
134+                    file.writeAll(fd_str) catch |err| {
135+                        std.debug.print("Failed to write client_fd: {s}\n", .{@errorName(err)});
136+                        return .rearm;
137+                    };
138+
139+                    startStdinReading(ctx);
140+                } else {
141+                    _ = posix.write(posix.STDERR_FILENO, "Attach failed: ") catch {};
142+                    _ = posix.write(posix.STDERR_FILENO, parsed.value.payload.status) catch {};
143+                    _ = posix.write(posix.STDERR_FILENO, "\n") catch {};
144+                }
145+            },
146+            .detach_session_response => {
147+                const parsed = protocol.parseMessage(protocol.DetachSessionResponse, ctx.allocator, msg_line) catch |err| {
148+                    std.debug.print("Failed to parse detach response: {s}\n", .{@errorName(err)});
149                     return .rearm;
150                 };
151+                defer parsed.deinit();
152 
153-                startStdinReading(ctx);
154-            } else {
155-                _ = posix.write(posix.STDERR_FILENO, "Attach failed: ") catch {};
156-                _ = posix.write(posix.STDERR_FILENO, status) catch {};
157-                _ = posix.write(posix.STDERR_FILENO, "\n") catch {};
158-            }
159-        } else if (std.mem.eql(u8, msg_type, "detach_session_response")) {
160-            const status = payload.get("status").?.string;
161-            if (std.mem.eql(u8, status, "ok")) {
162+                if (std.mem.eql(u8, parsed.value.payload.status, "ok")) {
163+                    cleanupClientFdFile(ctx);
164+                    _ = posix.write(posix.STDERR_FILENO, "\r\nDetached from session\r\n") catch {};
165+                    return cleanup(ctx, completion);
166+                }
167+            },
168+            .detach_notification => {
169                 cleanupClientFdFile(ctx);
170-                _ = posix.write(posix.STDERR_FILENO, "\r\nDetached from session\r\n") catch {};
171+                _ = posix.write(posix.STDERR_FILENO, "\r\nDetached from session (external request)\r\n") catch {};
172                 return cleanup(ctx, completion);
173-            }
174-        } else if (std.mem.eql(u8, msg_type, "detach_notification")) {
175-            cleanupClientFdFile(ctx);
176-            _ = posix.write(posix.STDERR_FILENO, "\r\nDetached from session (external request)\r\n") catch {};
177-            return cleanup(ctx, completion);
178-        } else if (std.mem.eql(u8, msg_type, "kill_notification")) {
179-            cleanupClientFdFile(ctx);
180-            _ = posix.write(posix.STDERR_FILENO, "\r\nSession killed\r\n") catch {};
181-            return cleanup(ctx, completion);
182-        } else if (std.mem.eql(u8, msg_type, "pty_out")) {
183-            const text = payload.get("text").?.string;
184-            _ = posix.write(posix.STDOUT_FILENO, text) catch {};
185+            },
186+            .kill_notification => {
187+                cleanupClientFdFile(ctx);
188+                _ = posix.write(posix.STDERR_FILENO, "\r\nSession killed\r\n") catch {};
189+                return cleanup(ctx, completion);
190+            },
191+            .pty_out => {
192+                const parsed = protocol.parseMessage(protocol.PtyOutput, ctx.allocator, msg_line) catch |err| {
193+                    std.debug.print("Failed to parse pty output: {s}\n", .{@errorName(err)});
194+                    return .rearm;
195+                };
196+                defer parsed.deinit();
197+
198+                _ = posix.write(posix.STDOUT_FILENO, parsed.value.payload.text) catch {};
199+            },
200+            else => {
201+                std.debug.print("Unexpected message type in attach client: {s}\n", .{msg_type.toString()});
202+            },
203         }
204 
205         return .rearm;
206@@ -292,17 +324,25 @@ fn cleanupClientFdFile(ctx: *Context) void {
207 }
208 
209 fn sendDetachRequest(ctx: *Context) void {
210-    const request = std.fmt.allocPrint(
211-        ctx.allocator,
212-        "{{\"type\":\"detach_session_request\",\"payload\":{{\"session_name\":\"{s}\"}}}}\n",
213-        .{ctx.session_name},
214-    ) catch return;
215-    defer ctx.allocator.free(request);
216+    const request_payload = protocol.DetachSessionRequest{ .session_name = ctx.session_name };
217+    var out: std.io.Writer.Allocating = .init(ctx.allocator);
218+    defer out.deinit();
219+
220+    const msg = protocol.Message(@TypeOf(request_payload)){
221+        .type = protocol.MessageType.detach_session_request.toString(),
222+        .payload = request_payload,
223+    };
224+
225+    var stringify: std.json.Stringify = .{ .writer = &out.writer };
226+    stringify.write(msg) catch return;
227+    out.writer.writeByte('\n') catch return;
228+
229+    const request = ctx.allocator.dupe(u8, out.written()) catch return;
230 
231     const write_ctx = ctx.allocator.create(StdinWriteContext) catch return;
232     write_ctx.* = .{
233         .allocator = ctx.allocator,
234-        .message = ctx.allocator.dupe(u8, request) catch return,
235+        .message = request,
236     };
237 
238     const write_completion = ctx.allocator.create(xev.Completion) catch return;
239@@ -310,32 +350,20 @@ fn sendDetachRequest(ctx: *Context) void {
240 }
241 
242 fn sendPtyInput(ctx: *Context, data: []const u8) void {
243-    var msg_buf = std.ArrayList(u8).initCapacity(ctx.allocator, 4096) catch return;
244-    defer msg_buf.deinit(ctx.allocator);
245-
246-    msg_buf.appendSlice(ctx.allocator, "{\"type\":\"pty_in\",\"payload\":{\"text\":\"") catch return;
247-
248-    for (data) |byte| {
249-        switch (byte) {
250-            '"' => msg_buf.appendSlice(ctx.allocator, "\\\"") catch return,
251-            '\\' => msg_buf.appendSlice(ctx.allocator, "\\\\") catch return,
252-            '\n' => msg_buf.appendSlice(ctx.allocator, "\\n") catch return,
253-            '\r' => msg_buf.appendSlice(ctx.allocator, "\\r") catch return,
254-            '\t' => msg_buf.appendSlice(ctx.allocator, "\\t") catch return,
255-            0x08 => msg_buf.appendSlice(ctx.allocator, "\\b") catch return,
256-            0x0C => msg_buf.appendSlice(ctx.allocator, "\\f") catch return,
257-            0x00...0x07, 0x0B, 0x0E...0x1F, 0x7F => {
258-                const escaped = std.fmt.allocPrint(ctx.allocator, "\\u{x:0>4}", .{byte}) catch return;
259-                defer ctx.allocator.free(escaped);
260-                msg_buf.appendSlice(ctx.allocator, escaped) catch return;
261-            },
262-            else => msg_buf.append(ctx.allocator, byte) catch return,
263-        }
264-    }
265+    const request_payload = protocol.PtyInput{ .text = data };
266+    var out: std.io.Writer.Allocating = .init(ctx.allocator);
267+    defer out.deinit();
268+
269+    const msg = protocol.Message(@TypeOf(request_payload)){
270+        .type = protocol.MessageType.pty_in.toString(),
271+        .payload = request_payload,
272+    };
273 
274-    msg_buf.appendSlice(ctx.allocator, "\"}}\n") catch return;
275+    var stringify: std.json.Stringify = .{ .writer = &out.writer };
276+    stringify.write(msg) catch return;
277+    out.writer.writeByte('\n') catch return;
278 
279-    const owned_message = ctx.allocator.dupe(u8, msg_buf.items) catch return;
280+    const owned_message = ctx.allocator.dupe(u8, out.written()) catch return;
281 
282     const write_ctx = ctx.allocator.create(StdinWriteContext) catch return;
283     write_ctx.* = .{
M src/daemon.zig
+113, -198
  1@@ -4,6 +4,7 @@ const xevg = @import("xev");
  2 const xev = xevg.Dynamic;
  3 const clap = @import("clap");
  4 const config_mod = @import("config.zig");
  5+const protocol = @import("protocol.zig");
  6 
  7 const ghostty = @import("ghostty-vt");
  8 
  9@@ -14,17 +15,6 @@ const c = @cImport({
 10     @cInclude("sys/ioctl.h");
 11 });
 12 
 13-// Generic JSON message structure used for parsing incoming protocol messages from clients
 14-const Message = struct {
 15-    type: []const u8,
 16-    payload: std.json.Value,
 17-};
 18-
 19-// Request payload for attaching to a session
 20-const AttachRequest = struct {
 21-    session_name: []const u8,
 22-};
 23-
 24 // Handler for processing VT sequences
 25 const VTHandler = struct {
 26     terminal: *ghostty.Terminal,
 27@@ -289,44 +279,51 @@ fn readCallback(
 28 fn handleMessage(client: *Client, data: []const u8) !void {
 29     std.debug.print("Received message from client fd={d}: {s}", .{ client.fd, data });
 30 
 31-    // Parse JSON message
 32-    const parsed = try std.json.parseFromSlice(
 33-        std.json.Value,
 34-        client.allocator,
 35-        data,
 36-        .{},
 37-    );
 38-    defer parsed.deinit();
 39-
 40-    const root = parsed.value.object;
 41-    const msg_type = root.get("type").?.string;
 42-    const payload = root.get("payload").?.object;
 43-
 44-    if (std.mem.eql(u8, msg_type, "attach_session_request")) {
 45-        const session_name = payload.get("session_name").?.string;
 46-        std.debug.print("Handling attach request for session: {s}\n", .{session_name});
 47-        try handleAttachSession(client.server_ctx, client, session_name);
 48-    } else if (std.mem.eql(u8, msg_type, "detach_session_request")) {
 49-        const session_name = payload.get("session_name").?.string;
 50-        const target_client_fd = if (payload.get("client_fd")) |fd_value| fd_value.integer else null;
 51-        std.debug.print("Handling detach request for session: {s}, target_fd: {any}\n", .{ session_name, target_client_fd });
 52-        try handleDetachSession(client, session_name, target_client_fd);
 53-    } else if (std.mem.eql(u8, msg_type, "kill_session_request")) {
 54-        const session_name = payload.get("session_name").?.string;
 55-        std.debug.print("Handling kill request for session: {s}\n", .{session_name});
 56-        try handleKillSession(client, session_name);
 57-    } else if (std.mem.eql(u8, msg_type, "list_sessions_request")) {
 58-        std.debug.print("Handling list sessions request\n", .{});
 59-        try handleListSessions(client.server_ctx, client);
 60-    } else if (std.mem.eql(u8, msg_type, "pty_in")) {
 61-        const text = payload.get("text").?.string;
 62-        try handlePtyInput(client, text);
 63-    } else if (std.mem.eql(u8, msg_type, "window_resize")) {
 64-        const rows = @as(u16, @intCast(payload.get("rows").?.integer));
 65-        const cols = @as(u16, @intCast(payload.get("cols").?.integer));
 66-        try handleWindowResize(client, rows, cols);
 67-    } else {
 68-        std.debug.print("Unknown message type: {s}\n", .{msg_type});
 69+    // Parse message type first for dispatching
 70+    const type_parsed = try protocol.parseMessageType(client.allocator, data);
 71+    defer type_parsed.deinit();
 72+
 73+    const msg_type = protocol.MessageType.fromString(type_parsed.value.type) orelse {
 74+        std.debug.print("Unknown message type: {s}\n", .{type_parsed.value.type});
 75+        return;
 76+    };
 77+
 78+    switch (msg_type) {
 79+        .attach_session_request => {
 80+            const parsed = try protocol.parseMessage(protocol.AttachSessionRequest, client.allocator, data);
 81+            defer parsed.deinit();
 82+            std.debug.print("Handling attach request for session: {s}\n", .{parsed.value.payload.session_name});
 83+            try handleAttachSession(client.server_ctx, client, parsed.value.payload.session_name);
 84+        },
 85+        .detach_session_request => {
 86+            const parsed = try protocol.parseMessage(protocol.DetachSessionRequest, client.allocator, data);
 87+            defer parsed.deinit();
 88+            std.debug.print("Handling detach request for session: {s}, target_fd: {any}\n", .{ parsed.value.payload.session_name, parsed.value.payload.client_fd });
 89+            try handleDetachSession(client, parsed.value.payload.session_name, parsed.value.payload.client_fd);
 90+        },
 91+        .kill_session_request => {
 92+            const parsed = try protocol.parseMessage(protocol.KillSessionRequest, client.allocator, data);
 93+            defer parsed.deinit();
 94+            std.debug.print("Handling kill request for session: {s}\n", .{parsed.value.payload.session_name});
 95+            try handleKillSession(client, parsed.value.payload.session_name);
 96+        },
 97+        .list_sessions_request => {
 98+            std.debug.print("Handling list sessions request\n", .{});
 99+            try handleListSessions(client.server_ctx, client);
100+        },
101+        .pty_in => {
102+            const parsed = try protocol.parseMessage(protocol.PtyInput, client.allocator, data);
103+            defer parsed.deinit();
104+            try handlePtyInput(client, parsed.value.payload.text);
105+        },
106+        .window_resize => {
107+            const parsed = try protocol.parseMessage(protocol.WindowResize, client.allocator, data);
108+            defer parsed.deinit();
109+            try handleWindowResize(client, parsed.value.payload.rows, parsed.value.payload.cols);
110+        },
111+        else => {
112+            std.debug.print("Unexpected message type: {s}\n", .{type_parsed.value.type});
113+        },
114     }
115 }
116 
117@@ -335,17 +332,12 @@ fn handleDetachSession(client: *Client, session_name: []const u8, target_client_
118 
119     // Check if the session exists
120     const session = ctx.sessions.get(session_name) orelse {
121-        const error_response = try std.fmt.allocPrint(
122-            client.allocator,
123-            "{{\"type\":\"detach_session_response\",\"payload\":{{\"status\":\"error\",\"error_message\":\"Session not found: {s}\"}}}}\n",
124-            .{session_name},
125-        );
126-        defer client.allocator.free(error_response);
127-
128-        _ = posix.write(client.fd, error_response) catch |err| {
129-            std.debug.print("Error writing to fd={d}: {s}\n", .{ client.fd, @errorName(err) });
130-            return err;
131-        };
132+        const error_msg = try std.fmt.allocPrint(client.allocator, "Session not found: {s}", .{session_name});
133+        defer client.allocator.free(error_msg);
134+        try protocol.writeJson(client.allocator, client.fd, .detach_session_response, protocol.DetachSessionResponse{
135+            .status = "error",
136+            .error_message = error_msg,
137+        });
138         return;
139     };
140 
141@@ -359,84 +351,64 @@ fn handleDetachSession(client: *Client, session_name: []const u8, target_client_
142                     _ = session.attached_clients.remove(target_fd_cast);
143 
144                     // Send notification to the target client
145-                    const notification = "{\"type\":\"detach_notification\",\"payload\":{\"status\":\"ok\"}}\n";
146-                    _ = posix.write(target_client.fd, notification) catch |err| {
147+                    protocol.writeJson(target_client.allocator, target_client.fd, .detach_notification, protocol.DetachNotification{
148+                        .session_name = session_name,
149+                    }) catch |err| {
150                         std.debug.print("Error notifying client fd={d}: {s}\n", .{ target_client.fd, @errorName(err) });
151                     };
152 
153-                    // Send response to the requesting client
154-                    const response = "{\"type\":\"detach_session_response\",\"payload\":{\"status\":\"ok\"}}\n";
155                     std.debug.print("Detached client fd={d} from session: {s}\n", .{ target_fd_cast, session_name });
156 
157-                    _ = posix.write(client.fd, response) catch |err| {
158-                        std.debug.print("Error writing to fd={d}: {s}\n", .{ client.fd, @errorName(err) });
159-                        return err;
160-                    };
161+                    // Send response to the requesting client
162+                    try protocol.writeJson(client.allocator, client.fd, .detach_session_response, protocol.DetachSessionResponse{
163+                        .status = "ok",
164+                    });
165                     return;
166                 } else {
167-                    const error_response = try std.fmt.allocPrint(
168-                        client.allocator,
169-                        "{{\"type\":\"detach_session_response\",\"payload\":{{\"status\":\"error\",\"error_message\":\"Target client not attached to session: {s}\"}}}}\n",
170-                        .{session_name},
171-                    );
172-                    defer client.allocator.free(error_response);
173-
174-                    _ = posix.write(client.fd, error_response) catch |err| {
175-                        std.debug.print("Error writing to fd={d}: {s}\n", .{ client.fd, @errorName(err) });
176-                        return err;
177-                    };
178+                    const error_msg = try std.fmt.allocPrint(client.allocator, "Target client not attached to session: {s}", .{session_name});
179+                    defer client.allocator.free(error_msg);
180+                    try protocol.writeJson(client.allocator, client.fd, .detach_session_response, protocol.DetachSessionResponse{
181+                        .status = "error",
182+                        .error_message = error_msg,
183+                    });
184                     return;
185                 }
186             }
187         }
188 
189-        const error_response = try std.fmt.allocPrint(
190-            client.allocator,
191-            "{{\"type\":\"detach_session_response\",\"payload\":{{\"status\":\"error\",\"error_message\":\"Target client fd={d} not found\"}}}}\n",
192-            .{target_fd},
193-        );
194-        defer client.allocator.free(error_response);
195-
196-        _ = posix.write(client.fd, error_response) catch |err| {
197-            std.debug.print("Error writing to fd={d}: {s}\n", .{ client.fd, @errorName(err) });
198-            return err;
199-        };
200+        const error_msg = try std.fmt.allocPrint(client.allocator, "Target client fd={d} not found", .{target_fd});
201+        defer client.allocator.free(error_msg);
202+        try protocol.writeJson(client.allocator, client.fd, .detach_session_response, protocol.DetachSessionResponse{
203+            .status = "error",
204+            .error_message = error_msg,
205+        });
206         return;
207     }
208 
209     // No target_client_fd provided, check if requesting client is attached
210     if (client.attached_session) |attached| {
211         if (!std.mem.eql(u8, attached, session_name)) {
212-            const error_response = try std.fmt.allocPrint(
213-                client.allocator,
214-                "{{\"type\":\"detach_session_response\",\"payload\":{{\"status\":\"error\",\"error_message\":\"Not attached to session: {s}\"}}}}\n",
215-                .{session_name},
216-            );
217-            defer client.allocator.free(error_response);
218-
219-            _ = posix.write(client.fd, error_response) catch |err| {
220-                std.debug.print("Error writing to fd={d}: {s}\n", .{ client.fd, @errorName(err) });
221-                return err;
222-            };
223+            const error_msg = try std.fmt.allocPrint(client.allocator, "Not attached to session: {s}", .{session_name});
224+            defer client.allocator.free(error_msg);
225+            try protocol.writeJson(client.allocator, client.fd, .detach_session_response, protocol.DetachSessionResponse{
226+                .status = "error",
227+                .error_message = error_msg,
228+            });
229             return;
230         }
231 
232         client.attached_session = null;
233         _ = session.attached_clients.remove(client.fd);
234 
235-        const response = "{\"type\":\"detach_session_response\",\"payload\":{\"status\":\"ok\"}}\n";
236-        std.debug.print("Sending detach response to client fd={d}: {s}", .{ client.fd, response });
237-
238-        _ = posix.write(client.fd, response) catch |err| {
239-            std.debug.print("Error writing to fd={d}: {s}\n", .{ client.fd, @errorName(err) });
240-            return err;
241-        };
242+        std.debug.print("Sending detach response to client fd={d}\n", .{client.fd});
243+        try protocol.writeJson(client.allocator, client.fd, .detach_session_response, protocol.DetachSessionResponse{
244+            .status = "ok",
245+        });
246     } else {
247-        const error_response = "{\"type\":\"detach_session_response\",\"payload\":{\"status\":\"error\",\"error_message\":\"Not attached to any session\"}}\n";
248-        _ = posix.write(client.fd, error_response) catch |err| {
249-            std.debug.print("Error writing to fd={d}: {s}\n", .{ client.fd, @errorName(err) });
250-            return err;
251-        };
252+        try protocol.writeJson(client.allocator, client.fd, .detach_session_response, protocol.DetachSessionResponse{
253+            .status = "error",
254+            .error_message = "Not attached to any session",
255+        });
256     }
257 }
258 
259@@ -445,17 +417,12 @@ fn handleKillSession(client: *Client, session_name: []const u8) !void {
260 
261     // Check if the session exists
262     const session = ctx.sessions.get(session_name) orelse {
263-        const error_response = try std.fmt.allocPrint(
264-            client.allocator,
265-            "{{\"type\":\"kill_session_response\",\"payload\":{{\"status\":\"error\",\"error_message\":\"Session not found: {s}\"}}}}\n",
266-            .{session_name},
267-        );
268-        defer client.allocator.free(error_response);
269-
270-        _ = posix.write(client.fd, error_response) catch |err| {
271-            std.debug.print("Error writing to fd={d}: {s}\n", .{ client.fd, @errorName(err) });
272-            return err;
273-        };
274+        const error_msg = try std.fmt.allocPrint(client.allocator, "Session not found: {s}", .{session_name});
275+        defer client.allocator.free(error_msg);
276+        try protocol.writeJson(client.allocator, client.fd, .kill_session_response, protocol.KillSessionResponse{
277+            .status = "error",
278+            .error_message = error_msg,
279+        });
280         return;
281     };
282 
283@@ -468,8 +435,9 @@ fn handleKillSession(client: *Client, session_name: []const u8) !void {
284                 attached_client.attached_session = null;
285 
286                 // Send kill notification to client
287-                const notification = "{\"type\":\"kill_notification\",\"payload\":{\"status\":\"ok\"}}\n";
288-                _ = posix.write(attached_client.fd, notification) catch |err| {
289+                protocol.writeJson(attached_client.allocator, attached_client.fd, .kill_notification, protocol.KillNotification{
290+                    .session_name = session_name,
291+                }) catch |err| {
292                     std.debug.print("Error notifying client fd={d}: {s}\n", .{ attached_client.fd, @errorName(err) });
293                 };
294             }
295@@ -495,16 +463,14 @@ fn handleKillSession(client: *Client, session_name: []const u8) !void {
296     ctx.allocator.destroy(session);
297 
298     // Send response to requesting client
299-    const response = "{\"type\":\"kill_session_response\",\"payload\":{\"status\":\"ok\"}}\n";
300     std.debug.print("Killed session: {s}\n", .{session_name});
301-
302-    _ = posix.write(client.fd, response) catch |err| {
303-        std.debug.print("Error writing to fd={d}: {s}\n", .{ client.fd, @errorName(err) });
304-        return err;
305-    };
306+    try protocol.writeJson(client.allocator, client.fd, .kill_session_response, protocol.KillSessionResponse{
307+        .status = "ok",
308+    });
309 }
310 
311 fn handleListSessions(ctx: *ServerContext, client: *Client) !void {
312+    // TODO: Refactor to use protocol.writeJson() once we have a better approach for dynamic arrays
313     var response = try std.ArrayList(u8).initCapacity(client.allocator, 1024);
314     defer response.deinit(client.allocator);
315 
316@@ -583,33 +549,16 @@ fn handleAttachSession(ctx: *ServerContext, client: *Client, session_name: []con
317 
318         // For first attach to new session, clear the client's terminal
319         if (!is_reattach) {
320-            var out: std.io.Writer.Allocating = .init(ctx.allocator);
321-            defer out.deinit();
322-            var json_writer: std.json.Stringify = .{ .writer = &out.writer };
323-
324-            try json_writer.beginObject();
325-            try json_writer.objectField("type");
326-            try json_writer.write("pty_out");
327-            try json_writer.objectField("payload");
328-            try json_writer.beginObject();
329-            try json_writer.objectField("text");
330-            try json_writer.write("\x1b[2J\x1b[H"); // Clear screen and move cursor to home
331-            try json_writer.endObject();
332-            try json_writer.endObject();
333-
334-            const response = try std.fmt.allocPrint(ctx.allocator, "{s}\n", .{out.written()});
335-            defer ctx.allocator.free(response);
336-            _ = try posix.write(client.fd, response);
337+            try protocol.writeJson(ctx.allocator, client.fd, .pty_out, protocol.PtyOutput{
338+                .text = "\x1b[2J\x1b[H", // Clear screen and move cursor to home
339+            });
340         }
341     } else {
342         // Send attach success response for additional clients
343-        const response = try std.fmt.allocPrint(
344-            ctx.allocator,
345-            "{{\"type\":\"attach_session_response\",\"payload\":{{\"status\":\"ok\",\"client_fd\":{d}}}}}\n",
346-            .{client.fd},
347-        );
348-        defer ctx.allocator.free(response);
349-        _ = try posix.write(client.fd, response);
350+        try protocol.writeJson(ctx.allocator, client.fd, .attach_session_response, protocol.AttachSessionResponse{
351+            .status = "ok",
352+            .client_fd = client.fd,
353+        });
354     }
355 
356     // If reattaching, send the scrollback buffer (raw PTY output with colors)
357@@ -626,35 +575,9 @@ fn handleAttachSession(ctx: *ServerContext, client: *Client, session_name: []con
358 
359         std.debug.print("Sending slice: {d} bytes (from offset {d})\n", .{ buffer_slice.len, buffer_start });
360 
361-        // Use Zig's JSON formatting to properly escape the buffer
362-        var out: std.io.Writer.Allocating = .init(ctx.allocator);
363-        defer out.deinit();
364-
365-        var json_writer: std.json.Stringify = .{ .writer = &out.writer };
366-
367-        json_writer.beginObject() catch |err| {
368-            std.debug.print("JSON beginObject error: {s}\n", .{@errorName(err)});
369-            return;
370-        };
371-        json_writer.objectField("type") catch return;
372-        json_writer.write("pty_out") catch return;
373-        json_writer.objectField("payload") catch return;
374-        json_writer.beginObject() catch return;
375-        json_writer.objectField("text") catch return;
376-        json_writer.write(buffer_slice) catch |err| {
377-            std.debug.print("JSON write buffer error: {s}\n", .{@errorName(err)});
378-            return;
379-        };
380-        json_writer.endObject() catch return;
381-        json_writer.endObject() catch return;
382-
383-        const json_output = out.written();
384-        std.debug.print("JSON output length: {d} bytes\n", .{json_output.len});
385-        std.debug.print("JSON first 100 bytes: {s}\n", .{json_output[0..@min(100, json_output.len)]});
386-
387-        const response = try std.fmt.allocPrint(ctx.allocator, "{s}\n", .{json_output});
388-        defer ctx.allocator.free(response);
389-        _ = try posix.write(client.fd, response);
390+        try protocol.writeJson(ctx.allocator, client.fd, .pty_out, protocol.PtyOutput{
391+            .text = buffer_slice,
392+        });
393         std.debug.print("Sent scrollback buffer to client fd={d}\n", .{client.fd});
394     }
395 }
396@@ -726,20 +649,12 @@ fn readFromPty(ctx: *ServerContext, client: *Client, session: *Session) !void {
397         readPtyCallback,
398     );
399 
400-    const response = try std.fmt.allocPrint(
401-        client.allocator,
402-        "{{\"type\":\"attach_session_response\",\"payload\":{{\"status\":\"ok\",\"client_fd\":{d}}}}}\n",
403-        .{client.fd},
404-    );
405-    defer client.allocator.free(response);
406+    std.debug.print("Sending attach response to client fd={d}\n", .{client.fd});
407 
408-    std.debug.print("Sending response to client fd={d}: {s}", .{ client.fd, response });
409-
410-    const written = posix.write(client.fd, response) catch |err| {
411-        std.debug.print("Error writing to fd={d}: {s}\n", .{ client.fd, @errorName(err) });
412-        return err;
413-    };
414-    _ = written;
415+    try protocol.writeJson(client.allocator, client.fd, .attach_session_response, protocol.AttachSessionResponse{
416+        .status = "ok",
417+        .client_fd = client.fd,
418+    });
419 }
420 
421 fn getSessionForClient(ctx: *ServerContext, client: *Client) ?*Session {
M src/detach.zig
+10, -27
 1@@ -2,6 +2,7 @@ const std = @import("std");
 2 const posix = std.posix;
 3 const clap = @import("clap");
 4 const config_mod = @import("config.zig");
 5+const protocol = @import("protocol.zig");
 6 
 7 const params = clap.parseParamsComptime(
 8     \\-s, --socket-path <str>  Path to the Unix socket file
 9@@ -87,21 +88,12 @@ pub fn main(config: config_mod.Config, iter: *std.process.ArgIterator) !void {
10         return err;
11     };
12 
13-    const request = if (client_fd) |fd|
14-        try std.fmt.allocPrint(
15-            allocator,
16-            "{{\"type\":\"detach_session_request\",\"payload\":{{\"session_name\":\"{s}\",\"client_fd\":{d}}}}}\n",
17-            .{ session_name.?, fd },
18-        )
19-    else
20-        try std.fmt.allocPrint(
21-            allocator,
22-            "{{\"type\":\"detach_session_request\",\"payload\":{{\"session_name\":\"{s}\"}}}}\n",
23-            .{session_name.?},
24-        );
25-    defer allocator.free(request);
26-
27-    _ = try posix.write(socket_fd, request);
28+    const request_payload = protocol.DetachSessionRequest{
29+        .session_name = session_name.?,
30+        .client_fd = client_fd,
31+    };
32+
33+    try protocol.writeJson(allocator, socket_fd, .detach_session_request, request_payload);
34 
35     var buffer: [4096]u8 = undefined;
36     const bytes_read = try posix.read(socket_fd, &buffer);
37@@ -115,22 +107,13 @@ pub fn main(config: config_mod.Config, iter: *std.process.ArgIterator) !void {
38     const newline_idx = std.mem.indexOf(u8, response, "\n") orelse bytes_read;
39     const msg_line = response[0..newline_idx];
40 
41-    const parsed = try std.json.parseFromSlice(
42-        std.json.Value,
43-        allocator,
44-        msg_line,
45-        .{},
46-    );
47+    const parsed = try protocol.parseMessage(protocol.DetachSessionResponse, allocator, msg_line);
48     defer parsed.deinit();
49 
50-    const root = parsed.value.object;
51-    const payload = root.get("payload").?.object;
52-    const status = payload.get("status").?.string;
53-
54-    if (std.mem.eql(u8, status, "ok")) {
55+    if (std.mem.eql(u8, parsed.value.payload.status, "ok")) {
56         std.debug.print("Detached from session: {s}\n", .{session_name.?});
57     } else {
58-        const error_msg = payload.get("error_message").?.string;
59+        const error_msg = parsed.value.payload.error_message orelse "Unknown error";
60         std.debug.print("Failed to detach: {s}\n", .{error_msg});
61         std.process.exit(1);
62     }
M src/kill.zig
+8, -18
 1@@ -2,6 +2,7 @@ const std = @import("std");
 2 const posix = std.posix;
 3 const clap = @import("clap");
 4 const config_mod = @import("config.zig");
 5+const protocol = @import("protocol.zig");
 6 
 7 const params = clap.parseParamsComptime(
 8     \\-s, --socket-path <str>  Path to the Unix socket file
 9@@ -47,14 +48,12 @@ pub fn main(config: config_mod.Config, iter: *std.process.ArgIterator) !void {
10         return err;
11     };
12 
13-    const request = try std.fmt.allocPrint(
14+    try protocol.writeJson(
15         allocator,
16-        "{{\"type\":\"kill_session_request\",\"payload\":{{\"session_name\":\"{s}\"}}}}\n",
17-        .{session_name},
18+        socket_fd,
19+        .kill_session_request,
20+        protocol.KillSessionRequest{ .session_name = session_name },
21     );
22-    defer allocator.free(request);
23-
24-    _ = try posix.write(socket_fd, request);
25 
26     var buffer: [4096]u8 = undefined;
27     const bytes_read = try posix.read(socket_fd, &buffer);
28@@ -68,22 +67,13 @@ pub fn main(config: config_mod.Config, iter: *std.process.ArgIterator) !void {
29     const newline_idx = std.mem.indexOf(u8, response, "\n") orelse bytes_read;
30     const msg_line = response[0..newline_idx];
31 
32-    const parsed = try std.json.parseFromSlice(
33-        std.json.Value,
34-        allocator,
35-        msg_line,
36-        .{},
37-    );
38+    const parsed = try protocol.parseMessage(protocol.KillSessionResponse, allocator, msg_line);
39     defer parsed.deinit();
40 
41-    const root = parsed.value.object;
42-    const payload = root.get("payload").?.object;
43-    const status = payload.get("status").?.string;
44-
45-    if (std.mem.eql(u8, status, "ok")) {
46+    if (std.mem.eql(u8, parsed.value.payload.status, "ok")) {
47         std.debug.print("Killed session: {s}\n", .{session_name});
48     } else {
49-        const error_msg = payload.get("error_message").?.string;
50+        const error_msg = parsed.value.payload.error_message orelse "Unknown error";
51         std.debug.print("Failed to kill session: {s}\n", .{error_msg});
52         std.process.exit(1);
53     }
M src/list.zig
+9, -24
 1@@ -2,6 +2,7 @@ const std = @import("std");
 2 const posix = std.posix;
 3 const clap = @import("clap");
 4 const config_mod = @import("config.zig");
 5+const protocol = @import("protocol.zig");
 6 
 7 const params = clap.parseParamsComptime(
 8     \\-s, --socket-path <str>  Path to the Unix socket file
 9@@ -41,8 +42,7 @@ pub fn main(config: config_mod.Config, iter: *std.process.ArgIterator) !void {
10         return err;
11     };
12 
13-    const request = "{\"type\":\"list_sessions_request\",\"payload\":{}}\n";
14-    _ = try posix.write(socket_fd, request);
15+    try protocol.writeJson(allocator, socket_fd, .list_sessions_request, protocol.ListSessionsRequest{});
16 
17     var buffer: [8192]u8 = undefined;
18     const bytes_read = try posix.read(socket_fd, &buffer);
19@@ -56,27 +56,18 @@ pub fn main(config: config_mod.Config, iter: *std.process.ArgIterator) !void {
20     const newline_idx = std.mem.indexOf(u8, response, "\n") orelse bytes_read;
21     const msg_line = response[0..newline_idx];
22 
23-    const parsed = try std.json.parseFromSlice(
24-        std.json.Value,
25-        allocator,
26-        msg_line,
27-        .{},
28-    );
29+    const parsed = try protocol.parseMessage(protocol.ListSessionsResponse, allocator, msg_line);
30     defer parsed.deinit();
31 
32-    const root = parsed.value.object;
33-    const payload = root.get("payload").?.object;
34-    const status = payload.get("status").?.string;
35+    const payload = parsed.value.payload;
36 
37-    if (!std.mem.eql(u8, status, "ok")) {
38-        const error_msg = payload.get("error_message").?.string;
39+    if (!std.mem.eql(u8, payload.status, "ok")) {
40+        const error_msg = payload.error_message orelse "Unknown error";
41         std.debug.print("Error: {s}\n", .{error_msg});
42         return;
43     }
44 
45-    const sessions = payload.get("sessions").?.array;
46-
47-    if (sessions.items.len == 0) {
48+    if (payload.sessions.len == 0) {
49         std.debug.print("No active sessions\n", .{});
50         return;
51     }
52@@ -85,13 +76,7 @@ pub fn main(config: config_mod.Config, iter: *std.process.ArgIterator) !void {
53     std.debug.print("{s:<20} {s:<12} {s:<8} {s}\n", .{ "NAME", "STATUS", "CLIENTS", "CREATED" });
54     std.debug.print("{s}\n", .{"-" ** 60});
55 
56-    for (sessions.items) |session_value| {
57-        const session = session_value.object;
58-        const name = session.get("name").?.string;
59-        const session_status = session.get("status").?.string;
60-        const clients = session.get("clients").?.integer;
61-        const created_at = session.get("created_at").?.string;
62-
63-        std.debug.print("{s:<20} {s:<12} {d:<8} {s}\n", .{ name, session_status, clients, created_at });
64+    for (payload.sessions) |session| {
65+        std.debug.print("{s:<20} {s:<12} {d:<8} {s}\n", .{ session.name, session.status, session.clients, session.created_at });
66     }
67 }
M src/protocol.zig
+6, -4
 1@@ -145,7 +145,8 @@ pub fn writeJson(allocator: std.mem.Allocator, fd: posix.fd_t, msg_type: Message
 2         .payload = payload,
 3     };
 4 
 5-    try std.json.stringify(msg, .{}, &out.writer);
 6+    var stringify: std.json.Stringify = .{ .writer = &out.writer };
 7+    try stringify.write(msg);
 8     try out.writer.writeByte('\n');
 9 
10     _ = try posix.write(fd, out.written());
11@@ -167,9 +168,10 @@ pub fn parseMessage(comptime T: type, allocator: std.mem.Allocator, line: []cons
12 }
13 
14 // Helper to parse just the message type from a line (for dispatching)
15-pub fn parseMessageType(allocator: std.mem.Allocator, line: []const u8) !std.json.Parsed(struct { type: []const u8 }) {
16+const MessageTypeOnly = struct { type: []const u8 };
17+pub fn parseMessageType(allocator: std.mem.Allocator, line: []const u8) !std.json.Parsed(MessageTypeOnly) {
18     return try std.json.parseFromSlice(
19-        struct { type: []const u8 },
20+        MessageTypeOnly,
21         allocator,
22         line,
23         .{ .ignore_unknown_fields = true },
24@@ -250,7 +252,7 @@ pub fn readBinaryFrame(allocator: std.mem.Allocator, fd: posix.fd_t) !struct { f
25     const read_len = try posix.read(fd, &header_bytes);
26     if (read_len != @sizeOf(FrameHeader)) return error.IncompleteFrame;
27 
28-    const header: *const FrameHeader = @alignCast(@ptrCast(&header_bytes));
29+    const header: *const FrameHeader = @ptrCast(@alignCast(&header_bytes));
30     const payload = try allocator.alloc(u8, header.length);
31     errdefer allocator.free(payload);
32