- 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
+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/
+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.
+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.
+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,
+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",
+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`.
+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.
+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
+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.
+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.
+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.
+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.
+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, ¶ms, 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-}
+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-}
+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-}
+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, ¶ms, 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-}
+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-}
+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, ¶ms, 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-}
+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+};
+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, ¶ms, 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-}
+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, ¶ms, 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-}
+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 }
+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-}
+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-}
+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-}
+1,
-3
1@@ -1,3 +1 @@
2-test {
3- _ = @import("daemon_test.zig");
4-}
5+test "dummy" {}
+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