Commit fdc2449

Eric Bower  ·  2026-08-20 10:23:44 -0400 EDT
parent fb1b6b6
feat: leader sets tracked env vars

When a client assumes the leader for a zmx session it sets the tracked
environment variables for the session which can the user can then print
using `zmx print-env .`.

This can be used inside a shell precmd hook to automatically set the leader's
env vars to the shell session: `eval "$(zmx print-env .)"`.

Link: https://github.com/neurosnap/zmx/issues/225
Link: https://github.com/neurosnap/zmx/issues/104
7 files changed,  +461, -38
+1, -0
......@@ -12,6 +12,7 @@ Use spec: https://common-changelog.org/
1212 - Added `--labels` flag to attach command
1313 - e.g. `zmx attach --labels "project=pico env=prod" pico`
1414 - e.g. `zmx attach --labels "$(zmx get pico)" pico.sub`
15+- New command `zmx print-env` which prints the leader's tracked env vars
1516
1617 ### Fixed
1718
+86, -8
......@@ -110,8 +110,8 @@ Commands:
110110 [l]ist|ls [--short] List active sessions
111111 [g]et <name> Get session labels
112112 set <name> k=v ... Set session labels
113- [un]set <name> key ... Remove session labels
114113 [cl]ear <name> Clear all session labels
114+ print-env [-s] <name> [key] Print tracked environment variables
115115 [k]ill <name>... [--force] Kill session and all attached clients
116116 [hi]story <name> [--vt|--html] Output session scrollback
117117 [w]ait <name>... Wait for session tasks to complete
......@@ -123,14 +123,9 @@ Commands:
123123
124124 ## nested sessions
125125
126-Nested sessions are not supported. Inside a session `ZMX_SESSION` is set, and
127-`attach` reads it: instead of creating another client it switches the calling
128-terminal to the session you named.
126+Nested sessions are not supported. Inside a session `ZMX_SESSION` is set, and `attach` reads it: instead of creating another client it switches the calling terminal to the session you named.
129127
130-That matters when the variable is inherited rather than chosen. A script, build
131-tool, or coding agent started inside a session runs with `ZMX_SESSION` set, so
132-`zmx attach other` from there moves the terminal somebody was using, and the
133-session it was showing is left with no client.
128+That matters when the variable is inherited rather than chosen. A script, build tool, or coding agent started inside a session runs with `ZMX_SESSION` set, so `zmx attach other` from there moves the terminal somebody was using, and the session it was showing is left with no client.
134129
135130 Unset it in anything that attaches on its own behalf:
136131
......@@ -216,6 +211,89 @@ description = "zmx session name"
216211 style = "bold magenta"
217212 ```
218213
214+## environment variables
215+
216+A session's environment is initialized when the session is created. When re-attaching from a new terminal or a different SSH connection, environment variables describing the client connection (such as `SSH_AUTH_SOCK`, `DISPLAY`, or terminal remote control variables like `KITTY_LISTEN_ON`, `KITTY_PID`, `KITTY_WINDOW_ID`) become outdated in the running shell.
217+
218+`zmx` tracks these environment variables from attaching clients. You can inspect the current leader client's environment with `zmx print-env`:
219+
220+```bash
221+# Print all tracked environment variables (set variables as KEY=VALUE, unset as -KEY)
222+zmx print-env .
223+zmx print-env dev
224+
225+# Print POSIX shell commands (export ... / unset ...) suitable for eval
226+zmx print-env -s .
227+
228+# Print a specific variable value
229+zmx print-env . SSH_AUTH_SOCK
230+```
231+
232+You can automatically update your shell environment before each prompt:
233+
234+### bash
235+
236+Add to `~/.bashrc`:
237+
238+```bash
239+_zmx_env_hook() {
240+ if [[ -n $ZMX_SESSION ]]; then
241+ eval "$(zmx print-env -s .)"
242+ fi
243+}
244+PROMPT_COMMAND="_zmx_env_hook${PROMPT_COMMAND:+; $PROMPT_COMMAND}"
245+```
246+
247+### zsh
248+
249+Add to `~/.zshrc`:
250+
251+```zsh
252+precmd() {
253+ if [[ -n $ZMX_SESSION ]]; then
254+ eval "$(zmx print-env -s .)"
255+ fi
256+}
257+```
258+
259+### fish
260+
261+Add to `~/.config/fish/config.fish`:
262+
263+```fish
264+function _zmx_env_hook --on-event fish_prompt
265+ if test -n "$ZMX_SESSION"
266+ for line in (zmx print-env .)
267+ if string match -q -- "-*" $line
268+ set -e (string sub -s 2 $line)
269+ else
270+ set -gx (string split -m 1 "=" $line)
271+ end
272+ end
273+ end
274+end
275+```
276+
277+### configuring tracked variables
278+
279+By default, `zmx` tracks:
280+
281+- `DISPLAY`
282+- `SSH_AUTH_SOCK`
283+- `SSH_AGENT_PID`
284+- `SSH_CONNECTION`
285+- `WINDOWID`
286+- `XAUTHORITY`
287+- `KITTY_LISTEN_ON`
288+- `KITTY_PID`
289+- `KITTY_WINDOW_ID`
290+
291+You can customize this list by setting `ZMX_TRACK_ENV` to a comma-separated list of variable names:
292+
293+```bash
294+export ZMX_TRACK_ENV="DISPLAY,SSH_AUTH_SOCK,GPG_AGENT_INFO"
295+```
296+
219297 ## shell completion
220298
221299 Shell auto-completion for `zmx` commands and session names can be enabled using the `completions` subcommand. Once configured, you'll get auto-complete for both local `zmx` commands and sessions:
+1, -0
......@@ -12,6 +12,7 @@ log_dir: []const u8,
1212 max_scrollback_lines: usize = 2_000, // same default as tmux
1313 dir_mode: u32 = 0o750,
1414 log_mode: u32 = 0o640,
15+tracked_envs: []const u8 = "DISPLAY,SSH_AUTH_SOCK,SSH_AGENT_PID,SSH_CONNECTION,WINDOWID,XAUTHORITY,KITTY_LISTEN_ON,KITTY_PID,KITTY_WINDOW_ID",
1516
1617 pub fn init(alloc: std.mem.Allocator, io: std.Io) !Cfg {
1718 const socket_dir = try socketDir(alloc);
+13, -4
......@@ -32,7 +32,7 @@ const bash_completions =
3232 \\ cur="${COMP_WORDS[COMP_CWORD]}"
3333 \\ prev="${COMP_WORDS[COMP_CWORD-1]}"
3434 \\
35- \\ local commands="attach run send print write detach list kill history get set clear wait tail completions version help"
35+ \\ local commands="attach run send print write detach list kill history get set clear print-env wait tail completions version help"
3636 \\
3737 \\ if [[ $COMP_CWORD -eq 1 ]]; then
3838 \\ COMPREPLY=($(compgen -W "$commands" -- "$cur"))
......@@ -40,7 +40,7 @@ const bash_completions =
4040 \\ fi
4141 \\
4242 \\ case "$prev" in
43- \\ attach|run|send|print|write|kill|history|get|set|clear|wait|tail)
43+ \\ attach|run|send|print|write|kill|history|get|set|clear|print-env|wait|tail)
4444 \\ local sessions=$(zmx list --short 2>/dev/null | tr '\n' ' ')
4545 \\ COMPREPLY=($(compgen -W "$sessions" -- "$cur"))
4646 \\ ;;
......@@ -89,6 +89,7 @@ const zsh_completions =
8989 \\ 'get:Get session labels'
9090 \\ 'set:Set session labels'
9191 \\ 'clear:Clear all session labels'
92+ \\ 'print-env:Print tracked environment variables'
9293 \\ 'version:Show version'
9394 \\ 'help:Show help message'
9495 \\ )
......@@ -96,7 +97,7 @@ const zsh_completions =
9697 \\ ;;
9798 \\ args)
9899 \\ case $words[2] in
99- \\ attach|a|kill|k|run|r|send|s|print|p|write|wr|history|get|g|set|clear|hi|wait|w|tail|t)
100+ \\ attach|a|kill|k|run|r|send|s|print|p|write|wr|history|get|g|set|clear|print-env|hi|wait|w|tail|t)
100101 \\ _zmx_sessions
101102 \\ ;;
102103 \\ completions|c)
......@@ -151,10 +152,11 @@ const fish_completions =
151152 \\complete -c zmx -n "__fish_is_nth_token 1" -a get -d 'Get session labels'
152153 \\complete -c zmx -n "__fish_is_nth_token 1" -a set -d 'Set session labels'
153154 \\complete -c zmx -n "__fish_is_nth_token 1" -a clear -d 'Clear all session labels'
155+ \\complete -c zmx -n "__fish_is_nth_token 1" -a print-env -d 'Print tracked environment variables'
154156 \\complete -c zmx -n "__fish_is_nth_token 1" -a help -d 'Show help message'
155157 \\
156158 \\# Complete session names and shells
157- \\complete -c zmx -n "__fish_is_nth_token 2; and __fish_seen_subcommand_from a attach r run s send p print wr write hi history g get se set cl clear" -a '(zmx list --short 2>/dev/null)' -d 'Session name'
159+ \\complete -c zmx -n "__fish_is_nth_token 2; and __fish_seen_subcommand_from a attach r run s send p print wr write hi history g get se set cl clear print-env" -a '(zmx list --short 2>/dev/null)' -d 'Session name'
158160 \\complete -c zmx -n "not __fish_is_nth_token 1; and __fish_seen_subcommand_from k kill w wait t tail" -a '(zmx list --short 2>/dev/null)' -d 'Session name'
159161 \\
160162 \\complete -c zmx -n "__fish_is_nth_token 2; and __fish_seen_subcommand_from c completions" -a 'bash zsh fish nu' -d Shell
......@@ -167,6 +169,7 @@ const fish_completions =
167169 \\complete -c zmx -n "__fish_seen_subcommand_from k kill" -l force -d 'Force kill'
168170 \\complete -c zmx -n "__fish_seen_subcommand_from hi history" -l vt -d 'History format for escape sequences'
169171 \\complete -c zmx -n "__fish_seen_subcommand_from hi history" -l html -d 'History format for escape sequences'
172+ \\complete -c zmx -n "__fish_seen_subcommand_from print-env" -s s -l shell -d 'Output POSIX export/unset commands for eval'
170173 ;
171174
172175 const nu_completions =
......@@ -231,5 +234,11 @@ const nu_completions =
231234 \\ name?: string@"nu-complete zmx sessions"
232235 \\]
233236 \\
237+ \\export extern "zmx print-env" [
238+ \\ name: string@"nu-complete zmx sessions"
239+ \\ key?: string
240+ \\ --shell(-s)
241+ \\]
242+ \\
234243 \\export extern "zmx help" []
235244 ;
+3, -0
......@@ -23,6 +23,9 @@ pub const Tag = enum(u8) {
2323 LabelClear = 16,
2424 LabelData = 17,
2525 Send = 18,
26+ EnvGet = 19,
27+ EnvSet = 20,
28+ EnvData = 21,
2629 // Non-exhaustive: this enum comes off the wire via bytesToValue and
2730 // @enumFromInt, so out-of-range values are representable
2831 // rather than UB. Switches must handle `_` (unknown tag).
+125, -6
......@@ -15,7 +15,7 @@ const builtin = @import("builtin");
1515
1616 /// clientLoop sends ipc commands to its corresponding daemon. It uses poll() as its non-blocking
1717 /// mechanism. It will send stdin to the daemon and receive stdout from the daemon.
18-pub fn clientLoop(client_sock_fd: i32) !ClientResult {
18+pub fn clientLoop(client_sock_fd: i32, env_str: []const u8) !ClientResult {
1919 std.log.info("client loop fd={d}", .{client_sock_fd});
2020 const gpa: std.mem.Allocator = blk: {
2121 if (builtin.mode == .Debug) {
......@@ -41,6 +41,10 @@ pub fn clientLoop(client_sock_fd: i32) !ClientResult {
4141 var sock_write_buf = try std.ArrayList(u8).initCapacity(gpa, 4096);
4242 defer sock_write_buf.deinit(gpa);
4343
44+ if (env_str.len > 0) {
45+ try ipc.appendMessage(gpa, &sock_write_buf, .EnvSet, env_str);
46+ }
47+
4448 // Send init message with terminal size (buffered)
4549 const size = ipc.getTerminalSize(lib_posix.STDOUT_FILENO);
4650 try ipc.appendMessage(gpa, &sock_write_buf, .Init, std.mem.asBytes(&size));
......@@ -473,9 +477,11 @@ fn daemonLoop(daemon: *Daemon, gpa: std.mem.Allocator, io: std.Io, server_sock_f
473477 .LabelGet => try daemon.handleLabelGet(gpa, client),
474478 .LabelSet => try daemon.handleLabelSet(gpa, client, msg.payload),
475479 .LabelClear => try daemon.handleLabelClear(gpa, client),
480+ .EnvGet => try daemon.handleEnvGet(gpa, client),
481+ .EnvSet => try daemon.handleEnvSet(gpa, client, msg.payload),
476482 .History => try daemon.handleHistory(gpa, client, &term, msg.payload),
477483 .Run => try daemon.handleRun(gpa, io, client, msg.payload),
478- .Ack, .TaskComplete, .LabelData => {},
484+ .Ack, .TaskComplete, .LabelData, .EnvData => {},
479485 .Write => try daemon.handleWrite(gpa, client, msg.payload),
480486 _ => std.log.warn(
481487 "ignoring unknown IPC tag={d}",
......@@ -530,11 +536,18 @@ pub const Client = struct {
530536 has_pending_output: bool = false,
531537 read_buf: ipc.SocketBuffer,
532538 write_buf: std.ArrayList(u8),
539+ env_str: ?[]u8 = null,
533540
534- pub fn deinit(self: *Client) void {
541+ pub fn deinit(self: *Client, gpa: std.mem.Allocator) void {
535542 lib_posix.close(self.socket_fd);
536543 self.read_buf.deinit();
537544 self.write_buf.deinit(self.alloc);
545+ if (self.env_str) |s| gpa.free(s);
546+ }
547+
548+ fn setEnv(self: *Client, gpa: std.mem.Allocator, env_str: []const u8) !void {
549+ if (self.env_str) |s| gpa.free(s);
550+ self.env_str = if (env_str.len > 0) try gpa.dupe(u8, env_str) else null;
538551 }
539552 };
540553
......@@ -611,7 +624,7 @@ pub const Daemon = struct {
611624 self.running = false;
612625
613626 for (self.clients.items) |client| {
614- client.deinit();
627+ client.deinit(gpa);
615628 gpa.destroy(client);
616629 }
617630 self.clients.clearRetainingCapacity();
......@@ -627,7 +640,7 @@ pub const Daemon = struct {
627640 );
628641 self.leader_client_fd = null;
629642 }
630- client.deinit();
643+ client.deinit(gpa);
631644 gpa.destroy(client);
632645 _ = self.clients.orderedRemove(i);
633646 std.log.info("client disconnected fd={d} remaining={d}", .{ fd, self.clients.items.len });
......@@ -812,6 +825,15 @@ pub const Daemon = struct {
812825 client.has_pending_output = true;
813826 }
814827
828+ fn getLeaderClient(self: *Daemon) ?*Client {
829+ for (self.clients.items) |client| {
830+ if (self.leader_client_fd == client.socket_fd) {
831+ return client;
832+ }
833+ }
834+ return null;
835+ }
836+
815837 const PTY_WRITE_BUF_MAX = 256 * 1024;
816838
817839 /// Queue bytes for the PTY's stdin. Flushed by daemonLoop on POLLOUT.
......@@ -1013,7 +1035,7 @@ pub const Daemon = struct {
10131035 pub fn handleDetachAll(self: *Daemon, gpa: std.mem.Allocator) void {
10141036 std.log.info("detach all clients={d}", .{self.clients.items.len});
10151037 for (self.clients.items) |client_to_close| {
1016- client_to_close.deinit();
1038+ client_to_close.deinit(gpa);
10171039 gpa.destroy(client_to_close);
10181040 }
10191041 self.clients.clearRetainingCapacity();
......@@ -1255,6 +1277,21 @@ pub const Daemon = struct {
12551277 );
12561278 }
12571279
1280+ fn handleEnvGet(self: *Daemon, gpa: std.mem.Allocator, client: *Client) !void {
1281+ const leader_opt = self.getLeaderClient();
1282+ const payload = if (leader_opt) |leader| leader.env_str orelse "" else "";
1283+ try ipc.appendMessage(gpa, &client.write_buf, .EnvData, payload);
1284+ client.has_pending_output = true;
1285+ }
1286+
1287+ fn handleEnvSet(_: *Daemon, gpa: std.mem.Allocator, client: *Client, env_str: []const u8) !void {
1288+ std.log.info("handle env set payload={s}", .{env_str});
1289+ try client.setEnv(gpa, env_str);
1290+
1291+ try ipc.appendMessage(gpa, &client.write_buf, .Ack, "");
1292+ client.has_pending_output = true;
1293+ }
1294+
12581295 fn handleLabelGet(self: *Daemon, gpa: std.mem.Allocator, client: *Client) !void {
12591296 const out = try label.labelsToU8(gpa, self.labels);
12601297 defer gpa.free(out);
......@@ -1323,3 +1360,85 @@ test "send queues PTY input without changing leader" {
13231360 try std.testing.expectEqual(@as(?i32, 42), daemon.leader_client_fd);
13241361 try std.testing.expectEqualStrings("hello", daemon.pty_write_buf.items);
13251362 }
1363+
1364+test "handleEnvGet returns leader client's environment variables including unsets" {
1365+ const alloc = std.testing.allocator;
1366+ var cfg = Cfg{
1367+ .socket_dir = "/tmp",
1368+ .log_dir = "/tmp",
1369+ };
1370+
1371+ var daemon = Daemon{
1372+ .cfg = &cfg,
1373+ .clients = .empty,
1374+ .session_name = "test",
1375+ .socket_path = try alloc.dupe(u8, ""),
1376+ .running = true,
1377+ .pid = 0,
1378+ .created_at = 0,
1379+ };
1380+ defer daemon.deinit(alloc);
1381+
1382+ const fds1 = try lib_posix.pipe2(.{});
1383+ defer lib_posix.close(fds1[1]);
1384+ var client1 = Client{
1385+ .alloc = alloc,
1386+ .socket_fd = fds1[0],
1387+ .read_buf = try ipc.SocketBuffer.init(alloc),
1388+ .write_buf = std.ArrayList(u8).empty,
1389+ };
1390+ defer client1.deinit(alloc);
1391+ try client1.setEnv(alloc, "DISPLAY=:1\nSSH_AUTH_SOCK=/tmp/ssh-1\nKITTY_LISTEN_ON=unix:/tmp/kitty-1\n-WINDOWID\n");
1392+
1393+ const fds2 = try lib_posix.pipe2(.{});
1394+ defer lib_posix.close(fds2[1]);
1395+ var client2 = Client{
1396+ .alloc = alloc,
1397+ .socket_fd = fds2[0],
1398+ .read_buf = try ipc.SocketBuffer.init(alloc),
1399+ .write_buf = std.ArrayList(u8).empty,
1400+ };
1401+ defer client2.deinit(alloc);
1402+ try client2.setEnv(alloc, "SSH_AUTH_SOCK=/tmp/ssh-2\nWINDOWID=12345\nKITTY_LISTEN_ON=unix:/tmp/kitty-2\n-DISPLAY\n");
1403+
1404+ const fds_req = try lib_posix.pipe2(.{});
1405+ defer lib_posix.close(fds_req[1]);
1406+ var client_req = Client{
1407+ .alloc = alloc,
1408+ .socket_fd = fds_req[0],
1409+ .read_buf = try ipc.SocketBuffer.init(alloc),
1410+ .write_buf = std.ArrayList(u8).empty,
1411+ };
1412+ defer client_req.deinit(alloc);
1413+
1414+ try daemon.clients.append(alloc, &client1);
1415+ try daemon.clients.append(alloc, &client2);
1416+
1417+ // No leader yet -> handleEnvGet returns empty string payload
1418+ try daemon.handleEnvGet(alloc, &client_req);
1419+ try std.testing.expect(client_req.write_buf.items.len > 0);
1420+ client_req.write_buf.clearRetainingCapacity();
1421+
1422+ // Set client1 as leader
1423+ try daemon.setLeader(alloc, &client1);
1424+ try std.testing.expectEqual(@as(?i32, fds1[0]), daemon.leader_client_fd);
1425+ try daemon.handleEnvGet(alloc, &client_req);
1426+ // Wire message: [Header][Payload]
1427+ const pay1 = client_req.write_buf.items[@sizeOf(ipc.Header)..];
1428+ try std.testing.expectEqualStrings("DISPLAY=:1\nSSH_AUTH_SOCK=/tmp/ssh-1\nKITTY_LISTEN_ON=unix:/tmp/kitty-1\n-WINDOWID\n", pay1);
1429+ client_req.write_buf.clearRetainingCapacity();
1430+
1431+ // Switch leader to client2
1432+ try daemon.setLeader(alloc, &client2);
1433+ try std.testing.expectEqual(@as(?i32, fds2[0]), daemon.leader_client_fd);
1434+ try daemon.handleEnvGet(alloc, &client_req);
1435+ const pay2 = client_req.write_buf.items[@sizeOf(ipc.Header)..];
1436+ try std.testing.expectEqualStrings("SSH_AUTH_SOCK=/tmp/ssh-2\nWINDOWID=12345\nKITTY_LISTEN_ON=unix:/tmp/kitty-2\n-DISPLAY\n", pay2);
1437+ client_req.write_buf.clearRetainingCapacity();
1438+
1439+ // Client2 updates env
1440+ try daemon.handleEnvSet(alloc, &client2, "DISPLAY=:99\n");
1441+ try daemon.handleEnvGet(alloc, &client_req);
1442+ const pay3 = client_req.write_buf.items[@sizeOf(ipc.Header)..];
1443+ try std.testing.expectEqualStrings("DISPLAY=:99\n", pay3);
1444+}
+232, -20
......@@ -1,4 +1,5 @@
11 const std = @import("std");
2+const Environ = std.process.Environ;
23 const build_options = @import("build_options");
34 const ghostty_vt = @import("ghostty-vt");
45 const ipc = @import("ipc.zig");
......@@ -160,7 +161,12 @@ pub fn main(init: std.process.Init) !void {
160161 daemon.setCwd(cwd);
161162 daemon.shell = shell_env;
162163 std.log.info("socket path={s}", .{daemon.socket_path});
163- return attach(gpa, io, &daemon, parsed.labels);
164+
165+ const env_keys = lib_posix.getenv("ZMX_TRACK_ENV") orelse cfg.tracked_envs;
166+ const env_str = try getTrackedEnvStr(gpa, env_keys, init.environ_map);
167+ defer gpa.free(env_str);
168+
169+ return attach(gpa, io, &daemon, env_str, parsed.labels);
164170 } else if (std.mem.eql(u8, cmd, "run") or std.mem.eql(u8, cmd, "r")) {
165171 const session_name = args.next() orelse "";
166172 if (std.mem.eql(u8, session_name, "--help") or std.mem.eql(u8, session_name, "-h")) {
......@@ -409,6 +415,26 @@ pub fn main(init: std.process.Init) !void {
409415 daemon.shell = shell_env;
410416 std.log.info("socket path={s}", .{daemon.socket_path});
411417 try writeFile(gpa, io, &daemon, file_path);
418+ } else if (std.mem.eql(u8, cmd, "print-env")) {
419+ var shell_mode = false;
420+ var session_name: ?[]const u8 = null;
421+ var single_kv: []const u8 = "";
422+
423+ while (args.next()) |arg| {
424+ if (detectHelp(arg)) return help(io);
425+ if (std.mem.eql(u8, arg, "-s") or std.mem.eql(u8, arg, "--shell")) {
426+ shell_mode = true;
427+ } else if (session_name == null) {
428+ session_name = arg;
429+ } else if (single_kv.len == 0) {
430+ single_kv = arg;
431+ }
432+ }
433+
434+ const sesh_arg = session_name orelse return error.SessionNameRequired;
435+ const sesh = try socket.resolveSessionOrEnv(gpa, io, sesh_arg);
436+ defer gpa.free(sesh);
437+ return envGet(gpa, io, &cfg, sesh, single_kv, shell_mode);
412438 } else {
413439 return help(io);
414440 }
......@@ -422,22 +448,23 @@ fn help(io: std.Io) !void {
422448 \\
423449 \\Commands:
424450 \\ [a]ttach [--labels kv] <name> [command...] Attach to session, creating if needed
425- \\ [r]un <name> [-d] [command...] Send command without attaching
426- \\ [s]end <name> <text...> Send raw input to session PTY
427- \\ [p]rint <name> <text...> Inject text into session display
428- \\ [wr]ite <name> <file_path> Write stdin to file_path through the session
429- \\ [d]etach Detach all clients (ctrl+\\ for current client)
430- \\ [l]ist|ls [--short] List active sessions
431- \\ [g]et <name> Get session labels
432- \\ set <name> k=v ... Set session labels (k= to remove)
433- \\ [cl]ear <name> Clear all session labels
434- \\ [k]ill <name>... [--force] Kill session and all attached clients
435- \\ [hi]story <name> [--vt|--html] Output session scrollback
436- \\ [w]ait <name>... Wait for session tasks to complete
437- \\ [t]ail <name>... Follow session output
438- \\ [c]ompletions <shell> Shell completions (bash, zsh, fish, nu)
439- \\ [v]ersion Show version and metadata (socket dir, log dir)
440- \\ [h]elp Show this help
451+ \\ [r]un <name> [-d] [command...] Send command without attaching
452+ \\ [s]end <name> <text...> Send raw input to session PTY
453+ \\ [p]rint <name> <text...> Inject text into session display
454+ \\ [wr]ite <name> <file_path> Write stdin to file_path through the session
455+ \\ [d]etach Detach all clients (ctrl+\\ for current client)
456+ \\ [l]ist|ls [--short] List active sessions
457+ \\ [g]et <name> Get session labels
458+ \\ set <name> k=v ... Set session labels (k= to remove)
459+ \\ [cl]ear <name> Clear all session labels
460+ \\ print-env [-s] <name> [key] Print tracked environment variables
461+ \\ [k]ill <name>... [--force] Kill session and all attached clients
462+ \\ [hi]story <name> [--vt|--html] Output session scrollback
463+ \\ [w]ait <name>... Wait for session tasks to complete
464+ \\ [t]ail <name>... Follow session output
465+ \\ [c]ompletions <shell> Shell completions (bash, zsh, fish, nu)
466+ \\ [v]ersion Show version and metadata (socket dir, log dir)
467+ \\ [h]elp Show this help
441468 \\
442469 \\Attach:
443470 \\ This will spawn a login $SHELL with a PTY. You can provide a
......@@ -545,6 +572,19 @@ fn help(io: std.Io) !void {
545572 \\ zmx list | grep project=zmx
546573 \\ zmx clear dev
547574 \\
575+ \\Print-env:
576+ \\ Print tracked environment variables for the leader client of a session.
577+ \\
578+ \\ Flags:
579+ \\ -s, --shell Output POSIX export/unset commands for eval
580+ \\
581+ \\ Examples:
582+ \\ zmx print-env .
583+ \\ zmx print-env -s .
584+ \\ zmx print-env dev
585+ \\ zmx print-env dev DISPLAY
586+ \\ eval "$(zmx print-env -s .)" # inside shell session precmd hook
587+ \\
548588 \\Environment variables:
549589 \\ SHELL Default shell for new sessions
550590 \\ ZMX_DIR Socket directory (priority 1)
......@@ -553,6 +593,8 @@ fn help(io: std.Io) !void {
553593 \\ ZMX_SESSION Session name (injected automatically; makes attach
554594 \\ switch this session)
555595 \\ ZMX_SESSION_PREFIX Prefix added to all session names
596+ \\ ZMX_TRACK_ENV Comma-separated list of environment variables to track
597+ \\ from attaching clients
556598 \\ ZMX_DIR_MODE Sets mode for socket and log directories (octal, defaults to 0750)
557599 \\ ZMX_LOG_MODE Sets mode for log files (octal, defaults to 0640)
558600 \\ ZMX_NO_DETACH_KEY Disables the ctrl+\ detach shortcut (set to any value)
......@@ -1122,6 +1164,134 @@ fn assertLabels(io: std.Io, labels: []const u8) void {
11221164 }
11231165 }
11241166
1167+fn getTrackedEnvStr(
1168+ alloc: std.mem.Allocator,
1169+ env_keys: []const u8,
1170+ env_map: *const std.process.Environ.Map,
1171+) ![]u8 {
1172+ var out = std.ArrayList(u8).empty;
1173+ errdefer out.deinit(alloc);
1174+
1175+ var iter = std.mem.splitScalar(u8, env_keys, ',');
1176+ while (iter.next()) |entry| {
1177+ const env = std.mem.trim(u8, entry, " \t\r\n");
1178+ if (env.len == 0) continue;
1179+ if (env_map.get(env)) |val| {
1180+ try out.appendSlice(alloc, env);
1181+ try out.append(alloc, '=');
1182+ try out.appendSlice(alloc, val);
1183+ try out.append(alloc, '\n');
1184+ } else {
1185+ try out.append(alloc, '-');
1186+ try out.appendSlice(alloc, env);
1187+ try out.append(alloc, '\n');
1188+ }
1189+ }
1190+
1191+ return out.toOwnedSlice(alloc);
1192+}
1193+
1194+pub const EnvEntry = struct {
1195+ key: []const u8,
1196+ value: ?[]const u8,
1197+};
1198+
1199+pub const EnvIterator = struct {
1200+ it: std.mem.SplitIterator(u8, .scalar),
1201+
1202+ pub fn init(payload: []const u8) EnvIterator {
1203+ return .{ .it = std.mem.splitScalar(u8, payload, '\n') };
1204+ }
1205+
1206+ pub fn next(self: *EnvIterator) ?EnvEntry {
1207+ while (self.it.next()) |line| {
1208+ if (line.len == 0) continue;
1209+ if (line[0] == '-') {
1210+ return .{ .key = line[1..], .value = null };
1211+ } else if (std.mem.indexOfScalar(u8, line, '=')) |eq_idx| {
1212+ return .{ .key = line[0..eq_idx], .value = line[eq_idx + 1 ..] };
1213+ }
1214+ }
1215+ return null;
1216+ }
1217+};
1218+
1219+fn getEnvValue(key: []const u8, payload: []const u8) error{EnvVarNotFound}![]const u8 {
1220+ var it = EnvIterator.init(payload);
1221+ while (it.next()) |entry| {
1222+ if (std.mem.eql(u8, entry.key, key)) {
1223+ return entry.value orelse return error.EnvVarNotFound;
1224+ }
1225+ }
1226+ return error.EnvVarNotFound;
1227+}
1228+
1229+fn printEnvError(io: std.Io, session_name: []const u8, err: anyerror) noreturn {
1230+ var buf: [4096]u8 = undefined;
1231+ var w = std.Io.File.stderr().writer(io, &buf);
1232+ switch (err) {
1233+ error.SessionNotFound => w.interface.print("error: session \"{s}\" not found\n", .{session_name}) catch {},
1234+ error.DaemonUnresponsive => w.interface.print("error: session \"{s}\" daemon unresponsive\n", .{session_name}) catch {},
1235+ error.EnvVarNotFound => w.interface.print("error: environment variable not found\n", .{}) catch {},
1236+ else => w.interface.print(
1237+ "error: could not communicate with session \"{s}\" err={s}\n",
1238+ .{ session_name, @errorName(err) },
1239+ ) catch {},
1240+ }
1241+ w.interface.flush() catch {};
1242+ std.process.exit(1);
1243+}
1244+
1245+fn envGet(alloc: std.mem.Allocator, io: std.Io, cfg: *Cfg, session_name: []const u8, single_kv: []const u8, shell_mode: bool) !void {
1246+ std.log.info("env get session={s}", .{session_name});
1247+
1248+ const socket_path = socket.getSocketPath(alloc, cfg.socket_dir, session_name) catch |err| switch (err) {
1249+ error.NameTooLong => return socket.printSessionNameTooLong(io, session_name, cfg.socket_dir),
1250+ error.OutOfMemory => return err,
1251+ };
1252+ defer alloc.free(socket_path);
1253+
1254+ const payload = ipc.roundTripForTag(alloc, socket_path, .EnvGet, "", .EnvData) catch |err| {
1255+ printEnvError(io, session_name, err);
1256+ };
1257+ defer alloc.free(payload);
1258+
1259+ var buf: [4096]u8 = undefined;
1260+ var stdout = std.Io.File.stdout().writer(io, &buf);
1261+ if (single_kv.len > 0) {
1262+ const val = getEnvValue(single_kv, payload) catch |err| {
1263+ printEnvError(io, session_name, err);
1264+ };
1265+ try stdout.interface.print("{s}\n", .{val});
1266+ try stdout.interface.flush();
1267+ return;
1268+ }
1269+
1270+ if (payload.len == 0) return;
1271+
1272+ if (shell_mode) {
1273+ var it = EnvIterator.init(payload);
1274+ while (it.next()) |entry| {
1275+ if (entry.value) |val| {
1276+ try stdout.interface.print("export {s}='", .{entry.key});
1277+ for (val) |c| {
1278+ if (c == '\'') {
1279+ try stdout.interface.print("'\\''", .{});
1280+ } else {
1281+ try stdout.interface.print("{c}", .{c});
1282+ }
1283+ }
1284+ try stdout.interface.print("';\n", .{});
1285+ } else {
1286+ try stdout.interface.print("unset {s};\n", .{entry.key});
1287+ }
1288+ }
1289+ } else {
1290+ try stdout.interface.print("{s}", .{payload});
1291+ }
1292+ try stdout.interface.flush();
1293+}
1294+
11251295 fn labelSet(alloc: std.mem.Allocator, io: std.Io, cfg: *Cfg, session_name: []const u8, labels: []const u8) !void {
11261296 std.log.info("label set session={s}", .{session_name});
11271297
......@@ -1358,7 +1528,7 @@ fn parseAttachArgs(argv: []const []const u8) AttachArgs {
13581528 return parsed;
13591529 }
13601530
1361-fn attach(gpa: std.mem.Allocator, io: std.Io, daemon: *Daemon, labels: ?[]const u8) !void {
1531+fn attach(gpa: std.mem.Allocator, io: std.Io, daemon: *Daemon, env_str: []const u8, labels: ?[]const u8) !void {
13621532 const sesh = socket.getSeshNameFromEnv();
13631533 if (sesh.len > 0) {
13641534 return switchSesh(gpa, io, daemon, sesh);
......@@ -1425,7 +1595,7 @@ fn attach(gpa: std.mem.Allocator, io: std.Io, daemon: *Daemon, labels: ?[]const
14251595 const clear_seq = "\x1b[2J\x1b[H";
14261596 _ = try lib_posix.write(lib_posix.STDOUT_FILENO, clear_seq);
14271597
1428- const looper = try loop.clientLoop(client_sock);
1598+ const looper = try loop.clientLoop(client_sock, env_str);
14291599 switch (looper.kind) {
14301600 .detach => return,
14311601 .switch_session => {
......@@ -1453,7 +1623,7 @@ fn attach(gpa: std.mem.Allocator, io: std.Io, daemon: *Daemon, labels: ?[]const
14531623 std.log.info("switching to new session cwd={s}", .{switch_cwd});
14541624 target_daemon.setCwd(switch_cwd);
14551625 target_daemon.shell = daemon.shell;
1456- return attach(gpa, io, &target_daemon, null);
1626+ return attach(gpa, io, &target_daemon, env_str, null);
14571627 }
14581628 },
14591629 }
......@@ -1751,3 +1921,45 @@ test "parseAttachArgs leaves labels unset when the flag is absent" {
17511921 try std.testing.expectEqual(@as(?[]const u8, null), parsed.labels);
17521922 try std.testing.expect(!parsed.missing_labels_value);
17531923 }
1924+
1925+test "getTrackedEnvStr includes set and unset entries" {
1926+ const alloc = std.testing.allocator;
1927+ var env_map = std.process.Environ.Map.init(alloc);
1928+ defer env_map.deinit();
1929+
1930+ try env_map.put("DISPLAY", ":1");
1931+ try env_map.put("SSH_AUTH_SOCK", "/tmp/ssh.sock");
1932+
1933+ const str = try getTrackedEnvStr(alloc, "DISPLAY, KITTY_WINDOW_ID, SSH_AUTH_SOCK", &env_map);
1934+ defer alloc.free(str);
1935+
1936+ try std.testing.expectEqualStrings("DISPLAY=:1\n-KITTY_WINDOW_ID\nSSH_AUTH_SOCK=/tmp/ssh.sock\n", str);
1937+}
1938+
1939+test "getEnvValue extracts set value or returns EnvVarNotFound for unset" {
1940+ const payload = "DISPLAY=:1\n-KITTY_WINDOW_ID\nSSH_AUTH_SOCK=/tmp/ssh.sock\n";
1941+
1942+ try std.testing.expectEqualStrings(":1", try getEnvValue("DISPLAY", payload));
1943+ try std.testing.expectEqualStrings("/tmp/ssh.sock", try getEnvValue("SSH_AUTH_SOCK", payload));
1944+ try std.testing.expectError(error.EnvVarNotFound, getEnvValue("KITTY_WINDOW_ID", payload));
1945+ try std.testing.expectError(error.EnvVarNotFound, getEnvValue("NONEXISTENT", payload));
1946+}
1947+
1948+test "EnvIterator parses set and unset entries correctly" {
1949+ const payload = "DISPLAY=:1\n-KITTY_WINDOW_ID\nSSH_AUTH_SOCK=/tmp/ssh.sock\n";
1950+ var it = EnvIterator.init(payload);
1951+
1952+ const e1 = it.next().?;
1953+ try std.testing.expectEqualStrings("DISPLAY", e1.key);
1954+ try std.testing.expectEqualStrings(":1", e1.value.?);
1955+
1956+ const e2 = it.next().?;
1957+ try std.testing.expectEqualStrings("KITTY_WINDOW_ID", e2.key);
1958+ try std.testing.expect(e2.value == null);
1959+
1960+ const e3 = it.next().?;
1961+ try std.testing.expectEqualStrings("SSH_AUTH_SOCK", e3.key);
1962+ try std.testing.expectEqualStrings("/tmp/ssh.sock", e3.value.?);
1963+
1964+ try std.testing.expect(it.next() == null);
1965+}