repos / zmx

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

commit
3e935f2
parent
e2ddda3
author
Eric Bower
date
2025-11-23 10:18:34 -0500 EST
daemon-per-session (#4)

This is a ground-up rewrite of this tool.  We have dramatically simplified how this tool works.  Instead of having a single daemon that manages all sessions, we have a daemon-per-session.

We employ a simple architecture: `fork() -> setsid() on child -> forkpty() -> attach on parent`

This has a bunch of benefits, one of which is we can safely remove libxev and use simple `poll()` loops for daemon and clients.
27 files changed,  +794, -4450
M .gitignore
+1, -1
1@@ -6,6 +6,6 @@ zig-out/
2 Session*.*vim
3 commit_msg
4 *.sw?
5-libxev_src/
6 zig_std_src/
7 ghostty_src/
8+prior_art/
M AGENTS.md
+1, -79
  1@@ -8,9 +8,6 @@ When researching `zmx`, also read the @README.md in the root of this project dir
  2 
  3 - `zig` v0.15.1
  4 - `libghostty-vt` for terminal escape codes and terminal state management
  5-- `libxev` for handling single-threaded, non-blocking, async flow control
  6-- `clap` for building the cli
  7-- `systemd` for background process supervision
  8 
  9 ## commands
 10 
 11@@ -28,8 +25,6 @@ Before trying anything else, run the `zigdoc` command to find an API with docume
 12 zigdoc {symbol}
 13 # examples
 14 zigdoc ghostty-vt
 15-zigdoc clap
 16-zigdoc xev
 17 zigdoc std.ArrayList
 18 zigdoc std.mem.Allocator
 19 zigdoc std.http.Server
 20@@ -37,10 +32,6 @@ zigdoc std.http.Server
 21 
 22 Only if that doesn't work should you grep the project dir.
 23 
 24-## find libxev source code
 25-
 26-To inspect the source code for libxev, look inside the `libxev_src` folder.
 27-
 28 ## find zig std library source code
 29 
 30 To inspect the source code for zig's standard library, look inside the `zig_std_src` folder.
 31@@ -53,73 +44,4 @@ To inspect the source code for zig's standard library, look inside the `ghostty_
 32 
 33 We use bd (beads, https://github.com/steveyegge/beads) for issue tracking instead of Markdown TODOs or external tools.
 34 
 35-### Quick Reference
 36-
 37-```bash
 38-# Find ready work (no blockers)
 39-bd ready --json
 40-
 41-# Create new issue
 42-bd create "Issue title" -t bug|feature|task -p 0-4 -d "Description" --json
 43-
 44-# Create with explicit ID (for parallel workers)
 45-bd create "Issue title" --id worker1-100 -p 1 --json
 46-
 47-# Update issue status
 48-bd update <id> --status in_progress --json
 49-
 50-# Link discovered work (old way)
 51-bd dep add <discovered-id> <parent-id> --type discovered-from
 52-
 53-# Create and link in one command (new way)
 54-bd create "Issue title" -t bug -p 1 --deps discovered-from:<parent-id> --json
 55-
 56-# Complete work
 57-bd close <id> --reason "Done" --json
 58-
 59-# Show dependency tree
 60-bd dep tree <id>
 61-
 62-# Get issue details
 63-bd show <id> --json
 64-
 65-# Import with collision detection
 66-bd import -i .beads/issues.jsonl --dry-run             # Preview only
 67-bd import -i .beads/issues.jsonl --resolve-collisions  # Auto-resolve
 68-```
 69-
 70-### Workflow
 71-
 72-1. **Check for ready work**: Run `bd ready` to see what's unblocked
 73-1. **Claim your task**: `bd update <id> --status in_progress`
 74-1. **Work on it**: Implement, test, document
 75-1. **Discover new work**: If you find bugs or TODOs, create issues:
 76-   - Old way (two commands): `bd create "Found bug in auth" -t bug -p 1 --json` then `bd dep add <new-id> <current-id> --type discovered-from`
 77-   - New way (one command): `bd create "Found bug in auth" -t bug -p 1 --deps discovered-from:<current-id> --json`
 78-1. **Complete**: `bd close <id> --reason "Implemented"`
 79-1. **Export**: Changes auto-sync to `.beads/issues.jsonl` (5-second debounce)
 80-
 81-### Issue Types
 82-
 83-- `bug` - Something broken that needs fixing
 84-- `feature` - New functionality
 85-- `task` - Work item (tests, docs, refactoring)
 86-- `epic` - Large feature composed of multiple issues
 87-- `chore` - Maintenance work (dependencies, tooling)
 88-
 89-### Priorities
 90-
 91-- `0` - Critical (security, data loss, broken builds)
 92-- `1` - High (major features, important bugs)
 93-- `2` - Medium (nice-to-have features, minor bugs)
 94-- `3` - Low (polish, optimization)
 95-- `4` - Backlog (future ideas)
 96-
 97-### Dependency Types
 98-
 99-- `blocks` - Hard dependency (issue X blocks issue Y)
100-- `related` - Soft relationship (issues are connected)
101-- `parent-child` - Epic/subtask relationship
102-- `discovered-from` - Track issues discovered during work
103-
104-Only `blocks` dependencies affect the ready work queue.
105+Run `bd quickstart` to learn how to use it.
M README.md
+8, -4
 1@@ -7,11 +7,10 @@
 2 - Native terminal scrollback
 3 - Manage shell sessions
 4 - Multiple clients can connect to the same session
 5-- Background process (`daemon`) manages all pty processes
 6-- A cli tool to interact with `daemon` and all pty processes
 7+- Each session creates its own unix socket
 8 - Re-attaching to a session restores previous terminal state and output
 9 - The `daemon` and client processes communicate via a unix socket
10-- The `daemon` is managed by a supervisor like `systemd`
11+- All sessions (via unix socket files) are managed by `systemd`
12 - We provide a `systemd` unit file that users can install that manages the `daemon` process
13 - The cli tool supports the following commands:
14   - `attach {session}`: attach to the pty process
15@@ -25,7 +24,6 @@
16 
17 ## usage
18 
19-- `zmx daemon` - start the background process that all other commands communicate with
20 - `zmx attach <session_name>` - create or attach to a session
21 - `zmx detach` (or Ctrl+b + d) - detach from session while keeping pty alive
22 - `zmx list` - list sessions
23@@ -46,3 +44,9 @@ You can find the source code at this repo: https://github.com/shell-pool/shpool
24 `shpool` can be thought of as a lighter weight alternative to tmux or GNU screen. While tmux and screen take over the whole terminal and provide window splitting and tiling features, `shpool` only provides persistent sessions.
25 
26 The biggest advantage of this approach is that `shpool` does not break native scrollback or copy-paste.
27+
28+### abduco
29+
30+You can find the source code at this repo: https://github.com/martanne/abduco
31+
32+abduco provides session management i.e. it allows programs to be run independently from its controlling terminal. That is programs can be detached - run in the background - and then later reattached. Together with dvtm it provides a simpler and cleaner alternative to tmux or screen.
M build.zig
+0, -13
 1@@ -25,19 +25,6 @@ pub fn build(b: *std.Build) void {
 2         );
 3     }
 4 
 5-    if (b.lazyDependency("libxev", .{
 6-        .target = target,
 7-        .optimize = optimize,
 8-    })) |dep| {
 9-        exe_mod.addImport("xev", dep.module("xev"));
10-    }
11-
12-    const clap_dep = b.dependency("clap", .{
13-        .target = target,
14-        .optimize = optimize,
15-    });
16-    exe_mod.addImport("clap", clap_dep.module("clap"));
17-
18     const toml_dep = b.dependency("toml", .{
19         .target = target,
20         .optimize = optimize,
M build.zig.zon
+0, -8
 1@@ -36,14 +36,6 @@
 2             .url = "git+https://github.com/ghostty-org/ghostty.git?ref=HEAD#42a38ff672fe0cbbb8588380058c91ac16ed9069",
 3             .hash = "ghostty-1.2.1-5UdBC-7EVAMgwHLhtsdeH4rgcZ7WlagXZ1QXN9bRKJ5s",
 4         },
 5-        .libxev = .{
 6-            .url = "git+https://github.com/mitchellh/libxev?ref=HEAD#34fa50878aec6e5fa8f532867001ab3c36fae23e",
 7-            .hash = "libxev-0.0.0-86vtc4IcEwCqEYxEYoN_3KXmc6A9VLcm22aVImfvecYs",
 8-        },
 9-        .clap = .{
10-            .url = "git+https://github.com/Hejsil/zig-clap?ref=HEAD#b7e3348ed60f99ba32c75aa707ff7c87adc31b36",
11-            .hash = "clap-0.11.0-oBajB-TnAQC7yPLnZRT5WzHZ_4Ly4dX2OILskli74b9H",
12-        },
13         .toml = .{
14             .url = "git+https://github.com/sam701/zig-toml#3aaeb0bf14935eabae118196f7949b404a42f8ea",
15             .hash = "toml-0.3.0-bV14BTd8AQA-wZERtB3dvRE3eSZ-m48AyXFUGkZ_Tm3d",
D plans/cli.md
+0, -34
 1@@ -1,34 +0,0 @@
 2-# cli scaffolding implementation plan
 3-
 4-This document outlines the plan for implementing the CLI scaffolding for the `zmx` tool, based on the `specs/cli.md` document.
 5-
 6-## 1. Add `zig-clap` Dependency
 7-
 8-- **Modify `build.zig.zon`**: Add `zig-clap` to the dependencies.
 9-- **Modify `build.zig`**: Fetch the `zig-clap` module and make it available to the executable.
10-
11-## 2. Create `src/cli.zig`
12-
13-- Create a new file `src/cli.zig` to encapsulate all CLI-related logic.
14-
15-## 3. Define Commands in `src/cli.zig`
16-
17-- Use `zig-clap` to define the command structure specified in `specs/cli.md`.
18-- This includes the global options (`-h`, `-v`) and the subcommands:
19-  - `daemon`
20-  - `list`
21-  - `attach <session>`
22-  - `detach <session>`
23-  - `kill <session>`
24-- For each command, define the expected arguments and options.
25-
26-## 4. Integrate with `src/main.zig`
27-
28-- In `src/main.zig`, import the `cli` module.
29-- Call the CLI parsing logic from the `main` function.
30-- The `main` function will dispatch to the appropriate command handler based on the parsed arguments.
31-- When the `daemon` subcommand is invoked, the application will act as a long-running "server".
32-
33-## 5. Single Executable
34-
35-- The `build.zig` will define a single executable named `zmx`.
D plans/daemon.md
+0, -56
 1@@ -1,56 +0,0 @@
 2-# zms daemon implementation plan
 3-
 4-This document outlines the plan for implementing the `zmx daemon` subcommand, based on the specifications in `specs/daemon.md` and `protocol.md`.
 5-
 6-## 1. Create `src/daemon.zig`
 7-
 8-- Create a new file `src/daemon.zig` to house the core logic for the daemon process.
 9-
10-## 2. Implement Unix Socket Communication
11-
12-- In `src/daemon.zig`, create and bind a Unix domain socket based on the `--socket-path` option.
13-- Listen for and accept incoming client connections.
14-- Implement a message-passing system using `std.json` for serialization and deserialization, adhering to the `protocol.md` specification.
15-
16-## 3. Implement Session Management
17-
18-- Define a `Session` struct to manage the state of each PTY process. This struct will include:
19-  - The session name.
20-  - The file descriptor for the PTY.
21-  - A buffer for the terminal output (scrollback).
22-  - The terminal state, managed by `libghostty-vt`.
23-  - A list of connected client IDs.
24-- Use a `std.StringHashMap(Session)` to store and manage all active sessions.
25-
26-## 4. Implement PTY Management
27-
28-- Create a function to spawn a new PTY process using `forkpty`.
29-- This function will be called when a new, non-existent session is requested.
30-- Implement functions to read from and write to the PTY file descriptor.
31-
32-## 5. Implement the Main Event Loop
33-
34-- The core of the daemon will be an event loop (using `libxev` on Linux) that concurrently handles:
35-  1. New client connections on the main Unix socket.
36-  1. Incoming requests from connected clients.
37-  1. Output from the PTY processes.
38-- This will allow the daemon to be single-threaded and highly concurrent.
39-
40-## 6. Implement Protocol Handlers
41-
42-- For each message type defined in `protocol.md`, create a handler function:
43-  - `handle_list_sessions_request`: Responds with a list of all active sessions.
44-  - `handle_attach_session_request`: Adds the client to the session's list of connected clients and sends them the scrollback buffer.
45-  - `handle_detach_session_request`: Removes the client from the session's list.
46-  - `handle_kill_session_request`: Terminates the PTY process and removes the session.
47-  - `handle_pty_input`: Writes the received data to the corresponding PTY.
48-- When there is output from a PTY, the daemon will create a `pty_output` message and send it to all attached clients.
49-
50-## 7. Integrate with `main.zig`
51-
52-- In `main.zig`, when the `daemon` subcommand is parsed, call the main entry point of the `daemon` module.
53-- Pass the parsed command-line options (e.g., `--socket-path`) to the daemon's initialization function.
54-
55-## 8. Do **NOT** Handle Daemonization
56-
57-This command will be run under a systemd unit file so it does not need to concern itself with daemonizing itself.
D plans/session-restore.md
+0, -302
  1@@ -1,302 +0,0 @@
  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-
 10-1. Parsing all PTY output through libghostty-vt to maintain an up-to-date terminal grid
 11-1. Proxying raw bytes to attached clients (no latency impact)
 12-1. Rendering the terminal grid to ANSI on reattach
 13-
 14-## 1. Add libghostty-vt Dependency
 15-
 16-- Add libghostty-vt to `build.zig` and `build.zig.zon`
 17-- Import the C bindings in `daemon.zig`
 18-- Document the library's memory model and API surface
 19-
 20-## 2. Extend the Session Struct
 21-
 22-Add to `Session` struct in `daemon.zig`:
 23-
 24-```zig
 25-const Session = struct {
 26-    name: []const u8,
 27-    pty_master_fd: std.posix.fd_t,
 28-    buffer: std.ArrayList(u8),           // Keep for backwards compat (may remove later)
 29-    child_pid: std.posix.pid_t,
 30-    allocator: std.mem.Allocator,
 31-    pty_read_buffer: [4096]u8,
 32-    created_at: i64,
 33-
 34-    // NEW: Terminal emulator state
 35-    vt: *c.ghostty_vt_t,                 // libghostty-vt terminal instance
 36-    vt_grid: *c.ghostty_grid_t,          // Current terminal grid snapshot
 37-    attached_clients: std.AutoHashMap(std.posix.fd_t, void),  // Track who's attached
 38-};
 39-```
 40-
 41-## 3. Initialize Terminal Emulator on Session Creation
 42-
 43-In `createSession()`:
 44-
 45-- After forking PTY, initialize libghostty-vt instance
 46-- Configure terminal size (rows, cols) - query from PTY or use defaults (e.g., 24x80)
 47-- Configure scrollback buffer size (make this configurable, default 10,000 lines)
 48-- Store the vt instance in the Session struct
 49-
 50-```zig
 51-fn createSession(allocator: std.mem.Allocator, session_name: []const u8) !*Session {
 52-    // ... existing PTY creation code ...
 53-    
 54-    // Initialize libghostty-vt
 55-    const vt = c.ghostty_vt_new(80, 24, 10000) orelse return error.VtInitFailed;
 56-    
 57-    session.* = .{
 58-        // ... existing fields ...
 59-        .vt = vt,
 60-        .vt_grid = null,  // Will be obtained from vt as needed
 61-        .attached_clients = std.AutoHashMap(std.posix.fd_t, void).init(allocator),
 62-    };
 63-    
 64-    return session;
 65-}
 66-```
 67-
 68-## 4. Parse PTY Output Through Terminal Emulator
 69-
 70-Modify `readPtyCallback()`:
 71-
 72-- Feed all PTY output bytes to libghostty-vt first
 73-- Check if there are attached clients
 74-- If clients attached: proxy raw bytes directly to them (existing behavior)
 75-- If no clients attached: still feed to vt but don't send anywhere
 76-
 77-```zig
 78-fn readPtyCallback(...) xev.CallbackAction {
 79-    const client = client_opt.?;
 80-    const session = getSessionForClient(client) orelse return .disarm;
 81-    
 82-    if (read_result) |bytes_read| {
 83-        const data = read_buffer.slice[0..bytes_read];
 84-        
 85-        // ALWAYS parse through libghostty-vt to maintain state
 86-        c.ghostty_vt_write(session.vt, data.ptr, data.len);
 87-        
 88-        // Only proxy to clients if someone is attached
 89-        if (session.attached_clients.count() > 0) {
 90-            // Send raw bytes to all attached clients
 91-            var it = session.attached_clients.keyIterator();
 92-            while (it.next()) |client_fd| {
 93-                const attached_client = ctx.clients.get(client_fd.*) orelse continue;
 94-                sendPtyOutput(attached_client, data) catch |err| {
 95-                    std.debug.print("Error sending to client {d}: {s}\n", .{client_fd.*, @errorName(err)});
 96-                };
 97-            }
 98-        }
 99-        
100-        return .rearm;
101-    }
102-    // ... error handling ...
103-}
104-```
105-
106-## 5. Render Terminal State on Reattach
107-
108-Create new function `renderTerminalSnapshot()`:
109-
110-- Get current grid from libghostty-vt
111-- Serialize grid to ANSI escape sequences
112-- Send rendered output to reattaching client
113-
114-```zig
115-fn renderTerminalSnapshot(session: *Session, allocator: std.mem.Allocator) ![]u8 {
116-    // Get current terminal snapshot from libghostty-vt
117-    const grid = c.ghostty_vt_get_grid(session.vt);
118-    
119-    var output = std.ArrayList(u8).init(allocator);
120-    errdefer output.deinit();
121-    
122-    // Clear screen and move to home
123-    try output.appendSlice("\x1b[2J\x1b[H");
124-    
125-    // Render each line of the grid
126-    const rows = c.ghostty_grid_rows(grid);
127-    const cols = c.ghostty_grid_cols(grid);
128-    
129-    var row: usize = 0;
130-    while (row < rows) : (row += 1) {
131-        // Get line data from libghostty-vt
132-        const line = c.ghostty_grid_get_line(grid, row);
133-        
134-        // Render cells with proper attributes (colors, bold, etc.)
135-        var col: usize = 0;
136-        while (col < cols) : (col += 1) {
137-            const cell = c.ghostty_line_get_cell(line, col);
138-            
139-            // Emit SGR codes for cell attributes
140-            try renderCellAttributes(&output, cell);
141-            
142-            // Emit the character
143-            const codepoint = c.ghostty_cell_get_codepoint(cell);
144-            try appendUtf8(&output, codepoint);
145-        }
146-        
147-        try output.append('\n');
148-    }
149-    
150-    // Reset attributes
151-    try output.appendSlice("\x1b[0m");
152-    
153-    return output.toOwnedSlice();
154-}
155-```
156-
157-## 6. Modify handleAttachSession()
158-
159-Update attach logic to:
160-
161-1. Check if session exists, create if not
162-1. If reattaching (session already exists):
163-   - Render current terminal state using libghostty-vt
164-   - Send rendered snapshot to client
165-1. Add client to session's attached_clients set
166-1. Start proxying raw PTY output
167-
168-```zig
169-fn handleAttachSession(ctx: *ServerContext, client: *Client, session_name: []const u8) !void {
170-    const session = ctx.sessions.get(session_name) orelse {
171-        // New session - create it
172-        const new_session = try createSession(ctx.allocator, session_name);
173-        try ctx.sessions.put(session_name, new_session);
174-        session = new_session;
175-    };
176-    
177-    // Mark client as attached
178-    client.attached_session = try ctx.allocator.dupe(u8, session_name);
179-    try session.attached_clients.put(client.fd, {});
180-    
181-    // Check if this is a reattach (session already running)
182-    const is_reattach = session.attached_clients.count() > 1 or session.buffer.items.len > 0;
183-    
184-    if (is_reattach) {
185-        // Render current terminal state and send it
186-        const snapshot = try renderTerminalSnapshot(session, ctx.allocator);
187-        defer ctx.allocator.free(snapshot);
188-        
189-        const response = try std.fmt.allocPrint(
190-            ctx.allocator,
191-            "{{\"type\":\"pty_out\",\"payload\":{{\"text\":\"{s}\"}}}}\n",
192-            .{snapshot},
193-        );
194-        defer ctx.allocator.free(response);
195-        
196-        _ = try posix.write(client.fd, response);
197-    }
198-    
199-    // Start reading from PTY if not already started
200-    if (!session.pty_reading) {
201-        try startPtyReading(ctx, session);
202-        session.pty_reading = true;
203-    }
204-    
205-    // Send attach success response
206-    // ... existing response code ...
207-}
208-```
209-
210-## 7. Handle Window Resize Events
211-
212-Add support for window size changes:
213-
214-- When client sends window resize event, update libghostty-vt
215-- Update PTY window size with ioctl TIOCSWINSZ
216-- libghostty-vt will handle reflow automatically
217-
218-```zig
219-// New message type in protocol: "window_resize"
220-fn handleWindowResize(client: *Client, rows: u16, cols: u16) !void {
221-    const session = getSessionForClient(client) orelse return error.NotAttached;
222-    
223-    // Update libghostty-vt
224-    c.ghostty_vt_resize(session.vt, cols, rows);
225-    
226-    // Update PTY
227-    var ws: c.winsize = .{
228-        .ws_row = rows,
229-        .ws_col = cols,
230-        .ws_xpixel = 0,
231-        .ws_ypixel = 0,
232-    };
233-    _ = c.ioctl(session.pty_master_fd, c.TIOCSWINSZ, &ws);
234-}
235-```
236-
237-## 8. Track Attached Clients Per Session
238-
239-Modify session management:
240-
241-- Remove client from session.attached_clients on detach
242-- On disconnect, automatically detach client
243-- Keep session alive even when no clients attached
244-
245-```zig
246-fn handleDetachSession(client: *Client, session_name: []const u8, target_client_fd: ?i64) !void {
247-    const session = ctx.sessions.get(session_name) orelse return error.SessionNotFound;
248-    
249-    const fd_to_remove = if (target_client_fd) |fd| @intCast(fd) else client.fd;
250-    _ = session.attached_clients.remove(fd_to_remove);
251-    
252-    // Note: DO NOT kill session when last client detaches
253-    // Session continues running in background
254-}
255-```
256-
257-## 9. Clean Up Terminal Emulator on Session Destroy
258-
259-In session deinit:
260-
261-- Free libghostty-vt resources
262-- Clean up attached_clients map
263-
264-```zig
265-fn deinit(self: *Session) void {
266-    self.allocator.free(self.name);
267-    self.buffer.deinit();
268-    self.attached_clients.deinit();
269-    
270-    // Free libghostty-vt
271-    c.ghostty_vt_free(self.vt);
272-}
273-```
274-
275-## 10. Configuration Options
276-
277-Add configurable options (future work):
278-
279-- Scrollback buffer size
280-- Default terminal dimensions
281-- Maximum grid memory usage
282-
283-## Implementation Order
284-
285-1. ✅ Add libghostty-vt C bindings and build integration
286-1. ✅ Extend Session struct with vt fields
287-1. ✅ Initialize vt in createSession()
288-1. ✅ Feed PTY output to vt in readPtyCallback()
289-1. ✅ Implement renderTerminalSnapshot()
290-1. ✅ Modify handleAttachSession() to render on reattach
291-1. ✅ Track attached_clients per session
292-1. ✅ Handle window resize events
293-1. ✅ Clean up vt resources in session deinit
294-1. ✅ Test with multiple attach/detach cycles
295-
296-## Testing Strategy
297-
298-- Create session, run commands, detach
299-- Verify PTY continues running (ps aux | grep)
300-- Reattach and verify terminal state is restored
301-- Test with various shell outputs: ls, vim, htop, long scrollback
302-- Test multiple clients attaching to same session
303-- Test window resize during detached state
D specs/cli.md
+0, -106
  1@@ -1,106 +0,0 @@
  2-# zmx cli specification
  3-
  4-This document outlines the command-line interface for the `zmx` tool.
  5-
  6-## third-party libraries
  7-
  8-We will use the `zig-clap` library for parsing command-line arguments. It provides a robust and flexible way to define commands, subcommands, and flags.
  9-
 10-## command structure
 11-
 12-The `zmx` tool will follow a subcommand-based structure.
 13-
 14-```
 15-zmx [command] [options]
 16-```
 17-
 18-### global options
 19-
 20-- `-h`, `--help`: Display help information.
 21-- `-v`, `--version`: Display the version of the tool.
 22-
 23-### commands
 24-
 25-#### `daemon`
 26-
 27-This is the background process that manages all the shell sessions (pty processes) that the client interacts with.
 28-
 29-**Usage:**
 30-
 31-```
 32-zmx daemon
 33-```
 34-
 35-**Arguments:**
 36-
 37-- `<socket>`: The location of the unix socket file. Clients connecting will also have to pass the same flag.
 38-
 39-#### `list`
 40-
 41-List all active sessions.
 42-
 43-**Usage:**
 44-
 45-```
 46-zmx list
 47-```
 48-
 49-**Output:**
 50-
 51-The `list` command will output a table with the following columns:
 52-
 53-- `SESSION`: The name of the session.
 54-- `STATUS`: The status of the session (e.g., `attached`, `detached`).
 55-- `CLIENTS`: The number of clients currently attached to the session.
 56-- `CREATED_AT`: The date when the session was created
 57-
 58-______________________________________________________________________
 59-
 60-#### `attach`
 61-
 62-Attach to a session.
 63-
 64-**Usage:**
 65-
 66-```
 67-zmx attach <session>
 68-```
 69-
 70-**Arguments:**
 71-
 72-- `<session>`: The name of the session to attach to. This is a required argument.
 73-- `<socket>`: The location of the unix socket file.
 74-
 75-______________________________________________________________________
 76-
 77-#### `detach`
 78-
 79-Detach from a session.
 80-
 81-**Usage:**
 82-
 83-```
 84-zmx detach <session>
 85-```
 86-
 87-**Arguments:**
 88-
 89-- `<session>`: The name of the session to detach from. This is a required argument.
 90-- `<socket>`: The location of the unix socket file.
 91-
 92-______________________________________________________________________
 93-
 94-#### `kill`
 95-
 96-Kill a session.
 97-
 98-**Usage:**
 99-
100-```
101-zmx kill <session>
102-```
103-
104-**Arguments:**
105-
106-- `<session>`: The name of the session to kill. This is a required argument.
107-- `<socket>`: The location of the unix socket file.
D specs/daemon.md
+0, -46
 1@@ -1,46 +0,0 @@
 2-# zmx daemon specification
 3-
 4-This document outlines the specification for the `zmx daemon` subcommand, which runs the background process responsible for managing terminal sessions.
 5-
 6-## purpose
 7-
 8-The `zmx daemon` subcommand starts the long-running background process that manages all pseudo-terminal (PTY) processes. It acts as the central hub for session persistence, allowing clients to attach to and detach from active terminal sessions without terminating the underlying processes.
 9-
10-## responsibilities
11-
12-The daemon is responsible for:
13-
14-1. **PTY Management**: Creating, managing, and destroying PTY processes using `fork` or `forkpty`.
15-1. **Session State Management**: Maintaining the terminal state and a buffer of text output for each active session. This ensures that when a client re-attaches, they see the previous output and the correct terminal state.
16-1. **Client Communication**: Facilitating communication between multiple `zmx` client instances and the managed PTY processes via a Unix socket.
17-1. **Session Lifecycle**: Handling the lifecycle of sessions, including creation, listing, attachment, detachment, and termination (killing).
18-1. **Resource Management**: Managing system resources associated with each session.
19-
20-## usage
21-
22-```
23-zmx daemon [options]
24-```
25-
26-## options
27-
28-- `-s`, `--socket-path <path>`: Specifies the path to the Unix socket for client-daemon communication. Defaults to a system-dependent location (e.g., `/tmp/zmx.sock`).
29-- `-b`, `--buffer-size <size>`: Sets the maximum size (in lines or bytes) for the session's scrollback buffer. Defaults to a reasonable value (e.g., 1000 lines).
30-- `-l`, `--log-level <level>`: Sets the logging level for the daemon (e.g., `debug`, `info`, `warn`, `error`). Defaults to `info`.
31-
32-## systemd integration
33-
34-The `zmx daemon` process is designed to be managed by `systemd`. A `systemd` unit file will be provided to ensure the daemon starts automatically on boot, restarts on failure, and logs its output appropriately.
35-
36-## communication protocol
37-
38-(To be defined in a separate `PROTOCOL.md` spec)
39-
40-The daemon will expose an API over the Unix socket to allow clients to:
41-
42-- List active sessions.
43-- Request attachment to a session.
44-- Send input to a session.
45-- Receive output from a session.
46-- Detach from a session.
47-- Kill a session.
D specs/protocol.md
+0, -169
  1@@ -1,169 +0,0 @@
  2-# ZMX Client-Daemon Communication Protocol
  3-
  4-This document specifies the communication protocol between `zmx` clients and the `zmx daemon` over a Unix socket.
  5-
  6-## Transport
  7-
  8-The communication occurs over a Unix domain socket. The path to the socket is configurable via the `--socket-path` option of the `daemon` subcommand.
  9-
 10-## Serialization
 11-
 12-All messages are currently serialized using **newline-delimited JSON (NDJSON)**. Each message is a JSON object terminated by a newline character (`\n`). This allows for simple streaming and parsing of messages while maintaining human-readable logs for debugging.
 13-
 14-### Implementation
 15-
 16-The protocol implementation is centralized in `src/protocol.zig`, which provides:
 17-
 18-- Typed message structs for all payloads
 19-- `MessageType` enum for type-safe dispatching
 20-- Helper functions: `writeJson()`, `parseMessage()`, `parseMessageType()`
 21-- `LineBuffer` for efficient NDJSON line buffering
 22-
 23-### Binary Frame Support
 24-
 25-The protocol uses a hybrid approach: JSON for control messages and binary frames for PTY output to avoid encoding overhead and improve throughput.
 26-
 27-**Frame Format:**
 28-
 29-```
 30-[4-byte length (little-endian)][2-byte type (little-endian)][payload...]
 31-```
 32-
 33-**Frame Types:**
 34-
 35-- Type 1 (`json_control`): JSON control messages (not currently used in framing)
 36-- Type 2 (`pty_binary`): Raw PTY output bytes
 37-
 38-**Current Usage:**
 39-
 40-- Control messages (attach, detach, kill, etc.): NDJSON format
 41-- PTY output from daemon to client: Binary frames (type 2)
 42-- PTY input from client to daemon: Binary frames (type 2)
 43-
 44-## Message Structure
 45-
 46-Each message is a JSON object with two top-level properties:
 47-
 48-- `type`: A string that identifies the type of the message (e.g., `list_sessions_request`).
 49-- `payload`: A JSON object containing the message-specific data.
 50-
 51-### Requests
 52-
 53-Requests are sent from the client to the daemon.
 54-
 55-### Responses
 56-
 57-Responses are sent from the daemon to the client in response to a request. Every response will have a `status` field in its payload, which can be either `ok` or `error`. If the status is `error`, the payload will also contain an `error_message` field.
 58-
 59-## Message Types
 60-
 61-### `list_sessions`
 62-
 63-- **Direction**: Client -> Daemon
 64-
 65-- **Request Type**: `list_sessions_request`
 66-
 67-- **Request Payload**: (empty)
 68-
 69-- **Direction**: Daemon -> Client
 70-
 71-- **Response Type**: `list_sessions_response`
 72-
 73-- **Response Payload**:
 74-
 75-  - `status`: `ok`
 76-  - `sessions`: An array of session objects.
 77-
 78-**Session Object:**
 79-
 80-- `name`: string
 81-- `status`: string (`attached` or `detached`)
 82-- `clients`: number
 83-- `created_at`: string (ISO 8601 format)
 84-
 85-### `attach_session`
 86-
 87-- **Direction**: Client -> Daemon
 88-
 89-- **Request Type**: `attach_session_request`
 90-
 91-- **Request Payload**:
 92-
 93-  - `session_name`: string
 94-  - `rows`: u16 (terminal height in rows)
 95-  - `cols`: u16 (terminal width in columns)
 96-
 97-- **Direction**: Daemon -> Client
 98-
 99-- **Response Type**: `attach_session_response`
100-
101-- **Response Payload**:
102-
103-  - `status`: `ok` or `error`
104-  - `error_message`: string (if status is `error`)
105-
106-### `detach_session`
107-
108-- **Direction**: Client -> Daemon
109-
110-- **Request Type**: `detach_session_request`
111-
112-- **Request Payload**:
113-
114-  - `session_name`: string
115-
116-- **Direction**: Daemon -> Client
117-
118-- **Response Type**: `detach_session_response`
119-
120-- **Response Payload**:
121-
122-  - `status`: `ok` or `error`
123-  - `error_message`: string (if status is `error`)
124-
125-### `kill_session`
126-
127-- **Direction**: Client -> Daemon
128-
129-- **Request Type**: `kill_session_request`
130-
131-- **Request Payload**:
132-
133-  - `session_name`: string
134-
135-- **Direction**: Daemon -> Client
136-
137-- **Response Type**: `kill_session_response`
138-
139-- **Response Payload**:
140-
141-  - `status`: `ok` or `error`
142-  - `error_message`: string (if status is `error`)
143-
144-### `pty_in`
145-
146-- **Direction**: Client -> Daemon
147-- **Message Type**: `pty_in`
148-- **Format**: NDJSON
149-- **Payload**:
150-  - `text`: string (raw UTF-8 text from terminal input)
151-
152-This message is sent when a client wants to send user input to the PTY. It is a fire-and-forget message with no direct response. The input is forwarded to the shell running in the session's PTY.
153-
154-### `pty_out`
155-
156-- **Direction**: Daemon -> Client
157-- **Message Type**: `pty_out`
158-- **Format**: NDJSON (used only for control sequences like screen clear)
159-- **Payload**:
160-  - `text`: string (escape sequences or control output)
161-
162-This JSON message is sent for special control output like initial screen clearing. Regular PTY output uses binary frames (see below).
163-
164-### PTY Binary Output
165-
166-- **Direction**: Daemon -> Client
167-- **Format**: Binary frame (type 2: `pty_binary`)
168-- **Payload**: Raw bytes from PTY output
169-
170-The majority of PTY output is sent using binary frames to avoid JSON encoding overhead. The frame consists of a 6-byte header (4-byte length + 2-byte type) followed by raw PTY bytes. This allows efficient streaming of terminal output without escaping or encoding.
D specs/restore-session.md
+0, -24
 1@@ -1,24 +0,0 @@
 2-# zmx session restore specification
 3-
 4-This document outlines the specification how we are going to preserve session state so when a client reattaches to a session using `zmx reattach {session}` it will restore the session to its last state.
 5-
 6-## purpose
 7-
 8-The `zmx attach` subcommand starts re-attaches to a previously created session. When doing this we want to restore the session to its current state, displaying the last working screen text, layout, text wrapping, etc. This will include a configurable scrollback buffer size that will also be restored upon reattach.
 9-
10-## technical details
11-
12-- The daemon spawns the shell on a pty master.
13-- Every byte the shell emits is parsed on-the-fly by the in-process terminal emulator (libghostty-vt).
14-- The emulator updates an internal 2-D cell grid (the “snapshot”) and forwards the same raw bytes to no-one while no client is attached.
15-- When a client is attached, the daemon also proxies those bytes straight to the client’s socket; the emulator runs in parallel only to keep the snapshot current.
16-- When you reattach, the daemon does not send the historic byte stream; instead it renders the current grid into a fresh ANSI sequence and ships that down the Unix-domain socket to the new shpool attach client.
17-- The client simply write()s that sequence to stdout—your local terminal sees it and redraws the screen instantly.
18-
19-So the emulator is not “between” client and daemon in the latency sense; it is alongside, maintaining state. The only time it interposes is on re-attach: it briefly synthesizes a single frame so your local terminal can show the exact session image without having to replay minutes or hours of output.
20-
21-## using libghostty-vt
22-
23-- Feature superset: SIMD parsing, full Unicode grapheme clusters, Kitty graphics, sixel, and thousands of CSI/DEC/OSC commands already implemented and fuzz-tested
24-- Memory model: it hands you a read-only snapshot of the grid that you can memcpy straight into your re-attach logic—no allocator churn.
25-- No I/O policy: it is stateless by design; you feed it bytes when they arrive from the pty and later ask for the current screen.
D src/attach.zig
+0, -655
  1@@ -1,655 +0,0 @@
  2-const std = @import("std");
  3-const posix = std.posix;
  4-const xevg = @import("xev");
  5-const xev = xevg.Dynamic;
  6-const clap = @import("clap");
  7-const config_mod = @import("config.zig");
  8-const protocol = @import("protocol.zig");
  9-
 10-const c = @cImport({
 11-    @cInclude("termios.h");
 12-    @cInclude("sys/ioctl.h");
 13-});
 14-
 15-// Main context for the attach client that manages connection to daemon and terminal I/O.
 16-// Handles async streams for daemon socket, stdin, and stdout using libxev's event loop.
 17-// Tracks detach key sequence state and buffers partial binary frames from PTY output.
 18-const Context = struct {
 19-    stream: xev.Stream,
 20-    stdin_stream: xev.Stream,
 21-    stdout_stream: xev.Stream,
 22-    socket_fd: std.posix.fd_t,
 23-    allocator: std.mem.Allocator,
 24-    loop: *xev.Loop,
 25-    session_name: []const u8,
 26-    prefix_pressed: bool = false,
 27-    should_exit: bool = false,
 28-    stdin_completion: ?*xev.Completion = null,
 29-    stdin_ctx: ?*StdinContext = null,
 30-    read_completion: ?*xev.Completion = null,
 31-    read_ctx: ?*ReadContext = null,
 32-    config: config_mod.Config,
 33-    frame_buffer: std.ArrayList(u8),
 34-    frame_expecting_bytes: usize = 0,
 35-};
 36-
 37-const params = clap.parseParamsComptime(
 38-    \\-s, --socket-path <str>  Path to the Unix socket file
 39-    \\<str>
 40-    \\
 41-);
 42-
 43-pub fn main(config: config_mod.Config, iter: *std.process.ArgIterator) !void {
 44-    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
 45-    defer _ = gpa.deinit();
 46-    const allocator = gpa.allocator();
 47-
 48-    var diag = clap.Diagnostic{};
 49-    var res = clap.parseEx(clap.Help, &params, clap.parsers.default, iter, .{
 50-        .diagnostic = &diag,
 51-        .allocator = allocator,
 52-    }) catch |err| {
 53-        var buf: [1024]u8 = undefined;
 54-        var stderr_file = std.fs.File{ .handle = posix.STDERR_FILENO };
 55-        var writer = stderr_file.writer(&buf);
 56-        diag.report(&writer.interface, err) catch {};
 57-        writer.interface.flush() catch {};
 58-        return err;
 59-    };
 60-    defer res.deinit();
 61-
 62-    const socket_path = res.args.@"socket-path" orelse config.socket_path;
 63-
 64-    const session_name = res.positionals[0] orelse {
 65-        std.debug.print("Usage: zmx attach <session-name>\n", .{});
 66-        return error.MissingSessionName;
 67-    };
 68-
 69-    var thread_pool = xevg.ThreadPool.init(.{});
 70-    defer thread_pool.deinit();
 71-    defer thread_pool.shutdown();
 72-
 73-    var loop = try xev.Loop.init(.{ .thread_pool = &thread_pool });
 74-    defer loop.deinit();
 75-
 76-    var unix_addr = try std.net.Address.initUnix(socket_path);
 77-    // AF.UNIX: Unix domain socket for local IPC with daemon process
 78-    // SOCK.STREAM: Reliable, connection-oriented communication for protocol messages
 79-    // SOCK.NONBLOCK: Prevents blocking to work with libxev's async event loop
 80-    const socket_fd = try posix.socket(posix.AF.UNIX, posix.SOCK.STREAM | posix.SOCK.NONBLOCK, 0);
 81-    // Save original terminal settings first (before connecting)
 82-    var orig_termios: c.termios = undefined;
 83-    _ = c.tcgetattr(posix.STDIN_FILENO, &orig_termios);
 84-
 85-    posix.connect(socket_fd, &unix_addr.any, unix_addr.getOsSockLen()) catch |err| {
 86-        if (err == error.ConnectionRefused) {
 87-            std.debug.print("Error: Unable to connect to zmx daemon at {s}\nPlease start the daemon first with: zmx daemon\n", .{socket_path});
 88-            return err;
 89-        }
 90-        return err;
 91-    };
 92-
 93-    // Set raw mode after successful connection
 94-    defer _ = c.tcsetattr(posix.STDIN_FILENO, c.TCSANOW, &orig_termios);
 95-
 96-    var raw_termios = orig_termios;
 97-    c.cfmakeraw(&raw_termios);
 98-    _ = c.tcsetattr(posix.STDIN_FILENO, c.TCSANOW, &raw_termios);
 99-
100-    const ctx = try allocator.create(Context);
101-    ctx.* = .{
102-        .stream = xev.Stream.initFd(socket_fd),
103-        .stdin_stream = xev.Stream.initFd(posix.STDIN_FILENO),
104-        .stdout_stream = xev.Stream.initFd(posix.STDOUT_FILENO),
105-        .socket_fd = socket_fd,
106-        .allocator = allocator,
107-        .loop = &loop,
108-        .session_name = session_name,
109-        .config = config,
110-        .frame_buffer = std.ArrayList(u8).initCapacity(allocator, 64 * 1024) catch unreachable, // 64KB initial capacity
111-    };
112-
113-    // Get terminal size
114-    var ws: c.struct_winsize = undefined;
115-    const result = c.ioctl(posix.STDOUT_FILENO, c.TIOCGWINSZ, &ws);
116-    const rows: u16 = if (result == 0) ws.ws_row else 24;
117-    const cols: u16 = if (result == 0) ws.ws_col else 80;
118-
119-    const cwd_buf = try allocator.alloc(u8, std.fs.max_path_bytes);
120-    defer allocator.free(cwd_buf);
121-    const cwd = try std.posix.getcwd(cwd_buf);
122-
123-    const request_payload = protocol.AttachSessionRequest{
124-        .session_name = session_name,
125-        .rows = rows,
126-        .cols = cols,
127-        .cwd = cwd,
128-    };
129-    var out: std.io.Writer.Allocating = .init(allocator);
130-    defer out.deinit();
131-
132-    const msg = protocol.Message(@TypeOf(request_payload)){
133-        .type = protocol.MessageType.attach_session_request.toString(),
134-        .payload = request_payload,
135-    };
136-
137-    var stringify: std.json.Stringify = .{ .writer = &out.writer };
138-    try stringify.write(msg);
139-    try out.writer.writeByte('\n');
140-
141-    const request = try allocator.dupe(u8, out.written());
142-    defer allocator.free(request);
143-
144-    const write_completion = try allocator.create(xev.Completion);
145-    ctx.stream.write(&loop, write_completion, .{ .slice = request }, Context, ctx, writeCallback);
146-
147-    try loop.run(.until_done);
148-}
149-
150-fn writeCallback(
151-    ctx_opt: ?*Context,
152-    _: *xev.Loop,
153-    completion: *xev.Completion,
154-    _: xev.Stream,
155-    _: xev.WriteBuffer,
156-    write_result: xev.WriteError!usize,
157-) xev.CallbackAction {
158-    const ctx = ctx_opt.?;
159-    if (write_result) |_| {
160-        // Request sent successfully
161-    } else |err| {
162-        std.debug.print("write failed: {s}\r\n", .{@errorName(err)});
163-        return cleanup(ctx, completion);
164-    }
165-
166-    // Now read the response
167-    const read_ctx = ctx.allocator.create(ReadContext) catch @panic("failed to create read context");
168-    read_ctx.* = .{
169-        .ctx = ctx,
170-        .buffer = undefined,
171-    };
172-
173-    const read_completion = ctx.allocator.create(xev.Completion) catch @panic("failed to create completion");
174-
175-    // Track read completion and context for cleanup
176-    ctx.read_completion = read_completion;
177-    ctx.read_ctx = read_ctx;
178-
179-    ctx.stream.read(ctx.loop, read_completion, .{ .slice = &read_ctx.buffer }, ReadContext, read_ctx, readCallback);
180-
181-    ctx.allocator.destroy(completion);
182-    return .disarm;
183-}
184-
185-// Context for async socket read operations from daemon.
186-// Uses large buffer (256KB) to handle initial session scrollback and binary PTY frames.
187-const ReadContext = struct {
188-    ctx: *Context,
189-    buffer: [256 * 1024]u8, // 256KB to handle large scrollback messages
190-};
191-
192-fn readCallback(
193-    read_ctx_opt: ?*ReadContext,
194-    _: *xev.Loop,
195-    completion: *xev.Completion,
196-    _: xev.Stream,
197-    read_buffer: xev.ReadBuffer,
198-    read_result: xev.ReadError!usize,
199-) xev.CallbackAction {
200-    const read_ctx = read_ctx_opt.?;
201-    const ctx = read_ctx.ctx;
202-
203-    if (read_result) |len| {
204-        if (len == 0) {
205-            std.debug.print("Server closed connection\r\n", .{});
206-            return cleanup(ctx, completion);
207-        }
208-
209-        const data = read_buffer.slice[0..len];
210-
211-        // Check if this is a binary frame (starts with FrameHeader)
212-        var remaining_data = data;
213-        if (data.len >= @sizeOf(protocol.FrameHeader)) {
214-            const potential_header = data[0..@sizeOf(protocol.FrameHeader)];
215-            const header: *const protocol.FrameHeader = @ptrCast(@alignCast(potential_header));
216-
217-            if (header.frame_type == @intFromEnum(protocol.FrameType.pty_binary)) {
218-                // This is a binary PTY frame
219-                const expected_total = @sizeOf(protocol.FrameHeader) + header.length;
220-                if (data.len >= expected_total) {
221-                    // We have the complete frame
222-                    const payload = data[@sizeOf(protocol.FrameHeader)..expected_total];
223-                    writeToStdout(ctx, payload);
224-
225-                    // Check if there's more data after this frame (e.g., JSON response)
226-                    if (data.len > expected_total) {
227-                        remaining_data = data[expected_total..];
228-                        // Continue processing remaining data as JSON below
229-                    } else {
230-                        return .rearm;
231-                    }
232-                } else {
233-                    // Partial frame, buffer it
234-                    ctx.frame_buffer.appendSlice(ctx.allocator, data) catch {};
235-                    ctx.frame_expecting_bytes = expected_total - data.len;
236-                    return .rearm;
237-                }
238-            }
239-        }
240-
241-        // If we're expecting more frame bytes, accumulate them
242-        if (ctx.frame_expecting_bytes > 0) {
243-            ctx.frame_buffer.appendSlice(ctx.allocator, data) catch {};
244-            if (ctx.frame_buffer.items.len >= @sizeOf(protocol.FrameHeader)) {
245-                const header: *const protocol.FrameHeader = @ptrCast(@alignCast(ctx.frame_buffer.items[0..@sizeOf(protocol.FrameHeader)]));
246-                const expected_total = @sizeOf(protocol.FrameHeader) + header.length;
247-
248-                if (ctx.frame_buffer.items.len >= expected_total) {
249-                    // Complete frame received
250-                    const payload = ctx.frame_buffer.items[@sizeOf(protocol.FrameHeader)..expected_total];
251-                    writeToStdout(ctx, payload);
252-                    ctx.frame_buffer.clearRetainingCapacity();
253-                    ctx.frame_expecting_bytes = 0;
254-                }
255-            }
256-            return .rearm;
257-        }
258-
259-        // Otherwise parse as JSON control message
260-        const newline_idx = std.mem.indexOf(u8, remaining_data, "\n") orelse {
261-            return .rearm;
262-        };
263-
264-        const msg_line = remaining_data[0..newline_idx];
265-
266-        const msg_type_parsed = protocol.parseMessageType(ctx.allocator, msg_line) catch |err| {
267-            std.debug.print("JSON parse error: {s}, data: {s}\r\n", .{ @errorName(err), msg_line });
268-            return .rearm;
269-        };
270-        defer msg_type_parsed.deinit();
271-
272-        const msg_type = protocol.MessageType.fromString(msg_type_parsed.value.type) orelse {
273-            std.debug.print("Unknown message type: {s}\r\n", .{msg_type_parsed.value.type});
274-            return .rearm;
275-        };
276-
277-        switch (msg_type) {
278-            .attach_session_response => {
279-                std.debug.print("Received attach_session_response\r\n", .{});
280-                const parsed = protocol.parseMessage(protocol.AttachSessionResponse, ctx.allocator, msg_line) catch |err| {
281-                    std.debug.print("Failed to parse attach response: {s}\r\n", .{@errorName(err)});
282-                    std.debug.print("Message line: {s}\r\n", .{msg_line});
283-                    return .rearm;
284-                };
285-                defer parsed.deinit();
286-
287-                std.debug.print("Parsed response: status={s}, client_fd={?d}\r\n", .{ parsed.value.payload.status, parsed.value.payload.client_fd });
288-                if (std.mem.eql(u8, parsed.value.payload.status, "ok")) {
289-                    std.debug.print("Status is OK, processing...\r\n", .{});
290-                    const client_fd = parsed.value.payload.client_fd orelse {
291-                        std.debug.print("Missing client_fd in response\r\n", .{});
292-                        return .rearm;
293-                    };
294-
295-                    // Write client_fd to a file so shell commands can read it
296-                    const home_dir = posix.getenv("HOME") orelse "/tmp";
297-                    const client_fd_path = std.fmt.allocPrint(
298-                        ctx.allocator,
299-                        "{s}/.zmx_client_fd_{s}",
300-                        .{ home_dir, ctx.session_name },
301-                    ) catch |err| {
302-                        std.debug.print("Failed to create client_fd path: {s}\r\n", .{@errorName(err)});
303-                        return .rearm;
304-                    };
305-                    defer ctx.allocator.free(client_fd_path);
306-
307-                    const file = std.fs.cwd().createFile(client_fd_path, .{ .truncate = true }) catch |err| {
308-                        std.debug.print("Failed to create client_fd file: {s}\r\n", .{@errorName(err)});
309-                        return .rearm;
310-                    };
311-                    defer file.close();
312-
313-                    const fd_str = std.fmt.allocPrint(ctx.allocator, "{d}", .{client_fd}) catch return .rearm;
314-                    defer ctx.allocator.free(fd_str);
315-
316-                    file.writeAll(fd_str) catch |err| {
317-                        std.debug.print("Failed to write client_fd: {s}\r\n", .{@errorName(err)});
318-                        return .rearm;
319-                    };
320-
321-                    startStdinReading(ctx);
322-                } else {
323-                    _ = posix.write(posix.STDERR_FILENO, "Attach failed: ") catch {};
324-                    _ = posix.write(posix.STDERR_FILENO, parsed.value.payload.status) catch {};
325-                    _ = posix.write(posix.STDERR_FILENO, "\n") catch {};
326-                }
327-            },
328-            .detach_session_response => {
329-                const parsed = protocol.parseMessage(protocol.DetachSessionResponse, ctx.allocator, msg_line) catch |err| {
330-                    std.debug.print("Failed to parse detach response: {s}\r\n", .{@errorName(err)});
331-                    return .rearm;
332-                };
333-                defer parsed.deinit();
334-
335-                if (std.mem.eql(u8, parsed.value.payload.status, "ok")) {
336-                    cleanupClientFdFile(ctx);
337-                    writeDetachCleanup();
338-                    _ = posix.write(posix.STDERR_FILENO, "Detached from session\r\n") catch {};
339-                    return cleanup(ctx, completion);
340-                }
341-            },
342-            .detach_notification => {
343-                cleanupClientFdFile(ctx);
344-                writeDetachCleanup();
345-                _ = posix.write(posix.STDERR_FILENO, "Detached from session (external request)\r\n") catch {};
346-                return cleanup(ctx, completion);
347-            },
348-            .kill_notification => {
349-                cleanupClientFdFile(ctx);
350-                _ = posix.write(posix.STDERR_FILENO, "\r\nSession killed\r\n") catch {};
351-                return cleanup(ctx, completion);
352-            },
353-
354-            else => {
355-                std.debug.print("Unexpected message type in attach client: {s}\r\n", .{msg_type.toString()});
356-            },
357-        }
358-
359-        return .rearm;
360-    } else |_| {
361-        // Connection closed by daemon (expected on shutdown)
362-    }
363-
364-    ctx.allocator.destroy(read_ctx);
365-    ctx.read_ctx = null;
366-    return cleanup(ctx, completion);
367-}
368-
369-fn startStdinReading(ctx: *Context) void {
370-    // Don't start if already reading
371-    if (ctx.stdin_completion != null) {
372-        std.debug.print("Stdin reading already started, skipping\r\n", .{});
373-        return;
374-    }
375-
376-    const stdin_ctx = ctx.allocator.create(StdinContext) catch @panic("failed to create stdin context");
377-    stdin_ctx.* = .{
378-        .ctx = ctx,
379-        .buffer = undefined,
380-    };
381-
382-    const stdin_completion = ctx.allocator.create(xev.Completion) catch @panic("failed to create completion");
383-
384-    // Track stdin completion and context for cleanup
385-    ctx.stdin_completion = stdin_completion;
386-    ctx.stdin_ctx = stdin_ctx;
387-
388-    std.debug.print("Starting stdin reading\r\n", .{});
389-    ctx.stdin_stream.read(ctx.loop, stdin_completion, .{ .slice = &stdin_ctx.buffer }, StdinContext, stdin_ctx, stdinReadCallback);
390-}
391-
392-// Context for async stdin read operations.
393-// Captures user terminal input to forward to PTY via daemon.
394-const StdinContext = struct {
395-    ctx: *Context,
396-    buffer: [4096]u8,
397-};
398-
399-fn cleanupClientFdFile(ctx: *Context) void {
400-    const home_dir = posix.getenv("HOME") orelse "/tmp";
401-    const client_fd_path = std.fmt.allocPrint(
402-        ctx.allocator,
403-        "{s}/.zmx_client_fd_{s}",
404-        .{ home_dir, ctx.session_name },
405-    ) catch return;
406-    defer ctx.allocator.free(client_fd_path);
407-
408-    std.fs.cwd().deleteFile(client_fd_path) catch {};
409-}
410-
411-fn writeDetachCleanup() void {
412-    // Clear screen artifacts before showing detach message:
413-    // \x1b[2J - Clear entire screen
414-    // \x1b[H  - Move cursor to home position (1,1)
415-    // \x1b[?25h - Show cursor
416-    // \x1b[0m - Reset all text attributes (colors, styles)
417-    _ = posix.write(posix.STDERR_FILENO, "\x1b[2J\x1b[H\x1b[?25h\x1b[0m") catch {};
418-}
419-
420-fn sendDetachRequest(ctx: *Context) void {
421-    const request_payload = protocol.DetachSessionRequest{ .session_name = ctx.session_name };
422-    var out: std.io.Writer.Allocating = .init(ctx.allocator);
423-    defer out.deinit();
424-
425-    const msg = protocol.Message(@TypeOf(request_payload)){
426-        .type = protocol.MessageType.detach_session_request.toString(),
427-        .payload = request_payload,
428-    };
429-
430-    var stringify: std.json.Stringify = .{ .writer = &out.writer };
431-    stringify.write(msg) catch return;
432-    out.writer.writeByte('\n') catch return;
433-
434-    const request = ctx.allocator.dupe(u8, out.written()) catch return;
435-
436-    const write_ctx = ctx.allocator.create(StdinWriteContext) catch return;
437-    write_ctx.* = .{
438-        .allocator = ctx.allocator,
439-        .message = request,
440-    };
441-
442-    const write_completion = ctx.allocator.create(xev.Completion) catch return;
443-    ctx.stream.write(ctx.loop, write_completion, .{ .slice = write_ctx.message }, StdinWriteContext, write_ctx, stdinWriteCallback);
444-}
445-
446-fn sendPtyInput(ctx: *Context, data: []const u8) void {
447-    protocol.writeBinaryFrame(ctx.socket_fd, .pty_binary, data) catch |err| {
448-        std.debug.print("Failed to send pty input: {s}\r\n", .{@errorName(err)});
449-    };
450-}
451-
452-// Context for async write operations to daemon socket.
453-// Owns message buffer that gets freed after write completes.
454-const StdinWriteContext = struct {
455-    allocator: std.mem.Allocator,
456-    message: []u8,
457-};
458-
459-// Context for async write operations to stdout.
460-// Owns PTY output data buffer that gets freed after write completes.
461-const StdoutWriteContext = struct {
462-    allocator: std.mem.Allocator,
463-    data: []u8,
464-};
465-
466-fn writeToStdout(ctx: *Context, data: []const u8) void {
467-    const owned_data = ctx.allocator.dupe(u8, data) catch return;
468-
469-    const write_ctx = ctx.allocator.create(StdoutWriteContext) catch {
470-        ctx.allocator.free(owned_data);
471-        return;
472-    };
473-    write_ctx.* = .{
474-        .allocator = ctx.allocator,
475-        .data = owned_data,
476-    };
477-
478-    const write_completion = ctx.allocator.create(xev.Completion) catch {
479-        ctx.allocator.free(owned_data);
480-        ctx.allocator.destroy(write_ctx);
481-        return;
482-    };
483-
484-    ctx.stdout_stream.write(ctx.loop, write_completion, .{ .slice = owned_data }, StdoutWriteContext, write_ctx, stdoutWriteCallback);
485-}
486-
487-fn stdoutWriteCallback(
488-    write_ctx_opt: ?*StdoutWriteContext,
489-    _: *xev.Loop,
490-    completion: *xev.Completion,
491-    _: xev.Stream,
492-    _: xev.WriteBuffer,
493-    write_result: xev.WriteError!usize,
494-) xev.CallbackAction {
495-    const write_ctx = write_ctx_opt.?;
496-    const allocator = write_ctx.allocator;
497-
498-    if (write_result) |_| {
499-        // Successfully wrote to stdout - flush to ensure immediate display
500-        var buf: [0]u8 = undefined;
501-        var stdout_file = std.fs.File{ .handle = posix.STDOUT_FILENO };
502-        var writer = stdout_file.writer(&buf);
503-        writer.interface.flush() catch {};
504-    } else |_| {
505-        // Silently ignore stdout write errors
506-    }
507-
508-    allocator.free(write_ctx.data);
509-    allocator.destroy(write_ctx);
510-    allocator.destroy(completion);
511-    return .disarm;
512-}
513-
514-fn stdinReadCallback(
515-    stdin_ctx_opt: ?*StdinContext,
516-    _: *xev.Loop,
517-    completion: *xev.Completion,
518-    _: xev.Stream,
519-    read_buffer: xev.ReadBuffer,
520-    read_result: xev.ReadError!usize,
521-) xev.CallbackAction {
522-    const stdin_ctx = stdin_ctx_opt.?;
523-    const ctx = stdin_ctx.ctx;
524-
525-    if (read_result) |len| {
526-        if (len == 0) {
527-            std.debug.print("stdin closed\n", .{});
528-            ctx.stdin_completion = null;
529-            ctx.stdin_ctx = null;
530-            ctx.allocator.destroy(stdin_ctx);
531-            ctx.allocator.destroy(completion);
532-            return .disarm;
533-        }
534-
535-        const data = read_buffer.slice[0..len];
536-
537-        // Detect prefix for detach command
538-        if (len == 1 and data[0] == ctx.config.detach_prefix) {
539-            ctx.prefix_pressed = true;
540-            return .rearm;
541-        }
542-
543-        // If prefix was pressed and now we got the detach key, detach
544-        if (ctx.prefix_pressed and len == 1 and data[0] == ctx.config.detach_key) {
545-            ctx.prefix_pressed = false;
546-            sendDetachRequest(ctx);
547-            return .rearm;
548-        }
549-
550-        // If prefix was pressed but we got something else, send the prefix and the new data
551-        if (ctx.prefix_pressed) {
552-            ctx.prefix_pressed = false;
553-            // Send the prefix that was buffered
554-            const prefix_data = [_]u8{ctx.config.detach_prefix};
555-            sendPtyInput(ctx, &prefix_data);
556-            // Fall through to send the current data
557-        }
558-
559-        sendPtyInput(ctx, data);
560-
561-        return .rearm;
562-    } else |err| {
563-        std.debug.print("stdin read failed: {s}\r\n", .{@errorName(err)});
564-        ctx.stdin_completion = null;
565-        ctx.stdin_ctx = null;
566-        ctx.allocator.destroy(stdin_ctx);
567-        ctx.allocator.destroy(completion);
568-        return .disarm;
569-    }
570-}
571-
572-fn stdinWriteCallback(
573-    write_ctx_opt: ?*StdinWriteContext,
574-    _: *xev.Loop,
575-    completion: *xev.Completion,
576-    _: xev.Stream,
577-    _: xev.WriteBuffer,
578-    write_result: xev.WriteError!usize,
579-) xev.CallbackAction {
580-    const write_ctx = write_ctx_opt.?;
581-
582-    if (write_result) |_| {
583-        // Successfully sent stdin to daemon
584-    } else |err| {
585-        std.debug.print("Failed to send stdin to daemon: {s}\n", .{@errorName(err)});
586-    }
587-
588-    // Clean up - save allocator before destroying write_ctx
589-    const allocator = write_ctx.allocator;
590-    allocator.free(write_ctx.message);
591-    allocator.destroy(write_ctx);
592-    allocator.destroy(completion);
593-    return .disarm;
594-}
595-
596-fn cleanup(ctx: *Context, completion: *xev.Completion) xev.CallbackAction {
597-    // Track whether we've freed the passed completion
598-    var completion_freed = false;
599-
600-    // Clean up stdin completion and context if they exist
601-    if (ctx.stdin_completion) |stdin_completion| {
602-        if (stdin_completion == completion) {
603-            completion_freed = true;
604-        }
605-        ctx.allocator.destroy(stdin_completion);
606-        ctx.stdin_completion = null;
607-    }
608-    if (ctx.stdin_ctx) |stdin_ctx| {
609-        ctx.allocator.destroy(stdin_ctx);
610-        ctx.stdin_ctx = null;
611-    }
612-
613-    // Clean up read completion and context if they exist
614-    if (ctx.read_completion) |read_completion| {
615-        if (read_completion == completion) {
616-            completion_freed = true;
617-        }
618-        ctx.allocator.destroy(read_completion);
619-        ctx.read_completion = null;
620-    }
621-    if (ctx.read_ctx) |read_ctx| {
622-        ctx.allocator.destroy(read_ctx);
623-        ctx.read_ctx = null;
624-    }
625-
626-    const close_completion = ctx.allocator.create(xev.Completion) catch @panic("failed to create completion");
627-    ctx.stream.close(ctx.loop, close_completion, Context, ctx, closeCallback);
628-
629-    // Only destroy completion if we haven't already freed it above
630-    if (!completion_freed) {
631-        ctx.allocator.destroy(completion);
632-    }
633-
634-    return .disarm;
635-}
636-
637-fn closeCallback(
638-    ctx_opt: ?*Context,
639-    loop: *xev.Loop,
640-    completion: *xev.Completion,
641-    _: xev.Stream,
642-    close_result: xev.CloseError!void,
643-) xev.CallbackAction {
644-    const ctx = ctx_opt.?;
645-    if (close_result) |_| {
646-        std.debug.print("Connection closed\r\n", .{});
647-    } else |err| {
648-        std.debug.print("close failed: {s}\r\n", .{@errorName(err)});
649-    }
650-    const allocator = ctx.allocator;
651-    ctx.frame_buffer.deinit(allocator);
652-    allocator.destroy(completion);
653-    allocator.destroy(ctx);
654-    loop.stop();
655-    return .disarm;
656-}
D src/cli.zig
+0, -75
 1@@ -1,75 +0,0 @@
 2-const std = @import("std");
 3-const clap = @import("clap");
 4-const posix = std.posix;
 5-
 6-pub const version = "0.1.0";
 7-
 8-const SubCommands = enum {
 9-    help,
10-    daemon,
11-    list,
12-    attach,
13-    detach,
14-    kill,
15-};
16-
17-const main_parsers = .{
18-    .command = clap.parsers.enumeration(SubCommands),
19-};
20-
21-// The parameters for `main`. Parameters for the subcommands are specified further down.
22-const main_params = clap.parseParamsComptime(
23-    \\-h, --help            Display this help message and exit
24-    \\-v, --version         Display version information and exit
25-    \\<command>
26-    \\
27-);
28-
29-// To pass around arguments returned by clap, `clap.Result` and `clap.ResultEx` can be used to
30-// get the return type of `clap.parse` and `clap.parseEx`.
31-const MainArgs = clap.ResultEx(clap.Help, &main_params, main_parsers);
32-
33-pub fn help() !void {
34-    const help_text =
35-        \\Usage: zmx <command>
36-        \\
37-        \\Commands:
38-        \\  help       Show this help message
39-        \\  daemon     Start the zmx daemon
40-        \\  attach     Attach to a session
41-        \\  detach     Detach from a session
42-        \\  kill       Kill a session
43-        \\  list       List all sessions
44-        \\
45-        \\Options:
46-        \\
47-    ;
48-    _ = try posix.write(posix.STDOUT_FILENO, help_text);
49-
50-    var buf: [1024]u8 = undefined;
51-    var stdout_file = std.fs.File{ .handle = posix.STDOUT_FILENO };
52-    var writer = stdout_file.writer(&buf);
53-    try clap.help(&writer.interface, clap.Help, &main_params, .{});
54-    try writer.interface.flush();
55-}
56-
57-pub fn parse(gpa: std.mem.Allocator, iter: *std.process.ArgIterator) !MainArgs {
58-    _ = iter.next();
59-
60-    var diag = clap.Diagnostic{};
61-    const res = clap.parseEx(clap.Help, &main_params, main_parsers, iter, .{
62-        .diagnostic = &diag,
63-        .allocator = gpa,
64-
65-        // Terminate the parsing of arguments after parsing the first positional (0 is passed
66-        // here because parsed positionals are, like slices and arrays, indexed starting at 0).
67-        //
68-        // This will terminate the parsing after parsing the subcommand enum and leave `iter`
69-        // not fully consumed. It can then be reused to parse the arguments for subcommands.
70-        .terminating_positional = 0,
71-    }) catch |err| {
72-        return err;
73-    };
74-
75-    return res;
76-}
D src/config.zig
+0, -55
 1@@ -1,55 +0,0 @@
 2-const std = @import("std");
 3-const toml = @import("toml");
 4-
 5-pub const Config = struct {
 6-    socket_path: []const u8 = "/tmp/zmx.sock",
 7-    socket_path_allocated: bool = false,
 8-    detach_prefix: u8 = 0x02, // Ctrl+B (like tmux)
 9-    detach_key: u8 = 'd',
10-
11-    pub fn load(allocator: std.mem.Allocator) !Config {
12-        const config_path = getConfigPath(allocator) catch |err| {
13-            if (err == error.FileNotFound) {
14-                return Config{};
15-            }
16-            return err;
17-        };
18-        defer allocator.free(config_path);
19-
20-        var parser = toml.Parser(Config).init(allocator);
21-        defer parser.deinit();
22-
23-        var result = parser.parseFile(config_path) catch |err| {
24-            if (err == error.FileNotFound) {
25-                return Config{};
26-            }
27-            return err;
28-        };
29-        defer result.deinit();
30-
31-        const config = Config{
32-            .socket_path = try allocator.dupe(u8, result.value.socket_path),
33-            .socket_path_allocated = true,
34-            .detach_prefix = result.value.detach_prefix,
35-            .detach_key = result.value.detach_key,
36-        };
37-        return config;
38-    }
39-
40-    pub fn deinit(self: *Config, allocator: std.mem.Allocator) void {
41-        if (self.socket_path_allocated) {
42-            allocator.free(self.socket_path);
43-        }
44-    }
45-};
46-
47-fn getConfigPath(allocator: std.mem.Allocator) ![]const u8 {
48-    const home = std.posix.getenv("HOME") orelse return error.FileNotFound;
49-    const xdg_config_home = std.posix.getenv("XDG_CONFIG_HOME");
50-
51-    if (xdg_config_home) |config_home| {
52-        return try std.fs.path.join(allocator, &.{ config_home, "zmx", "config.toml" });
53-    } else {
54-        return try std.fs.path.join(allocator, &.{ home, ".config", "zmx", "config.toml" });
55-    }
56-}
D src/daemon.zig
+0, -1415
   1@@ -1,1415 +0,0 @@
   2-const std = @import("std");
   3-const posix = std.posix;
   4-const xevg = @import("xev");
   5-const xev = xevg.Dynamic;
   6-const clap = @import("clap");
   7-const config_mod = @import("config.zig");
   8-const protocol = @import("protocol.zig");
   9-const builtin = @import("builtin");
  10-
  11-const ghostty = @import("ghostty-vt");
  12-const sgr = @import("sgr.zig");
  13-const terminal_snapshot = @import("terminal_snapshot.zig");
  14-
  15-const c = switch (builtin.os.tag) {
  16-    .macos => @cImport({
  17-        @cInclude("sys/ioctl.h"); // ioctl and constants
  18-        @cInclude("util.h"); // openpty()
  19-        @cInclude("stdlib.h");
  20-    }),
  21-    .freebsd => @cImport({
  22-        @cInclude("termios.h"); // ioctl and constants
  23-        @cInclude("libutil.h"); // openpty()
  24-        @cInclude("stdlib.h");
  25-    }),
  26-    else => @cImport({
  27-        @cInclude("sys/ioctl.h"); // ioctl and constants
  28-        @cInclude("pty.h");
  29-        @cInclude("stdlib.h");
  30-    }),
  31-};
  32-
  33-// Handler for processing VT sequences
  34-const VTHandler = struct {
  35-    terminal: *ghostty.Terminal,
  36-    pty_master_fd: std.posix.fd_t,
  37-
  38-    pub fn print(self: *VTHandler, cp: u21) !void {
  39-        try self.terminal.print(cp);
  40-    }
  41-
  42-    pub fn setMode(self: *VTHandler, mode: ghostty.Mode, enabled: bool) !void {
  43-        self.terminal.modes.set(mode, enabled);
  44-        std.debug.print("Mode changed: {s} = {}\n", .{ @tagName(mode), enabled });
  45-    }
  46-
  47-    // SGR attributes (colors, bold, italic, etc.)
  48-    pub fn setAttribute(self: *VTHandler, attr: ghostty.Attribute) !void {
  49-        try self.terminal.setAttribute(attr);
  50-    }
  51-
  52-    // Cursor positioning
  53-    pub fn setCursorPos(self: *VTHandler, row: usize, col: usize) !void {
  54-        self.terminal.setCursorPos(row, col);
  55-    }
  56-
  57-    pub fn setCursorRow(self: *VTHandler, row: usize) !void {
  58-        self.terminal.setCursorPos(row, self.terminal.screen.cursor.x);
  59-    }
  60-
  61-    pub fn setCursorCol(self: *VTHandler, col: usize) !void {
  62-        self.terminal.setCursorPos(self.terminal.screen.cursor.y, col);
  63-    }
  64-
  65-    // Screen/line erasing
  66-    pub fn eraseDisplay(self: *VTHandler, mode: ghostty.EraseDisplay, protected: bool) !void {
  67-        self.terminal.eraseDisplay(mode, protected);
  68-    }
  69-
  70-    pub fn eraseLine(self: *VTHandler, mode: ghostty.EraseLine, protected: bool) !void {
  71-        self.terminal.eraseLine(mode, protected);
  72-    }
  73-
  74-    // Scroll regions
  75-    pub fn setTopAndBottomMargin(self: *VTHandler, top: usize, bottom: usize) !void {
  76-        self.terminal.setTopAndBottomMargin(top, bottom);
  77-    }
  78-
  79-    // Cursor save/restore
  80-    pub fn saveCursor(self: *VTHandler) !void {
  81-        self.terminal.saveCursor();
  82-    }
  83-
  84-    pub fn restoreCursor(self: *VTHandler) !void {
  85-        try self.terminal.restoreCursor();
  86-    }
  87-
  88-    // Tab stops
  89-    pub fn tabSet(self: *VTHandler) !void {
  90-        self.terminal.tabSet();
  91-    }
  92-
  93-    pub fn tabClear(self: *VTHandler, cmd: ghostty.TabClear) !void {
  94-        self.terminal.tabClear(cmd);
  95-    }
  96-
  97-    pub fn tabReset(self: *VTHandler) !void {
  98-        self.terminal.tabReset();
  99-    }
 100-
 101-    // Cursor movement (relative)
 102-    pub fn cursorUp(self: *VTHandler, count: usize) !void {
 103-        self.terminal.cursorUp(count);
 104-    }
 105-
 106-    pub fn cursorDown(self: *VTHandler, count: usize) !void {
 107-        self.terminal.cursorDown(count);
 108-    }
 109-
 110-    pub fn cursorForward(self: *VTHandler, count: usize) !void {
 111-        self.terminal.cursorRight(count);
 112-    }
 113-
 114-    pub fn cursorBack(self: *VTHandler, count: usize) !void {
 115-        self.terminal.cursorLeft(count);
 116-    }
 117-
 118-    pub fn setCursorColRelative(self: *VTHandler, count: usize) !void {
 119-        const new_col = self.terminal.screen.cursor.x + count;
 120-        self.terminal.setCursorPos(self.terminal.screen.cursor.y, new_col);
 121-    }
 122-
 123-    pub fn setCursorRowRelative(self: *VTHandler, count: usize) !void {
 124-        const new_row = self.terminal.screen.cursor.y + count;
 125-        self.terminal.setCursorPos(new_row, self.terminal.screen.cursor.x);
 126-    }
 127-
 128-    // Special movement (ESC sequences)
 129-    pub fn index(self: *VTHandler) !void {
 130-        try self.terminal.index();
 131-    }
 132-
 133-    pub fn reverseIndex(self: *VTHandler) !void {
 134-        self.terminal.reverseIndex();
 135-    }
 136-
 137-    pub fn nextLine(self: *VTHandler) !void {
 138-        try self.terminal.linefeed();
 139-        self.terminal.carriageReturn();
 140-    }
 141-
 142-    pub fn prevLine(self: *VTHandler) !void {
 143-        self.terminal.reverseIndex();
 144-        self.terminal.carriageReturn();
 145-    }
 146-
 147-    // Line/char editing
 148-    pub fn insertLines(self: *VTHandler, count: usize) !void {
 149-        self.terminal.insertLines(count);
 150-    }
 151-
 152-    pub fn deleteLines(self: *VTHandler, count: usize) !void {
 153-        self.terminal.deleteLines(count);
 154-    }
 155-
 156-    pub fn deleteChars(self: *VTHandler, count: usize) !void {
 157-        self.terminal.deleteChars(count);
 158-    }
 159-
 160-    pub fn eraseChars(self: *VTHandler, count: usize) !void {
 161-        self.terminal.eraseChars(count);
 162-    }
 163-
 164-    pub fn scrollUp(self: *VTHandler, count: usize) !void {
 165-        self.terminal.scrollUp(count);
 166-    }
 167-
 168-    pub fn scrollDown(self: *VTHandler, count: usize) !void {
 169-        self.terminal.scrollDown(count);
 170-    }
 171-
 172-    // Basic control characters
 173-    pub fn carriageReturn(self: *VTHandler) !void {
 174-        self.terminal.carriageReturn();
 175-    }
 176-
 177-    pub fn linefeed(self: *VTHandler) !void {
 178-        try self.terminal.linefeed();
 179-    }
 180-
 181-    pub fn backspace(self: *VTHandler) !void {
 182-        self.terminal.backspace();
 183-    }
 184-
 185-    pub fn horizontalTab(self: *VTHandler, count: usize) !void {
 186-        _ = count; // stream always passes 1
 187-        try self.terminal.horizontalTab();
 188-    }
 189-
 190-    pub fn horizontalTabBack(self: *VTHandler, count: usize) !void {
 191-        _ = count; // stream always passes 1
 192-        try self.terminal.horizontalTabBack();
 193-    }
 194-
 195-    pub fn bell(self: *VTHandler) !void {
 196-        _ = self;
 197-        // Ignore bell in daemon context - no UI to notify
 198-    }
 199-
 200-    pub fn deviceAttributes(
 201-        self: *VTHandler,
 202-        req: ghostty.DeviceAttributeReq,
 203-        da_params: []const u16,
 204-    ) !void {
 205-        _ = da_params;
 206-
 207-        const response = getDeviceAttributeResponse(req) orelse return;
 208-
 209-        _ = posix.write(self.pty_master_fd, response) catch |err| {
 210-            std.debug.print("Error writing DA response to PTY: {s}\n", .{@errorName(err)});
 211-        };
 212-
 213-        std.debug.print("Responded to DA query ({s}) with {s}\n", .{ @tagName(req), response });
 214-    }
 215-};
 216-
 217-// Context for PTY read callbacks
 218-const PtyReadContext = struct {
 219-    session: *Session,
 220-    server_ctx: *ServerContext,
 221-};
 222-
 223-// Context for PTY write callbacks
 224-const PtyWriteContext = struct {
 225-    allocator: std.mem.Allocator,
 226-    message: []u8,
 227-};
 228-
 229-// A PTY session that manages a persistent shell process
 230-// Stores the PTY master file descriptor, shell process PID, scrollback buffer,
 231-// and a read buffer for async I/O with libxev
 232-const Session = struct {
 233-    name: []const u8,
 234-    pty_master_fd: std.posix.fd_t,
 235-    child_pid: std.posix.pid_t,
 236-    allocator: std.mem.Allocator,
 237-    pty_read_buffer: [128 * 1024]u8, // 128KB for high-throughput PTY output
 238-    created_at: i64,
 239-
 240-    // Terminal emulator state for session restore
 241-    vt: ghostty.Terminal,
 242-    vt_stream: ghostty.Stream(*VTHandler),
 243-    vt_handler: VTHandler,
 244-    attached_clients: std.AutoHashMap(std.posix.fd_t, void),
 245-    pty_reading: bool = false, // Track if PTY reads are active
 246-
 247-    fn deinit(self: *Session) void {
 248-        self.allocator.free(self.name);
 249-        self.vt.deinit(self.allocator);
 250-        self.vt_stream.deinit();
 251-        self.attached_clients.deinit();
 252-    }
 253-};
 254-
 255-// A connected client that communicates with the daemon over a Unix socket
 256-// Tracks the client's file descriptor, async stream for I/O, read buffer,
 257-// and which session (if any) the client is currently attached to
 258-const Client = struct {
 259-    fd: std.posix.fd_t,
 260-    stream: xev.Stream,
 261-    read_buffer: [128 * 1024]u8, // 128KB for high-throughput socket reads
 262-    allocator: std.mem.Allocator,
 263-    attached_session: ?[]const u8,
 264-    server_ctx: *ServerContext,
 265-    message_buffer: std.ArrayList(u8),
 266-    /// Gate for preventing live PTY output during snapshot send
 267-    /// When true, this client will not receive live PTY frames
 268-    muted: bool = false,
 269-};
 270-
 271-// Main daemon server state that manages the event loop, Unix socket server,
 272-// all active client connections, and all persistent PTY sessions
 273-const ServerContext = struct {
 274-    loop: *xev.Loop,
 275-    server_fd: std.posix.fd_t,
 276-    accept_completion: xev.Completion,
 277-    clients: std.AutoHashMap(std.posix.fd_t, *Client),
 278-    sessions: std.StringHashMap(*Session),
 279-    allocator: std.mem.Allocator,
 280-};
 281-
 282-const params = clap.parseParamsComptime(
 283-    \\-s, --socket-path <str>  Path to the Unix socket file
 284-    \\
 285-);
 286-
 287-pub fn main(config: config_mod.Config, iter: *std.process.ArgIterator) !void {
 288-    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
 289-    defer _ = gpa.deinit();
 290-    const allocator = gpa.allocator();
 291-
 292-    var diag = clap.Diagnostic{};
 293-    var res = clap.parseEx(clap.Help, &params, clap.parsers.default, iter, .{
 294-        .diagnostic = &diag,
 295-        .allocator = allocator,
 296-    }) catch |err| {
 297-        var buf: [1024]u8 = undefined;
 298-        var stderr_file = std.fs.File{ .handle = posix.STDERR_FILENO };
 299-        var writer = stderr_file.writer(&buf);
 300-        diag.report(&writer.interface, err) catch {};
 301-        writer.interface.flush() catch {};
 302-        return err;
 303-    };
 304-    defer res.deinit();
 305-
 306-    const socket_path = res.args.@"socket-path" orelse config.socket_path;
 307-
 308-    var thread_pool = xevg.ThreadPool.init(.{});
 309-    defer thread_pool.deinit();
 310-    defer thread_pool.shutdown();
 311-
 312-    var loop = try xev.Loop.init(.{ .thread_pool = &thread_pool });
 313-    defer loop.deinit();
 314-
 315-    std.debug.print("zmx daemon starting...\n", .{});
 316-    std.debug.print("Configuration:\n", .{});
 317-    std.debug.print("  socket_path: {s}\n", .{socket_path});
 318-
 319-    _ = std.fs.cwd().deleteFile(socket_path) catch {};
 320-
 321-    // AF.UNIX: Unix domain socket for local IPC with client processes
 322-    // SOCK.STREAM: Reliable, bidirectional communication for JSON protocol messages
 323-    // SOCK.NONBLOCK: Prevents blocking to work with libxev's async event loop
 324-    const server_fd = try posix.socket(posix.AF.UNIX, posix.SOCK.STREAM | posix.SOCK.NONBLOCK, 0);
 325-    defer {
 326-        posix.close(server_fd);
 327-        std.fs.cwd().deleteFile(socket_path) catch {};
 328-    }
 329-
 330-    var unix_addr = std.net.Address.initUnix(socket_path) catch |err| {
 331-        std.debug.print("initUnix failed: {s}\n", .{@errorName(err)});
 332-        return err;
 333-    };
 334-    try posix.bind(server_fd, &unix_addr.any, unix_addr.getOsSockLen());
 335-    try posix.listen(server_fd, 128);
 336-
 337-    var server_stream = xev.Stream.initFd(server_fd);
 338-    var server_context = ServerContext{
 339-        .loop = &loop,
 340-        .server_fd = server_fd,
 341-        .accept_completion = .{},
 342-        .clients = std.AutoHashMap(std.posix.fd_t, *Client).init(allocator),
 343-        .sessions = std.StringHashMap(*Session).init(allocator),
 344-        .allocator = allocator,
 345-    };
 346-    defer server_context.clients.deinit();
 347-    defer {
 348-        var it = server_context.sessions.valueIterator();
 349-        while (it.next()) |session| {
 350-            session.*.deinit();
 351-            allocator.destroy(session.*);
 352-        }
 353-        server_context.sessions.deinit();
 354-    }
 355-
 356-    server_stream.poll(
 357-        &loop,
 358-        &server_context.accept_completion,
 359-        .read,
 360-        ServerContext,
 361-        &server_context,
 362-        acceptCallback,
 363-    );
 364-
 365-    try loop.run(.until_done);
 366-}
 367-
 368-fn acceptCallback(
 369-    ctx_opt: ?*ServerContext,
 370-    _: *xev.Loop,
 371-    _: *xev.Completion,
 372-    _: xev.Stream,
 373-    poll_result: xev.PollError!xev.PollEvent,
 374-) xev.CallbackAction {
 375-    const ctx = ctx_opt.?;
 376-    if (poll_result) |_| {
 377-        while (true) {
 378-            // SOCK.CLOEXEC: Close socket on exec to prevent child PTY processes from inheriting client connections
 379-            // SOCK.NONBLOCK: Make client socket non-blocking for async I/O
 380-            const client_fd = posix.accept(ctx.server_fd, null, null, posix.SOCK.CLOEXEC | posix.SOCK.NONBLOCK) catch |err| {
 381-                if (err == error.WouldBlock) {
 382-                    // No more pending connections
 383-                    break;
 384-                }
 385-                std.debug.print("accept failed: {s}\n", .{@errorName(err)});
 386-                return .disarm; // Stop polling on error
 387-            };
 388-
 389-            const client = ctx.allocator.create(Client) catch @panic("failed to create client");
 390-            const message_buffer = std.ArrayList(u8).initCapacity(ctx.allocator, 512) catch @panic("failed to create message buffer");
 391-            client.* = .{
 392-                .fd = client_fd,
 393-                .stream = xev.Stream.initFd(client_fd),
 394-                .read_buffer = undefined,
 395-                .allocator = ctx.allocator,
 396-                .attached_session = null,
 397-                .server_ctx = ctx,
 398-                .message_buffer = message_buffer,
 399-            };
 400-
 401-            ctx.clients.put(client_fd, client) catch @panic("failed to add client");
 402-            std.debug.print("new client connected fd={d}\n", .{client_fd});
 403-
 404-            const read_completion = ctx.allocator.create(xev.Completion) catch @panic("failed to create completion");
 405-            client.stream.read(ctx.loop, read_completion, .{ .slice = &client.read_buffer }, Client, client, readCallback);
 406-        }
 407-    } else |err| {
 408-        std.debug.print("poll failed: {s}\n", .{@errorName(err)});
 409-    }
 410-
 411-    // Re-arm the poll
 412-    return .rearm;
 413-}
 414-
 415-fn readCallback(
 416-    client_opt: ?*Client,
 417-    loop: *xev.Loop,
 418-    completion: *xev.Completion,
 419-    _: xev.Stream,
 420-    read_buffer: xev.ReadBuffer,
 421-    read_result: xev.ReadError!usize,
 422-) xev.CallbackAction {
 423-    _ = loop;
 424-    const client = client_opt.?;
 425-    if (read_result) |len| {
 426-        if (len == 0) {
 427-            return closeClient(client, completion);
 428-        }
 429-        const data = read_buffer.slice[0..len];
 430-
 431-        // Append to message buffer
 432-        client.message_buffer.appendSlice(client.allocator, data) catch {
 433-            return closeClient(client, completion);
 434-        };
 435-
 436-        // Check for binary frames first
 437-        while (client.message_buffer.items.len >= @sizeOf(protocol.FrameHeader)) {
 438-            const header: *const protocol.FrameHeader = @ptrCast(@alignCast(client.message_buffer.items.ptr));
 439-
 440-            if (header.frame_type == @intFromEnum(protocol.FrameType.pty_binary)) {
 441-                const expected_total = @sizeOf(protocol.FrameHeader) + header.length;
 442-                if (client.message_buffer.items.len >= expected_total) {
 443-                    const payload = client.message_buffer.items[@sizeOf(protocol.FrameHeader)..expected_total];
 444-                    handleBinaryFrame(client, payload) catch |err| {
 445-                        std.debug.print("handleBinaryFrame failed: {s}\n", .{@errorName(err)});
 446-                        return closeClient(client, completion);
 447-                    };
 448-
 449-                    // Remove processed frame from buffer
 450-                    const remaining = client.message_buffer.items[expected_total..];
 451-                    const remaining_copy = client.allocator.dupe(u8, remaining) catch {
 452-                        return closeClient(client, completion);
 453-                    };
 454-                    client.message_buffer.clearRetainingCapacity();
 455-                    client.message_buffer.appendSlice(client.allocator, remaining_copy) catch {
 456-                        client.allocator.free(remaining_copy);
 457-                        return closeClient(client, completion);
 458-                    };
 459-                    client.allocator.free(remaining_copy);
 460-                } else {
 461-                    // Incomplete frame, wait for more data
 462-                    break;
 463-                }
 464-            } else {
 465-                // Not a binary frame, try JSON
 466-                break;
 467-            }
 468-        }
 469-
 470-        // Process complete JSON messages (delimited by newline)
 471-        while (std.mem.indexOf(u8, client.message_buffer.items, "\n")) |newline_pos| {
 472-            const message = client.message_buffer.items[0..newline_pos];
 473-            handleMessage(client, message) catch |err| {
 474-                std.debug.print("handleMessage failed: {s}\n", .{@errorName(err)});
 475-                return closeClient(client, completion);
 476-            };
 477-
 478-            // Remove processed message from buffer (including newline)
 479-            const remaining = client.message_buffer.items[newline_pos + 1 ..];
 480-            const remaining_copy = client.allocator.dupe(u8, remaining) catch {
 481-                return closeClient(client, completion);
 482-            };
 483-            client.message_buffer.clearRetainingCapacity();
 484-            client.message_buffer.appendSlice(client.allocator, remaining_copy) catch {
 485-                client.allocator.free(remaining_copy);
 486-                return closeClient(client, completion);
 487-            };
 488-            client.allocator.free(remaining_copy);
 489-        }
 490-
 491-        return .rearm;
 492-    } else |err| {
 493-        if (err == error.EndOfStream or err == error.EOF) {
 494-            return closeClient(client, completion);
 495-        }
 496-        std.debug.print("read failed: {s}\n", .{@errorName(err)});
 497-        return closeClient(client, completion);
 498-    }
 499-}
 500-
 501-fn handleBinaryFrame(client: *Client, payload: []const u8) !void {
 502-    try handlePtyInput(client, payload);
 503-}
 504-
 505-fn handleMessage(client: *Client, data: []const u8) !void {
 506-    std.debug.print("Received message from client fd={d}: {s}\n", .{ client.fd, data });
 507-
 508-    // Parse message type first for dispatching
 509-    const type_parsed = try protocol.parseMessageType(client.allocator, data);
 510-    defer type_parsed.deinit();
 511-
 512-    const msg_type = protocol.MessageType.fromString(type_parsed.value.type) orelse {
 513-        std.debug.print("Unknown message type: {s}\n", .{type_parsed.value.type});
 514-        return;
 515-    };
 516-
 517-    switch (msg_type) {
 518-        .attach_session_request => {
 519-            const parsed = try protocol.parseMessage(protocol.AttachSessionRequest, client.allocator, data);
 520-            defer parsed.deinit();
 521-            std.debug.print("Handling attach request for session: {s} ({}x{}) cwd={s}\n", .{ parsed.value.payload.session_name, parsed.value.payload.cols, parsed.value.payload.rows, parsed.value.payload.cwd });
 522-            try handleAttachSession(client.server_ctx, client, parsed.value.payload.session_name, parsed.value.payload.rows, parsed.value.payload.cols, parsed.value.payload.cwd);
 523-        },
 524-        .detach_session_request => {
 525-            const parsed = try protocol.parseMessage(protocol.DetachSessionRequest, client.allocator, data);
 526-            defer parsed.deinit();
 527-            std.debug.print("Handling detach request for session: {s}, target_fd: {any}\n", .{ parsed.value.payload.session_name, parsed.value.payload.client_fd });
 528-            try handleDetachSession(client, parsed.value.payload.session_name, parsed.value.payload.client_fd);
 529-        },
 530-        .kill_session_request => {
 531-            const parsed = try protocol.parseMessage(protocol.KillSessionRequest, client.allocator, data);
 532-            defer parsed.deinit();
 533-            std.debug.print("Handling kill request for session: {s}\n", .{parsed.value.payload.session_name});
 534-            try handleKillSession(client, parsed.value.payload.session_name);
 535-        },
 536-        .list_sessions_request => {
 537-            std.debug.print("Handling list sessions request\n", .{});
 538-            try handleListSessions(client.server_ctx, client);
 539-        },
 540-        .window_resize => {
 541-            const parsed = try protocol.parseMessage(protocol.WindowResize, client.allocator, data);
 542-            defer parsed.deinit();
 543-            try handleWindowResize(client, parsed.value.payload.rows, parsed.value.payload.cols);
 544-        },
 545-        else => {
 546-            std.debug.print("Unexpected message type: {s}\n", .{type_parsed.value.type});
 547-        },
 548-    }
 549-}
 550-
 551-fn handleDetachSession(client: *Client, session_name: []const u8, target_client_fd: ?i64) !void {
 552-    const ctx = client.server_ctx;
 553-
 554-    // Check if the session exists
 555-    const session = ctx.sessions.get(session_name) orelse {
 556-        const error_msg = try std.fmt.allocPrint(client.allocator, "Session not found: {s}", .{session_name});
 557-        defer client.allocator.free(error_msg);
 558-        try protocol.writeJson(client.allocator, client.fd, .detach_session_response, protocol.DetachSessionResponse{
 559-            .status = "error",
 560-            .error_message = error_msg,
 561-        });
 562-        return;
 563-    };
 564-
 565-    // If target_client_fd is provided, find and detach that specific client
 566-    if (target_client_fd) |target_fd| {
 567-        const target_fd_cast: std.posix.fd_t = @intCast(target_fd);
 568-        if (ctx.clients.get(target_fd_cast)) |target_client| {
 569-            if (target_client.attached_session) |attached| {
 570-                if (std.mem.eql(u8, attached, session_name)) {
 571-                    target_client.attached_session = null;
 572-                    _ = session.attached_clients.remove(target_fd_cast);
 573-
 574-                    // Send notification to the target client
 575-                    protocol.writeJson(target_client.allocator, target_client.fd, .detach_notification, protocol.DetachNotification{
 576-                        .session_name = session_name,
 577-                    }) catch |err| {
 578-                        std.debug.print("Error notifying client fd={d}: {s}\n", .{ target_client.fd, @errorName(err) });
 579-                    };
 580-
 581-                    std.debug.print("Detached client fd={d} from session: {s}\n", .{ target_fd_cast, session_name });
 582-
 583-                    // Send response to the requesting client
 584-                    try protocol.writeJson(client.allocator, client.fd, .detach_session_response, protocol.DetachSessionResponse{
 585-                        .status = "ok",
 586-                    });
 587-                    return;
 588-                } else {
 589-                    const error_msg = try std.fmt.allocPrint(client.allocator, "Target client not attached to session: {s}", .{session_name});
 590-                    defer client.allocator.free(error_msg);
 591-                    try protocol.writeJson(client.allocator, client.fd, .detach_session_response, protocol.DetachSessionResponse{
 592-                        .status = "error",
 593-                        .error_message = error_msg,
 594-                    });
 595-                    return;
 596-                }
 597-            }
 598-        }
 599-
 600-        const error_msg = try std.fmt.allocPrint(client.allocator, "Target client fd={d} not found", .{target_fd});
 601-        defer client.allocator.free(error_msg);
 602-        try protocol.writeJson(client.allocator, client.fd, .detach_session_response, protocol.DetachSessionResponse{
 603-            .status = "error",
 604-            .error_message = error_msg,
 605-        });
 606-        return;
 607-    }
 608-
 609-    // No target_client_fd provided, check if requesting client is attached
 610-    if (client.attached_session) |attached| {
 611-        if (!std.mem.eql(u8, attached, session_name)) {
 612-            const error_msg = try std.fmt.allocPrint(client.allocator, "Not attached to session: {s}", .{session_name});
 613-            defer client.allocator.free(error_msg);
 614-            try protocol.writeJson(client.allocator, client.fd, .detach_session_response, protocol.DetachSessionResponse{
 615-                .status = "error",
 616-                .error_message = error_msg,
 617-            });
 618-            return;
 619-        }
 620-
 621-        client.attached_session = null;
 622-        _ = session.attached_clients.remove(client.fd);
 623-
 624-        std.debug.print("Sending detach response to client fd={d}\n", .{client.fd});
 625-        try protocol.writeJson(client.allocator, client.fd, .detach_session_response, protocol.DetachSessionResponse{
 626-            .status = "ok",
 627-        });
 628-    } else {
 629-        try protocol.writeJson(client.allocator, client.fd, .detach_session_response, protocol.DetachSessionResponse{
 630-            .status = "error",
 631-            .error_message = "Not attached to any session",
 632-        });
 633-    }
 634-}
 635-
 636-fn handleKillSession(client: *Client, session_name: []const u8) !void {
 637-    const ctx = client.server_ctx;
 638-
 639-    // Check if the session exists
 640-    const session = ctx.sessions.get(session_name) orelse {
 641-        const error_msg = try std.fmt.allocPrint(client.allocator, "Session not found: {s}", .{session_name});
 642-        defer client.allocator.free(error_msg);
 643-        try protocol.writeJson(client.allocator, client.fd, .kill_session_response, protocol.KillSessionResponse{
 644-            .status = "error",
 645-            .error_message = error_msg,
 646-        });
 647-        return;
 648-    };
 649-
 650-    // Notify all attached clients to exit
 651-    var client_it = ctx.clients.iterator();
 652-    while (client_it.next()) |entry| {
 653-        const attached_client = entry.value_ptr.*;
 654-        if (attached_client.attached_session) |attached| {
 655-            if (std.mem.eql(u8, attached, session_name)) {
 656-                attached_client.attached_session = null;
 657-
 658-                // Send kill notification to client
 659-                protocol.writeJson(attached_client.allocator, attached_client.fd, .kill_notification, protocol.KillNotification{
 660-                    .session_name = session_name,
 661-                }) catch |err| {
 662-                    std.debug.print("Error notifying client fd={d}: {s}\n", .{ attached_client.fd, @errorName(err) });
 663-                };
 664-            }
 665-        }
 666-    }
 667-
 668-    // Kill the PTY process
 669-    const kill_result = posix.kill(session.child_pid, posix.SIG.TERM);
 670-    if (kill_result) |_| {
 671-        std.debug.print("Sent SIGTERM to PID {d}\n", .{session.child_pid});
 672-    } else |err| {
 673-        std.debug.print("Error killing PID {d}: {s}\n", .{ session.child_pid, @errorName(err) });
 674-    }
 675-
 676-    // Close PTY master fd
 677-    posix.close(session.pty_master_fd);
 678-
 679-    // Remove from sessions map BEFORE cleaning up (session.deinit frees session.name)
 680-    _ = ctx.sessions.remove(session_name);
 681-
 682-    // Clean up session
 683-    session.deinit();
 684-    ctx.allocator.destroy(session);
 685-
 686-    // Send response to requesting client
 687-    std.debug.print("Killed session: {s}\n", .{session_name});
 688-    try protocol.writeJson(client.allocator, client.fd, .kill_session_response, protocol.KillSessionResponse{
 689-        .status = "ok",
 690-    });
 691-}
 692-
 693-fn handleListSessions(ctx: *ServerContext, client: *Client) !void {
 694-    // TODO: Refactor to use protocol.writeJson() once we have a better approach for dynamic arrays
 695-    var response = try std.ArrayList(u8).initCapacity(client.allocator, 1024);
 696-    defer response.deinit(client.allocator);
 697-
 698-    try response.appendSlice(client.allocator, "{\"type\":\"list_sessions_response\",\"payload\":{\"status\":\"ok\",\"sessions\":[");
 699-
 700-    var it = ctx.sessions.iterator();
 701-    var first = true;
 702-    while (it.next()) |entry| {
 703-        const session = entry.value_ptr.*;
 704-
 705-        if (!first) {
 706-            try response.append(client.allocator, ',');
 707-        }
 708-        first = false;
 709-
 710-        var clients_count: i64 = 0;
 711-        var client_it = ctx.clients.iterator();
 712-        while (client_it.next()) |client_entry| {
 713-            const attached_client = client_entry.value_ptr.*;
 714-            if (attached_client.attached_session) |attached| {
 715-                if (std.mem.eql(u8, attached, session.name)) {
 716-                    clients_count += 1;
 717-                }
 718-            }
 719-        }
 720-
 721-        const status = if (clients_count > 0) "attached" else "detached";
 722-
 723-        const epoch_seconds = std.time.epoch.EpochSeconds{ .secs = @intCast(session.created_at) };
 724-        const day_seconds = epoch_seconds.getDaySeconds();
 725-        const year_day = epoch_seconds.getEpochDay().calculateYearDay();
 726-        const month_day = year_day.calculateMonthDay();
 727-
 728-        const session_json = try std.fmt.allocPrint(
 729-            client.allocator,
 730-            "{{\"name\":\"{s}\",\"status\":\"{s}\",\"clients\":{d},\"created_at\":\"{d:0>4}-{d:0>2}-{d:0>2}T{d:0>2}:{d:0>2}:{d:0>2}Z\"}}",
 731-            .{ session.name, status, clients_count, year_day.year, month_day.month.numeric(), month_day.day_index + 1, day_seconds.getHoursIntoDay(), day_seconds.getMinutesIntoHour(), day_seconds.getSecondsIntoMinute() },
 732-        );
 733-        defer client.allocator.free(session_json);
 734-
 735-        try response.appendSlice(client.allocator, session_json);
 736-    }
 737-
 738-    try response.appendSlice(client.allocator, "]}}\n");
 739-
 740-    std.debug.print("Sending list response to client fd={d}: {s}\n", .{ client.fd, response.items });
 741-
 742-    const written = posix.write(client.fd, response.items) catch |err| {
 743-        std.debug.print("Error writing to fd={d}: {s}\n", .{ client.fd, @errorName(err) });
 744-        return err;
 745-    };
 746-    _ = written;
 747-}
 748-
 749-fn handleAttachSession(ctx: *ServerContext, client: *Client, session_name: []const u8, rows: u16, cols: u16, cwd: []const u8) !void {
 750-    // Check if session already exists
 751-    const is_reattach = ctx.sessions.contains(session_name);
 752-    const session = if (is_reattach) blk: {
 753-        std.debug.print("Attaching to existing session: {s}\n", .{session_name});
 754-        break :blk ctx.sessions.get(session_name).?;
 755-    } else blk: {
 756-        // Create new session with forkpty
 757-        std.debug.print("Creating new session: {s}\n", .{session_name});
 758-        const new_session = try createSession(ctx.allocator, session_name, cwd);
 759-        try ctx.sessions.put(new_session.name, new_session);
 760-        break :blk new_session;
 761-    };
 762-
 763-    // Mark client as attached
 764-    client.attached_session = session.name;
 765-
 766-    // Mute client before adding to attached_clients to prevent PTY interleaving during snapshot
 767-    if (is_reattach) {
 768-        client.muted = true;
 769-    }
 770-
 771-    try session.attached_clients.put(client.fd, {});
 772-
 773-    // Start reading from PTY if not already started (first client)
 774-    const is_first_client = session.attached_clients.count() == 1;
 775-    std.debug.print("is_reattach={}, is_first_client={}, attached_clients.count={}\n", .{ is_reattach, is_first_client, session.attached_clients.count() });
 776-
 777-    // For reattaching clients, resize VT BEFORE snapshot so snapshot matches client size
 778-    // But defer TIOCSWINSZ until after snapshot to prevent SIGWINCH during send
 779-    if (is_reattach) {
 780-        if (rows > 0 and cols > 0) {
 781-            // Only resize VT if geometry changed
 782-            if (session.vt.cols != cols or session.vt.rows != rows) {
 783-                try session.vt.resize(session.allocator, cols, rows);
 784-                std.debug.print("Resized VT to {d}x{d} before snapshot\n", .{ cols, rows });
 785-            }
 786-        }
 787-
 788-        // Render snapshot at correct client size
 789-        const buffer_slice = try terminal_snapshot.render(&session.vt, client.allocator);
 790-        defer client.allocator.free(buffer_slice);
 791-
 792-        try protocol.writeBinaryFrame(client.fd, .pty_binary, buffer_slice);
 793-        std.debug.print("Sent scrollback buffer to client fd={d} ({d} bytes)\n", .{ client.fd, buffer_slice.len });
 794-
 795-        // Unmute client before TIOCSWINSZ so client can receive the redraw
 796-        client.muted = false;
 797-
 798-        // Now send TIOCSWINSZ to trigger app (vim) redraw - client will receive it
 799-        try applyWinsize(session, rows, cols);
 800-    } else if (!is_reattach and rows > 0 and cols > 0) {
 801-        // New session: just resize normally
 802-        try session.vt.resize(session.allocator, cols, rows);
 803-        try applyWinsize(session, rows, cols);
 804-    }
 805-
 806-    // Only start PTY reading if not already started
 807-    if (!session.pty_reading) {
 808-        session.pty_reading = true;
 809-        std.debug.print("Starting PTY reads for session {s}\n", .{session.name});
 810-        // Start PTY reads AFTER snapshot is sent (readFromPty sends attach response)
 811-        try readFromPty(ctx, client, session);
 812-
 813-        // For first attach to new session, clear the client's terminal
 814-        if (!is_reattach) {
 815-            try protocol.writeBinaryFrame(client.fd, .pty_binary, "\x1b[2J\x1b[H");
 816-        }
 817-    } else {
 818-        // PTY already reading - just send attach response
 819-        std.debug.print("PTY already reading for session {s}, sending attach response to client fd={d}\n", .{ session.name, client.fd });
 820-        const response = protocol.AttachSessionResponse{
 821-            .status = "ok",
 822-            .client_fd = client.fd,
 823-        };
 824-        std.debug.print("Response payload: status={s}, client_fd={?d}\n", .{ response.status, response.client_fd });
 825-        try protocol.writeJson(ctx.allocator, client.fd, .attach_session_response, response);
 826-        std.debug.print("Attach response sent successfully\n", .{});
 827-    }
 828-}
 829-
 830-/// Returns the device attribute response zmx should send (matching tmux/screen)
 831-/// Returns null for tertiary DA (ignored)
 832-fn getDeviceAttributeResponse(req: ghostty.DeviceAttributeReq) ?[]const u8 {
 833-    return switch (req) {
 834-        .primary => "\x1b[?1;2c", // VT100 with AVO (matches screen/tmux)
 835-        .secondary => "\x1b[>0;0;0c", // Conservative secondary DA
 836-        .tertiary => null, // Ignore tertiary DA
 837-    };
 838-}
 839-
 840-/// Filter out terminal response sequences that the client's terminal sends
 841-/// These should not be written to the PTY since the daemon handles queries itself
 842-///
 843-/// Architecture: When apps send queries (e.g., ESC[c), the client's terminal
 844-/// auto-responds. We must drop those responses because:
 845-/// 1. VTHandler already responds with correct zmx terminal capabilities
 846-/// 2. Client responses describe the client's terminal, not zmx's virtual terminal
 847-/// 3. Without filtering, responses get echoed by PTY and appear as literal text
 848-///
 849-/// This matches tmux/screen behavior: intercept queries, respond ourselves, drop client responses
 850-fn filterTerminalResponses(input: []const u8, output_buf: []u8) usize {
 851-    var out_idx: usize = 0;
 852-    var i: usize = 0;
 853-
 854-    while (i < input.len) {
 855-        // Look for ESC sequences
 856-        if (input[i] == 0x1b and i + 1 < input.len and input[i + 1] == '[') {
 857-            // CSI sequence - parse it
 858-            const seq_start = i;
 859-            i += 2; // Skip ESC [
 860-
 861-            // Collect parameter bytes (0x30-0x3F)
 862-            const param_start = i;
 863-            while (i < input.len and input[i] >= 0x30 and input[i] <= 0x3F) : (i += 1) {}
 864-            const seq_params = input[param_start..i];
 865-
 866-            // Collect intermediate bytes (0x20-0x2F)
 867-            while (i < input.len and input[i] >= 0x20 and input[i] <= 0x2F) : (i += 1) {}
 868-
 869-            // Final byte (0x40-0x7E)
 870-            if (i < input.len and input[i] >= 0x40 and input[i] <= 0x7E) {
 871-                const final = input[i];
 872-                const should_drop = blk: {
 873-                    // Device Attributes responses: ESC[?...c, ESC[>...c, ESC[=...c
 874-                    // These match the responses defined in getDeviceAttributeResponse()
 875-                    if (final == 'c' and seq_params.len > 0) {
 876-                        if (seq_params[0] == '?' or seq_params[0] == '>' or seq_params[0] == '=') {
 877-                            std.debug.print("Filtered DA response: ESC[{s}c\n", .{seq_params});
 878-                            break :blk true;
 879-                        }
 880-                    }
 881-                    // Cursor Position Report: ESC[<row>;<col>R
 882-                    if (final == 'R' and seq_params.len > 0) {
 883-                        // Simple heuristic: if params look like digits/semicolon, it's likely CPR
 884-                        var is_cpr = true;
 885-                        for (seq_params) |byte| {
 886-                            if (byte != ';' and (byte < '0' or byte > '9')) {
 887-                                is_cpr = false;
 888-                                break;
 889-                            }
 890-                        }
 891-                        if (is_cpr) {
 892-                            std.debug.print("Filtered CPR: ESC[{s}R\n", .{seq_params});
 893-                            break :blk true;
 894-                        }
 895-                    }
 896-                    // DSR responses: ESC[0n, ESC[3n, ESC[?...n
 897-                    if (final == 'n' and seq_params.len > 0) {
 898-                        if ((seq_params.len == 1 and (seq_params[0] == '0' or seq_params[0] == '3')) or
 899-                            seq_params[0] == '?')
 900-                        {
 901-                            std.debug.print("Filtered DSR response: ESC[{s}n\n", .{seq_params});
 902-                            break :blk true;
 903-                        }
 904-                    }
 905-                    break :blk false;
 906-                };
 907-
 908-                if (should_drop) {
 909-                    // Skip this entire sequence, continue to next character
 910-                    i += 1;
 911-                } else {
 912-                    // Copy the entire sequence to output
 913-                    const seq_len = (i + 1) - seq_start;
 914-                    @memcpy(output_buf[out_idx .. out_idx + seq_len], input[seq_start .. i + 1]);
 915-                    out_idx += seq_len;
 916-                    i += 1;
 917-                }
 918-            } else {
 919-                // Incomplete sequence, copy what we have
 920-                const seq_len = i - seq_start;
 921-                @memcpy(output_buf[out_idx .. out_idx + seq_len], input[seq_start..i]);
 922-                out_idx += seq_len;
 923-                // i is already positioned at the next byte
 924-            }
 925-        } else {
 926-            // Not a CSI sequence, copy the byte
 927-            output_buf[out_idx] = input[i];
 928-            out_idx += 1;
 929-            i += 1;
 930-        }
 931-    }
 932-
 933-    return out_idx;
 934-}
 935-
 936-fn handlePtyInput(client: *Client, text: []const u8) !void {
 937-    const session_name = client.attached_session orelse {
 938-        std.debug.print("Client fd={d} not attached to any session\n", .{client.fd});
 939-        return error.NotAttached;
 940-    };
 941-
 942-    const session = client.server_ctx.sessions.get(session_name) orelse {
 943-        std.debug.print("Session {s} not found\n", .{session_name});
 944-        return error.SessionNotFound;
 945-    };
 946-
 947-    // Filter out terminal response sequences before writing to PTY
 948-    var filtered_buf: [128 * 1024]u8 = undefined;
 949-    const filtered_len = filterTerminalResponses(text, &filtered_buf);
 950-
 951-    if (filtered_len == 0) {
 952-        return; // All input was filtered, nothing to write
 953-    }
 954-
 955-    const filtered_text = filtered_buf[0..filtered_len];
 956-    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 });
 957-
 958-    // Write input to PTY master fd
 959-    const written = posix.write(session.pty_master_fd, filtered_text) catch |err| {
 960-        std.debug.print("Error writing to PTY: {s}\n", .{@errorName(err)});
 961-        return err;
 962-    };
 963-    _ = written;
 964-}
 965-
 966-fn applyWinsize(session: *Session, rows: u16, cols: u16) !void {
 967-    if (rows == 0 or cols == 0) return;
 968-
 969-    var ws = c.struct_winsize{
 970-        .ws_row = rows,
 971-        .ws_col = cols,
 972-        .ws_xpixel = 0,
 973-        .ws_ypixel = 0,
 974-    };
 975-    const result = c.ioctl(session.pty_master_fd, c.TIOCSWINSZ, &ws);
 976-    if (result < 0) {
 977-        return error.IoctlFailed;
 978-    }
 979-
 980-    try sendInBandSizeReportIfEnabled(session, rows, cols);
 981-}
 982-
 983-fn sendInBandSizeReportIfEnabled(session: *Session, rows: u16, cols: u16) !void {
 984-    // Check if in-band size reports mode (2048) is enabled
 985-    if (!session.vt.modes.get(.in_band_size_reports)) {
 986-        return;
 987-    }
 988-
 989-    // Format: CSI 48 ; height_chars ; width_chars ; height_pix ; width_pix t
 990-    // We don't track pixel sizes, so report 0 for pixels
 991-    var buf: [128]u8 = undefined;
 992-    const size_report = try std.fmt.bufPrint(&buf, "\x1b[48;{d};{d};0;0t", .{ rows, cols });
 993-
 994-    // Write directly to PTY master so app receives it
 995-    _ = try posix.write(session.pty_master_fd, size_report);
 996-    std.debug.print("Sent in-band size report: {s}\n", .{size_report});
 997-}
 998-
 999-fn handleWindowResize(client: *Client, rows: u16, cols: u16) !void {
1000-    const session_name = client.attached_session orelse {
1001-        std.debug.print("Client fd={d} not attached to any session\n", .{client.fd});
1002-        return error.NotAttached;
1003-    };
1004-
1005-    const session = client.server_ctx.sessions.get(session_name) orelse {
1006-        std.debug.print("Session {s} not found\n", .{session_name});
1007-        return error.SessionNotFound;
1008-    };
1009-
1010-    std.debug.print("Resizing session {s} to {d}x{d}\n", .{ session_name, cols, rows });
1011-
1012-    // Update libghostty-vt terminal size
1013-    try session.vt.resize(session.allocator, cols, rows);
1014-
1015-    // Update PTY window size and send notifications
1016-    try applyWinsize(session, rows, cols);
1017-}
1018-
1019-fn readFromPty(ctx: *ServerContext, client: *Client, session: *Session) !void {
1020-    const stream = xev.Stream.initFd(session.pty_master_fd);
1021-    const read_compl = client.allocator.create(xev.Completion) catch @panic("failed to create completion");
1022-    const pty_ctx = client.allocator.create(PtyReadContext) catch @panic("failed to create PTY context");
1023-    pty_ctx.* = .{
1024-        .session = session,
1025-        .server_ctx = ctx,
1026-    };
1027-    stream.read(
1028-        ctx.loop,
1029-        read_compl,
1030-        .{ .slice = &session.pty_read_buffer },
1031-        PtyReadContext,
1032-        pty_ctx,
1033-        readPtyCallback,
1034-    );
1035-
1036-    std.debug.print("Sending attach response to client fd={d}\n", .{client.fd});
1037-
1038-    try protocol.writeJson(client.allocator, client.fd, .attach_session_response, protocol.AttachSessionResponse{
1039-        .status = "ok",
1040-        .client_fd = client.fd,
1041-    });
1042-}
1043-
1044-fn getSessionForClient(ctx: *ServerContext, client: *Client) ?*Session {
1045-    const session_name = client.attached_session orelse return null;
1046-    return ctx.sessions.get(session_name);
1047-}
1048-
1049-fn notifyAttachedClientsAndCleanup(session: *Session, ctx: *ServerContext, reason: []const u8) void {
1050-    // Copy the session name FIRST before doing anything else, including printing
1051-    // This protects against any potential memory corruption
1052-    const session_name = ctx.allocator.dupe(u8, session.name) catch {
1053-        // Fallback: skip notification and just cleanup
1054-        std.debug.print("Failed to allocate session name copy during cleanup\n", .{});
1055-        posix.close(session.pty_master_fd);
1056-        session.deinit();
1057-        ctx.allocator.destroy(session);
1058-        return;
1059-    };
1060-    defer ctx.allocator.free(session_name);
1061-
1062-    std.debug.print("Session '{s}' ending: {s}\n", .{ session_name, reason });
1063-
1064-    // Notify all attached clients
1065-    var it = session.attached_clients.keyIterator();
1066-    while (it.next()) |client_fd| {
1067-        const client = ctx.clients.get(client_fd.*) orelse continue;
1068-        protocol.writeJson(
1069-            client.allocator,
1070-            client.fd,
1071-            .kill_notification,
1072-            protocol.KillNotification{ .session_name = session_name },
1073-        ) catch |err| {
1074-            std.debug.print("Failed to notify client {d}: {s}\n", .{ client_fd.*, @errorName(err) });
1075-        };
1076-        // Clear client's attached session reference (just null it, don't free - it points to session.name)
1077-        client.attached_session = null;
1078-    }
1079-
1080-    // Close PTY master fd
1081-    posix.close(session.pty_master_fd);
1082-
1083-    // Remove from sessions map BEFORE session.deinit frees the key
1084-    _ = ctx.sessions.remove(session_name);
1085-
1086-    // Clean up session
1087-    session.deinit();
1088-    ctx.allocator.destroy(session);
1089-}
1090-
1091-fn readPtyCallback(
1092-    pty_ctx_opt: ?*PtyReadContext,
1093-    loop: *xev.Loop,
1094-    completion: *xev.Completion,
1095-    stream: xev.Stream,
1096-    read_buffer: xev.ReadBuffer,
1097-    read_result: xev.ReadError!usize,
1098-) xev.CallbackAction {
1099-    const pty_ctx = pty_ctx_opt.?;
1100-    const session = pty_ctx.session;
1101-    const ctx = pty_ctx.server_ctx;
1102-
1103-    // Check if session still exists (might have been killed by another client)
1104-    const session_exists = blk: {
1105-        var it = ctx.sessions.valueIterator();
1106-        while (it.next()) |s| {
1107-            if (s.* == session) break :blk true;
1108-        }
1109-        break :blk false;
1110-    };
1111-
1112-    if (!session_exists) {
1113-        // Session was already cleaned up, just free our context
1114-        ctx.allocator.destroy(pty_ctx);
1115-        ctx.allocator.destroy(completion);
1116-        return .disarm;
1117-    }
1118-
1119-    if (read_result) |bytes_read| {
1120-        if (bytes_read == 0) {
1121-            std.debug.print("PTY closed (EOF)\n", .{});
1122-            notifyAttachedClientsAndCleanup(session, ctx, "PTY closed");
1123-            ctx.allocator.destroy(pty_ctx);
1124-            ctx.allocator.destroy(completion);
1125-            return .disarm;
1126-        }
1127-
1128-        const data = read_buffer.slice[0..bytes_read];
1129-        std.debug.print("PTY output ({d} bytes)\n", .{bytes_read});
1130-
1131-        session.vt_stream.nextSlice(data) catch |err| {
1132-            std.debug.print("VT parse error: {s}\n", .{@errorName(err)});
1133-        };
1134-
1135-        // Only proxy to clients if someone is attached
1136-        if (session.attached_clients.count() > 0 and data.len > 0) {
1137-            // Send PTY output as binary frame to avoid JSON escaping issues
1138-            // Frame format: [4-byte length][2-byte type][payload]
1139-            const header = protocol.FrameHeader{
1140-                .length = @intCast(data.len),
1141-                .frame_type = @intFromEnum(protocol.FrameType.pty_binary),
1142-            };
1143-
1144-            // Build complete frame with header + payload
1145-            var frame_buf = std.ArrayList(u8).initCapacity(session.allocator, @sizeOf(protocol.FrameHeader) + data.len) catch return .disarm;
1146-            defer frame_buf.deinit(session.allocator);
1147-
1148-            const header_bytes = std.mem.asBytes(&header);
1149-            frame_buf.appendSlice(session.allocator, header_bytes) catch return .disarm;
1150-            frame_buf.appendSlice(session.allocator, data) catch return .disarm;
1151-
1152-            // Send to all attached clients using async write (skip muted clients)
1153-            var it = session.attached_clients.keyIterator();
1154-            while (it.next()) |client_fd| {
1155-                const attached_client = ctx.clients.get(client_fd.*) orelse continue;
1156-                // Skip muted clients (during snapshot send)
1157-                if (attached_client.muted) continue;
1158-                const owned_frame = session.allocator.dupe(u8, frame_buf.items) catch continue;
1159-
1160-                const write_ctx = session.allocator.create(PtyWriteContext) catch {
1161-                    session.allocator.free(owned_frame);
1162-                    continue;
1163-                };
1164-                write_ctx.* = .{
1165-                    .allocator = session.allocator,
1166-                    .message = owned_frame,
1167-                };
1168-
1169-                const write_completion = session.allocator.create(xev.Completion) catch {
1170-                    session.allocator.free(owned_frame);
1171-                    session.allocator.destroy(write_ctx);
1172-                    continue;
1173-                };
1174-
1175-                attached_client.stream.write(
1176-                    loop,
1177-                    write_completion,
1178-                    .{ .slice = owned_frame },
1179-                    PtyWriteContext,
1180-                    write_ctx,
1181-                    ptyWriteCallback,
1182-                );
1183-            }
1184-        }
1185-
1186-        // Re-arm to continue reading
1187-        stream.read(
1188-            loop,
1189-            completion,
1190-            .{ .slice = &session.pty_read_buffer },
1191-            PtyReadContext,
1192-            pty_ctx,
1193-            readPtyCallback,
1194-        );
1195-        return .disarm;
1196-    } else |err| {
1197-        // WouldBlock is expected for non-blocking I/O
1198-        if (err == error.WouldBlock) {
1199-            stream.read(
1200-                loop,
1201-                completion,
1202-                .{ .slice = &session.pty_read_buffer },
1203-                PtyReadContext,
1204-                pty_ctx,
1205-                readPtyCallback,
1206-            );
1207-            return .disarm;
1208-        }
1209-
1210-        // Fatal error - notify clients and clean up
1211-        std.debug.print("PTY read error: {s}\n", .{@errorName(err)});
1212-        const error_msg = std.fmt.allocPrint(
1213-            ctx.allocator,
1214-            "PTY read error: {s}",
1215-            .{@errorName(err)},
1216-        ) catch "PTY read error";
1217-        defer if (!std.mem.eql(u8, error_msg, "PTY read error")) ctx.allocator.free(error_msg);
1218-
1219-        notifyAttachedClientsAndCleanup(session, ctx, error_msg);
1220-        ctx.allocator.destroy(pty_ctx);
1221-        ctx.allocator.destroy(completion);
1222-        return .disarm;
1223-    }
1224-    unreachable;
1225-}
1226-
1227-fn ptyWriteCallback(
1228-    write_ctx_opt: ?*PtyWriteContext,
1229-    _: *xev.Loop,
1230-    completion: *xev.Completion,
1231-    _: xev.Stream,
1232-    _: xev.WriteBuffer,
1233-    write_result: xev.WriteError!usize,
1234-) xev.CallbackAction {
1235-    const write_ctx = write_ctx_opt.?;
1236-    const allocator = write_ctx.allocator;
1237-
1238-    if (write_result) |_| {
1239-        // Successfully sent PTY output to client
1240-    } else |_| {
1241-        // Silently ignore write errors to prevent log spam
1242-    }
1243-
1244-    allocator.free(write_ctx.message);
1245-    allocator.destroy(write_ctx);
1246-    allocator.destroy(completion);
1247-    return .disarm;
1248-}
1249-
1250-fn execShellWithPrompt(allocator: std.mem.Allocator, session_name: []const u8, shell: [*:0]const u8) noreturn {
1251-    // Detect shell type and add prompt injection
1252-    const shell_name = std.fs.path.basename(std.mem.span(shell));
1253-
1254-    if (std.mem.eql(u8, shell_name, "fish")) {
1255-        // Fish: wrap the existing fish_prompt function
1256-        const init_cmd = std.fmt.allocPrint(allocator, "if test -e ~/.config/fish/config.fish; source ~/.config/fish/config.fish; end; " ++
1257-            "functions -q fish_prompt; and functions -c fish_prompt _zmx_original_prompt; " ++
1258-            "function fish_prompt; echo -n '[{s}] '; _zmx_original_prompt; end\x00", .{session_name}) catch {
1259-            std.posix.exit(1);
1260-        };
1261-        const argv = [_:null]?[*:0]const u8{ shell, "--init-command".ptr, @ptrCast(init_cmd.ptr), null };
1262-        const err = std.posix.execveZ(shell, &argv, std.c.environ);
1263-        std.debug.print("execve failed: {s}\n", .{@errorName(err)});
1264-        std.posix.exit(1);
1265-    } else if (std.mem.eql(u8, shell_name, "bash")) {
1266-        // Bash: prepend to PS1 via bashrc injection
1267-        const bashrc = std.fmt.allocPrint(allocator, "[ -f ~/.bashrc ] && source ~/.bashrc; PS1='[{s}] '$PS1\x00", .{session_name}) catch {
1268-            std.posix.exit(1);
1269-        };
1270-        const argv = [_:null]?[*:0]const u8{ shell, "--rcfile".ptr, "/dev/stdin".ptr, null };
1271-        // Note: This approach doesn't work well. Let's use PROMPT_COMMAND instead
1272-        const argv2 = [_:null]?[*:0]const u8{ shell, "--init-file".ptr, @ptrCast(bashrc.ptr), null };
1273-        _ = argv2;
1274-        const err = std.posix.execveZ(shell, &argv, std.c.environ);
1275-        std.debug.print("execve failed: {s}\n", .{@errorName(err)});
1276-        std.posix.exit(1);
1277-    } else if (std.mem.eql(u8, shell_name, "zsh")) {
1278-        // Zsh: prepend to PROMPT after loading zshrc
1279-        const zdotdir = std.posix.getenv("ZDOTDIR") orelse std.posix.getenv("HOME") orelse "/tmp";
1280-        const zshrc = std.fmt.allocPrint(allocator, "[ -f {s}/.zshrc ] && source {s}/.zshrc; PROMPT='[{s}] '$PROMPT\x00", .{ zdotdir, zdotdir, session_name }) catch {
1281-            std.posix.exit(1);
1282-        };
1283-        _ = zshrc;
1284-        // For zsh, just set the environment variable and let it prepend
1285-        const prompt_var = std.fmt.allocPrint(allocator, "PROMPT=[{s}] ${{PROMPT:-'%# '}}\x00", .{session_name}) catch {
1286-            std.posix.exit(1);
1287-        };
1288-        _ = c.putenv(@ptrCast(prompt_var.ptr));
1289-        const argv = [_:null]?[*:0]const u8{ shell, null };
1290-        const err = std.posix.execveZ(shell, &argv, std.c.environ);
1291-        std.debug.print("execve failed: {s}\n", .{@errorName(err)});
1292-        std.posix.exit(1);
1293-    } else {
1294-        // Default: just run the shell
1295-        const argv = [_:null]?[*:0]const u8{ shell, null };
1296-        const err = std.posix.execveZ(shell, &argv, std.c.environ);
1297-        std.debug.print("execve failed: {s}\n", .{@errorName(err)});
1298-        std.posix.exit(1);
1299-    }
1300-}
1301-
1302-fn createSession(allocator: std.mem.Allocator, session_name: []const u8, cwd: []const u8) !*Session {
1303-    var master_fd: c_int = undefined;
1304-
1305-    // Fork and create PTY
1306-    const pid = c.forkpty(&master_fd, null, null, null);
1307-    if (pid < 0) {
1308-        return error.ForkPtyFailed;
1309-    }
1310-
1311-    if (pid == 0) {
1312-        // Child process - set environment and execute shell with prompt
1313-
1314-        // Change to client's working directory
1315-        std.posix.chdir(cwd) catch {
1316-            std.posix.exit(1);
1317-        };
1318-
1319-        // Set ZMX_SESSION to identify the session
1320-        const zmx_session_var = std.fmt.allocPrint(allocator, "ZMX_SESSION={s}\x00", .{session_name}) catch {
1321-            std.posix.exit(1);
1322-        };
1323-        _ = c.putenv(@ptrCast(zmx_session_var.ptr));
1324-
1325-        const shell = std.posix.getenv("SHELL") orelse "/bin/sh";
1326-        execShellWithPrompt(allocator, session_name, shell);
1327-    }
1328-
1329-    // Parent process - setup session
1330-    std.debug.print("✓ Created PTY session: name={s}, master_fd={d}, child_pid={d}\n", .{
1331-        session_name,
1332-        master_fd,
1333-        pid,
1334-    });
1335-
1336-    // Make PTY master fd non-blocking for async I/O
1337-    const flags = try posix.fcntl(master_fd, posix.F.GETFL, 0);
1338-    _ = try posix.fcntl(master_fd, posix.F.SETFL, flags | @as(u32, 0o4000));
1339-
1340-    // Initialize terminal emulator for session restore
1341-    var vt = try ghostty.Terminal.init(allocator, .{
1342-        .cols = 80,
1343-        .rows = 24,
1344-        .max_scrollback = 10000,
1345-    });
1346-    errdefer vt.deinit(allocator);
1347-
1348-    const session = try allocator.create(Session);
1349-    session.* = .{
1350-        .name = try allocator.dupe(u8, session_name),
1351-        .pty_master_fd = @intCast(master_fd),
1352-        .child_pid = pid,
1353-        .allocator = allocator,
1354-        .pty_read_buffer = undefined,
1355-        .created_at = std.time.timestamp(),
1356-        .vt = vt,
1357-        .vt_handler = VTHandler{
1358-            .terminal = &session.vt,
1359-            .pty_master_fd = @intCast(master_fd),
1360-        },
1361-        .vt_stream = undefined,
1362-        .attached_clients = std.AutoHashMap(std.posix.fd_t, void).init(allocator),
1363-    };
1364-
1365-    // Initialize the stream after session is created since handler needs terminal pointer
1366-    session.vt_stream = ghostty.Stream(*VTHandler).init(&session.vt_handler);
1367-    session.vt_stream.parser.osc_parser.alloc = allocator;
1368-
1369-    return session;
1370-}
1371-
1372-fn closeClient(client: *Client, completion: *xev.Completion) xev.CallbackAction {
1373-    std.debug.print("Closing client fd={d}\n", .{client.fd});
1374-
1375-    // Remove client from attached session if any
1376-    if (client.attached_session) |session_name| {
1377-        if (client.server_ctx.sessions.get(session_name)) |session| {
1378-            _ = session.attached_clients.remove(client.fd);
1379-            std.debug.print("Removed client fd={d} from session {s} attached_clients\n", .{ client.fd, session_name });
1380-        }
1381-    }
1382-
1383-    // Remove client from the clients map
1384-    _ = client.server_ctx.clients.remove(client.fd);
1385-
1386-    // Initiate async close of the client stream
1387-    const close_completion = client.allocator.create(xev.Completion) catch {
1388-        // If we can't allocate, just clean up synchronously
1389-        posix.close(client.fd);
1390-        client.allocator.destroy(completion);
1391-        client.allocator.destroy(client);
1392-        return .disarm;
1393-    };
1394-
1395-    client.stream.close(client.server_ctx.loop, close_completion, Client, client, closeCallback);
1396-    client.allocator.destroy(completion);
1397-    return .disarm;
1398-}
1399-
1400-fn closeCallback(
1401-    client_opt: ?*Client,
1402-    _: *xev.Loop,
1403-    completion: *xev.Completion,
1404-    _: xev.Stream,
1405-    close_result: xev.CloseError!void,
1406-) xev.CallbackAction {
1407-    const client = client_opt.?;
1408-    if (close_result) |_| {} else |err| {
1409-        std.debug.print("close failed: {s}\n", .{@errorName(err)});
1410-    }
1411-    std.debug.print("client disconnected fd={d}\n", .{client.fd});
1412-    client.message_buffer.deinit(client.allocator);
1413-    client.allocator.destroy(completion);
1414-    client.allocator.destroy(client);
1415-    return .disarm;
1416-}
D src/daemon_test.zig
+0, -130
  1@@ -1,130 +0,0 @@
  2-const std = @import("std");
  3-const posix = std.posix;
  4-
  5-test "daemon lifecycle: attach, verify PTY, detach, shutdown" {
  6-    const allocator = std.testing.allocator;
  7-
  8-    // 1. Start the daemon process with SHELL=/bin/bash
  9-    std.debug.print("\n=== Starting daemon ===\n", .{});
 10-    const daemon_args = [_][]const u8{ "zig-out/bin/zmx", "daemon" };
 11-    var daemon_process = std.process.Child.init(&daemon_args, allocator);
 12-    daemon_process.stdin_behavior = .Ignore;
 13-    daemon_process.stdout_behavior = .Ignore;
 14-    daemon_process.stderr_behavior = .Pipe; // daemon uses stderr for debug output
 15-
 16-    var env_map = try std.process.getEnvMap(allocator);
 17-    defer env_map.deinit();
 18-    try env_map.put("SHELL", "/bin/bash");
 19-    daemon_process.env_map = &env_map;
 20-
 21-    try daemon_process.spawn();
 22-    defer {
 23-        _ = daemon_process.kill() catch {};
 24-    }
 25-
 26-    // Give daemon time to start
 27-    std.Thread.sleep(500 * std.time.ns_per_ms);
 28-
 29-    // 2. Attach to a test session
 30-    std.debug.print("=== Attaching to session 'test' ===\n", .{});
 31-    const attach_args = [_][]const u8{ "zig-out/bin/zmx", "attach", "test" };
 32-    var attach_process = std.process.Child.init(&attach_args, allocator);
 33-    attach_process.stdin_behavior = .Pipe;
 34-    attach_process.stdout_behavior = .Ignore; // We don't read it
 35-    attach_process.stderr_behavior = .Ignore; // We don't read it
 36-
 37-    try attach_process.spawn();
 38-    defer {
 39-        _ = attach_process.kill() catch {};
 40-    }
 41-
 42-    // Give time for PTY session to be created
 43-    std.Thread.sleep(300 * std.time.ns_per_ms);
 44-
 45-    // 3. Verify PTY was created by reading daemon stderr with timeout
 46-    std.debug.print("=== Verifying PTY creation ===\n", .{});
 47-    const out_file = daemon_process.stderr.?;
 48-    const flags = try posix.fcntl(out_file.handle, posix.F.GETFL, 0);
 49-    const new_flags = posix.O{
 50-        .ACCMODE = .RDONLY,
 51-        .CREAT = false,
 52-        .EXCL = false,
 53-        .NOCTTY = false,
 54-        .TRUNC = false,
 55-        .APPEND = false,
 56-        .NONBLOCK = true,
 57-    };
 58-    _ = try posix.fcntl(out_file.handle, posix.F.SETFL, @as(u32, @bitCast(new_flags)) | flags);
 59-
 60-    var stdout_buf: [8192]u8 = undefined;
 61-    var stdout_len: usize = 0;
 62-    const needle = "child_pid";
 63-    const deadline_ms = std.time.milliTimestamp() + 3000;
 64-
 65-    while (std.time.milliTimestamp() < deadline_ms) {
 66-        var pfd = [_]posix.pollfd{.{ .fd = out_file.handle, .events = posix.POLL.IN, .revents = 0 }};
 67-        _ = try posix.poll(&pfd, 200);
 68-        if ((pfd[0].revents & posix.POLL.IN) != 0) {
 69-            const n = posix.read(out_file.handle, stdout_buf[stdout_len..]) catch |e| switch (e) {
 70-                error.WouldBlock => 0,
 71-                else => return e,
 72-            };
 73-            stdout_len += n;
 74-            if (std.mem.indexOf(u8, stdout_buf[0..stdout_len], needle) != null) break;
 75-        }
 76-    }
 77-
 78-    const stdout = stdout_buf[0..stdout_len];
 79-    std.debug.print("Daemon output ({d} bytes): {s}\n", .{ stdout_len, stdout });
 80-
 81-    // Parse the child PID from daemon output (format: "child_pid={d}")
 82-    const child_pid_prefix = "child_pid=";
 83-    const pid_start = std.mem.indexOf(u8, stdout, child_pid_prefix) orelse {
 84-        std.debug.print("Expected 'child_pid=' in output\n", .{});
 85-        return error.NoPidInOutput;
 86-    };
 87-    const pid_str_start = pid_start + child_pid_prefix.len;
 88-    const pid_str_end = std.mem.indexOfAnyPos(u8, stdout, pid_str_start, "\n ") orelse stdout.len;
 89-    const pid_str = stdout[pid_str_start..pid_str_end];
 90-    const child_pid = try std.fmt.parseInt(i32, pid_str, 10);
 91-
 92-    std.debug.print("✓ PTY created with child PID: {d}\n", .{child_pid});
 93-
 94-    // Verify the shell process exists
 95-    const proc_path = try std.fmt.allocPrint(allocator, "/proc/{d}", .{child_pid});
 96-    defer allocator.free(proc_path);
 97-
 98-    var proc_dir = std.fs.openDirAbsolute(proc_path, .{}) catch |err| {
 99-        std.debug.print("Process {d} does not exist: {s}\n", .{ child_pid, @errorName(err) });
100-        return err;
101-    };
102-    proc_dir.close();
103-
104-    // Verify it's bash
105-    const comm_path = try std.fmt.allocPrint(allocator, "/proc/{d}/comm", .{child_pid});
106-    defer allocator.free(comm_path);
107-
108-    const comm = try std.fs.cwd().readFileAlloc(allocator, comm_path, 1024);
109-    defer allocator.free(comm);
110-
111-    const process_name = std.mem.trim(u8, comm, "\n ");
112-    try std.testing.expectEqualStrings("bash", process_name);
113-    std.debug.print("✓ Shell process verified: {s}\n", .{process_name});
114-
115-    // 4. Send detach command
116-    std.debug.print("=== Detaching from session ===\n", .{});
117-    const detach_seq = [_]u8{ 0x00, 'd' }; // Ctrl+Space followed by 'd'
118-    _ = try attach_process.stdin.?.write(&detach_seq);
119-    std.Thread.sleep(200 * std.time.ns_per_ms);
120-
121-    // Kill attach process (will close stdin internally)
122-    _ = attach_process.kill() catch {};
123-    std.debug.print("✓ Detached from session\n", .{});
124-
125-    // 5. Shutdown daemon
126-    std.debug.print("=== Shutting down daemon ===\n", .{});
127-    _ = daemon_process.kill() catch {};
128-    std.debug.print("✓ Daemon killed\n", .{});
129-
130-    std.debug.print("=== Test completed successfully ===\n", .{});
131-}
D src/detach.zig
+0, -119
  1@@ -1,119 +0,0 @@
  2-const std = @import("std");
  3-const posix = std.posix;
  4-const clap = @import("clap");
  5-const config_mod = @import("config.zig");
  6-const protocol = @import("protocol.zig");
  7-
  8-const params = clap.parseParamsComptime(
  9-    \\-s, --socket-path <str>  Path to the Unix socket file
 10-    \\
 11-);
 12-
 13-pub fn main(config: config_mod.Config, iter: *std.process.ArgIterator) !void {
 14-    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
 15-    defer _ = gpa.deinit();
 16-    const allocator = gpa.allocator();
 17-
 18-    var diag = clap.Diagnostic{};
 19-    var res = clap.parseEx(clap.Help, &params, clap.parsers.default, iter, .{
 20-        .diagnostic = &diag,
 21-        .allocator = allocator,
 22-    }) catch |err| {
 23-        var buf: [1024]u8 = undefined;
 24-        var stderr_file = std.fs.File{ .handle = posix.STDERR_FILENO };
 25-        var writer = stderr_file.writer(&buf);
 26-        diag.report(&writer.interface, err) catch {};
 27-        writer.interface.flush() catch {};
 28-        return err;
 29-    };
 30-    defer res.deinit();
 31-
 32-    const socket_path = res.args.@"socket-path" orelse config.socket_path;
 33-
 34-    // Find the client_fd file in home directory
 35-    const home_dir = posix.getenv("HOME") orelse "/tmp";
 36-
 37-    var session_name: ?[]const u8 = null;
 38-    var client_fd: ?i64 = null;
 39-
 40-    // Look for .zmx_client_fd_* files
 41-    var dir = std.fs.cwd().openDir(home_dir, .{ .iterate = true }) catch {
 42-        std.debug.print("Error: Cannot access home directory\n", .{});
 43-        return error.CannotAccessHomeDirectory;
 44-    };
 45-    defer dir.close();
 46-
 47-    var dir_iter = dir.iterate();
 48-    while (dir_iter.next() catch null) |entry| {
 49-        if (entry.kind != .file) continue;
 50-        if (!std.mem.startsWith(u8, entry.name, ".zmx_client_fd_")) continue;
 51-
 52-        // Extract session name from filename
 53-        const name_start = ".zmx_client_fd_".len;
 54-        session_name = try allocator.dupe(u8, entry.name[name_start..]);
 55-
 56-        // Read the client_fd from the file
 57-        const full_path = try std.fs.path.join(allocator, &[_][]const u8{ home_dir, entry.name });
 58-        defer allocator.free(full_path);
 59-
 60-        if (std.fs.cwd().openFile(full_path, .{})) |file| {
 61-            defer file.close();
 62-            var buf: [32]u8 = undefined;
 63-            const bytes_read = file.readAll(&buf) catch 0;
 64-            if (bytes_read > 0) {
 65-                const fd_str = std.mem.trim(u8, buf[0..bytes_read], &std.ascii.whitespace);
 66-                client_fd = std.fmt.parseInt(i64, fd_str, 10) catch null;
 67-            }
 68-        } else |_| {}
 69-
 70-        break; // Found one, use it
 71-    }
 72-
 73-    if (session_name == null) {
 74-        std.debug.print("Error: Not currently attached to any session\n", .{});
 75-        std.debug.print("Use Ctrl-Space d to detach from within an attached session\n", .{});
 76-        return error.NotAttached;
 77-    }
 78-    defer if (session_name) |name| allocator.free(name);
 79-
 80-    const unix_addr = try std.net.Address.initUnix(socket_path);
 81-    const socket_fd = try posix.socket(posix.AF.UNIX, posix.SOCK.STREAM, 0);
 82-    defer posix.close(socket_fd);
 83-
 84-    posix.connect(socket_fd, &unix_addr.any, unix_addr.getOsSockLen()) catch |err| {
 85-        if (err == error.ConnectionRefused) {
 86-            std.debug.print("Error: Unable to connect to zmx daemon at {s}\nPlease start the daemon first with: zmx daemon\n", .{socket_path});
 87-        }
 88-        return err;
 89-    };
 90-
 91-    const request_payload = protocol.DetachSessionRequest{
 92-        .session_name = session_name.?,
 93-        .client_fd = client_fd,
 94-    };
 95-
 96-    try protocol.writeJson(allocator, socket_fd, .detach_session_request, request_payload);
 97-
 98-    var buffer: [16 * 1024]u8 = undefined; // 16KB for robustness
 99-    const bytes_read = try posix.read(socket_fd, &buffer);
100-
101-    if (bytes_read == 0) {
102-        std.debug.print("No response from daemon\n", .{});
103-        return;
104-    }
105-
106-    const response = buffer[0..bytes_read];
107-    const newline_idx = std.mem.indexOf(u8, response, "\n") orelse bytes_read;
108-    const msg_line = response[0..newline_idx];
109-
110-    const parsed = try protocol.parseMessage(protocol.DetachSessionResponse, allocator, msg_line);
111-    defer parsed.deinit();
112-
113-    if (std.mem.eql(u8, parsed.value.payload.status, "ok")) {
114-        std.debug.print("Detached from session: {s}\n", .{session_name.?});
115-    } else {
116-        const error_msg = parsed.value.payload.error_message orelse "Unknown error";
117-        std.debug.print("Failed to detach: {s}\n", .{error_msg});
118-        return error.DetachFailed;
119-    }
120-}
A src/ipc.zig
+132, -0
  1@@ -0,0 +1,132 @@
  2+const std = @import("std");
  3+const posix = std.posix;
  4+
  5+pub const Tag = enum(u8) {
  6+    Input = 0,
  7+    Output = 1,
  8+    Resize = 2,
  9+    Detach = 3,
 10+    DetachAll = 4,
 11+    Kill = 5,
 12+};
 13+
 14+pub const Header = packed struct {
 15+    tag: Tag,
 16+    len: u32,
 17+};
 18+
 19+pub const Resize = packed struct {
 20+    rows: u16,
 21+    cols: u16,
 22+};
 23+
 24+pub fn expectedLength(data: []const u8) ?usize {
 25+    if (data.len < @sizeOf(Header)) return null;
 26+    const header = std.mem.bytesToValue(Header, data[0..@sizeOf(Header)]);
 27+    return @sizeOf(Header) + header.len;
 28+}
 29+
 30+pub fn send(fd: i32, tag: Tag, data: []const u8) !void {
 31+    const header = Header{
 32+        .tag = tag,
 33+        .len = @intCast(data.len),
 34+    };
 35+    const header_bytes = std.mem.asBytes(&header);
 36+    try writeAll(fd, header_bytes);
 37+    if (data.len > 0) {
 38+        try writeAll(fd, data);
 39+    }
 40+}
 41+
 42+pub fn appendMessage(alloc: std.mem.Allocator, list: *std.ArrayList(u8), tag: Tag, data: []const u8) !void {
 43+    const header = Header{
 44+        .tag = tag,
 45+        .len = @intCast(data.len),
 46+    };
 47+    try list.appendSlice(alloc, std.mem.asBytes(&header));
 48+    if (data.len > 0) {
 49+        try list.appendSlice(alloc, data);
 50+    }
 51+}
 52+
 53+fn writeAll(fd: i32, data: []const u8) !void {
 54+    var index: usize = 0;
 55+    while (index < data.len) {
 56+        const n = try posix.write(fd, data[index..]);
 57+        if (n == 0) return error.DiskQuota;
 58+        index += n;
 59+    }
 60+}
 61+
 62+pub const Message = struct {
 63+    tag: Tag,
 64+    data: []u8,
 65+
 66+    pub fn deinit(self: Message, alloc: std.mem.Allocator) void {
 67+        if (self.data.len > 0) {
 68+            alloc.free(self.data);
 69+        }
 70+    }
 71+};
 72+
 73+pub const SocketMsg = struct {
 74+    header: Header,
 75+    payload: []const u8,
 76+};
 77+
 78+pub const SocketBuffer = struct {
 79+    buf: std.ArrayList(u8),
 80+    alloc: std.mem.Allocator,
 81+    head: usize,
 82+
 83+    pub fn init(alloc: std.mem.Allocator) !SocketBuffer {
 84+        return .{
 85+            .buf = try std.ArrayList(u8).initCapacity(alloc, 4096),
 86+            .alloc = alloc,
 87+            .head = 0,
 88+        };
 89+    }
 90+
 91+    pub fn deinit(self: *SocketBuffer) void {
 92+        self.buf.deinit(self.alloc);
 93+    }
 94+
 95+    /// Reads from fd into buffer.
 96+    /// Returns number of bytes read.
 97+    /// Propagates error.WouldBlock and other errors to caller.
 98+    /// Returns 0 on EOF.
 99+    pub fn read(self: *SocketBuffer, fd: i32) !usize {
100+        if (self.head > 0) {
101+            const remaining = self.buf.items.len - self.head;
102+            if (remaining > 0) {
103+                std.mem.copyForwards(u8, self.buf.items[0..remaining], self.buf.items[self.head..]);
104+                self.buf.items.len = remaining;
105+            } else {
106+                self.buf.clearRetainingCapacity();
107+            }
108+            self.head = 0;
109+        }
110+
111+        var tmp: [4096]u8 = undefined;
112+        const n = try posix.read(fd, &tmp);
113+        if (n > 0) {
114+            try self.buf.appendSlice(self.alloc, tmp[0..n]);
115+        }
116+        return n;
117+    }
118+
119+    /// Returns the next complete message or `null` when none available.
120+    /// `buf` is advanced automatically; caller keeps the returned slices
121+    /// valid until the following `next()` (or `deinit`).
122+    pub fn next(self: *SocketBuffer) ?SocketMsg {
123+        const available = self.buf.items[self.head..];
124+        const total = expectedLength(available) orelse return null;
125+        if (available.len < total) return null;
126+
127+        const hdr = std.mem.bytesToValue(Header, available[0..@sizeOf(Header)]);
128+        const pay = available[@sizeOf(Header)..total];
129+
130+        self.head += total;
131+        return .{ .header = hdr, .payload = pay };
132+    }
133+};
D src/kill.zig
+0, -79
 1@@ -1,79 +0,0 @@
 2-const std = @import("std");
 3-const posix = std.posix;
 4-const clap = @import("clap");
 5-const config_mod = @import("config.zig");
 6-const protocol = @import("protocol.zig");
 7-
 8-const params = clap.parseParamsComptime(
 9-    \\-s, --socket-path <str>  Path to the Unix socket file
10-    \\<str>
11-    \\
12-);
13-
14-pub fn main(config: config_mod.Config, iter: *std.process.ArgIterator) !void {
15-    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
16-    defer _ = gpa.deinit();
17-    const allocator = gpa.allocator();
18-
19-    var diag = clap.Diagnostic{};
20-    var res = clap.parseEx(clap.Help, &params, clap.parsers.default, iter, .{
21-        .diagnostic = &diag,
22-        .allocator = allocator,
23-    }) catch |err| {
24-        var buf: [1024]u8 = undefined;
25-        var stderr_file = std.fs.File{ .handle = posix.STDERR_FILENO };
26-        var writer = stderr_file.writer(&buf);
27-        diag.report(&writer.interface, err) catch {};
28-        writer.interface.flush() catch {};
29-        return err;
30-    };
31-    defer res.deinit();
32-
33-    const socket_path = res.args.@"socket-path" orelse config.socket_path;
34-
35-    const session_name = res.positionals[0] orelse {
36-        std.debug.print("Usage: zmx kill <session-name>\n", .{});
37-        return error.MissingSessionName;
38-    };
39-
40-    const unix_addr = try std.net.Address.initUnix(socket_path);
41-    const socket_fd = try posix.socket(posix.AF.UNIX, posix.SOCK.STREAM, 0);
42-    defer posix.close(socket_fd);
43-
44-    posix.connect(socket_fd, &unix_addr.any, unix_addr.getOsSockLen()) catch |err| {
45-        if (err == error.ConnectionRefused) {
46-            std.debug.print("Error: Unable to connect to zmx daemon at {s}\nPlease start the daemon first with: zmx daemon\n", .{socket_path});
47-        }
48-        return err;
49-    };
50-
51-    try protocol.writeJson(
52-        allocator,
53-        socket_fd,
54-        .kill_session_request,
55-        protocol.KillSessionRequest{ .session_name = session_name },
56-    );
57-
58-    var buffer: [16 * 1024]u8 = undefined; // 16KB for robustness
59-    const bytes_read = try posix.read(socket_fd, &buffer);
60-
61-    if (bytes_read == 0) {
62-        std.debug.print("No response from daemon\n", .{});
63-        return;
64-    }
65-
66-    const response = buffer[0..bytes_read];
67-    const newline_idx = std.mem.indexOf(u8, response, "\n") orelse bytes_read;
68-    const msg_line = response[0..newline_idx];
69-
70-    const parsed = try protocol.parseMessage(protocol.KillSessionResponse, allocator, msg_line);
71-    defer parsed.deinit();
72-
73-    if (std.mem.eql(u8, parsed.value.payload.status, "ok")) {
74-        std.debug.print("Killed session: {s}\n", .{session_name});
75-    } else {
76-        const error_msg = parsed.value.payload.error_message orelse "Unknown error";
77-        std.debug.print("Failed to kill session: {s}\n", .{error_msg});
78-        return error.KillFailed;
79-    }
80-}
D src/list.zig
+0, -81
 1@@ -1,81 +0,0 @@
 2-const std = @import("std");
 3-const posix = std.posix;
 4-const clap = @import("clap");
 5-const config_mod = @import("config.zig");
 6-const protocol = @import("protocol.zig");
 7-
 8-const params = clap.parseParamsComptime(
 9-    \\-s, --socket-path <str>  Path to the Unix socket file
10-    \\
11-);
12-
13-pub fn main(config: config_mod.Config, iter: *std.process.ArgIterator) !void {
14-    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
15-    defer _ = gpa.deinit();
16-    const allocator = gpa.allocator();
17-
18-    var diag = clap.Diagnostic{};
19-    var res = clap.parseEx(clap.Help, &params, clap.parsers.default, iter, .{
20-        .diagnostic = &diag,
21-        .allocator = allocator,
22-    }) catch |err| {
23-        var buf: [1024]u8 = undefined;
24-        var stderr_file = std.fs.File{ .handle = posix.STDERR_FILENO };
25-        var writer = stderr_file.writer(&buf);
26-        diag.report(&writer.interface, err) catch {};
27-        writer.interface.flush() catch {};
28-        return err;
29-    };
30-    defer res.deinit();
31-
32-    const socket_path = res.args.@"socket-path" orelse config.socket_path;
33-
34-    const unix_addr = try std.net.Address.initUnix(socket_path);
35-    const socket_fd = try posix.socket(posix.AF.UNIX, posix.SOCK.STREAM, 0);
36-    defer posix.close(socket_fd);
37-
38-    posix.connect(socket_fd, &unix_addr.any, unix_addr.getOsSockLen()) catch |err| {
39-        if (err == error.ConnectionRefused) {
40-            std.debug.print("Error: Unable to connect to zmx daemon at {s}\nPlease start the daemon first with: zmx daemon\n", .{socket_path});
41-        }
42-        return err;
43-    };
44-
45-    try protocol.writeJson(allocator, socket_fd, .list_sessions_request, protocol.ListSessionsRequest{});
46-
47-    var buffer: [32 * 1024]u8 = undefined; // 32KB to handle many sessions
48-    const bytes_read = try posix.read(socket_fd, &buffer);
49-
50-    if (bytes_read == 0) {
51-        std.debug.print("No response from daemon\n", .{});
52-        return;
53-    }
54-
55-    const response = buffer[0..bytes_read];
56-    const newline_idx = std.mem.indexOf(u8, response, "\n") orelse bytes_read;
57-    const msg_line = response[0..newline_idx];
58-
59-    const parsed = try protocol.parseMessage(protocol.ListSessionsResponse, allocator, msg_line);
60-    defer parsed.deinit();
61-
62-    const payload = parsed.value.payload;
63-
64-    if (!std.mem.eql(u8, payload.status, "ok")) {
65-        const error_msg = payload.error_message orelse "Unknown error";
66-        std.debug.print("Error: {s}\n", .{error_msg});
67-        return;
68-    }
69-
70-    if (payload.sessions.len == 0) {
71-        std.debug.print("No active sessions\n", .{});
72-        return;
73-    }
74-
75-    std.debug.print("Active sessions:\n", .{});
76-    std.debug.print("{s:<20} {s:<12} {s:<8} {s}\n", .{ "NAME", "STATUS", "CLIENTS", "CREATED" });
77-    std.debug.print("{s}\n", .{"-" ** 60});
78-
79-    for (payload.sessions) |session| {
80-        std.debug.print("{s:<20} {s:<12} {d:<8} {s}\n", .{ session.name, session.status, session.clients, session.created_at });
81-    }
82-}
M src/main.zig
+651, -32
  1@@ -1,48 +1,667 @@
  2 const std = @import("std");
  3-const cli = @import("cli.zig");
  4-const config_mod = @import("config.zig");
  5-const daemon = @import("daemon.zig");
  6-const attach = @import("attach.zig");
  7-const detach = @import("detach.zig");
  8-const kill = @import("kill.zig");
  9-const list = @import("list.zig");
 10-const clap = @import("clap");
 11-
 12-pub const std_options: std.Options = .{
 13-    .log_level = .err,
 14+const posix = std.posix;
 15+const ipc = @import("ipc.zig");
 16+const builtin = @import("builtin");
 17+
 18+const c = switch (builtin.os.tag) {
 19+    .macos => @cImport({
 20+        @cInclude("sys/ioctl.h"); // ioctl and constants
 21+        @cInclude("util.h"); // openpty()
 22+        @cInclude("stdlib.h");
 23+        @cInclude("unistd.h");
 24+    }),
 25+    .freebsd => @cImport({
 26+        @cInclude("termios.h"); // ioctl and constants
 27+        @cInclude("libutil.h"); // openpty()
 28+        @cInclude("stdlib.h");
 29+        @cInclude("unistd.h");
 30+    }),
 31+    else => @cImport({
 32+        @cInclude("sys/ioctl.h"); // ioctl and constants
 33+        @cInclude("pty.h");
 34+        @cInclude("stdlib.h");
 35+        @cInclude("unistd.h");
 36+    }),
 37+};
 38+
 39+// pub const std_options: std.Options = .{
 40+//     .log_level = .err,
 41+// };
 42+
 43+const Client = struct {
 44+    alloc: std.mem.Allocator,
 45+    socket_fd: i32,
 46+    has_pending_output: bool = false,
 47+    read_buf: ipc.SocketBuffer,
 48+    write_buf: std.ArrayList(u8),
 49+
 50+    pub fn deinit(self: *Client) void {
 51+        posix.close(self.socket_fd);
 52+        self.read_buf.deinit();
 53+        self.write_buf.deinit(self.alloc);
 54+    }
 55+};
 56+
 57+const Cfg = struct {
 58+    socket_dir: []const u8 = "/tmp/zmx",
 59+    prefix_key: []const u8 = "^", // control key
 60+    detach_key: []const u8 = "\\", // backslash key
 61+
 62+    pub fn mkdir(self: *Cfg) !void {
 63+        std.log.info("creating socket dir: socket_dir={s}", .{self.socket_dir});
 64+        std.fs.makeDirAbsolute(self.socket_dir) catch |err| switch (err) {
 65+            error.PathAlreadyExists => {
 66+                std.log.info("socket dir already exists", .{});
 67+            },
 68+            else => return err,
 69+        };
 70+    }
 71+};
 72+
 73+const Daemon = struct {
 74+    cfg: *Cfg,
 75+    alloc: std.mem.Allocator,
 76+    clients: std.ArrayList(*Client),
 77+    session_name: []const u8,
 78+    socket_path: []const u8,
 79+    running: bool,
 80+
 81+    pub fn deinit(self: *Daemon) void {
 82+        self.clients.deinit(self.alloc);
 83+        self.alloc.free(self.socket_path);
 84+    }
 85+
 86+    pub fn shutdown(self: *Daemon) void {
 87+        std.log.info("shutting down daemon session_name={s}", .{self.session_name});
 88+        self.running = false;
 89+
 90+        for (self.clients.items) |client| {
 91+            client.deinit();
 92+            self.alloc.destroy(client);
 93+        }
 94+        self.clients.clearRetainingCapacity();
 95+    }
 96+
 97+    pub fn closeClient(self: *Daemon, client: *Client, i: usize, shutdown_on_last: bool) bool {
 98+        std.log.info("closing client idx={d}", .{i});
 99+        client.deinit();
100+        self.alloc.destroy(client);
101+        _ = self.clients.orderedRemove(i);
102+        if (shutdown_on_last and self.clients.items.len == 0) {
103+            std.log.info("last client disconnected, shutting down", .{});
104+            self.shutdown();
105+            return true;
106+        }
107+        return false;
108+    }
109 };
110 
111 pub fn main() !void {
112+    std.log.info("running cli", .{});
113+    // use c_allocator to avoid "reached unreachable code" panic in DebugAllocator when forking
114+    const alloc = std.heap.c_allocator;
115+
116+    var args = try std.process.argsWithAllocator(alloc);
117+    defer args.deinit();
118+    _ = args.skip(); // skip program name
119+
120+    var cfg = Cfg{};
121+    try cfg.mkdir();
122+
123+    const cmd = args.next() orelse {
124+        return list(&cfg);
125+    };
126+
127+    if (std.mem.eql(u8, cmd, "help")) {
128+        return help();
129+    } else if (std.mem.eql(u8, cmd, "detach")) {
130+        return detachAll(&cfg);
131+    } else if (std.mem.eql(u8, cmd, "kill")) {
132+        const session_name = args.next() orelse {
133+            std.log.err("session name required", .{});
134+            return;
135+        };
136+        return kill(&cfg, session_name);
137+    } else if (std.mem.eql(u8, cmd, "attach")) {
138+        const session_name = args.next() orelse {
139+            std.log.err("session name required", .{});
140+            return;
141+        };
142+        const clients = try std.ArrayList(*Client).initCapacity(alloc, 10);
143+        var daemon = Daemon{
144+            .running = true,
145+            .cfg = &cfg,
146+            .alloc = alloc,
147+            .clients = clients,
148+            .session_name = session_name,
149+            .socket_path = undefined,
150+        };
151+        daemon.socket_path = try getSocketPath(alloc, cfg.socket_dir, session_name);
152+        std.log.info("socket path={s}", .{daemon.socket_path});
153+        return attach(&daemon);
154+    } else {
155+        std.log.err("unknown cmd={s}", .{cmd});
156+    }
157+}
158+
159+fn help() !void {
160+    std.log.info("running cmd=help", .{});
161+}
162+
163+fn list(_: *Cfg) !void {
164+    std.log.info("running cmd=list", .{});
165+}
166+
167+fn detachAll(cfg: *Cfg) !void {
168+    std.log.info("running cmd=detach", .{});
169     var gpa = std.heap.GeneralPurposeAllocator(.{}){};
170     defer _ = gpa.deinit();
171-    const allocator = gpa.allocator();
172+    const alloc = gpa.allocator();
173+    const session_name = std.process.getEnvVarOwned(alloc, "ZMX_SESSION") catch |err| switch (err) {
174+        error.EnvironmentVariableNotFound => {
175+            std.log.err("ZMX_SESSION env var not found: are you inside a zmx session?", .{});
176+            return;
177+        },
178+        else => return err,
179+    };
180+    defer alloc.free(session_name);
181 
182-    var iter = try std.process.ArgIterator.initWithAllocator(allocator);
183-    defer iter.deinit();
184+    const socket_path = try getSocketPath(alloc, cfg.socket_dir, session_name);
185+    defer alloc.free(socket_path);
186+    const client_sock_fd = try sessionConnect(socket_path);
187+    ipc.send(client_sock_fd, .DetachAll, "") catch |err| switch (err) {
188+        error.BrokenPipe, error.ConnectionResetByPeer => return,
189+        else => return err,
190+    };
191+}
192+
193+fn kill(cfg: *Cfg, session_name: []const u8) !void {
194+    std.log.info("running cmd=kill session_name={s}", .{session_name});
195+    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
196+    defer _ = gpa.deinit();
197+    const alloc = gpa.allocator();
198 
199-    var res = try cli.parse(allocator, &iter);
200-    defer res.deinit();
201+    var dir = try std.fs.openDirAbsolute(cfg.socket_dir, .{});
202+    defer dir.close();
203 
204-    if (res.args.version != 0) {
205-        const version_text = "zmx " ++ cli.version ++ "\n";
206-        _ = try std.posix.write(std.posix.STDOUT_FILENO, version_text);
207-        return;
208+    const exists = try sessionExists(dir, session_name);
209+    if (!exists) {
210+        std.log.err("cannot kill session because it does not exist session_name={s}", .{session_name});
211     }
212 
213-    const command = res.positionals[0] orelse {
214-        try cli.help();
215-        return;
216+    const socket_path = try getSocketPath(alloc, cfg.socket_dir, session_name);
217+    defer alloc.free(socket_path);
218+    const client_sock_fd = try sessionConnect(socket_path);
219+    ipc.send(client_sock_fd, .Kill, "") catch |err| switch (err) {
220+        error.BrokenPipe, error.ConnectionResetByPeer => return,
221+        else => return err,
222     };
223+}
224+
225+fn attach(daemon: *Daemon) !void {
226+    std.log.info("running cmd=attach {s}", .{daemon.session_name});
227+
228+    var dir = try std.fs.openDirAbsolute(daemon.cfg.socket_dir, .{});
229+    defer dir.close();
230+
231+    const exists = try sessionExists(dir, daemon.session_name);
232+    var should_create = !exists;
233+
234+    if (exists) {
235+        std.log.info("reattaching to session session_name={s}", .{daemon.session_name});
236+        const fd = sessionConnect(daemon.socket_path) catch |err| switch (err) {
237+            error.ConnectionRefused => blk: {
238+                std.log.warn("stale socket found, cleaning up fname={s}", .{daemon.socket_path});
239+                try dir.deleteFile(daemon.socket_path);
240+                should_create = true;
241+                break :blk -1;
242+            },
243+            else => return err,
244+        };
245+        if (fd != -1) {
246+            posix.close(fd);
247+        }
248+    }
249 
250-    var config = try config_mod.Config.load(allocator);
251-    defer config.deinit(allocator);
252+    if (should_create) {
253+        std.log.info("creating session session_name={s}", .{daemon.session_name});
254+        const server_sock_fd = try createSocket(daemon.socket_path);
255+        std.log.info("unix socket created server_sock_fd={d}", .{server_sock_fd});
256 
257-    switch (command) {
258-        .help => try cli.help(),
259-        .daemon => try daemon.main(config, &iter),
260-        .list => try list.main(config, &iter),
261-        .attach => try attach.main(config, &iter),
262-        .detach => try detach.main(config, &iter),
263-        .kill => try kill.main(config, &iter),
264+        const pid = try posix.fork();
265+        if (pid == 0) { // child
266+            _ = try posix.setsid();
267+            const pty_fd = try spawnPty(daemon);
268+            defer {
269+                posix.close(server_sock_fd);
270+                std.log.info("deleting socket file session_name={s}", .{daemon.session_name});
271+                dir.deleteFile(daemon.session_name) catch |err| {
272+                    std.log.warn("failed to delete socket file err={s}", .{@errorName(err)});
273+                };
274+            }
275+            try daemonLoop(daemon, server_sock_fd, pty_fd);
276+            return;
277+        }
278+        posix.close(server_sock_fd);
279+        std.Thread.sleep(10 * std.time.ns_per_ms);
280     }
281+
282+    const client_sock = try sessionConnect(daemon.socket_path);
283+
284+    std.log.info("setting client stdin to raw mode", .{});
285+    //  this is typically used with tcsetattr() to modify terminal settings.
286+    //      - you first get the current settings with tcgetattr()
287+    //      - modify the desired attributes in the termios structure
288+    //      - then apply the changes with tcsetattr().
289+    //  This prevents unintended side effects by preserving other settings.
290+    var orig_termios: c.termios = undefined;
291+    _ = c.tcgetattr(posix.STDIN_FILENO, &orig_termios);
292+
293+    // restore stdin fd to its original state and exit alternate buffer after exiting.
294+    defer {
295+        _ = c.tcsetattr(posix.STDIN_FILENO, c.TCSANOW, &orig_termios);
296+        // Restore normal buffer and show cursor
297+        const restore_seq = "\x1b[?25h\x1b[?1049l";
298+        _ = posix.write(posix.STDOUT_FILENO, restore_seq) catch {};
299+    }
300+
301+    var raw_termios = orig_termios;
302+    //  set raw mode after successful connection.
303+    //      disables canonical mode (line buffering), input echoing, signal generation from
304+    //      control characters (like Ctrl+C), and flow control.
305+    c.cfmakeraw(&raw_termios);
306+
307+    // Additional granular raw mode settings for precise control
308+    // (matches what abduco and shpool do)
309+    raw_termios.c_cc[c.VLNEXT] = c._POSIX_VDISABLE; // Disable literal-next (Ctrl-V)
310+    raw_termios.c_cc[c.VMIN] = 1; // Minimum chars to read: return after 1 byte
311+    raw_termios.c_cc[c.VTIME] = 0; // Read timeout: no timeout, return immediately
312+
313+    _ = c.tcsetattr(posix.STDIN_FILENO, c.TCSANOW, &raw_termios);
314+
315+    // Switch to alternate screen buffer and home cursor
316+    // This prevents session output from polluting the terminal after detach
317+    const alt_buffer_seq = "\x1b[?1049h\x1b[H";
318+    _ = try posix.write(posix.STDOUT_FILENO, alt_buffer_seq);
319+
320+    try clientLoop(client_sock);
321+}
322+
323+fn clientLoop(client_sock_fd: i32) !void {
324+    std.log.info("starting client loop client_sock_fd={d}", .{client_sock_fd});
325+    // use c_allocator to avoid "reached unreachable code" panic in DebugAllocator when forking
326+    const alloc = std.heap.c_allocator;
327+
328+    var poll_fds = try std.ArrayList(posix.pollfd).initCapacity(alloc, 2);
329+    defer poll_fds.deinit(alloc);
330+
331+    var read_buf = try ipc.SocketBuffer.init(alloc);
332+    defer read_buf.deinit();
333+
334+    var stdout_buf = try std.ArrayList(u8).initCapacity(alloc, 4096);
335+    defer stdout_buf.deinit(alloc);
336+
337+    const stdin_fd = posix.STDIN_FILENO;
338+
339+    // Make stdin non-blocking
340+    const flags = try posix.fcntl(stdin_fd, posix.F.GETFL, 0);
341+    _ = try posix.fcntl(stdin_fd, posix.F.SETFL, flags | posix.SOCK.NONBLOCK);
342+
343+    while (true) {
344+        poll_fds.clearRetainingCapacity();
345+
346+        try poll_fds.append(alloc, .{
347+            .fd = stdin_fd,
348+            .events = posix.POLL.IN,
349+            .revents = 0,
350+        });
351+
352+        try poll_fds.append(alloc, .{
353+            .fd = client_sock_fd,
354+            .events = posix.POLL.IN,
355+            .revents = 0,
356+        });
357+
358+        if (stdout_buf.items.len > 0) {
359+            try poll_fds.append(alloc, .{
360+                .fd = posix.STDOUT_FILENO,
361+                .events = posix.POLL.OUT,
362+                .revents = 0,
363+            });
364+        }
365+
366+        _ = posix.poll(poll_fds.items, -1) catch |err| {
367+            if (err == error.SystemResources) continue;
368+            return err;
369+        };
370+
371+        // Handle stdin -> socket (Input)
372+        if (poll_fds.items[0].revents & (posix.POLL.IN | posix.POLL.HUP | posix.POLL.ERR) != 0) {
373+            var buf: [4096]u8 = undefined;
374+            const n_opt: ?usize = posix.read(stdin_fd, &buf) catch |err| blk: {
375+                if (err == error.WouldBlock) break :blk null;
376+                return err;
377+            };
378+
379+            if (n_opt) |n| {
380+                if (n > 0) {
381+                    ipc.send(client_sock_fd, .Input, buf[0..n]) catch |err| switch (err) {
382+                        error.BrokenPipe, error.ConnectionResetByPeer => return,
383+                        else => return err,
384+                    };
385+                } else {
386+                    // EOF on stdin
387+                    return;
388+                }
389+            }
390+        }
391+
392+        // Handle socket -> stdout (Output)
393+        if (poll_fds.items[1].revents & posix.POLL.IN != 0) {
394+            const n = read_buf.read(client_sock_fd) catch |err| {
395+                if (err == error.WouldBlock) continue;
396+                if (err == error.ConnectionResetByPeer or err == error.BrokenPipe) {
397+                    std.log.info("client daemon disconnected", .{});
398+                    return;
399+                }
400+                std.log.err("client read error from daemon: {s}", .{@errorName(err)});
401+                return err;
402+            };
403+            if (n == 0) {
404+                std.log.info("client daemon closed connection", .{});
405+                return; // Server closed connection
406+            }
407+
408+            while (read_buf.next()) |msg| {
409+                switch (msg.header.tag) {
410+                    .Output => {
411+                        if (msg.payload.len > 0) {
412+                            try stdout_buf.appendSlice(alloc, msg.payload);
413+                        }
414+                    },
415+                    else => {},
416+                }
417+            }
418+        }
419+
420+        if (stdout_buf.items.len > 0) {
421+            const n = posix.write(posix.STDOUT_FILENO, stdout_buf.items) catch |err| blk: {
422+                if (err == error.WouldBlock) break :blk 0;
423+                return err;
424+            };
425+            if (n > 0) {
426+                try stdout_buf.replaceRange(alloc, 0, n, &[_]u8{});
427+            }
428+        }
429+
430+        if (poll_fds.items[1].revents & (posix.POLL.HUP | posix.POLL.ERR | posix.POLL.NVAL) != 0) {
431+            return;
432+        }
433+    }
434+}
435+
436+fn daemonLoop(daemon: *Daemon, server_sock_fd: i32, pty_fd: i32) !void {
437+    std.log.info("starting daemon loop server_sock_fd={d} pty_fd={d}", .{ server_sock_fd, pty_fd });
438+    var should_exit = false;
439+    var poll_fds = try std.ArrayList(posix.pollfd).initCapacity(daemon.alloc, 8);
440+    defer poll_fds.deinit(daemon.alloc);
441+
442+    while (!should_exit and daemon.running) {
443+        poll_fds.clearRetainingCapacity();
444+
445+        try poll_fds.append(daemon.alloc, .{
446+            .fd = server_sock_fd,
447+            .events = posix.POLL.IN,
448+            .revents = 0,
449+        });
450+
451+        try poll_fds.append(daemon.alloc, .{
452+            .fd = pty_fd,
453+            .events = posix.POLL.IN,
454+            .revents = 0,
455+        });
456+
457+        for (daemon.clients.items) |client| {
458+            var events: i16 = posix.POLL.IN;
459+            if (client.has_pending_output) {
460+                events |= posix.POLL.OUT;
461+            }
462+            try poll_fds.append(daemon.alloc, .{
463+                .fd = client.socket_fd,
464+                .events = events,
465+                .revents = 0,
466+            });
467+        }
468+
469+        _ = posix.poll(poll_fds.items, -1) catch |err| {
470+            if (err == error.SystemResources) {
471+                // Interrupted by signal (EINTR) - check flags (e.g. child exit) and continue
472+                continue;
473+            }
474+            return err;
475+        };
476+
477+        if (poll_fds.items[0].revents & posix.POLL.IN != 0) {
478+            const client_fd = try posix.accept(server_sock_fd, null, null, posix.SOCK.NONBLOCK | posix.SOCK.CLOEXEC);
479+            const client = try daemon.alloc.create(Client);
480+            client.* = Client{
481+                .alloc = daemon.alloc,
482+                .socket_fd = client_fd,
483+                .read_buf = try ipc.SocketBuffer.init(daemon.alloc),
484+                .write_buf = undefined,
485+            };
486+            client.write_buf = try std.ArrayList(u8).initCapacity(client.alloc, 4096);
487+            try daemon.clients.append(daemon.alloc, client);
488+        }
489+
490+        if (poll_fds.items[1].revents & (posix.POLL.IN | posix.POLL.HUP | posix.POLL.ERR) != 0) {
491+            // Read from PTY
492+            var buf: [4096]u8 = undefined;
493+            const n_opt: ?usize = posix.read(pty_fd, &buf) catch |err| blk: {
494+                if (err == error.WouldBlock) break :blk null;
495+                break :blk 0;
496+            };
497+
498+            if (n_opt) |n| {
499+                if (n == 0) {
500+                    // EOF: Shell exited
501+                    std.log.info("shell exited pty_fd={d}", .{pty_fd});
502+                    should_exit = true;
503+                } else {
504+                    // Broadcast data to all clients
505+                    for (daemon.clients.items) |client| {
506+                        ipc.appendMessage(daemon.alloc, &client.write_buf, .Output, buf[0..n]) catch |err| {
507+                            std.log.warn("failed to buffer output for client err={s}", .{@errorName(err)});
508+                            continue;
509+                        };
510+                        client.has_pending_output = true;
511+                    }
512+                }
513+            }
514+        }
515+
516+        var i: usize = daemon.clients.items.len;
517+        // Only iterate over clients that were present when poll_fds was constructed
518+        // poll_fds contains [server, pty, client0, client1, ...]
519+        // So number of clients in poll_fds is poll_fds.items.len - 2
520+        const num_polled_clients = poll_fds.items.len - 2;
521+        if (i > num_polled_clients) {
522+            // If we have more clients than polled (i.e. we just accepted one), start from the polled ones
523+            i = num_polled_clients;
524+        }
525+
526+        clients_loop: while (i > 0) {
527+            i -= 1;
528+            const client = daemon.clients.items[i];
529+            const revents = poll_fds.items[i + 2].revents;
530+
531+            if (revents & posix.POLL.IN != 0) {
532+                const n = client.read_buf.read(client.socket_fd) catch |err| {
533+                    if (err == error.WouldBlock) continue;
534+                    std.log.warn("client read error err={s}", .{@errorName(err)});
535+                    const last = daemon.closeClient(client, i, true);
536+                    if (last) should_exit = true;
537+                    continue;
538+                };
539+
540+                if (n == 0) {
541+                    // Client closed connection
542+                    const last = daemon.closeClient(client, i, true);
543+                    if (last) should_exit = true;
544+                    continue;
545+                }
546+
547+                while (client.read_buf.next()) |msg| {
548+                    switch (msg.header.tag) {
549+                        .Input => {
550+                            if (msg.payload.len > 0) {
551+                                _ = try posix.write(pty_fd, msg.payload);
552+                            }
553+                        },
554+                        .Resize => {
555+                            if (msg.payload.len == @sizeOf(ipc.Resize)) {
556+                                const resize = std.mem.bytesToValue(ipc.Resize, msg.payload);
557+                                var ws: c.struct_winsize = .{
558+                                    .ws_row = resize.rows,
559+                                    .ws_col = resize.cols,
560+                                    .ws_xpixel = 0,
561+                                    .ws_ypixel = 0,
562+                                };
563+                                _ = c.ioctl(pty_fd, c.TIOCSWINSZ, &ws);
564+                            }
565+                        },
566+                        .Detach => {
567+                            _ = daemon.closeClient(client, i, false);
568+                            break :clients_loop;
569+                        },
570+                        .DetachAll => {
571+                            for (daemon.clients.items) |client_to_close| {
572+                                client_to_close.deinit();
573+                                daemon.alloc.destroy(client_to_close);
574+                            }
575+                            daemon.clients.clearRetainingCapacity();
576+                            break :clients_loop;
577+                        },
578+                        .Kill => {
579+                            daemon.shutdown();
580+                            should_exit = true;
581+                            break :clients_loop;
582+                        },
583+                        .Output => {}, // Clients shouldn't send output
584+                    }
585+                }
586+            }
587+
588+            if (revents & posix.POLL.OUT != 0) {
589+                // Flush pending output buffers
590+                const n = posix.write(client.socket_fd, client.write_buf.items) catch |err| blk: {
591+                    if (err == error.WouldBlock) break :blk 0;
592+                    // Error on write, close client
593+                    const last = daemon.closeClient(client, i, true);
594+                    if (last) should_exit = true;
595+                    continue;
596+                };
597+
598+                if (n > 0) {
599+                    client.write_buf.replaceRange(daemon.alloc, 0, n, &[_]u8{}) catch unreachable;
600+                }
601+
602+                if (client.write_buf.items.len == 0) {
603+                    client.has_pending_output = false;
604+                }
605+            }
606+
607+            if (revents & (posix.POLL.HUP | posix.POLL.ERR | posix.POLL.NVAL) != 0) {
608+                const last = daemon.closeClient(client, i, true);
609+                if (last) should_exit = true;
610+            }
611+        }
612+    }
613+}
614+
615+fn spawnPty(daemon: *Daemon) !c_int {
616+    std.log.info("spawning pty session_name={s}", .{daemon.session_name});
617+    // Get terminal size
618+    var orig_ws: c.struct_winsize = undefined;
619+    const result = c.ioctl(posix.STDOUT_FILENO, c.TIOCGWINSZ, &orig_ws);
620+    const rows: u16 = if (result == 0) orig_ws.ws_row else 24;
621+    const cols: u16 = if (result == 0) orig_ws.ws_col else 80;
622+    var ws: c.struct_winsize = .{
623+        .ws_row = rows,
624+        .ws_col = cols,
625+        .ws_xpixel = 0,
626+        .ws_ypixel = 0,
627+    };
628+
629+    var master_fd: c_int = undefined;
630+    const pid = c.forkpty(&master_fd, null, null, &ws);
631+    if (pid < 0) {
632+        return error.ForkPtyFailed;
633+    }
634+
635+    if (pid == 0) { // child pid code path
636+        const session_env = try std.fmt.allocPrint(daemon.alloc, "ZMX_SESSION={s}\x00", .{daemon.session_name});
637+        _ = c.putenv(@ptrCast(session_env.ptr));
638+
639+        const shell = std.posix.getenv("SHELL") orelse "/bin/sh";
640+        const argv = [_:null]?[*:0]const u8{ shell, null };
641+        const err = std.posix.execveZ(shell, &argv, std.c.environ);
642+        std.log.err("execve failed: err={s}", .{@errorName(err)});
643+        std.posix.exit(1);
644+    }
645+    // master pid code path
646+
647+    std.log.info("created pty session: session_name={s} master_pid={d} child_pid={d}", .{ daemon.session_name, master_fd, pid });
648+
649+    // make pty non-blocking
650+    const flags = try posix.fcntl(master_fd, posix.F.GETFL, 0);
651+    _ = try posix.fcntl(master_fd, posix.F.SETFL, flags | @as(u32, 0o4000));
652+    return master_fd;
653+}
654+
655+fn sessionConnect(fname: []const u8) !i32 {
656+    var unix_addr = try std.net.Address.initUnix(fname);
657+    const socket_fd = try posix.socket(posix.AF.UNIX, posix.SOCK.STREAM | posix.SOCK.CLOEXEC, 0);
658+    posix.connect(socket_fd, &unix_addr.any, unix_addr.getOsSockLen()) catch |err| {
659+        if (err == error.ConnectionRefused) {
660+            std.log.err("unable to connect to unix socket fname={s}", .{fname});
661+            return err;
662+        }
663+        return err;
664+    };
665+    std.log.info("unix socket connected client_socket_fd={d}", .{socket_fd});
666+    return socket_fd;
667+}
668+
669+fn sessionExists(dir: std.fs.Dir, name: []const u8) !bool {
670+    const stat = dir.statFile(name) catch |err| switch (err) {
671+        error.FileNotFound => return false,
672+        else => return err,
673+    };
674+    if (stat.kind != .unix_domain_socket) {
675+        return error.FileNotUnixSocket;
676+    }
677+    return true;
678+}
679+
680+fn createSocket(fname: []const u8) !i32 {
681+    std.log.info("creating unix socket fname={s}", .{fname});
682+    // AF.UNIX: Unix domain socket for local IPC with client processes
683+    // SOCK.STREAM: Reliable, bidirectional communication
684+    // SOCK.NONBLOCK: Set socket to non-blocking
685+    const fd = try posix.socket(posix.AF.UNIX, posix.SOCK.STREAM | posix.SOCK.NONBLOCK | posix.SOCK.CLOEXEC, 0);
686+
687+    var unix_addr = try std.net.Address.initUnix(fname);
688+    try posix.bind(fd, &unix_addr.any, unix_addr.getOsSockLen());
689+    try posix.listen(fd, 128);
690+    return fd;
691+}
692+
693+pub fn getSocketPath(alloc: std.mem.Allocator, socket_dir: []const u8, session_name: []const u8) ![]const u8 {
694+    const dir = socket_dir;
695+    const fname = try alloc.alloc(u8, dir.len + session_name.len + 1);
696+    @memcpy(fname[0..dir.len], dir);
697+    @memcpy(fname[dir.len .. dir.len + 1], "/");
698+    @memcpy(fname[dir.len + 1 ..], session_name);
699+    return fname;
700 }
D src/protocol.zig
+0, -289
  1@@ -1,289 +0,0 @@
  2-const std = @import("std");
  3-const posix = std.posix;
  4-
  5-// Message types enum for type-safe dispatching
  6-pub const MessageType = enum {
  7-    // Client -> Daemon requests
  8-    attach_session_request,
  9-    detach_session_request,
 10-    kill_session_request,
 11-    list_sessions_request,
 12-    window_resize,
 13-
 14-    // Daemon -> Client responses
 15-    attach_session_response,
 16-    detach_session_response,
 17-    kill_session_response,
 18-    list_sessions_response,
 19-
 20-    // Daemon -> Client notifications
 21-    detach_notification,
 22-    kill_notification,
 23-
 24-    pub fn toString(self: MessageType) []const u8 {
 25-        return switch (self) {
 26-            .attach_session_request => "attach_session_request",
 27-            .detach_session_request => "detach_session_request",
 28-            .kill_session_request => "kill_session_request",
 29-            .list_sessions_request => "list_sessions_request",
 30-            .window_resize => "window_resize",
 31-            .attach_session_response => "attach_session_response",
 32-            .detach_session_response => "detach_session_response",
 33-            .kill_session_response => "kill_session_response",
 34-            .list_sessions_response => "list_sessions_response",
 35-            .detach_notification => "detach_notification",
 36-            .kill_notification => "kill_notification",
 37-        };
 38-    }
 39-
 40-    pub fn fromString(s: []const u8) ?MessageType {
 41-        const map = std.StaticStringMap(MessageType).initComptime(.{
 42-            .{ "attach_session_request", .attach_session_request },
 43-            .{ "detach_session_request", .detach_session_request },
 44-            .{ "kill_session_request", .kill_session_request },
 45-            .{ "list_sessions_request", .list_sessions_request },
 46-            .{ "window_resize", .window_resize },
 47-            .{ "attach_session_response", .attach_session_response },
 48-            .{ "detach_session_response", .detach_session_response },
 49-            .{ "kill_session_response", .kill_session_response },
 50-            .{ "list_sessions_response", .list_sessions_response },
 51-            .{ "detach_notification", .detach_notification },
 52-            .{ "kill_notification", .kill_notification },
 53-        });
 54-        return map.get(s);
 55-    }
 56-};
 57-
 58-// Typed payload structs for requests
 59-pub const AttachSessionRequest = struct {
 60-    session_name: []const u8,
 61-    rows: u16,
 62-    cols: u16,
 63-    cwd: []const u8,
 64-};
 65-
 66-pub const DetachSessionRequest = struct {
 67-    session_name: []const u8,
 68-    client_fd: ?i64 = null,
 69-};
 70-
 71-pub const KillSessionRequest = struct {
 72-    session_name: []const u8,
 73-};
 74-
 75-pub const ListSessionsRequest = struct {};
 76-
 77-pub const WindowResize = struct {
 78-    rows: u16,
 79-    cols: u16,
 80-};
 81-
 82-// Typed payload structs for responses
 83-pub const SessionInfo = struct {
 84-    name: []const u8,
 85-    status: []const u8,
 86-    clients: i64,
 87-    created_at: []const u8,
 88-};
 89-
 90-pub const AttachSessionResponse = struct {
 91-    status: []const u8,
 92-    client_fd: ?i64 = null,
 93-    error_message: ?[]const u8 = null,
 94-};
 95-
 96-pub const DetachSessionResponse = struct {
 97-    status: []const u8,
 98-    error_message: ?[]const u8 = null,
 99-};
100-
101-pub const KillSessionResponse = struct {
102-    status: []const u8,
103-    error_message: ?[]const u8 = null,
104-};
105-
106-pub const ListSessionsResponse = struct {
107-    status: []const u8,
108-    sessions: []SessionInfo = &.{},
109-    error_message: ?[]const u8 = null,
110-};
111-
112-pub const DetachNotification = struct {
113-    session_name: []const u8,
114-};
115-
116-pub const KillNotification = struct {
117-    session_name: []const u8,
118-};
119-
120-// Generic message wrapper
121-pub fn Message(comptime T: type) type {
122-    return struct {
123-        type: []const u8,
124-        payload: T,
125-    };
126-}
127-
128-// Helper to write a JSON message to a file descriptor
129-pub fn writeJson(allocator: std.mem.Allocator, fd: posix.fd_t, msg_type: MessageType, payload: anytype) !void {
130-    var out: std.io.Writer.Allocating = .init(allocator);
131-    defer out.deinit();
132-
133-    const msg = Message(@TypeOf(payload)){
134-        .type = msg_type.toString(),
135-        .payload = payload,
136-    };
137-
138-    var stringify: std.json.Stringify = .{ .writer = &out.writer };
139-    try stringify.write(msg);
140-    try out.writer.writeByte('\n');
141-
142-    _ = try posix.write(fd, out.written());
143-}
144-
145-// Helper to write a raw JSON response (for complex cases like list_sessions with dynamic arrays)
146-pub fn writeJsonRaw(fd: posix.fd_t, json_str: []const u8) !void {
147-    _ = try posix.write(fd, json_str);
148-}
149-
150-// Helper to parse a JSON message from a line
151-pub fn parseMessage(comptime T: type, allocator: std.mem.Allocator, line: []const u8) !std.json.Parsed(Message(T)) {
152-    return try std.json.parseFromSlice(
153-        Message(T),
154-        allocator,
155-        line,
156-        .{ .ignore_unknown_fields = true },
157-    );
158-}
159-
160-// Helper to parse just the message type from a line (for dispatching)
161-const MessageTypeOnly = struct { type: []const u8 };
162-pub fn parseMessageType(allocator: std.mem.Allocator, line: []const u8) !std.json.Parsed(MessageTypeOnly) {
163-    return try std.json.parseFromSlice(
164-        MessageTypeOnly,
165-        allocator,
166-        line,
167-        .{ .ignore_unknown_fields = true },
168-    );
169-}
170-
171-// NDJSON line buffering helper
172-pub const LineBuffer = struct {
173-    buffer: std.ArrayList(u8),
174-
175-    pub fn init(allocator: std.mem.Allocator) LineBuffer {
176-        return .{ .buffer = std.ArrayList(u8).init(allocator) };
177-    }
178-
179-    pub fn deinit(self: *LineBuffer) void {
180-        self.buffer.deinit();
181-    }
182-
183-    // Append new data and return an iterator over complete lines
184-    pub fn appendData(self: *LineBuffer, data: []const u8) !LineIterator {
185-        try self.buffer.appendSlice(data);
186-        return LineIterator{ .buffer = &self.buffer };
187-    }
188-
189-    pub const LineIterator = struct {
190-        buffer: *std.ArrayList(u8),
191-        offset: usize = 0,
192-
193-        pub fn next(self: *LineIterator) ?[]const u8 {
194-            if (self.offset >= self.buffer.items.len) return null;
195-
196-            const remaining = self.buffer.items[self.offset..];
197-            const newline_idx = std.mem.indexOf(u8, remaining, "\n") orelse return null;
198-
199-            const line = remaining[0..newline_idx];
200-            self.offset += newline_idx + 1;
201-            return line;
202-        }
203-
204-        // Call this after iteration to remove processed lines
205-        pub fn compact(self: *LineIterator) void {
206-            if (self.offset > 0) {
207-                const remaining = self.buffer.items[self.offset..];
208-                std.mem.copyForwards(u8, self.buffer.items, remaining);
209-                self.buffer.shrinkRetainingCapacity(remaining.len);
210-            }
211-        }
212-    };
213-};
214-
215-// Binary frame support for PTY data
216-// This infrastructure allows us to add binary framing later without breaking existing code
217-pub const FrameType = enum(u16) {
218-    json_control = 1, // JSON-encoded control messages (current protocol)
219-    pty_binary = 2, // Raw PTY bytes
220-};
221-
222-pub const FrameHeader = packed struct {
223-    length: u32, // little-endian, total payload length
224-    frame_type: u16, // little-endian, FrameType value
225-};
226-
227-// Helper to write a binary frame
228-pub fn writeBinaryFrame(fd: posix.fd_t, frame_type: FrameType, payload: []const u8) !void {
229-    const header = FrameHeader{
230-        .length = @intCast(payload.len),
231-        .frame_type = @intFromEnum(frame_type),
232-    };
233-
234-    const header_bytes = std.mem.asBytes(&header);
235-    _ = try posix.write(fd, header_bytes);
236-    _ = try posix.write(fd, payload);
237-}
238-
239-// Helper to read a binary frame (not used yet)
240-pub fn readBinaryFrame(allocator: std.mem.Allocator, fd: posix.fd_t) !struct { frame_type: FrameType, payload: []u8 } {
241-    var header_bytes: [@sizeOf(FrameHeader)]u8 = undefined;
242-    const read_len = try posix.read(fd, &header_bytes);
243-    if (read_len != @sizeOf(FrameHeader)) return error.IncompleteFrame;
244-
245-    const header: *const FrameHeader = @ptrCast(@alignCast(&header_bytes));
246-    const payload = try allocator.alloc(u8, header.length);
247-    errdefer allocator.free(payload);
248-
249-    const payload_read = try posix.read(fd, payload);
250-    if (payload_read != header.length) return error.IncompleteFrame;
251-
252-    return .{
253-        .frame_type = @enumFromInt(header.frame_type),
254-        .payload = payload,
255-    };
256-}
257-
258-// Tests
259-test "MessageType string conversion" {
260-    const attach = MessageType.attach_session_request;
261-    try std.testing.expectEqualStrings("attach_session_request", attach.toString());
262-
263-    const parsed = MessageType.fromString("attach_session_request");
264-    try std.testing.expect(parsed != null);
265-    try std.testing.expectEqual(MessageType.attach_session_request, parsed.?);
266-}
267-
268-test "LineBuffer iteration" {
269-    const allocator = std.testing.allocator;
270-    var buf = LineBuffer.init(allocator);
271-    defer buf.deinit();
272-
273-    var iter = try buf.appendData("line1\nline2\n");
274-    try std.testing.expectEqualStrings("line1", iter.next().?);
275-    try std.testing.expectEqualStrings("line2", iter.next().?);
276-    try std.testing.expect(iter.next() == null);
277-    iter.compact();
278-
279-    // Incomplete line should remain
280-    iter = try buf.appendData("incomplete");
281-    try std.testing.expect(iter.next() == null);
282-    iter.compact();
283-    try std.testing.expectEqual(10, buf.buffer.items.len);
284-
285-    // Complete the line
286-    iter = try buf.appendData(" line\n");
287-    try std.testing.expectEqualStrings("incomplete line", iter.next().?);
288-    iter.compact();
289-    try std.testing.expectEqual(0, buf.buffer.items.len);
290-}
D src/sgr.zig
+0, -233
  1@@ -1,233 +0,0 @@
  2-const std = @import("std");
  3-const ghostty = @import("ghostty-vt");
  4-
  5-/// Helper functions for generating ANSI SGR (Select Graphic Rendition) escape sequences
  6-/// from ghostty-vt Style objects. Used to restore terminal styling when reattaching to sessions.
  7-/// Generate SGR sequence to change from old_style to new_style.
  8-/// Emits minimal SGR codes to reduce output size.
  9-/// Returns owned slice that caller must free.
 10-pub fn emitStyleChange(
 11-    allocator: std.mem.Allocator,
 12-    old_style: ghostty.Style,
 13-    new_style: ghostty.Style,
 14-) ![]u8 {
 15-    var buf = try std.ArrayList(u8).initCapacity(allocator, 64);
 16-
 17-    // If new style is default, emit reset
 18-    if (new_style.default()) {
 19-        try buf.appendSlice(allocator, "\x1b[0m");
 20-        return buf.toOwnedSlice(allocator);
 21-    }
 22-
 23-    // Start escape sequence
 24-    try buf.appendSlice(allocator, "\x1b[");
 25-    var first = true;
 26-
 27-    // Helper to add separator
 28-    const addSep = struct {
 29-        fn call(b: *std.ArrayList(u8), alloc: std.mem.Allocator, is_first: *bool) !void {
 30-            if (!is_first.*) {
 31-                try b.append(alloc, ';');
 32-            }
 33-            is_first.* = false;
 34-        }
 35-    }.call;
 36-
 37-    // Bold
 38-    if (new_style.flags.bold != old_style.flags.bold) {
 39-        try addSep(&buf, allocator, &first);
 40-        if (new_style.flags.bold) {
 41-            try buf.append(allocator, '1');
 42-        } else {
 43-            try buf.appendSlice(allocator, "22");
 44-        }
 45-    }
 46-
 47-    // Faint
 48-    if (new_style.flags.faint != old_style.flags.faint) {
 49-        try addSep(&buf, allocator, &first);
 50-        if (new_style.flags.faint) {
 51-            try buf.append(allocator, '2');
 52-        } else {
 53-            try buf.appendSlice(allocator, "22");
 54-        }
 55-    }
 56-
 57-    // Italic
 58-    if (new_style.flags.italic != old_style.flags.italic) {
 59-        try addSep(&buf, allocator, &first);
 60-        if (new_style.flags.italic) {
 61-            try buf.append(allocator, '3');
 62-        } else {
 63-            try buf.appendSlice(allocator, "23");
 64-        }
 65-    }
 66-
 67-    // Underline
 68-    if (!std.meta.eql(new_style.flags.underline, old_style.flags.underline)) {
 69-        try addSep(&buf, allocator, &first);
 70-        switch (new_style.flags.underline) {
 71-            .none => try buf.appendSlice(allocator, "24"),
 72-            .single => try buf.append(allocator, '4'),
 73-            .double => try buf.appendSlice(allocator, "21"),
 74-            .curly => try buf.appendSlice(allocator, "4:3"),
 75-            .dotted => try buf.appendSlice(allocator, "4:4"),
 76-            .dashed => try buf.appendSlice(allocator, "4:5"),
 77-        }
 78-    }
 79-
 80-    // Blink
 81-    if (new_style.flags.blink != old_style.flags.blink) {
 82-        try addSep(&buf, allocator, &first);
 83-        if (new_style.flags.blink) {
 84-            try buf.append(allocator, '5');
 85-        } else {
 86-            try buf.appendSlice(allocator, "25");
 87-        }
 88-    }
 89-
 90-    // Inverse
 91-    if (new_style.flags.inverse != old_style.flags.inverse) {
 92-        try addSep(&buf, allocator, &first);
 93-        if (new_style.flags.inverse) {
 94-            try buf.append(allocator, '7');
 95-        } else {
 96-            try buf.appendSlice(allocator, "27");
 97-        }
 98-    }
 99-
100-    // Invisible
101-    if (new_style.flags.invisible != old_style.flags.invisible) {
102-        try addSep(&buf, allocator, &first);
103-        if (new_style.flags.invisible) {
104-            try buf.append(allocator, '8');
105-        } else {
106-            try buf.appendSlice(allocator, "28");
107-        }
108-    }
109-
110-    // Strikethrough
111-    if (new_style.flags.strikethrough != old_style.flags.strikethrough) {
112-        try addSep(&buf, allocator, &first);
113-        if (new_style.flags.strikethrough) {
114-            try buf.append(allocator, '9');
115-        } else {
116-            try buf.appendSlice(allocator, "29");
117-        }
118-    }
119-
120-    // Foreground color
121-    if (!std.meta.eql(new_style.fg_color, old_style.fg_color)) {
122-        try addSep(&buf, allocator, &first);
123-        switch (new_style.fg_color) {
124-            .none => try buf.appendSlice(allocator, "39"),
125-            .palette => |idx| {
126-                try buf.appendSlice(allocator, "38;5;");
127-                try std.fmt.format(buf.writer(allocator), "{d}", .{idx});
128-            },
129-            .rgb => |rgb| {
130-                try buf.appendSlice(allocator, "38;2;");
131-                try std.fmt.format(buf.writer(allocator), "{d};{d};{d}", .{ rgb.r, rgb.g, rgb.b });
132-            },
133-        }
134-    }
135-
136-    // Background color
137-    if (!std.meta.eql(new_style.bg_color, old_style.bg_color)) {
138-        try addSep(&buf, allocator, &first);
139-        switch (new_style.bg_color) {
140-            .none => try buf.appendSlice(allocator, "49"),
141-            .palette => |idx| {
142-                try buf.appendSlice(allocator, "48;5;");
143-                try std.fmt.format(buf.writer(allocator), "{d}", .{idx});
144-            },
145-            .rgb => |rgb| {
146-                try buf.appendSlice(allocator, "48;2;");
147-                try std.fmt.format(buf.writer(allocator), "{d};{d};{d}", .{ rgb.r, rgb.g, rgb.b });
148-            },
149-        }
150-    }
151-
152-    // Underline color (not all terminals support this, but emit it anyway)
153-    if (!std.meta.eql(new_style.underline_color, old_style.underline_color)) {
154-        try addSep(&buf, allocator, &first);
155-        switch (new_style.underline_color) {
156-            .none => try buf.appendSlice(allocator, "59"),
157-            .palette => |idx| {
158-                try buf.appendSlice(allocator, "58;5;");
159-                try std.fmt.format(buf.writer(allocator), "{d}", .{idx});
160-            },
161-            .rgb => |rgb| {
162-                try buf.appendSlice(allocator, "58;2;");
163-                try std.fmt.format(buf.writer(allocator), "{d};{d};{d}", .{ rgb.r, rgb.g, rgb.b });
164-            },
165-        }
166-    }
167-
168-    // End escape sequence
169-    try buf.append(allocator, 'm');
170-
171-    // If we only added the escape opener and closer with nothing in between,
172-    // return empty string (no change needed)
173-    if (first) {
174-        buf.deinit(allocator);
175-        return allocator.dupe(u8, "");
176-    }
177-
178-    return buf.toOwnedSlice(allocator);
179-}
180-
181-test "emitStyleChange: default to default" {
182-    const allocator = std.testing.allocator;
183-    const result = try emitStyleChange(allocator, .{}, .{});
184-    defer allocator.free(result);
185-    try std.testing.expectEqualStrings("", result);
186-}
187-
188-test "emitStyleChange: bold" {
189-    const allocator = std.testing.allocator;
190-    var new_style = ghostty.Style{};
191-    new_style.flags.bold = true;
192-    const result = try emitStyleChange(allocator, .{}, new_style);
193-    defer allocator.free(result);
194-    try std.testing.expectEqualStrings("\x1b[1m", result);
195-}
196-
197-test "emitStyleChange: reset to default" {
198-    const allocator = std.testing.allocator;
199-    var old_style = ghostty.Style{};
200-    old_style.flags.bold = true;
201-    old_style.flags.italic = true;
202-    const result = try emitStyleChange(allocator, old_style, .{});
203-    defer allocator.free(result);
204-    try std.testing.expectEqualStrings("\x1b[0m", result);
205-}
206-
207-test "emitStyleChange: palette color" {
208-    const allocator = std.testing.allocator;
209-    var new_style = ghostty.Style{};
210-    new_style.fg_color = .{ .palette = 196 }; // red
211-    const result = try emitStyleChange(allocator, .{}, new_style);
212-    defer allocator.free(result);
213-    try std.testing.expectEqualStrings("\x1b[38;5;196m", result);
214-}
215-
216-test "emitStyleChange: rgb color" {
217-    const allocator = std.testing.allocator;
218-    var new_style = ghostty.Style{};
219-    new_style.bg_color = .{ .rgb = .{ .r = 255, .g = 128, .b = 64 } };
220-    const result = try emitStyleChange(allocator, .{}, new_style);
221-    defer allocator.free(result);
222-    try std.testing.expectEqualStrings("\x1b[48;2;255;128;64m", result);
223-}
224-
225-test "emitStyleChange: multiple attributes" {
226-    const allocator = std.testing.allocator;
227-    var new_style = ghostty.Style{};
228-    new_style.flags.bold = true;
229-    new_style.flags.italic = true;
230-    new_style.fg_color = .{ .palette = 10 };
231-    const result = try emitStyleChange(allocator, .{}, new_style);
232-    defer allocator.free(result);
233-    try std.testing.expectEqualStrings("\x1b[1;3;38;5;10m", result);
234-}
D src/terminal_snapshot.zig
+0, -425
  1@@ -1,425 +0,0 @@
  2-const std = @import("std");
  3-const ghostty = @import("ghostty-vt");
  4-const sgr = @import("sgr.zig");
  5-
  6-/// Terminal snapshot rendering for session persistence.
  7-///
  8-/// This module renders the current viewport state (text, colors, cursor position)
  9-/// as a sequence of ANSI escape codes that can be sent to a client to restore
 10-/// the visual terminal state when reattaching to a session.
 11-///
 12-/// Current implementation: Viewport-only rendering
 13-/// - Renders only the visible active viewport (not full scrollback)
 14-/// - Includes text content, SGR attributes (colors, bold, italic, etc.), and cursor position
 15-/// - Handles single/multi-codepoint graphemes and wide characters correctly
 16-///
 17-/// Future work (see bd-10, bd-11):
 18-/// - Full scrollback history rendering (see bd-10)
 19-/// - Alternate screen buffer detection and handling (see bd-11)
 20-/// - Mode restoration (bracketed paste, origin mode, etc.)
 21-/// - Hyperlink reconstitution (OSC 8 sequences)
 22-/// - Handling buffered/unprocessed PTY data (see bd-11)
 23-///
 24-/// Why viewport-only?
 25-/// - Avoids payload bloat on reattach (megabytes for large scrollback)
 26-/// - Prevents scrollback duplication in client terminal on multiple reattaches
 27-/// - Faster rendering and simpler implementation
 28-/// - Server owns the "true" scrollback, can add browsing features later
 29-/// Extract UTF-8 text content from a cell, including multi-codepoint graphemes
 30-fn extractCellText(pin: ghostty.Pin, cell: *const ghostty.Cell, buf: *std.ArrayList(u8), allocator: std.mem.Allocator) !void {
 31-    // Skip empty cells and spacer cells
 32-    if (cell.isEmpty()) return;
 33-    if (cell.wide == .spacer_tail or cell.wide == .spacer_head) return;
 34-
 35-    // Get the first codepoint
 36-    const cp = cell.codepoint();
 37-    if (cp == 0) return; // Empty cell
 38-
 39-    // Encode first codepoint to UTF-8
 40-    var utf8_buf: [4]u8 = undefined;
 41-    const len = std.unicode.utf8Encode(cp, &utf8_buf) catch return;
 42-    try buf.appendSlice(allocator, utf8_buf[0..len]);
 43-
 44-    // If this is a multi-codepoint grapheme, encode the rest
 45-    if (cell.hasGrapheme()) {
 46-        if (pin.grapheme(cell)) |codepoints| {
 47-            for (codepoints) |extra_cp| {
 48-                const extra_len = std.unicode.utf8Encode(extra_cp, &utf8_buf) catch continue;
 49-                try buf.appendSlice(allocator, utf8_buf[0..extra_len]);
 50-            }
 51-        }
 52-    }
 53-}
 54-
 55-/// Render the current terminal viewport state as text with proper escape sequences
 56-/// Returns owned slice that must be freed by caller
 57-pub fn render(vt: *ghostty.Terminal, allocator: std.mem.Allocator) ![]u8 {
 58-    var output = try std.ArrayList(u8).initCapacity(allocator, 4096);
 59-    errdefer output.deinit(allocator);
 60-
 61-    // Check if we're on alternate screen (vim, less, etc.)
 62-    const is_alt_screen = vt.active_screen == .alternate;
 63-
 64-    // Prepare terminal: hide cursor, reset scroll region, reset SGR
 65-    try output.appendSlice(allocator, "\x1b[?25l"); // Hide cursor
 66-    try output.appendSlice(allocator, "\x1b[r"); // Reset scroll region
 67-    try output.appendSlice(allocator, "\x1b[0m"); // Reset SGR (colors/styles)
 68-
 69-    // If alternate screen, switch to it before rendering
 70-    if (is_alt_screen) {
 71-        try output.appendSlice(allocator, "\x1b[?1049h"); // Enter alt screen (save cursor, switch, clear)
 72-        try output.appendSlice(allocator, "\x1b[2J"); // Clear alt screen explicitly
 73-        try output.appendSlice(allocator, "\x1b[H"); // Home cursor
 74-    } else {
 75-        try output.appendSlice(allocator, "\x1b[2J"); // Clear entire screen
 76-        try output.appendSlice(allocator, "\x1b[H"); // Home cursor (1,1)
 77-    }
 78-
 79-    // Get the terminal's page list (for active screen)
 80-    const pages = &vt.screen.pages;
 81-
 82-    // Create row iterator for active viewport
 83-    var row_it = pages.rowIterator(.right_down, .{ .active = .{} }, null);
 84-
 85-    // Iterate through viewport rows
 86-    var row_idx: usize = 0;
 87-    while (row_it.next()) |pin| : (row_idx += 1) {
 88-        // Position cursor at the start of this row (1-based indexing)
 89-        const row_num = row_idx + 1;
 90-        try std.fmt.format(output.writer(allocator), "\x1b[{d};1H", .{row_num});
 91-
 92-        // Clear the entire line to avoid stale content
 93-        try output.appendSlice(allocator, "\x1b[2K");
 94-
 95-        // Get row and cell data from pin
 96-        const rac = pin.rowAndCell();
 97-        const row = rac.row;
 98-        const page = &pin.node.data;
 99-        const cells = page.getCells(row);
100-
101-        // Track style changes to emit SGR sequences
102-        var last_style = ghostty.Style{}; // Start with default style
103-
104-        // Extract text from each cell in the row
105-        var col_idx: usize = 0;
106-        while (col_idx < cells.len) : (col_idx += 1) {
107-            const cell = &cells[col_idx];
108-
109-            // Skip spacer cells (already handled by extractCellText, but we still need to skip the iteration)
110-            if (cell.wide == .spacer_tail or cell.wide == .spacer_head) continue;
111-
112-            // Create a pin for this specific cell to access graphemes
113-            const cell_pin = ghostty.Pin{
114-                .node = pin.node,
115-                .y = pin.y,
116-                .x = @intCast(col_idx),
117-            };
118-
119-            // Get the style for this cell
120-            const cell_style = cell_pin.style(cell);
121-
122-            // If style changed, emit SGR sequence
123-            if (!cell_style.eql(last_style)) {
124-                const sgr_seq = try sgr.emitStyleChange(allocator, last_style, cell_style);
125-                defer allocator.free(sgr_seq);
126-                try output.appendSlice(allocator, sgr_seq);
127-                last_style = cell_style;
128-            }
129-
130-            try extractCellText(cell_pin, cell, &output, allocator);
131-
132-            // If this is a wide character, skip the next cell (spacer_tail)
133-            if (cell.wide == .wide) {
134-                col_idx += 1; // Skip the spacer cell that follows
135-            }
136-        }
137-
138-        // Reset style at end of row to avoid style bleeding
139-        if (!last_style.default()) {
140-            try output.appendSlice(allocator, "\x1b[0m");
141-        }
142-    }
143-
144-    // Restore cursor position from terminal state
145-    const cursor = vt.screen.cursor;
146-    const cursor_row = cursor.y + 1; // Convert to 1-based
147-    var cursor_col: u16 = @intCast(cursor.x + 1); // Convert to 1-based
148-
149-    // If cursor is at x=0, try to find the actual end of content on that row
150-    // This handles race conditions where the cursor position wasn't updated yet
151-    if (cursor.x == 0) {
152-        const cursor_pin = pages.pin(.{ .active = .{ .x = 0, .y = cursor.y } });
153-        if (cursor_pin) |cpin| {
154-            const crac = cpin.rowAndCell();
155-            const crow = crac.row;
156-            const cpage = &cpin.node.data;
157-            const ccells = cpage.getCells(crow);
158-
159-            // Find the last non-empty cell (including spaces)
160-            var last_col: usize = 0;
161-            var col: usize = 0;
162-            while (col < ccells.len) : (col += 1) {
163-                const cell = &ccells[col];
164-                if (cell.wide == .spacer_tail or cell.wide == .spacer_head) continue;
165-                const cp = cell.codepoint();
166-                if (cp != 0) { // Include spaces, just not null
167-                    last_col = col;
168-                }
169-                if (cell.wide == .wide) col += 1;
170-            }
171-
172-            // If we found content, position cursor after the last character
173-            if (last_col > 0) {
174-                cursor_col = @intCast(last_col + 2); // +1 for after character, +1 for 1-based
175-            }
176-        }
177-    }
178-
179-    // Restore scroll margins from terminal state (critical for vim scrolling)
180-    const scroll = vt.scrolling_region;
181-    const is_full_tb = (scroll.top == 0 and scroll.bottom == vt.rows - 1);
182-
183-    if (!is_full_tb) {
184-        // Restore top/bottom margins
185-        const top = scroll.top + 1; // Convert to 1-based
186-        const bottom = scroll.bottom + 1; // Convert to 1-based
187-        try std.fmt.format(output.writer(allocator), "\x1b[{d};{d}r", .{ top, bottom });
188-    }
189-
190-    // Restore terminal modes (critical for vim and other apps)
191-    // These modes affect cursor positioning, scrolling, and input behavior
192-
193-    // Origin mode (?6 / DECOM): cursor positioning relative to margins
194-    const origin = vt.modes.get(.origin);
195-    if (origin) {
196-        try output.appendSlice(allocator, "\x1b[?6h");
197-    }
198-
199-    // Wraparound mode (?7 / DECAWM): automatic line wrapping
200-    const wrap = vt.modes.get(.wraparound);
201-    if (!wrap) { // Default is true, so only emit if disabled
202-        try output.appendSlice(allocator, "\x1b[?7l");
203-    }
204-
205-    // Reverse wraparound (?45): bidirectional wrapping
206-    const reverse_wrap = vt.modes.get(.reverse_wrap);
207-    if (reverse_wrap) {
208-        try output.appendSlice(allocator, "\x1b[?45h");
209-    }
210-
211-    // Bracketed paste (?2004): paste detection
212-    const bracketed = vt.modes.get(.bracketed_paste);
213-    if (bracketed) {
214-        try output.appendSlice(allocator, "\x1b[?2004h");
215-    }
216-
217-    // TODO: Restore left/right margins if enabled (need to check modes for left_right_margins)
218-
219-    // Compute cursor position (may be relative to scroll margins if origin mode is on)
220-    // Note: The terminal stores cursor.y as absolute coordinates (0-based from top of screen)
221-    // When origin mode is enabled, the CSI H (cursor position) escape code expects coordinates
222-    // relative to the scroll region, so we need to subtract the top margin offset
223-    var final_cursor_row = cursor_row;
224-    const final_cursor_col = cursor_col;
225-
226-    // If origin mode is on, cursor coordinates must be relative to the top/left margins
227-    if (origin and !is_full_tb) {
228-        final_cursor_row = (cursor_row -| (scroll.top + 1)) + 1;
229-        // TODO: Also handle left/right margins if left_right_margins mode is enabled
230-    }
231-
232-    try std.fmt.format(output.writer(allocator), "\x1b[{d};{d}H", .{ final_cursor_row, final_cursor_col });
233-
234-    // Show cursor
235-    try output.appendSlice(allocator, "\x1b[?25h");
236-
237-    return output.toOwnedSlice(allocator);
238-}
239-
240-test "render: rowIterator viewport iteration" {
241-    const testing = std.testing;
242-    const allocator = testing.allocator;
243-
244-    // Create a simple terminal
245-    var vt = try ghostty.Terminal.init(allocator, 80, 24, 100);
246-    defer vt.deinit(allocator);
247-
248-    // Write some content
249-    try vt.print('H');
250-    try vt.print('e');
251-    try vt.print('l');
252-    try vt.print('l');
253-    try vt.print('o');
254-
255-    // Test that we can iterate through viewport using rowIterator
256-    const pages = &vt.screen.pages;
257-    var row_it = pages.rowIterator(.right_down, .{ .active = .{} }, null);
258-
259-    var row_count: usize = 0;
260-    while (row_it.next()) |pin| : (row_count += 1) {
261-        const rac = pin.rowAndCell();
262-        _ = rac; // Just verify we can access row and cell
263-    }
264-
265-    try testing.expectEqual(pages.rows, row_count);
266-}
267-
268-test "extractCellText: single codepoint" {
269-    const testing = std.testing;
270-    const allocator = testing.allocator;
271-
272-    var vt = try ghostty.Terminal.init(allocator, 80, 24, 100);
273-    defer vt.deinit(allocator);
274-
275-    // Write ASCII text
276-    try vt.print('A');
277-    try vt.print('B');
278-    try vt.print('C');
279-
280-    // Get the first cell
281-    const pages = &vt.screen.pages;
282-    const pin = pages.pin(.{ .active = .{} }).?;
283-    const rac = pin.rowAndCell();
284-    const page = &pin.node.data;
285-    const cells = page.getCells(rac.row);
286-
287-    // Extract text from first 3 cells
288-    var buf = try std.ArrayList(u8).initCapacity(allocator, 64);
289-    defer buf.deinit(allocator);
290-
291-    for (cells[0..3], 0..) |*cell, col_idx| {
292-        const cell_pin = ghostty.Pin{
293-            .node = pin.node,
294-            .y = pin.y,
295-            .x = @intCast(col_idx),
296-        };
297-        try extractCellText(cell_pin, cell, &buf, allocator);
298-    }
299-
300-    try testing.expectEqualStrings("ABC", buf.items);
301-}
302-
303-test "extractCellText: multi-codepoint grapheme (emoji)" {
304-    const testing = std.testing;
305-    const allocator = testing.allocator;
306-
307-    var vt = try ghostty.Terminal.init(allocator, 80, 24, 100);
308-    defer vt.deinit(allocator);
309-
310-    // Write an emoji with skin tone modifier (multi-codepoint grapheme)
311-    // 👋 (waving hand) + skin tone modifier
312-    try vt.print(0x1F44B); // 👋
313-    try vt.print(0x1F3FB); // light skin tone
314-
315-    const pages = &vt.screen.pages;
316-    const pin = pages.pin(.{ .active = .{} }).?;
317-    const rac = pin.rowAndCell();
318-    const page = &pin.node.data;
319-    const cells = page.getCells(rac.row);
320-
321-    var buf = try std.ArrayList(u8).initCapacity(allocator, 64);
322-    defer buf.deinit(allocator);
323-
324-    try extractCellText(pin, &cells[0], &buf, allocator);
325-
326-    // Should have both codepoints encoded as UTF-8
327-    try testing.expect(buf.items.len > 4); // At least 2 multi-byte UTF-8 sequences
328-}
329-
330-test "wide character handling: skip spacer cells" {
331-    const testing = std.testing;
332-    const allocator = testing.allocator;
333-
334-    var vt = try ghostty.Terminal.init(allocator, 80, 24, 100);
335-    defer vt.deinit(allocator);
336-
337-    // Write wide character (emoji) followed by ASCII
338-    try vt.print(0x1F44B); // 👋 (wide, takes 2 cells)
339-    try vt.print('A');
340-    try vt.print('B');
341-
342-    // Render the terminal
343-    const result = try render(&vt, allocator);
344-    defer allocator.free(result);
345-
346-    // Should have emoji + AB (not emoji + A + B with drift)
347-    // The emoji is UTF-8 encoded, so we just check we have content
348-    try testing.expect(result.len > 0);
349-    try testing.expect(std.mem.indexOf(u8, result, "AB") != null);
350-}
351-
352-test "render: colored text with SGR sequences" {
353-    const testing = std.testing;
354-    const allocator = testing.allocator;
355-
356-    var vt = try ghostty.Terminal.init(allocator, 80, 24, 100);
357-    defer vt.deinit(allocator);
358-
359-    // Set bold and write text
360-    try vt.setAttribute(.{ .bold = {} });
361-    try vt.print('B');
362-    try vt.print('O');
363-    try vt.print('L');
364-    try vt.print('D');
365-
366-    // Reset and write normal text
367-    try vt.setAttribute(.{ .reset = {} });
368-    try vt.print(' ');
369-    try vt.print('n');
370-    try vt.print('o');
371-    try vt.print('r');
372-    try vt.print('m');
373-
374-    const result = try render(&vt, allocator);
375-    defer allocator.free(result);
376-
377-    // Should contain bold SGR (ESC[1m) and text "BOLD"
378-    try testing.expect(std.mem.indexOf(u8, result, "\x1b[1m") != null);
379-    try testing.expect(std.mem.indexOf(u8, result, "BOLD") != null);
380-    try testing.expect(std.mem.indexOf(u8, result, "norm") != null);
381-}
382-
383-test "render: cursor position restoration" {
384-    const testing = std.testing;
385-    const allocator = testing.allocator;
386-
387-    var vt = try ghostty.Terminal.init(allocator, 80, 24, 100);
388-    defer vt.deinit(allocator);
389-
390-    // Write some text and move cursor
391-    try vt.print('T');
392-    try vt.print('e');
393-    try vt.print('s');
394-    try vt.print('t');
395-
396-    const result = try render(&vt, allocator);
397-    defer allocator.free(result);
398-
399-    // Should contain cursor positioning sequences
400-    try testing.expect(std.mem.indexOf(u8, result, "\x1b[1;1H") != null); // First row positioning
401-    try testing.expect(std.mem.indexOf(u8, result, "\x1b[?25h") != null); // Show cursor at end
402-    try testing.expect(std.mem.indexOf(u8, result, "Test") != null);
403-}
404-
405-test "render: alternate screen detection" {
406-    const testing = std.testing;
407-    const allocator = testing.allocator;
408-
409-    var vt = try ghostty.Terminal.init(allocator, 80, 24, 100);
410-    defer vt.deinit(allocator);
411-
412-    // Switch to alternate screen
413-    _ = vt.switchScreen(.alternate);
414-
415-    // Write content to alt screen
416-    try vt.print('V');
417-    try vt.print('I');
418-    try vt.print('M');
419-
420-    const result = try render(&vt, allocator);
421-    defer allocator.free(result);
422-
423-    // Should contain alt screen switch sequence
424-    try testing.expect(std.mem.indexOf(u8, result, "\x1b[?1049h") != null);
425-    try testing.expect(std.mem.indexOf(u8, result, "VIM") != null);
426-}
M src/test.zig
+1, -3
1@@ -1,3 +1 @@
2-test {
3-    _ = @import("daemon_test.zig");
4-}
5+test "dummy" {}
D systemd/zmx.service
+0, -17
 1@@ -1,17 +0,0 @@
 2-[Unit]
 3-Description=zmx daemon - terminal session persistence
 4-Documentation=https://github.com/neurosnap/zmx
 5-After=local-fs.target
 6-
 7-[Service]
 8-Type=simple
 9-ExecStart=%h/.local/bin/zmx daemon
10-Restart=on-failure
11-RestartSec=5
12-
13-# Security hardening
14-NoNewPrivileges=true
15-PrivateTmp=true
16-
17-[Install]
18-WantedBy=default.target