repos / zmx

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

commit
89393db
parent
50d7e24
author
Eric Bower
date
2025-10-10 20:55:54 -0400 EDT
fix: ghostty impl
5 files changed,  +568, -55
M .gitignore
+1, -0
1@@ -8,3 +8,4 @@ commit_msg
2 *.sw?
3 libxev_src/
4 zig_std_src/
5+ghostty_src/
M AGENTS.md
+4, -0
 1@@ -26,6 +26,10 @@ To inspect the source code for libxev, look inside the `libxev_src` folder.
 2 
 3 To inspect the source code for zig's standard library, look inside the `zig_std_src` folder.
 4 
 5+## finding ghostty library source code
 6+
 7+To inspect the source code for zig's standard library, look inside the `ghostty_src` folder.
 8+
 9 ### prior art - shpool
10 
11 The project that most closely resembles `shpool`.
A plans/session-restore.md
+292, -0
  1@@ -0,0 +1,292 @@
  2+# Session Restore Implementation Plan
  3+
  4+This document outlines the plan for implementing session restore functionality in `daemon.zig` using `libghostty-vt` to preserve and restore terminal state when clients reattach to sessions.
  5+
  6+## Overview
  7+
  8+When a client detaches and later reattaches to a session, we need to restore the terminal to its exact visual state without replaying all historical output. We achieve this by:
  9+1. Parsing all PTY output through libghostty-vt to maintain an up-to-date terminal grid
 10+2. Proxying raw bytes to attached clients (no latency impact)
 11+3. Rendering the terminal grid to ANSI on reattach
 12+
 13+## 1. Add libghostty-vt Dependency
 14+
 15+- Add libghostty-vt to `build.zig` and `build.zig.zon`
 16+- Import the C bindings in `daemon.zig`
 17+- Document the library's memory model and API surface
 18+
 19+## 2. Extend the Session Struct
 20+
 21+Add to `Session` struct in `daemon.zig`:
 22+```zig
 23+const Session = struct {
 24+    name: []const u8,
 25+    pty_master_fd: std.posix.fd_t,
 26+    buffer: std.ArrayList(u8),           // Keep for backwards compat (may remove later)
 27+    child_pid: std.posix.pid_t,
 28+    allocator: std.mem.Allocator,
 29+    pty_read_buffer: [4096]u8,
 30+    created_at: i64,
 31+
 32+    // NEW: Terminal emulator state
 33+    vt: *c.ghostty_vt_t,                 // libghostty-vt terminal instance
 34+    vt_grid: *c.ghostty_grid_t,          // Current terminal grid snapshot
 35+    attached_clients: std.AutoHashMap(std.posix.fd_t, void),  // Track who's attached
 36+};
 37+```
 38+
 39+## 3. Initialize Terminal Emulator on Session Creation
 40+
 41+In `createSession()`:
 42+- After forking PTY, initialize libghostty-vt instance
 43+- Configure terminal size (rows, cols) - query from PTY or use defaults (e.g., 24x80)
 44+- Configure scrollback buffer size (make this configurable, default 10,000 lines)
 45+- Store the vt instance in the Session struct
 46+
 47+```zig
 48+fn createSession(allocator: std.mem.Allocator, session_name: []const u8) !*Session {
 49+    // ... existing PTY creation code ...
 50+    
 51+    // Initialize libghostty-vt
 52+    const vt = c.ghostty_vt_new(80, 24, 10000) orelse return error.VtInitFailed;
 53+    
 54+    session.* = .{
 55+        // ... existing fields ...
 56+        .vt = vt,
 57+        .vt_grid = null,  // Will be obtained from vt as needed
 58+        .attached_clients = std.AutoHashMap(std.posix.fd_t, void).init(allocator),
 59+    };
 60+    
 61+    return session;
 62+}
 63+```
 64+
 65+## 4. Parse PTY Output Through Terminal Emulator
 66+
 67+Modify `readPtyCallback()`:
 68+- Feed all PTY output bytes to libghostty-vt first
 69+- Check if there are attached clients
 70+- If clients attached: proxy raw bytes directly to them (existing behavior)
 71+- If no clients attached: still feed to vt but don't send anywhere
 72+
 73+```zig
 74+fn readPtyCallback(...) xev.CallbackAction {
 75+    const client = client_opt.?;
 76+    const session = getSessionForClient(client) orelse return .disarm;
 77+    
 78+    if (read_result) |bytes_read| {
 79+        const data = read_buffer.slice[0..bytes_read];
 80+        
 81+        // ALWAYS parse through libghostty-vt to maintain state
 82+        c.ghostty_vt_write(session.vt, data.ptr, data.len);
 83+        
 84+        // Only proxy to clients if someone is attached
 85+        if (session.attached_clients.count() > 0) {
 86+            // Send raw bytes to all attached clients
 87+            var it = session.attached_clients.keyIterator();
 88+            while (it.next()) |client_fd| {
 89+                const attached_client = ctx.clients.get(client_fd.*) orelse continue;
 90+                sendPtyOutput(attached_client, data) catch |err| {
 91+                    std.debug.print("Error sending to client {d}: {s}\n", .{client_fd.*, @errorName(err)});
 92+                };
 93+            }
 94+        }
 95+        
 96+        return .rearm;
 97+    }
 98+    // ... error handling ...
 99+}
100+```
101+
102+## 5. Render Terminal State on Reattach
103+
104+Create new function `renderTerminalSnapshot()`:
105+- Get current grid from libghostty-vt
106+- Serialize grid to ANSI escape sequences
107+- Send rendered output to reattaching client
108+
109+```zig
110+fn renderTerminalSnapshot(session: *Session, allocator: std.mem.Allocator) ![]u8 {
111+    // Get current terminal snapshot from libghostty-vt
112+    const grid = c.ghostty_vt_get_grid(session.vt);
113+    
114+    var output = std.ArrayList(u8).init(allocator);
115+    errdefer output.deinit();
116+    
117+    // Clear screen and move to home
118+    try output.appendSlice("\x1b[2J\x1b[H");
119+    
120+    // Render each line of the grid
121+    const rows = c.ghostty_grid_rows(grid);
122+    const cols = c.ghostty_grid_cols(grid);
123+    
124+    var row: usize = 0;
125+    while (row < rows) : (row += 1) {
126+        // Get line data from libghostty-vt
127+        const line = c.ghostty_grid_get_line(grid, row);
128+        
129+        // Render cells with proper attributes (colors, bold, etc.)
130+        var col: usize = 0;
131+        while (col < cols) : (col += 1) {
132+            const cell = c.ghostty_line_get_cell(line, col);
133+            
134+            // Emit SGR codes for cell attributes
135+            try renderCellAttributes(&output, cell);
136+            
137+            // Emit the character
138+            const codepoint = c.ghostty_cell_get_codepoint(cell);
139+            try appendUtf8(&output, codepoint);
140+        }
141+        
142+        try output.append('\n');
143+    }
144+    
145+    // Reset attributes
146+    try output.appendSlice("\x1b[0m");
147+    
148+    return output.toOwnedSlice();
149+}
150+```
151+
152+## 6. Modify handleAttachSession()
153+
154+Update attach logic to:
155+1. Check if session exists, create if not
156+2. If reattaching (session already exists):
157+   - Render current terminal state using libghostty-vt
158+   - Send rendered snapshot to client
159+3. Add client to session's attached_clients set
160+4. Start proxying raw PTY output
161+
162+```zig
163+fn handleAttachSession(ctx: *ServerContext, client: *Client, session_name: []const u8) !void {
164+    const session = ctx.sessions.get(session_name) orelse {
165+        // New session - create it
166+        const new_session = try createSession(ctx.allocator, session_name);
167+        try ctx.sessions.put(session_name, new_session);
168+        session = new_session;
169+    };
170+    
171+    // Mark client as attached
172+    client.attached_session = try ctx.allocator.dupe(u8, session_name);
173+    try session.attached_clients.put(client.fd, {});
174+    
175+    // Check if this is a reattach (session already running)
176+    const is_reattach = session.attached_clients.count() > 1 or session.buffer.items.len > 0;
177+    
178+    if (is_reattach) {
179+        // Render current terminal state and send it
180+        const snapshot = try renderTerminalSnapshot(session, ctx.allocator);
181+        defer ctx.allocator.free(snapshot);
182+        
183+        const response = try std.fmt.allocPrint(
184+            ctx.allocator,
185+            "{{\"type\":\"pty_out\",\"payload\":{{\"text\":\"{s}\"}}}}\n",
186+            .{snapshot},
187+        );
188+        defer ctx.allocator.free(response);
189+        
190+        _ = try posix.write(client.fd, response);
191+    }
192+    
193+    // Start reading from PTY if not already started
194+    if (!session.pty_reading) {
195+        try startPtyReading(ctx, session);
196+        session.pty_reading = true;
197+    }
198+    
199+    // Send attach success response
200+    // ... existing response code ...
201+}
202+```
203+
204+## 7. Handle Window Resize Events
205+
206+Add support for window size changes:
207+- When client sends window resize event, update libghostty-vt
208+- Update PTY window size with ioctl TIOCSWINSZ
209+- libghostty-vt will handle reflow automatically
210+
211+```zig
212+// New message type in protocol: "window_resize"
213+fn handleWindowResize(client: *Client, rows: u16, cols: u16) !void {
214+    const session = getSessionForClient(client) orelse return error.NotAttached;
215+    
216+    // Update libghostty-vt
217+    c.ghostty_vt_resize(session.vt, cols, rows);
218+    
219+    // Update PTY
220+    var ws: c.winsize = .{
221+        .ws_row = rows,
222+        .ws_col = cols,
223+        .ws_xpixel = 0,
224+        .ws_ypixel = 0,
225+    };
226+    _ = c.ioctl(session.pty_master_fd, c.TIOCSWINSZ, &ws);
227+}
228+```
229+
230+## 8. Track Attached Clients Per Session
231+
232+Modify session management:
233+- Remove client from session.attached_clients on detach
234+- On disconnect, automatically detach client
235+- Keep session alive even when no clients attached
236+
237+```zig
238+fn handleDetachSession(client: *Client, session_name: []const u8, target_client_fd: ?i64) !void {
239+    const session = ctx.sessions.get(session_name) orelse return error.SessionNotFound;
240+    
241+    const fd_to_remove = if (target_client_fd) |fd| @intCast(fd) else client.fd;
242+    _ = session.attached_clients.remove(fd_to_remove);
243+    
244+    // Note: DO NOT kill session when last client detaches
245+    // Session continues running in background
246+}
247+```
248+
249+## 9. Clean Up Terminal Emulator on Session Destroy
250+
251+In session deinit:
252+- Free libghostty-vt resources
253+- Clean up attached_clients map
254+
255+```zig
256+fn deinit(self: *Session) void {
257+    self.allocator.free(self.name);
258+    self.buffer.deinit();
259+    self.attached_clients.deinit();
260+    
261+    // Free libghostty-vt
262+    c.ghostty_vt_free(self.vt);
263+}
264+```
265+
266+## 10. Configuration Options
267+
268+Add configurable options (future work):
269+- Scrollback buffer size
270+- Default terminal dimensions
271+- Maximum grid memory usage
272+
273+## Implementation Order
274+
275+1. ✅ Add libghostty-vt C bindings and build integration
276+2. ✅ Extend Session struct with vt fields
277+3. ✅ Initialize vt in createSession()
278+4. ✅ Feed PTY output to vt in readPtyCallback()
279+5. ✅ Implement renderTerminalSnapshot()
280+6. ✅ Modify handleAttachSession() to render on reattach
281+7. ✅ Track attached_clients per session
282+8. ✅ Handle window resize events
283+9. ✅ Clean up vt resources in session deinit
284+10. ✅ Test with multiple attach/detach cycles
285+
286+## Testing Strategy
287+
288+- Create session, run commands, detach
289+- Verify PTY continues running (ps aux | grep)
290+- Reattach and verify terminal state is restored
291+- Test with various shell outputs: ls, vim, htop, long scrollback
292+- Test multiple clients attaching to same session
293+- Test window resize during detached state
M src/attach.zig
+1, -0
1@@ -235,6 +235,7 @@ fn readCallback(
2     }
3 
4     ctx.allocator.destroy(read_ctx);
5+    ctx.read_ctx = null;
6     return cleanup(ctx, completion);
7 }
8 
M src/daemon.zig
+270, -55
  1@@ -4,10 +4,13 @@ const xevg = @import("xev");
  2 const xev = xevg.Dynamic;
  3 const socket_path = "/tmp/zmx.sock";
  4 
  5+const ghostty = @import("ghostty-vt");
  6+
  7 const c = @cImport({
  8     @cInclude("pty.h");
  9     @cInclude("utmp.h");
 10     @cInclude("stdlib.h");
 11+    @cInclude("sys/ioctl.h");
 12 });
 13 
 14 // Generic JSON message structure used for parsing incoming protocol messages from clients
 15@@ -21,6 +24,21 @@ const AttachRequest = struct {
 16     session_name: []const u8,
 17 };
 18 
 19+// Handler for processing VT sequences
 20+const VTHandler = struct {
 21+    terminal: *ghostty.Terminal,
 22+
 23+    pub fn print(self: *VTHandler, cp: u21) !void {
 24+        try self.terminal.print(cp);
 25+    }
 26+};
 27+
 28+// Context for PTY read callbacks
 29+const PtyReadContext = struct {
 30+    session: *Session,
 31+    server_ctx: *ServerContext,
 32+};
 33+
 34 // A PTY session that manages a persistent shell process
 35 // Stores the PTY master file descriptor, shell process PID, scrollback buffer,
 36 // and a read buffer for async I/O with libxev
 37@@ -33,9 +51,18 @@ const Session = struct {
 38     pty_read_buffer: [4096]u8,
 39     created_at: i64,
 40 
 41+    // Terminal emulator state for session restore
 42+    vt: ghostty.Terminal,
 43+    vt_stream: ghostty.Stream(*VTHandler),
 44+    vt_handler: VTHandler,
 45+    attached_clients: std.AutoHashMap(std.posix.fd_t, void),
 46+
 47     fn deinit(self: *Session) void {
 48         self.allocator.free(self.name);
 49         self.buffer.deinit(self.allocator);
 50+        self.vt.deinit(self.allocator);
 51+        self.vt_stream.deinit();
 52+        self.attached_clients.deinit();
 53     }
 54 };
 55 
 56@@ -232,6 +259,10 @@ fn handleMessage(client: *Client, data: []const u8) !void {
 57     } else if (std.mem.eql(u8, msg_type, "pty_in")) {
 58         const text = payload.get("text").?.string;
 59         try handlePtyInput(client, text);
 60+    } else if (std.mem.eql(u8, msg_type, "window_resize")) {
 61+        const rows = @as(u16, @intCast(payload.get("rows").?.integer));
 62+        const cols = @as(u16, @intCast(payload.get("cols").?.integer));
 63+        try handleWindowResize(client, rows, cols);
 64     } else {
 65         std.debug.print("Unknown message type: {s}\n", .{msg_type});
 66     }
 67@@ -241,7 +272,7 @@ fn handleDetachSession(client: *Client, session_name: []const u8, target_client_
 68     const ctx = client.server_ctx;
 69 
 70     // Check if the session exists
 71-    if (!ctx.sessions.contains(session_name)) {
 72+    const session = ctx.sessions.get(session_name) orelse {
 73         const error_response = try std.fmt.allocPrint(
 74             client.allocator,
 75             "{{\"type\":\"detach_session_response\",\"payload\":{{\"status\":\"error\",\"error_message\":\"Session not found: {s}\"}}}}\n",
 76@@ -254,7 +285,7 @@ fn handleDetachSession(client: *Client, session_name: []const u8, target_client_
 77             return err;
 78         };
 79         return;
 80-    }
 81+    };
 82 
 83     // If target_client_fd is provided, find and detach that specific client
 84     if (target_client_fd) |target_fd| {
 85@@ -263,6 +294,7 @@ fn handleDetachSession(client: *Client, session_name: []const u8, target_client_
 86             if (target_client.attached_session) |attached| {
 87                 if (std.mem.eql(u8, attached, session_name)) {
 88                     target_client.attached_session = null;
 89+                    _ = session.attached_clients.remove(target_fd_cast);
 90 
 91                     // Send notification to the target client
 92                     const notification = "{\"type\":\"detach_notification\",\"payload\":{\"status\":\"ok\"}}\n";
 93@@ -328,6 +360,8 @@ fn handleDetachSession(client: *Client, session_name: []const u8, target_client_
 94         }
 95 
 96         client.attached_session = null;
 97+        _ = session.attached_clients.remove(client.fd);
 98+        
 99         const response = "{\"type\":\"detach_session_response\",\"payload\":{\"status\":\"ok\"}}\n";
100         std.debug.print("Sending detach response to client fd={d}: {s}", .{ client.fd, response });
101 
102@@ -465,20 +499,69 @@ fn handleListSessions(ctx: *ServerContext, client: *Client) !void {
103 
104 fn handleAttachSession(ctx: *ServerContext, client: *Client, session_name: []const u8) !void {
105     // Check if session already exists
106-    if (ctx.sessions.get(session_name)) |session| {
107+    const is_reattach = ctx.sessions.contains(session_name);
108+    const session = if (is_reattach) blk: {
109         std.debug.print("Attaching to existing session: {s}\n", .{session_name});
110-        client.attached_session = session.name;
111-        try readFromPty(ctx, client, session);
112-        // TODO: Send scrollback buffer to client
113-        return;
114-    }
115+        break :blk ctx.sessions.get(session_name).?;
116+    } else blk: {
117+        // Create new session with forkpty
118+        std.debug.print("Creating new session: {s}\n", .{session_name});
119+        const new_session = try createSession(ctx.allocator, session_name);
120+        try ctx.sessions.put(new_session.name, new_session);
121+        break :blk new_session;
122+    };
123 
124-    // Create new session with forkpty
125-    std.debug.print("Creating new session: {s}\n", .{session_name});
126-    const session = try createSession(ctx.allocator, session_name);
127-    try ctx.sessions.put(session.name, session);
128+    // Mark client as attached
129     client.attached_session = session.name;
130-    try readFromPty(ctx, client, session);
131+    try session.attached_clients.put(client.fd, {});
132+
133+    // If reattaching, send the current terminal state
134+    if (is_reattach and session.attached_clients.count() > 1) {
135+        const snapshot = try renderTerminalSnapshot(session, ctx.allocator);
136+        defer ctx.allocator.free(snapshot);
137+
138+        // Build JSON response with escaped snapshot
139+        var response_buf = try std.ArrayList(u8).initCapacity(ctx.allocator, 4096);
140+        defer response_buf.deinit(ctx.allocator);
141+
142+        try response_buf.appendSlice(ctx.allocator, "{\"type\":\"pty_out\",\"payload\":{\"text\":\"");
143+
144+        // Escape the snapshot for JSON
145+        for (snapshot) |byte| {
146+            switch (byte) {
147+                '"' => try response_buf.appendSlice(ctx.allocator, "\\\""),
148+                '\\' => try response_buf.appendSlice(ctx.allocator, "\\\\"),
149+                '\n' => try response_buf.appendSlice(ctx.allocator, "\\n"),
150+                '\r' => try response_buf.appendSlice(ctx.allocator, "\\r"),
151+                '\t' => try response_buf.appendSlice(ctx.allocator, "\\t"),
152+                0x08 => try response_buf.appendSlice(ctx.allocator, "\\b"),
153+                0x0C => try response_buf.appendSlice(ctx.allocator, "\\f"),
154+                0x00...0x07, 0x0B, 0x0E...0x1F, 0x7F...0xFF => {
155+                    const escaped = try std.fmt.allocPrint(ctx.allocator, "\\u{x:0>4}", .{byte});
156+                    defer ctx.allocator.free(escaped);
157+                    try response_buf.appendSlice(ctx.allocator, escaped);
158+                },
159+                else => try response_buf.append(ctx.allocator, byte),
160+            }
161+        }
162+
163+        try response_buf.appendSlice(ctx.allocator, "\"}}\n");
164+        _ = try posix.write(client.fd, response_buf.items);
165+    }
166+
167+    // Start reading from PTY if not already started (first client)
168+    if (session.attached_clients.count() == 1) {
169+        try readFromPty(ctx, client, session);
170+    } else {
171+        // Send attach success response for additional clients
172+        const response = try std.fmt.allocPrint(
173+            ctx.allocator,
174+            "{{\"type\":\"attach_session_response\",\"payload\":{{\"status\":\"ok\",\"client_fd\":{d}}}}}\n",
175+            .{client.fd},
176+        );
177+        defer ctx.allocator.free(response);
178+        _ = try posix.write(client.fd, response);
179+    }
180 }
181 
182 fn handlePtyInput(client: *Client, text: []const u8) !void {
183@@ -502,16 +585,49 @@ fn handlePtyInput(client: *Client, text: []const u8) !void {
184     _ = written;
185 }
186 
187+fn handleWindowResize(client: *Client, rows: u16, cols: u16) !void {
188+    const session_name = client.attached_session orelse {
189+        std.debug.print("Client fd={d} not attached to any session\n", .{client.fd});
190+        return error.NotAttached;
191+    };
192+
193+    const session = client.server_ctx.sessions.get(session_name) orelse {
194+        std.debug.print("Session {s} not found\n", .{session_name});
195+        return error.SessionNotFound;
196+    };
197+
198+    std.debug.print("Resizing session {s} to {d}x{d}\n", .{ session_name, cols, rows });
199+
200+    // Update libghostty-vt terminal size
201+    try session.vt.resize(session.allocator, cols, rows);
202+
203+    // Update PTY window size
204+    var ws = c.struct_winsize{
205+        .ws_row = rows,
206+        .ws_col = cols,
207+        .ws_xpixel = 0,
208+        .ws_ypixel = 0,
209+    };
210+    const result = c.ioctl(session.pty_master_fd, c.TIOCSWINSZ, &ws);
211+    if (result < 0) {
212+        return error.IoctlFailed;
213+    }
214+}
215+
216 fn readFromPty(ctx: *ServerContext, client: *Client, session: *Session) !void {
217-    _ = ctx;
218     const stream = xev.Stream.initFd(session.pty_master_fd);
219     const read_compl = client.allocator.create(xev.Completion) catch @panic("failed to create completion");
220+    const pty_ctx = client.allocator.create(PtyReadContext) catch @panic("failed to create PTY context");
221+    pty_ctx.* = .{
222+        .session = session,
223+        .server_ctx = ctx,
224+    };
225     stream.read(
226-        client.server_ctx.loop,
227+        ctx.loop,
228         read_compl,
229         .{ .slice = &session.pty_read_buffer },
230-        Client,
231-        client,
232+        PtyReadContext,
233+        pty_ctx,
234         readPtyCallback,
235     );
236 
237@@ -531,18 +647,75 @@ fn readFromPty(ctx: *ServerContext, client: *Client, session: *Session) !void {
238     _ = written;
239 }
240 
241+fn getSessionForClient(ctx: *ServerContext, client: *Client) ?*Session {
242+    const session_name = client.attached_session orelse return null;
243+    return ctx.sessions.get(session_name);
244+}
245+
246+fn renderTerminalSnapshot(session: *Session, allocator: std.mem.Allocator) ![]u8 {
247+    var output = try std.ArrayList(u8).initCapacity(allocator, 4096);
248+    errdefer output.deinit(allocator);
249+
250+    // Clear screen and move to home
251+    try output.appendSlice(allocator, "\x1b[2J\x1b[H");
252+
253+    // Get the active screen from the terminal
254+    const screen = &session.vt.screen;
255+    const rows = screen.pages.rows;
256+    const cols = screen.pages.cols;
257+    
258+    var row: usize = 0;
259+    while (row < rows) : (row += 1) {
260+        var col: usize = 0;
261+        while (col < cols) : (col += 1) {
262+            // Build a point.Point referring to the active (visible) page
263+            const pt: ghostty.point.Point = .{ .active = .{ 
264+                .x = @as(u16, @intCast(col)), 
265+                .y = @as(u16, @intCast(row)),
266+            } };
267+            
268+            if (screen.pages.getCell(pt)) |cell_ref| {
269+                const cp = cell_ref.cell.content.codepoint;
270+                if (cp == 0) {
271+                    try output.append(allocator, ' ');
272+                } else {
273+                    var buf: [4]u8 = undefined;
274+                    const len = std.unicode.utf8Encode(cp, &buf) catch 0;
275+                    if (len == 0) {
276+                        try output.append(allocator, ' ');
277+                    } else {
278+                        try output.appendSlice(allocator, buf[0..len]);
279+                    }
280+                }
281+            } else {
282+                // Outside bounds or no cell => space to preserve width
283+                try output.append(allocator, ' ');
284+            }
285+        }
286+        
287+        if (row < rows - 1) {
288+            try output.appendSlice(allocator, "\r\n");
289+        }
290+    }
291+
292+    // Position cursor at correct location (ANSI is 1-based)
293+    const cursor = screen.cursor;
294+    try output.writer(allocator).print("\x1b[{d};{d}H", .{ cursor.y + 1, cursor.x + 1 });
295+
296+    return output.toOwnedSlice(allocator);
297+}
298+
299 fn readPtyCallback(
300-    client_opt: ?*Client,
301+    pty_ctx_opt: ?*PtyReadContext,
302     loop: *xev.Loop,
303     completion: *xev.Completion,
304     stream: xev.Stream,
305     read_buffer: xev.ReadBuffer,
306     read_result: xev.ReadError!usize,
307 ) xev.CallbackAction {
308-    _ = loop;
309-    _ = completion;
310-    _ = stream;
311-    const client = client_opt.?;
312+    const pty_ctx = pty_ctx_opt.?;
313+    const session = pty_ctx.session;
314+    const ctx = pty_ctx.server_ctx;
315 
316     if (read_result) |bytes_read| {
317         if (bytes_read == 0) {
318@@ -553,44 +726,62 @@ fn readPtyCallback(
319         const data = read_buffer.slice[0..bytes_read];
320         std.debug.print("PTY output ({d} bytes)\n", .{bytes_read});
321 
322-        // Build JSON response with properly escaped text
323-        var response_buf = std.ArrayList(u8).initCapacity(client.allocator, 4096) catch return .disarm;
324-        defer response_buf.deinit(client.allocator);
325-
326-        response_buf.appendSlice(client.allocator, "{\"type\":\"pty_out\",\"payload\":{\"text\":\"") catch return .disarm;
327+        // ALWAYS parse through libghostty-vt to maintain state
328+        session.vt_stream.nextSlice(data) catch |err| {
329+            std.debug.print("VT parse error: {s}\n", .{@errorName(err)});
330+        };
331 
332-        // Manually escape JSON special characters
333-        for (data) |byte| {
334-            switch (byte) {
335-                '"' => response_buf.appendSlice(client.allocator, "\\\"") catch return .disarm,
336-                '\\' => response_buf.appendSlice(client.allocator, "\\\\") catch return .disarm,
337-                '\n' => response_buf.appendSlice(client.allocator, "\\n") catch return .disarm,
338-                '\r' => response_buf.appendSlice(client.allocator, "\\r") catch return .disarm,
339-                '\t' => response_buf.appendSlice(client.allocator, "\\t") catch return .disarm,
340-                0x08 => response_buf.appendSlice(client.allocator, "\\b") catch return .disarm,
341-                0x0C => response_buf.appendSlice(client.allocator, "\\f") catch return .disarm,
342-                0x00...0x07, 0x0B, 0x0E...0x1F, 0x7F...0xFF => {
343-                    const escaped = std.fmt.allocPrint(client.allocator, "\\u{x:0>4}", .{byte}) catch return .disarm;
344-                    defer client.allocator.free(escaped);
345-                    response_buf.appendSlice(client.allocator, escaped) catch return .disarm;
346-                },
347-                else => response_buf.append(client.allocator, byte) catch return .disarm,
348+        // Only proxy to clients if someone is attached
349+        if (session.attached_clients.count() > 0) {
350+            // Build JSON response with properly escaped text
351+            var response_buf = std.ArrayList(u8).initCapacity(session.allocator, 4096) catch return .disarm;
352+            defer response_buf.deinit(session.allocator);
353+
354+            response_buf.appendSlice(session.allocator, "{\"type\":\"pty_out\",\"payload\":{\"text\":\"") catch return .disarm;
355+
356+            // Manually escape JSON special characters
357+            for (data) |byte| {
358+                switch (byte) {
359+                    '"' => response_buf.appendSlice(session.allocator, "\\\"") catch return .disarm,
360+                    '\\' => response_buf.appendSlice(session.allocator, "\\\\") catch return .disarm,
361+                    '\n' => response_buf.appendSlice(session.allocator, "\\n") catch return .disarm,
362+                    '\r' => response_buf.appendSlice(session.allocator, "\\r") catch return .disarm,
363+                    '\t' => response_buf.appendSlice(session.allocator, "\\t") catch return .disarm,
364+                    0x08 => response_buf.appendSlice(session.allocator, "\\b") catch return .disarm,
365+                    0x0C => response_buf.appendSlice(session.allocator, "\\f") catch return .disarm,
366+                    0x00...0x07, 0x0B, 0x0E...0x1F, 0x7F...0xFF => {
367+                        const escaped = std.fmt.allocPrint(session.allocator, "\\u{x:0>4}", .{byte}) catch return .disarm;
368+                        defer session.allocator.free(escaped);
369+                        response_buf.appendSlice(session.allocator, escaped) catch return .disarm;
370+                    },
371+                    else => response_buf.append(session.allocator, byte) catch return .disarm,
372+                }
373             }
374-        }
375 
376-        response_buf.appendSlice(client.allocator, "\"}}\n") catch return .disarm;
377+            response_buf.appendSlice(session.allocator, "\"}}\n") catch return .disarm;
378+            const response = response_buf.items;
379 
380-        const response = response_buf.items;
381-        std.debug.print("Sending response to client fd={d}\n", .{client.fd});
382-
383-        // Send synchronously for now (blocking write)
384-        const written = posix.write(client.fd, response) catch |err| {
385-            std.debug.print("Error writing to fd={d}: {s}", .{ client.fd, @errorName(err) });
386-            return .disarm;
387-        };
388-        _ = written;
389+            // Send to all attached clients
390+            var it = session.attached_clients.keyIterator();
391+            while (it.next()) |client_fd| {
392+                const attached_client = ctx.clients.get(client_fd.*) orelse continue;
393+                std.debug.print("Sending response to client fd={d}\n", .{client_fd.*});
394+                _ = posix.write(attached_client.fd, response) catch |err| {
395+                    std.debug.print("Error writing to fd={d}: {s}\n", .{ client_fd.*, @errorName(err) });
396+                };
397+            }
398+        }
399 
400-        return .rearm;
401+        // Re-arm to continue reading
402+        stream.read(
403+            loop,
404+            completion,
405+            .{ .slice = &session.pty_read_buffer },
406+            PtyReadContext,
407+            pty_ctx,
408+            readPtyCallback,
409+        );
410+        return .disarm;
411     } else |err| {
412         std.debug.print("PTY read error: {s}\n", .{@errorName(err)});
413         return .disarm;
414@@ -681,6 +872,14 @@ fn createSession(allocator: std.mem.Allocator, session_name: []const u8) !*Sessi
415     const flags = try posix.fcntl(master_fd, posix.F.GETFL, 0);
416     _ = try posix.fcntl(master_fd, posix.F.SETFL, flags | @as(u32, 0o4000));
417 
418+    // Initialize terminal emulator for session restore
419+    var vt = try ghostty.Terminal.init(allocator, .{
420+        .cols = 80,
421+        .rows = 24,
422+        .max_scrollback = 10000,
423+    });
424+    errdefer vt.deinit(allocator);
425+
426     const session = try allocator.create(Session);
427     session.* = .{
428         .name = try allocator.dupe(u8, session_name),
429@@ -690,14 +889,30 @@ fn createSession(allocator: std.mem.Allocator, session_name: []const u8) !*Sessi
430         .allocator = allocator,
431         .pty_read_buffer = undefined,
432         .created_at = std.time.timestamp(),
433+        .vt = vt,
434+        .vt_handler = VTHandler{ .terminal = &session.vt },
435+        .vt_stream = undefined,
436+        .attached_clients = std.AutoHashMap(std.posix.fd_t, void).init(allocator),
437     };
438 
439+    // Initialize the stream after session is created since handler needs terminal pointer
440+    session.vt_stream = ghostty.Stream(*VTHandler).init(&session.vt_handler);
441+    session.vt_stream.parser.osc_parser.alloc = allocator;
442+
443     return session;
444 }
445 
446 fn closeClient(client: *Client, completion: *xev.Completion) xev.CallbackAction {
447     std.debug.print("Closing client fd={d}\n", .{client.fd});
448 
449+    // Remove client from attached session if any
450+    if (client.attached_session) |session_name| {
451+        if (client.server_ctx.sessions.get(session_name)) |session| {
452+            _ = session.attached_clients.remove(client.fd);
453+            std.debug.print("Removed client fd={d} from session {s} attached_clients\n", .{ client.fd, session_name });
454+        }
455+    }
456+
457     // Remove client from the clients map
458     _ = client.server_ctx.clients.remove(client.fd);
459