repos / zmx

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

commit
cca1a8a
parent
cedbe7f
author
Eric Bower
date
2025-10-15 22:47:50 -0400 EDT
fix: filter our client input queries from echoing to stdout
2 files changed,  +119, -8
M src/attach.zig
+1, -1
1@@ -258,7 +258,7 @@ fn readCallback(
2         const msg_line = remaining_data[0..newline_idx];
3 
4         const msg_type_parsed = protocol.parseMessageType(ctx.allocator, msg_line) catch |err| {
5-            std.debug.print("JSON parse error: {s}\r\n", .{@errorName(err)});
6+            std.debug.print("JSON parse error: {s}, data: {s}\r\n", .{ @errorName(err), msg_line });
7             return .rearm;
8         };
9         defer msg_type_parsed.deinit();
M src/daemon.zig
+118, -7
  1@@ -203,11 +203,7 @@ const VTHandler = struct {
  2     ) !void {
  3         _ = da_params;
  4 
  5-        const response = switch (req) {
  6-            .primary => "\x1b[?1;2c", // VT100 with AVO (matches screen/tmux)
  7-            .secondary => "\x1b[>0;0;0c", // Conservative secondary DA
  8-            .tertiary => return, // Ignore tertiary DA
  9-        };
 10+        const response = getDeviceAttributeResponse(req) orelse return;
 11 
 12         _ = posix.write(self.pty_master_fd, response) catch |err| {
 13             std.debug.print("Error writing DA response to PTY: {s}\n", .{@errorName(err)});
 14@@ -830,6 +826,112 @@ fn handleAttachSession(ctx: *ServerContext, client: *Client, session_name: []con
 15     }
 16 }
 17 
 18+/// Returns the device attribute response zmx should send (matching tmux/screen)
 19+/// Returns null for tertiary DA (ignored)
 20+fn getDeviceAttributeResponse(req: ghostty.DeviceAttributeReq) ?[]const u8 {
 21+    return switch (req) {
 22+        .primary => "\x1b[?1;2c", // VT100 with AVO (matches screen/tmux)
 23+        .secondary => "\x1b[>0;0;0c", // Conservative secondary DA
 24+        .tertiary => null, // Ignore tertiary DA
 25+    };
 26+}
 27+
 28+/// Filter out terminal response sequences that the client's terminal sends
 29+/// These should not be written to the PTY since the daemon handles queries itself
 30+///
 31+/// Architecture: When apps send queries (e.g., ESC[c), the client's terminal
 32+/// auto-responds. We must drop those responses because:
 33+/// 1. VTHandler already responds with correct zmx terminal capabilities
 34+/// 2. Client responses describe the client's terminal, not zmx's virtual terminal
 35+/// 3. Without filtering, responses get echoed by PTY and appear as literal text
 36+///
 37+/// This matches tmux/screen behavior: intercept queries, respond ourselves, drop client responses
 38+fn filterTerminalResponses(input: []const u8, output_buf: []u8) usize {
 39+    var out_idx: usize = 0;
 40+    var i: usize = 0;
 41+
 42+    while (i < input.len) {
 43+        // Look for ESC sequences
 44+        if (input[i] == 0x1b and i + 1 < input.len and input[i + 1] == '[') {
 45+            // CSI sequence - parse it
 46+            const seq_start = i;
 47+            i += 2; // Skip ESC [
 48+
 49+            // Collect parameter bytes (0x30-0x3F)
 50+            const param_start = i;
 51+            while (i < input.len and input[i] >= 0x30 and input[i] <= 0x3F) : (i += 1) {}
 52+            const seq_params = input[param_start..i];
 53+
 54+            // Collect intermediate bytes (0x20-0x2F)
 55+            while (i < input.len and input[i] >= 0x20 and input[i] <= 0x2F) : (i += 1) {}
 56+
 57+            // Final byte (0x40-0x7E)
 58+            if (i < input.len and input[i] >= 0x40 and input[i] <= 0x7E) {
 59+                const final = input[i];
 60+                const should_drop = blk: {
 61+                    // Device Attributes responses: ESC[?...c, ESC[>...c, ESC[=...c
 62+                    // These match the responses defined in getDeviceAttributeResponse()
 63+                    if (final == 'c' and seq_params.len > 0) {
 64+                        if (seq_params[0] == '?' or seq_params[0] == '>' or seq_params[0] == '=') {
 65+                            std.debug.print("Filtered DA response: ESC[{s}c\n", .{seq_params});
 66+                            break :blk true;
 67+                        }
 68+                    }
 69+                    // Cursor Position Report: ESC[<row>;<col>R
 70+                    if (final == 'R' and seq_params.len > 0) {
 71+                        // Simple heuristic: if params look like digits/semicolon, it's likely CPR
 72+                        var is_cpr = true;
 73+                        for (seq_params) |byte| {
 74+                            if (byte != ';' and (byte < '0' or byte > '9')) {
 75+                                is_cpr = false;
 76+                                break;
 77+                            }
 78+                        }
 79+                        if (is_cpr) {
 80+                            std.debug.print("Filtered CPR: ESC[{s}R\n", .{seq_params});
 81+                            break :blk true;
 82+                        }
 83+                    }
 84+                    // DSR responses: ESC[0n, ESC[3n, ESC[?...n
 85+                    if (final == 'n' and seq_params.len > 0) {
 86+                        if ((seq_params.len == 1 and (seq_params[0] == '0' or seq_params[0] == '3')) or
 87+                            seq_params[0] == '?')
 88+                        {
 89+                            std.debug.print("Filtered DSR response: ESC[{s}n\n", .{seq_params});
 90+                            break :blk true;
 91+                        }
 92+                    }
 93+                    break :blk false;
 94+                };
 95+
 96+                if (should_drop) {
 97+                    // Skip this entire sequence, continue to next character
 98+                    i += 1;
 99+                } else {
100+                    // Copy the entire sequence to output
101+                    const seq_len = (i + 1) - seq_start;
102+                    @memcpy(output_buf[out_idx .. out_idx + seq_len], input[seq_start .. i + 1]);
103+                    out_idx += seq_len;
104+                    i += 1;
105+                }
106+            } else {
107+                // Incomplete sequence, copy what we have
108+                const seq_len = i - seq_start;
109+                @memcpy(output_buf[out_idx .. out_idx + seq_len], input[seq_start..i]);
110+                out_idx += seq_len;
111+                // i is already positioned at the next byte
112+            }
113+        } else {
114+            // Not a CSI sequence, copy the byte
115+            output_buf[out_idx] = input[i];
116+            out_idx += 1;
117+            i += 1;
118+        }
119+    }
120+
121+    return out_idx;
122+}
123+
124 fn handlePtyInput(client: *Client, text: []const u8) !void {
125     const session_name = client.attached_session orelse {
126         std.debug.print("Client fd={d} not attached to any session\n", .{client.fd});
127@@ -841,10 +943,19 @@ fn handlePtyInput(client: *Client, text: []const u8) !void {
128         return error.SessionNotFound;
129     };
130 
131-    std.debug.print("Client fd={d}: Writing {d} bytes to PTY fd={d} (first byte: {d})\n", .{ client.fd, text.len, session.pty_master_fd, if (text.len > 0) text[0] else 0 });
132+    // Filter out terminal response sequences before writing to PTY
133+    var filtered_buf: [128 * 1024]u8 = undefined;
134+    const filtered_len = filterTerminalResponses(text, &filtered_buf);
135+
136+    if (filtered_len == 0) {
137+        return; // All input was filtered, nothing to write
138+    }
139+
140+    const filtered_text = filtered_buf[0..filtered_len];
141+    std.debug.print("Client fd={d}: Writing {d} bytes to PTY fd={d} (filtered from {d} bytes)\n", .{ client.fd, filtered_len, session.pty_master_fd, text.len });
142 
143     // Write input to PTY master fd
144-    const written = posix.write(session.pty_master_fd, text) catch |err| {
145+    const written = posix.write(session.pty_master_fd, filtered_text) catch |err| {
146         std.debug.print("Error writing to PTY: {s}\n", .{@errorName(err)});
147         return err;
148     };