Commit 8d5e827
Max Rydahl Andersen
·
2026-06-29 02:43:28 -0400 EDT
parent ce52eb7
feat: session labels Now you can apply ephemeral key-value pairs to a zmx session and see them when running `zmx list`. This allows users to tag sessions with metadata instead of only relying on the session name.
11 files changed,
+674,
-31
+10,
-0
1@@ -4,6 +4,16 @@ Use spec: https://common-changelog.org/
2
3 ## Staged
4
5+### Added
6+
7+- Label system for sessions:
8+ - `zmx set <name> k=v ...` to attach key=value labels to live sessions
9+ - `zmx set <name> key=` to remove a specific label (empty value = delete)
10+ - `zmx get <name>` to read labels from a session
11+ - `zmx get <name> key` to print a single value
12+ - `zmx clear <name>` to remove all labels
13+ - `zmx list` now shows labels by default as tab-separated fields
14+
15 ### Fixed
16
17 - `zmx run` will now detect heredocs and add the completion marker to a newline
+24,
-1
1@@ -83,7 +83,11 @@ Commands:
2 [p]rint <name> <text...> Inject text into session display
3 [wr]ite <name> <file_path> Write stdin to file_path through the session
4 [d]etach Detach all clients (ctrl+\\ for current client)
5- [l]ist|ls [--short] List active sessions
6+ [l]ist|ls [--short|--where k=v] List active sessions
7+ [g]et <name> Get session labels
8+ set <name> k=v ... Set session labels
9+ [un]set <name> key ... Remove session labels
10+ [cl]ear <name> Clear all session labels
11 [k]ill <name>... [--force] Kill session and all attached clients
12 [hi]story <name> [--vt|--html] Output session scrollback
13 [w]ait <name>... Wait for session tasks to complete
14@@ -286,6 +290,25 @@ zmx k tests # kills d.tests
15 zmx wait # suspends until all tasks prefixed with "d." are complete
16 ```
17
18+## label inheritance
19+
20+When creating a new session from inside an existing one (`$ZMX_SESSION` is set), labels are automatically inherited from the parent session. This means if you set `project=zmx` on a session, any child sessions created from within it will also have `project=zmx`.
21+
22+To control which labels are inherited, set `ZMX_INHERIT_LABELS`:
23+
24+```bash
25+# Inherit all labels (default)
26+zmx a child
27+
28+# Inherit no labels
29+export ZMX_INHERIT_LABELS=
30+zmx a child
31+
32+# Inherit only specific labels
33+export ZMX_INHERIT_LABELS=project,team
34+zmx a child
35+```
36+
37 ## philosophy
38
39 The entire argument for `zmx` instead of something like `tmux` that has windows, panes, splits, etc. is that job should be handled by your os window manager. By using something like `tmux` you now have redundant functionality in your dev stack: a window manager for your os and a window manager for your terminal. Further, in order to use modern terminal features, your terminal emulator **and** `tmux` need to have support for them. This holds back the terminal enthusiast community and feature development.
+3,
-0
1@@ -37,6 +37,7 @@ pub fn build(b: *std.Build) void {
2 // on PATH" (true even via the CLT stub), which pulls in the iOS SDK at
3 // configure time and breaks builds without full Xcode.
4 .@"emit-xcframework" = false,
5+ .@"emit-macos-app" = false,
6 });
7 exe_mod.addImport(
8 "ghostty-vt",
9@@ -73,6 +74,7 @@ pub fn build(b: *std.Build) void {
10 .optimize = optimize,
11 .@"emit-lib-vt" = true,
12 .@"emit-xcframework" = false,
13+ .@"emit-macos-app" = false,
14 });
15 test_module.addImport(
16 "ghostty-vt",
17@@ -126,6 +128,7 @@ pub fn build(b: *std.Build) void {
18 .optimize = .ReleaseSafe,
19 .@"emit-lib-vt" = true,
20 .@"emit-xcframework" = false,
21+ .@"emit-macos-app" = false,
22 })) |release_dep| {
23 release_mod.addImport("ghostty-vt", release_dep.module("ghostty-vt"));
24 }
M
pico.sh
+1,
-0
1@@ -3,6 +3,7 @@ set -euo pipefail
2
3 export ZMX_SESSION_PREFIX="${ZMX_SESSION_PREFIX:-ci.zmx.}"
4 EVENT="${PICI_EVENT:-manual}"
5+BRANCH="${PICI_BRANCH:-tmp}"
6
7 echo "running ci event=${EVENT} session=${ZMX_SESSION_PREFIX}"
8
+24,
-4
1@@ -32,7 +32,7 @@ const bash_completions =
2 \\ cur="${COMP_WORDS[COMP_CWORD]}"
3 \\ prev="${COMP_WORDS[COMP_CWORD-1]}"
4 \\
5- \\ local commands="attach run send print write detach list kill history wait tail completions version help"
6+ \\ local commands="attach run send print write detach list kill history get set clear wait tail completions version help"
7 \\
8 \\ if [[ $COMP_CWORD -eq 1 ]]; then
9 \\ COMPREPLY=($(compgen -W "$commands" -- "$cur"))
10@@ -40,7 +40,7 @@ const bash_completions =
11 \\ fi
12 \\
13 \\ case "$prev" in
14- \\ attach|run|send|print|write|kill|history|wait|tail)
15+ \\ attach|run|send|print|write|kill|history|get|set|clear|wait|tail)
16 \\ local sessions=$(zmx list --short 2>/dev/null | tr '\n' ' ')
17 \\ COMPREPLY=($(compgen -W "$sessions" -- "$cur"))
18 \\ ;;
19@@ -86,6 +86,9 @@ const zsh_completions =
20 \\ 'wait:Wait for session tasks to complete'
21 \\ 'tail:Follow session output'
22 \\ 'completions:Shell completion scripts'
23+ \\ 'get:Get session labels'
24+ \\ 'set:Set session labels'
25+ \\ 'clear:Clear all session labels'
26 \\ 'version:Show version'
27 \\ 'help:Show help message'
28 \\ )
29@@ -93,7 +96,7 @@ const zsh_completions =
30 \\ ;;
31 \\ args)
32 \\ case $words[2] in
33- \\ attach|a|kill|k|run|r|send|s|print|p|write|wr|history|hi|wait|w|tail|t)
34+ \\ attach|a|kill|k|run|r|send|s|print|p|write|wr|history|get|g|set|clear|hi|wait|w|tail|t)
35 \\ _zmx_sessions
36 \\ ;;
37 \\ completions|c)
38@@ -145,10 +148,13 @@ const fish_completions =
39 \\complete -c zmx -n "__fish_is_nth_token 1" -a tail -d 'Follow session output'
40 \\complete -c zmx -n "__fish_is_nth_token 1" -a completions -d 'Shell completions (bash, zsh, fish, nu)'
41 \\complete -c zmx -n "__fish_is_nth_token 1" -a version -d 'Show version'
42+ \\complete -c zmx -n "__fish_is_nth_token 1" -a get -d 'Get session labels'
43+ \\complete -c zmx -n "__fish_is_nth_token 1" -a set -d 'Set session labels'
44+ \\complete -c zmx -n "__fish_is_nth_token 1" -a clear -d 'Clear all session labels'
45 \\complete -c zmx -n "__fish_is_nth_token 1" -a help -d 'Show help message'
46 \\
47 \\# Complete session names and shells
48- \\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" -a '(zmx list --short 2>/dev/null)' -d 'Session name'
49+ \\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'
50 \\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'
51 \\
52 \\complete -c zmx -n "__fish_is_nth_token 2; and __fish_seen_subcommand_from c completions" -a 'bash zsh fish nu' -d Shell
53@@ -157,6 +163,7 @@ const fish_completions =
54 \\complete -c zmx -n "__fish_seen_subcommand_from r run" -s d -d 'Detach from the calling terminal; use `wait` to track its status'
55 \\complete -c zmx -n "__fish_seen_subcommand_from r run" -l fish -d 'Required when the session runs fish shell'
56 \\complete -c zmx -n "__fish_seen_subcommand_from l list" -l short -d 'Short output'
57+ \\complete -c zmx -n "__fish_seen_subcommand_from l list" -l where -d 'Filter by label (key=value)' -r
58 \\complete -c zmx -n "__fish_seen_subcommand_from k kill" -l force -d 'Force kill'
59 \\complete -c zmx -n "__fish_seen_subcommand_from hi history" -l vt -d 'History format for escape sequences'
60 \\complete -c zmx -n "__fish_seen_subcommand_from hi history" -l html -d 'History format for escape sequences'
61@@ -210,5 +217,18 @@ const nu_completions =
62 \\export extern "zmx tail" [...sessions: string@"nu-complete zmx sessions"]
63 \\export extern "zmx version" []
64 \\export extern "completions" [shell: string@"nu-complete zmx complete"]
65+ \\export extern "zmx get" [
66+ \\ name?: string@"nu-complete zmx sessions"
67+ \\]
68+ \\
69+ \\export extern "zmx set" [
70+ \\ name?: string@"nu-complete zmx sessions"
71+ \\ ...pairs: string
72+ \\]
73+ \\
74+ \\export extern "zmx clear" [
75+ \\ name?: string@"nu-complete zmx sessions"
76+ \\]
77+ \\
78 \\export extern "zmx help" []
79 ;
+81,
-13
1@@ -18,6 +18,10 @@ pub const Tag = enum(u8) {
2 Switch = 11,
3 Write = 12,
4 TaskComplete = 13,
5+ LabelGet = 14,
6+ LabelSet = 15,
7+ LabelClear = 16,
8+ LabelData = 17,
9 // Non-exhaustive: this enum comes off the wire via bytesToValue and
10 // @enumFromInt, so out-of-range values (14-255) are representable
11 // rather than UB. Switches must handle `_` (unknown tag).
12@@ -213,6 +217,13 @@ const SessionProbeError = error{
13 const SessionProbeResult = struct {
14 fd: i32,
15 info: Info,
16+ labels: ?[]const u8,
17+ alloc: std.mem.Allocator,
18+
19+ pub fn deinit(self: *const SessionProbeResult) void {
20+ if (self.labels) |lbl| self.alloc.free(lbl);
21+ posix.close(self.fd);
22+ }
23 };
24
25 pub fn probeSession(
26@@ -224,6 +235,7 @@ pub fn probeSession(
27 errdefer posix.close(fd);
28
29 send(fd, .Info, "") catch return error.Unexpected;
30+ send(fd, .LabelGet, "") catch {};
31
32 var poll_fds = [_]posix.pollfd{.{ .fd = fd, .events = posix.POLL.IN, .revents = 0 }};
33 const poll_result = posix.poll(&poll_fds, timeout_ms) catch return error.Unexpected;
34@@ -237,19 +249,43 @@ pub fn probeSession(
35 const n = sb.read(fd) catch return error.Unexpected;
36 if (n == 0) return error.Unexpected;
37
38- while (sb.next()) |msg| {
39- if (msg.header.tag == .Info) {
40- if (msg.payload.len != @sizeOf(Info)) return error.InfoSizeMismatch;
41- return .{
42- .fd = fd,
43- .info = std.mem.bytesToValue(Info, msg.payload[0..@sizeOf(Info)]),
44- };
45+ var info_result: ?Info = null;
46+ var labels: ?[]const u8 = null;
47+ errdefer if (labels) |lbl| alloc.free(lbl);
48+
49+ while (true) {
50+ if (sb.next()) |msg| {
51+ if (msg.header.tag == .Info) {
52+ if (msg.payload.len != @sizeOf(Info)) return error.InfoSizeMismatch;
53+ info_result = std.mem.bytesToValue(Info, msg.payload[0..@sizeOf(Info)]);
54+ }
55+ if (msg.header.tag == .LabelData) {
56+ labels = alloc.dupe(u8, msg.payload) catch null;
57+ }
58+
59+ if (info_result != null and labels != null) break;
60+ continue;
61 }
62+
63+ // No complete message available, wait for more data
64+ const more = posix.poll(&poll_fds, 50) catch break;
65+ if (more == 0) break;
66+ const n_read = sb.read(fd) catch break;
67+ if (n_read == 0) break;
68+ }
69+
70+ if (info_result) |info| {
71+ return .{
72+ .fd = fd,
73+ .info = info,
74+ .labels = labels,
75+ .alloc = alloc,
76+ };
77 }
78 return error.Unexpected;
79 }
80
81-// WIRE PROTOCOL FREEZE — read before "fixing" any test below.
82+// WIRE PROTOCOL FREEZE: read before "fixing" any test below.
83 //
84 // Changing these constants does not fix the test; it breaks every
85 // running daemon for every user until they `pkill -f zmx`.
86@@ -264,14 +300,46 @@ test "Info wire size is frozen" {
87
88 test "Tag wire values are frozen" {
89 inline for (.{
90- .{ Tag.Input, 0 }, .{ Tag.Output, 1 }, .{ Tag.Resize, 2 },
91- .{ Tag.Detach, 3 }, .{ Tag.DetachAll, 4 }, .{ Tag.Kill, 5 },
92- .{ Tag.Info, 6 }, .{ Tag.Init, 7 }, .{ Tag.History, 8 },
93- .{ Tag.Run, 9 }, .{ Tag.Ack, 10 }, .{ Tag.Switch, 11 },
94- .{ Tag.Write, 12 }, .{ Tag.TaskComplete, 13 },
95+ .{ Tag.Input, 0 }, .{ Tag.Output, 1 }, .{ Tag.Resize, 2 },
96+ .{ Tag.Detach, 3 }, .{ Tag.DetachAll, 4 }, .{ Tag.Kill, 5 },
97+ .{ Tag.Info, 6 }, .{ Tag.Init, 7 }, .{ Tag.History, 8 },
98+ .{ Tag.Run, 9 }, .{ Tag.Ack, 10 }, .{ Tag.Switch, 11 },
99+ .{ Tag.Write, 12 }, .{ Tag.TaskComplete, 13 }, .{ Tag.LabelGet, 14 },
100+ .{ Tag.LabelSet, 15 }, .{ Tag.LabelClear, 16 }, .{ Tag.LabelData, 17 },
101 }) |p| try std.testing.expectEqual(@as(u8, p[1]), @intFromEnum(p[0]));
102 }
103
104+pub fn roundTripForTag(
105+ alloc: std.mem.Allocator,
106+ socket_path: []const u8,
107+ request_tag: Tag,
108+ payload: []const u8,
109+ expected_tag: Tag,
110+) SessionProbeError![]u8 {
111+ const timeout_ms = 1000;
112+ const fd = try connectSession(socket_path);
113+ defer posix.close(fd);
114+
115+ send(fd, request_tag, payload) catch return error.Unexpected;
116+
117+ var poll_fds = [_]posix.pollfd{.{ .fd = fd, .events = posix.POLL.IN, .revents = 0 }};
118+ const poll_result = posix.poll(&poll_fds, timeout_ms) catch return error.Unexpected;
119+ if (poll_result == 0) return error.Timeout;
120+
121+ var sb = SocketBuffer.init(alloc) catch return error.Unexpected;
122+ defer sb.deinit();
123+
124+ const n = sb.read(fd) catch return error.Unexpected;
125+ if (n == 0) return error.Unexpected;
126+
127+ while (sb.next()) |msg| {
128+ if (msg.header.tag == expected_tag) {
129+ return alloc.dupe(u8, msg.payload) catch return error.Unexpected;
130+ }
131+ }
132+ return error.Unexpected;
133+}
134+
135 test "zeroed Info has no stack garbage in wire bytes" {
136 var info = std.mem.zeroes(Info);
137 info.clients_len = 3;
+145,
-0
1@@ -0,0 +1,145 @@
2+const std = @import("std");
3+
4+pub const LabelError = error{
5+ LabelKeyEmpty,
6+ LabelKeyInvalidChar,
7+ LabelValueInvalidChar,
8+ LabelKeyReservedName,
9+};
10+
11+const reserved_keys = [_][]const u8{ "name", "start_dir", "cmd" };
12+
13+fn isAlnum(c: u8) bool {
14+ return (c >= 'a' and c <= 'z') or
15+ (c >= 'A' and c <= 'Z') or
16+ (c >= '0' and c <= '9');
17+}
18+
19+pub fn assertLabel(key: []const u8, value: []const u8) LabelError!void {
20+ if (key.len == 0) {
21+ return LabelError.LabelKeyEmpty;
22+ }
23+
24+ for (reserved_keys) |rk| {
25+ if (std.mem.eql(u8, key, rk)) return error.LabelKeyReservedName;
26+ }
27+
28+ for (key) |ch| {
29+ if (!isAlnum(ch) and ch != '-' and ch != '_' and ch != '.') {
30+ return LabelError.LabelKeyInvalidChar;
31+ }
32+ }
33+
34+ for (value) |ch| {
35+ if (!isAlnum(ch) and ch != '-' and ch != '_' and ch != '.') {
36+ return LabelError.LabelValueInvalidChar;
37+ }
38+ }
39+}
40+
41+pub fn labelsToU8(alloc: std.mem.Allocator, labels: std.StringHashMapUnmanaged([]u8)) ![]u8 {
42+ var out = std.ArrayList(u8).empty;
43+ var keys = std.ArrayList([]const u8).empty;
44+ defer keys.deinit(alloc);
45+
46+ var it = labels.iterator();
47+ while (it.next()) |entry| {
48+ try keys.append(alloc, entry.key_ptr.*);
49+ }
50+ std.mem.sort([]const u8, keys.items, {}, struct {
51+ fn lessThan(_: void, a: []const u8, b: []const u8) bool {
52+ return std.mem.order(u8, a, b) == .lt;
53+ }
54+ }.lessThan);
55+
56+ var idx: usize = 1;
57+ for (keys.items) |key| {
58+ defer idx += 1;
59+ const value = labels.get(key).?;
60+ try out.appendSlice(alloc, key);
61+ try out.append(alloc, '=');
62+ try out.appendSlice(alloc, value);
63+ if (idx < keys.items.len) {
64+ try out.append(alloc, ' ');
65+ }
66+ }
67+ return out.toOwnedSlice(alloc);
68+}
69+
70+pub const LabelIterator = struct {
71+ labels: []const u8,
72+ idx: usize = 0,
73+
74+ const LabelKeyValue = struct {
75+ key: []const u8,
76+ value: []const u8,
77+ };
78+
79+ pub fn init(labels: []const u8) LabelIterator {
80+ return .{
81+ .labels = labels,
82+ };
83+ }
84+
85+ pub fn next(self: *LabelIterator) ?LabelKeyValue {
86+ const labels = self.labels;
87+ while (self.idx < labels.len) {
88+ var eql_idx = self.idx;
89+ // scan to '=' char
90+ while (eql_idx < labels.len and labels[eql_idx] != '=') eql_idx += 1;
91+ if (eql_idx == labels.len) break;
92+
93+ var space_idx = eql_idx + 1;
94+ // scan to ' ' char
95+ while (space_idx < labels.len and labels[space_idx] != ' ') space_idx += 1;
96+
97+ const kv = LabelKeyValue{
98+ .key = labels[self.idx..eql_idx],
99+ .value = labels[eql_idx + 1 .. space_idx],
100+ };
101+ // move the pointer so next() will start where it left off
102+ self.idx = if (space_idx < labels.len) space_idx + 1 else labels.len;
103+ return kv;
104+ }
105+
106+ return null;
107+ }
108+};
109+
110+pub fn getLabelValueFromPairs(single_kv: []const u8, labels: []const u8) error{LabelKeyNotFound}![]const u8 {
111+ var iter = LabelIterator.init(labels);
112+ while (iter.next()) |kv| {
113+ if (std.mem.eql(u8, single_kv, kv.key)) {
114+ return kv.value;
115+ }
116+ }
117+ return error.LabelKeyNotFound;
118+}
119+
120+test "getLabelValueFromPairs" {
121+ try std.testing.expect(std.mem.eql(u8, "zmx", try getLabelValueFromPairs("project", "project=zmx env=prd")));
122+ try std.testing.expect(std.mem.eql(u8, "zmx", try getLabelValueFromPairs("project", "env=prd status=done project=zmx")));
123+ try std.testing.expectError(error.LabelKeyNotFound, getLabelValueFromPairs("sha", "env=prd status=done project=zmx"));
124+}
125+
126+test "assertLabel" {
127+ try assertLabel("key", "");
128+ try assertLabel("1337", "");
129+ try assertLabel("key.key_key-key", "");
130+ try std.testing.expectError(error.LabelKeyEmpty, assertLabel("", "value"));
131+ try std.testing.expectError(error.LabelKeyInvalidChar, assertLabel("key key", ""));
132+ try std.testing.expectError(error.LabelKeyInvalidChar, assertLabel("key:key", ""));
133+ try std.testing.expectError(error.LabelKeyInvalidChar, assertLabel("key/key", ""));
134+
135+ try assertLabel("key", "");
136+ try assertLabel("key", "1337");
137+ try assertLabel("key", "value");
138+ try assertLabel("key", "value.value_value-value");
139+ try std.testing.expectError(error.LabelValueInvalidChar, assertLabel("key", "value value"));
140+ try std.testing.expectError(error.LabelValueInvalidChar, assertLabel("key", "value:value"));
141+ try std.testing.expectError(error.LabelValueInvalidChar, assertLabel("key", "value/value"));
142+
143+ try std.testing.expectError(error.LabelKeyReservedName, assertLabel("name", "dev"));
144+ try std.testing.expectError(error.LabelKeyReservedName, assertLabel("start_dir", "dev"));
145+ try std.testing.expectError(error.LabelKeyReservedName, assertLabel("cmd", "dev"));
146+}
+267,
-12
1@@ -8,6 +8,7 @@ const completions = @import("completions.zig");
2 const util = @import("util.zig");
3 const cross = @import("cross.zig");
4 const socket = @import("socket.zig");
5+const label = @import("label.zig");
6
7 pub const version = build_options.version;
8 pub const ghostty_version = build_options.ghostty_version;
9@@ -46,6 +47,25 @@ const SessionMatch = struct {
10 }
11 };
12
13+fn resolveSessionOrEnv(alloc: std.mem.Allocator, session_name: ?[]const u8) ![]const u8 {
14+ const sesh_env = socket.getSeshNameFromEnv();
15+ const raw = if (session_name) |name|
16+ if (std.mem.eql(u8, name, ".")) blk: {
17+ if (sesh_env.len > 0) break :blk sesh_env;
18+ var buf: [4096]u8 = undefined;
19+ var w = std.fs.File.stderr().writer(&buf);
20+ w.interface.print("error: \".\" requires ZMX_SESSION (are you inside a zmx session?)\n", .{}) catch {};
21+ w.interface.flush() catch {};
22+ return error.SessionNameRequired;
23+ } else name
24+ else if (sesh_env.len > 0)
25+ sesh_env
26+ else {
27+ return error.SessionNameRequired;
28+ };
29+ return socket.getSeshName(alloc, raw);
30+}
31+
32 fn parseSessionArg(alloc: std.mem.Allocator, raw: []const u8) !SessionMatch {
33 if (raw.len > 0 and raw[raw.len - 1] == '*') {
34 const name = try socket.getSeshName(alloc, raw[0 .. raw.len - 1]);
35@@ -67,6 +87,10 @@ fn drainSignalPipe() void {
36 }
37 }
38
39+fn detectHelp(arg: []const u8) bool {
40+ return (std.mem.eql(u8, arg, "--help") or std.mem.eql(u8, arg, "-h"));
41+}
42+
43 pub fn main() !void {
44 // use c_allocator to avoid "reached unreachable code" panic in DebugAllocator when forking
45 const alloc = std.heap.c_allocator;
46@@ -99,13 +123,39 @@ pub fn main() !void {
47 return help();
48 } else if (std.mem.eql(u8, cmd, "list") or std.mem.eql(u8, cmd, "l") or std.mem.eql(u8, cmd, "ls")) {
49 var short = false;
50- if (args.next()) |arg| {
51- if (std.mem.eql(u8, arg, "--help") or std.mem.eql(u8, arg, "-h")) {
52- return help();
53- }
54- short = std.mem.eql(u8, arg, "--short");
55+ while (args.next()) |arg| {
56+ if (detectHelp(arg)) return help();
57+ if (std.mem.eql(u8, arg, "--short")) short = true;
58 }
59 return list(&cfg, short);
60+ } else if (std.mem.eql(u8, cmd, "get") or std.mem.eql(u8, cmd, "g")) {
61+ const sesh_name = args.next() orelse return error.SessionNameRequired;
62+ if (detectHelp(sesh_name)) return help();
63+ const sesh = try resolveSessionOrEnv(alloc, sesh_name);
64+ defer alloc.free(sesh);
65+ const single_kv = args.next() orelse "";
66+ return labelGet(&cfg, sesh, single_kv);
67+ } else if (std.mem.eql(u8, cmd, "set")) {
68+ const sesh_name = args.next() orelse return error.SessionNameRequired;
69+ if (detectHelp(sesh_name)) return help();
70+ const sesh = try resolveSessionOrEnv(alloc, sesh_name);
71+ defer alloc.free(sesh);
72+
73+ var kvs = std.ArrayList(u8).empty;
74+ defer kvs.deinit(alloc);
75+ var first = true;
76+ while (args.next()) |arg| {
77+ if (!first) try kvs.append(alloc, ' ');
78+ try kvs.appendSlice(alloc, arg);
79+ first = false;
80+ }
81+ return labelSet(&cfg, sesh, kvs.items);
82+ } else if (std.mem.eql(u8, cmd, "clear")) {
83+ const sesh_name = args.next() orelse return error.SessionNameRequired;
84+ if (detectHelp(sesh_name)) return help();
85+ const sesh = try resolveSessionOrEnv(alloc, sesh_name);
86+ defer alloc.free(sesh);
87+ return labelClear(&cfg, sesh);
88 } else if (std.mem.eql(u8, cmd, "completions") or std.mem.eql(u8, cmd, "c")) {
89 const arg = args.next() orelse return;
90 if (std.mem.eql(u8, arg, "--help") or std.mem.eql(u8, arg, "-h")) {
91@@ -187,9 +237,9 @@ pub fn main() !void {
92 while (args.next()) |arg| {
93 if (std.mem.startsWith(u8, arg, "-d")) {
94 detached = true;
95- continue;
96+ } else {
97+ try cmd_args_raw.append(alloc, arg);
98 }
99- try cmd_args_raw.append(alloc, arg);
100 }
101 const clients = try std.ArrayList(*Client).initCapacity(alloc, 10);
102
103@@ -604,6 +654,7 @@ const Daemon = struct {
104 cfg: *Cfg,
105 alloc: std.mem.Allocator,
106 clients: std.ArrayList(*Client),
107+ labels: std.StringHashMapUnmanaged([]u8) = .empty,
108 // This control which client is the leader. The leader controls terminal state and
109 // cols/rows of session.
110 leader_client_fd: ?i32,
111@@ -630,10 +681,64 @@ const Daemon = struct {
112
113 pub fn deinit(self: *Daemon) void {
114 self.clients.deinit(self.alloc);
115+ var it = self.labels.iterator();
116+ while (it.next()) |entry| {
117+ self.alloc.free(entry.key_ptr.*);
118+ self.alloc.free(entry.value_ptr.*);
119+ }
120+ self.labels.deinit(self.alloc);
121 self.pty_write_buf.deinit(self.alloc);
122 self.alloc.free(self.socket_path);
123 }
124
125+ fn handleLabelGet(self: *Daemon, client: *Client) !void {
126+ const out = try label.labelsToU8(self.alloc, self.labels);
127+ defer self.alloc.free(out);
128+ try ipc.appendMessage(self.alloc, &client.write_buf, .LabelData, out);
129+ client.has_pending_output = true;
130+ }
131+
132+ fn handleLabelSet(self: *Daemon, client: *Client, labels: []const u8) !void {
133+ std.log.info("handle label set payload={s}", .{labels});
134+
135+ var kvs = label.LabelIterator.init(labels);
136+ while (kvs.next()) |kv| {
137+ if (kv.value.len == 0) {
138+ if (self.labels.fetchRemove(kv.key)) |existing| {
139+ self.alloc.free(existing.key);
140+ self.alloc.free(existing.value);
141+ }
142+ continue;
143+ }
144+
145+ const owned_key = try self.alloc.dupe(u8, kv.key);
146+ errdefer self.alloc.free(owned_key);
147+ const owned_value = try self.alloc.dupe(u8, kv.value);
148+ errdefer self.alloc.free(owned_value);
149+ if (try self.labels.fetchPut(self.alloc, owned_key, owned_value)) |existing| {
150+ // fetchPut does NOT replace the key in the map, the old
151+ // key pointer stays. So free the new (unused) key and the
152+ // old value.
153+ self.alloc.free(owned_key);
154+ self.alloc.free(existing.value);
155+ }
156+ }
157+
158+ try ipc.appendMessage(self.alloc, &client.write_buf, .Ack, "");
159+ client.has_pending_output = true;
160+ }
161+
162+ fn handleLabelClear(self: *Daemon, client: *Client) !void {
163+ var it = self.labels.iterator();
164+ while (it.next()) |entry| {
165+ self.alloc.free(entry.key_ptr.*);
166+ self.alloc.free(entry.value_ptr.*);
167+ }
168+ self.labels.clearRetainingCapacity();
169+ try ipc.appendMessage(self.alloc, &client.write_buf, .Ack, "");
170+ client.has_pending_output = true;
171+ }
172+
173 pub fn shutdown(self: *Daemon) void {
174 std.log.info("shutting down daemon session={s}", .{self.session_name});
175 self.running = false;
176@@ -762,6 +867,7 @@ const Daemon = struct {
177 /// ensureSession "upserts" a session by checking if the unix socket exists already.
178 /// If not it creates one and spawns the daemon.
179 fn ensureSession(self: *Daemon) !EnsureSessionResult {
180+ std.log.info("ensure session session={s}", .{self.session_name});
181 var dir = try std.fs.openDirAbsolute(self.cfg.socket_dir, .{});
182 defer dir.close();
183
184@@ -889,6 +995,7 @@ const Daemon = struct {
185 }
186
187 try daemonLoop(self, server_sock_fd, pty_fd);
188+ std.log.info("daemon loop shutdown", .{});
189 return .{ .created = true, .is_daemon = true };
190 }
191 posix.close(server_sock_fd);
192@@ -1287,7 +1394,10 @@ fn help() !void {
193 \\ [p]rint <name> <text...> Inject text into session display
194 \\ [wr]ite <name> <file_path> Write stdin to file_path through the session
195 \\ [d]etach Detach all clients (ctrl+\\ for current client)
196- \\ [l]ist|ls [--short] List active sessions
197+ \\ [l]ist|ls [--short|--where k=v] List active sessions
198+ \\ [g]et <name> Get session labels
199+ \\ set <name> k=v ... Set session labels (k= to remove)
200+ \\ [cl]ear <name> Clear all session labels
201 \\ [k]ill <name>... [--force] Kill session and all attached clients
202 \\ [hi]story <name> [--vt|--html] Output session scrollback
203 \\ [w]ait <name>... Wait for session tasks to complete
204@@ -1383,6 +1493,20 @@ fn help() !void {
205 \\ zmx wait dev
206 \\ zmx wait dev other
207 \\
208+ \\Labels:
209+ \\ Attach key=value labels to live sessions for discovery and
210+ \\ filtering. Labels are in-memory and scoped to session lifetime.
211+ \\
212+ \\ Examples:
213+ \\ zmx set dev project=zmx env=dev
214+ \\ zmx set dev project= # unset a label
215+ \\ zmx set . status=fail # "." resolves to current session
216+ \\ zmx get dev
217+ \\ zmx get dev project
218+ \\ zmx set next "$(zmx get prev)" # set labels from other session
219+ \\ zmx list | grep project=zmx
220+ \\ zmx clear dev
221+ \\
222 \\Environment variables:
223 \\ SHELL Default shell for new sessions
224 \\ ZMX_DIR Socket directory (priority 1)
225@@ -1747,7 +1871,6 @@ fn list(cfg: *Cfg, short: bool) !void {
226 const current_session = socket.getSeshNameFromEnv();
227 var buf: [4096]u8 = undefined;
228 var stdout = std.fs.File.stdout().writer(&buf);
229-
230 var sessions = try util.get_session_entries(alloc, cfg.socket_dir);
231 defer {
232 for (sessions.items) |session| {
233@@ -1768,6 +1891,12 @@ fn list(cfg: *Cfg, short: bool) !void {
234 std.mem.sort(util.SessionEntry, sessions.items, {}, util.SessionEntry.lessThan);
235
236 for (sessions.items) |session| {
237+ if (session.is_error) {
238+ try util.writeSessionLine(&stdout.interface, session, short, current_session);
239+ try stdout.interface.flush();
240+ continue;
241+ }
242+
243 try util.writeSessionLine(&stdout.interface, session, short, current_session);
244 try stdout.interface.flush();
245 }
246@@ -1782,6 +1911,7 @@ fn detachAll(cfg: *Cfg) !void {
247 std.log.err("ZMX_SESSION env var not found: are you inside a zmx session?", .{});
248 return;
249 }
250+ std.log.info("detach all session={s}", .{session_name});
251
252 var dir = try std.fs.openDirAbsolute(cfg.socket_dir, .{});
253 defer dir.close();
254@@ -1804,6 +1934,7 @@ fn detachAll(cfg: *Cfg) !void {
255 }
256
257 fn kill(cfg: *Cfg, session_name: []const u8, force: bool) !void {
258+ std.log.info("kill session={s}", .{session_name});
259 var gpa = std.heap.GeneralPurposeAllocator(.{}){};
260 defer _ = gpa.deinit();
261 const alloc = gpa.allocator();
262@@ -1865,6 +1996,116 @@ fn kill(cfg: *Cfg, session_name: []const u8, force: bool) !void {
263 try w.interface.flush();
264 }
265
266+fn printLabelError(session_name: []const u8, err: anyerror) noreturn {
267+ var buf: [4096]u8 = undefined;
268+ var w = std.fs.File.stderr().writer(&buf);
269+ switch (err) {
270+ error.Timeout => w.interface.print(
271+ "error: session \"{s}\" does not support labels (daemon too old?)\n",
272+ .{session_name},
273+ ) catch {},
274+ error.ConnectionRefused, error.Unexpected => w.interface.print(
275+ "error: session \"{s}\" not found or unresponsive\n",
276+ .{session_name},
277+ ) catch {},
278+ else => w.interface.print(
279+ "error: {s}\n",
280+ .{@errorName(err)},
281+ ) catch {},
282+ }
283+ w.interface.flush() catch {};
284+ std.process.exit(1);
285+}
286+
287+fn labelGet(cfg: *Cfg, session_name: []const u8, single_kv: []const u8) !void {
288+ std.log.info("label get session={s}", .{session_name});
289+ var gpa = std.heap.GeneralPurposeAllocator(.{}){};
290+ defer _ = gpa.deinit();
291+ const alloc = gpa.allocator();
292+
293+ const socket_path = socket.getSocketPath(alloc, cfg.socket_dir, session_name) catch |err| switch (err) {
294+ error.NameTooLong => return socket.printSessionNameTooLong(session_name, cfg.socket_dir),
295+ error.OutOfMemory => return err,
296+ };
297+ defer alloc.free(socket_path);
298+
299+ const payload = ipc.roundTripForTag(alloc, socket_path, .LabelGet, "", .LabelData) catch |err| {
300+ printLabelError(session_name, err);
301+ };
302+ defer alloc.free(payload);
303+
304+ var buf: [4096]u8 = undefined;
305+ var stdout = std.fs.File.stdout().writer(&buf);
306+ if (single_kv.len == 0) {
307+ try stdout.interface.print("{s}", .{payload});
308+ try stdout.interface.flush();
309+ return;
310+ }
311+
312+ const val = try label.getLabelValueFromPairs(single_kv, payload);
313+ try stdout.interface.print("{s}", .{val});
314+ try stdout.interface.flush();
315+}
316+
317+fn labelSet(cfg: *Cfg, session_name: []const u8, labels: []const u8) !void {
318+ std.log.info("label set session={s}", .{session_name});
319+ var gpa = std.heap.GeneralPurposeAllocator(.{}){};
320+ defer _ = gpa.deinit();
321+ const alloc = gpa.allocator();
322+
323+ var kvs = label.LabelIterator.init(labels);
324+ while (kvs.next()) |kv| {
325+ label.assertLabel(kv.key, kv.value) catch |err| {
326+ var buf: [4096]u8 = undefined;
327+ var w = std.fs.File.stderr().writer(&buf);
328+ const msg = "error: key-value kvs can only contain [a-z, A-Z, 0-9, -_.] characters";
329+ switch (err) {
330+ error.LabelKeyEmpty => {
331+ w.interface.print("error: label key cannot be empty\n", .{}) catch {};
332+ },
333+ error.LabelKeyReservedName => {
334+ w.interface.print("error: \"{s}\" is a read-only built-in field\n", .{kv.key}) catch {};
335+ },
336+ error.LabelKeyInvalidChar => {
337+ w.interface.print("{s}: key=[{s}]\n", .{ msg, kv.key }) catch {};
338+ },
339+ error.LabelValueInvalidChar => {
340+ w.interface.print("{s}: value=[{s}]\n", .{ msg, kv.value }) catch {};
341+ },
342+ }
343+ w.interface.flush() catch {};
344+ std.process.exit(1);
345+ };
346+ }
347+
348+ const socket_path = socket.getSocketPath(alloc, cfg.socket_dir, session_name) catch |err| switch (err) {
349+ error.NameTooLong => return socket.printSessionNameTooLong(session_name, cfg.socket_dir),
350+ error.OutOfMemory => return err,
351+ };
352+ defer alloc.free(socket_path);
353+
354+ _ = ipc.roundTripForTag(alloc, socket_path, .LabelSet, labels, .Ack) catch |err| {
355+ printLabelError(session_name, err);
356+ };
357+}
358+
359+fn labelClear(cfg: *Cfg, session_name: []const u8) !void {
360+ std.log.info("label clear session={s}", .{session_name});
361+ var gpa = std.heap.GeneralPurposeAllocator(.{}){};
362+ defer _ = gpa.deinit();
363+ const alloc = gpa.allocator();
364+
365+ const socket_path = socket.getSocketPath(alloc, cfg.socket_dir, session_name) catch |err| switch (err) {
366+ error.NameTooLong => return socket.printSessionNameTooLong(session_name, cfg.socket_dir),
367+ error.OutOfMemory => return err,
368+ };
369+ defer alloc.free(socket_path);
370+
371+ _ = ipc.roundTripForTag(alloc, socket_path, .LabelClear, "", .Ack) catch |err| {
372+ printLabelError(session_name, err);
373+ };
374+}
375+
376 /// Fetch terminal history from a session socket, returning it as an allocated
377 /// string. Caller owns the returned memory and must free it.
378 fn fetchHistory(
379@@ -1872,6 +2113,7 @@ fn fetchHistory(
380 cfg: *Cfg,
381 session_name: []const u8,
382 ) ![]const u8 {
383+ std.log.info("fetch history session={s}", .{session_name});
384 const socket_path = socket.getSocketPath(alloc, cfg.socket_dir, session_name) catch |err| switch (err) {
385 error.NameTooLong => {
386 socket.printSessionNameTooLong(session_name, cfg.socket_dir);
387@@ -1930,6 +2172,7 @@ fn fetchHistory(
388 }
389
390 fn history(cfg: *Cfg, session_name: []const u8, format: util.HistoryFormat) !void {
391+ std.log.info("history session={s}", .{session_name});
392 var gpa = std.heap.GeneralPurposeAllocator(.{}){};
393 defer _ = gpa.deinit();
394 const alloc = gpa.allocator();
395@@ -1991,6 +2234,7 @@ fn switchSesh(daemon: *Daemon, current_sesh: []const u8) !void {
396 // we want daemon.session_name because that's the session name the user provided during zmx attach
397 // instead of the name of the session they are currently inside of.
398 const next_session = daemon.session_name;
399+ std.log.info("switch session cur={s} next={s}", .{ current_sesh, next_session });
400
401 const socket_path = socket.getSocketPath(daemon.alloc, daemon.cfg.socket_dir, current_sesh) catch |err| switch (err) {
402 error.NameTooLong => return socket.printSessionNameTooLong(current_sesh, daemon.cfg.socket_dir),
403@@ -2170,7 +2414,7 @@ fn writeFile(daemon: *Daemon, file_path: []const u8) !void {
404 return;
405 };
406
407- defer posix.close(result.fd);
408+ defer result.deinit();
409
410 // Build wire payload: [u32 path len][path bytes][file content]
411 var wire_buf = try std.ArrayList(u8).initCapacity(
412@@ -2206,6 +2450,7 @@ fn writeFile(daemon: *Daemon, file_path: []const u8) !void {
413 }
414
415 fn send(cfg: *Cfg, session_name: []const u8, socket_path: []const u8, text_parts: [][]const u8, tag: ipc.Tag) !void {
416+ std.log.info("send session={s}", .{session_name});
417 const alloc = std.heap.c_allocator;
418 var buf: [4096]u8 = undefined;
419 var w = std.fs.File.stdout().writer(&buf);
420@@ -2259,7 +2504,7 @@ fn send(cfg: *Cfg, session_name: []const u8, socket_path: []const u8, text_parts
421 try w.interface.flush();
422 return;
423 };
424- defer posix.close(probe_result.fd);
425+ defer probe_result.deinit();
426
427 ipc.send(probe_result.fd, tag, payload.items) catch |err| switch (err) {
428 error.ConnectionResetByPeer, error.BrokenPipe => return,
429@@ -2372,6 +2617,7 @@ const ClientResult = struct {
430 /// clientLoop sends ipc commands to its corresponding daemon. It uses poll() as its non-blocking
431 /// mechanism. It will send stdin to the daemon and receive stdout from the daemon.
432 fn clientLoop(client_sock_fd: i32) !ClientResult {
433+ std.log.info("client loop fd={d}", .{client_sock_fd});
434 // use c_allocator to avoid "reached unreachable code" panic in DebugAllocator when forking
435 const alloc = std.heap.c_allocator;
436 defer posix.close(client_sock_fd);
437@@ -2461,11 +2707,13 @@ fn clientLoop(client_sock_fd: i32) !ClientResult {
438 if (n > 0) {
439 // Check for detach sequences (ctrl+\ as first byte or Kitty escape sequence)
440 if (util.isCtrlBackslash(buf[0..n])) {
441+ std.log.info("detach key detected", .{});
442 try ipc.appendMessage(alloc, &sock_write_buf, .Detach, "");
443 } else {
444 try ipc.appendMessage(alloc, &sock_write_buf, .Input, buf[0..n]);
445 }
446 } else {
447+ std.log.info("eof stdin", .{});
448 // EOF on stdin
449 return ClientResult{ .kind = .detach, .session_name = null };
450 }
451@@ -2483,6 +2731,7 @@ fn clientLoop(client_sock_fd: i32) !ClientResult {
452 return err;
453 };
454 if (n == 0) {
455+ std.log.info("server closed connection", .{});
456 // Server closed connection
457 return ClientResult{ .kind = .detach, .session_name = null };
458 }
459@@ -2506,6 +2755,7 @@ fn clientLoop(client_sock_fd: i32) !ClientResult {
460 );
461 },
462 .Switch => {
463+ std.log.info("switch session", .{});
464 return ClientResult{ .kind = .switch_session, .session_name = try alloc.dupe(u8, msg.payload) };
465 },
466 else => {},
467@@ -2519,6 +2769,7 @@ fn clientLoop(client_sock_fd: i32) !ClientResult {
468 const n = posix.write(client_sock_fd, sock_write_buf.items) catch |err| blk: {
469 if (err == error.WouldBlock) break :blk 0;
470 if (err == error.ConnectionResetByPeer or err == error.BrokenPipe) {
471+ std.log.info("connection reset or broken pipe", .{});
472 return ClientResult{ .kind = .detach, .session_name = null };
473 }
474 return err;
475@@ -2540,6 +2791,7 @@ fn clientLoop(client_sock_fd: i32) !ClientResult {
476 }
477
478 if (poll_fds.items[1].revents & (posix.POLL.HUP | posix.POLL.ERR | posix.POLL.NVAL) != 0) {
479+ std.log.info("poll hup|err|nval", .{});
480 return ClientResult{ .kind = .detach, .session_name = null };
481 }
482 }
483@@ -2798,9 +3050,12 @@ fn daemonLoop(daemon: *Daemon, server_sock_fd: i32, pty_fd: i32) !void {
484 break :daemon_loop;
485 },
486 .Info => try daemon.handleInfo(client),
487+ .LabelGet => try daemon.handleLabelGet(client),
488+ .LabelSet => try daemon.handleLabelSet(client, msg.payload),
489+ .LabelClear => try daemon.handleLabelClear(client),
490 .History => try daemon.handleHistory(client, &term, msg.payload),
491 .Run => try daemon.handleRun(client, msg.payload),
492- .Ack, .TaskComplete => {},
493+ .Ack, .TaskComplete, .LabelData => {},
494 .Write => try daemon.handleWrite(client, msg.payload),
495 _ => std.log.warn(
496 "ignoring unknown IPC tag={d}",
+1,
-0
1@@ -3,4 +3,5 @@ comptime {
2 _ = @import("util.zig");
3 _ = @import("socket.zig");
4 _ = @import("ipc.zig");
5+ _ = @import("label.zig");
6 }
+19,
-1
1@@ -3,6 +3,7 @@ const posix = std.posix;
2 const ghostty_vt = @import("ghostty-vt");
3 const ipc = @import("ipc.zig");
4 const socket = @import("socket.zig");
5+const label = @import("label.zig");
6 const testing = std.testing;
7
8 pub const SessionEntry = struct {
9@@ -13,6 +14,7 @@ pub const SessionEntry = struct {
10 error_name: ?[]const u8,
11 cmd: ?[]const u8 = null,
12 cwd: ?[]const u8 = null,
13+ labels: ?[]const u8 = null,
14 created_at: u64,
15 task_ended_at: ?u64,
16 task_exit_code: ?u8,
17@@ -21,6 +23,7 @@ pub const SessionEntry = struct {
18 alloc.free(self.name);
19 if (self.cmd) |cmd| alloc.free(cmd);
20 if (self.cwd) |cwd| alloc.free(cwd);
21+ if (self.labels) |l| alloc.free(l);
22 }
23
24 pub fn lessThan(_: void, a: SessionEntry, b: SessionEntry) bool {
25@@ -32,6 +35,7 @@ pub fn get_session_entries(
26 alloc: std.mem.Allocator,
27 socket_dir: []const u8,
28 ) !std.ArrayList(SessionEntry) {
29+ std.log.info("get session entries socket_dir={s}", .{socket_dir});
30 var dir = try std.fs.openDirAbsolute(socket_dir, .{ .iterate = true });
31 defer dir.close();
32 var iter = dir.iterate();
33@@ -60,6 +64,7 @@ pub fn get_session_entries(
34 .created_at = 0,
35 .task_exit_code = 1,
36 .task_ended_at = 0,
37+ .labels = "",
38 });
39 // Only clean up when the daemon is definitively gone. A busy
40 // daemon can miss the probe timeout; deleting its socket
41@@ -69,7 +74,7 @@ pub fn get_session_entries(
42 }
43 continue;
44 };
45- posix.close(result.fd);
46+ defer result.deinit();
47
48 // Extract cmd and cwd from the fixed-size arrays. Lengths come
49 // off the wire (u16 range), so clamp to the actual array size.
50@@ -79,11 +84,17 @@ pub fn get_session_entries(
51 alloc.dupe(u8, result.info.cmd[0..cmd_len]) catch null
52 else
53 null;
54+
55 const cwd: ?[]const u8 = if (cwd_len > 0)
56 alloc.dupe(u8, result.info.cwd[0..cwd_len]) catch null
57 else
58 null;
59
60+ const labels = if (result.labels) |lbl|
61+ alloc.dupe(u8, lbl) catch null
62+ else
63+ null;
64+
65 try sessions.append(alloc, .{
66 .name = name,
67 .pid = result.info.pid,
68@@ -92,6 +103,7 @@ pub fn get_session_entries(
69 .error_name = null,
70 .cmd = cmd,
71 .cwd = cwd,
72+ .labels = labels,
73 .created_at = result.info.created_at,
74 .task_ended_at = result.info.task_ended_at,
75 .task_exit_code = result.info.task_exit_code,
76@@ -739,6 +751,12 @@ pub fn writeSessionLine(
77 }
78 }
79 }
80+ if (session.labels) |labels| {
81+ var kvs = label.LabelIterator.init(labels);
82+ while (kvs.next()) |kv| {
83+ try writer.print("\t{s}={s}", .{ kv.key, kv.value });
84+ }
85+ }
86 try writer.print("\n", .{});
87 }
88
+99,
-0
1@@ -0,0 +1,99 @@
2+#!/usr/bin/env bats
3+# Label tests for zmx.
4+
5+load test_helper
6+
7+# ============================================================================
8+# Label CRUD
9+# ============================================================================
10+
11+@test "set/get: round-trips labels" {
12+ "$ZMX" run test-labels -d sleep 30
13+ wait_for_session test-labels
14+
15+ run "$ZMX" set test-labels project=zmx env=dev
16+ [ "$status" -eq 0 ]
17+
18+ run "$ZMX" get test-labels
19+ [ "$status" -eq 0 ]
20+ [[ "$output" == *"env=dev"* ]]
21+ [[ "$output" == *"project=zmx"* ]]
22+}
23+
24+@test "set: updates existing label" {
25+ "$ZMX" run test-update -d sleep 30
26+ wait_for_session test-update
27+
28+ run "$ZMX" set test-update status=busy
29+ [ "$status" -eq 0 ]
30+ run "$ZMX" set test-update status=done
31+ [ "$status" -eq 0 ]
32+
33+ run "$ZMX" get test-update
34+ [ "$status" -eq 0 ]
35+ [[ "$output" == *"status=done"* ]]
36+ [[ "$output" != *"status=busy"* ]]
37+}
38+
39+@test "set: rejects reserved key 'name'" {
40+ "$ZMX" run test-reserved -d sleep 30
41+ wait_for_session test-reserved
42+
43+ run "$ZMX" set test-reserved name=bad
44+ [ "$status" -ne 0 ]
45+ [[ "$output" == *"read-only built-in field"* ]]
46+}
47+
48+@test "set: rejects reserved key 'start_dir'" {
49+ "$ZMX" run test-reserved2 -d sleep 30
50+ wait_for_session test-reserved2
51+
52+ run "$ZMX" set test-reserved2 start_dir=/tmp
53+ [ "$status" -ne 0 ]
54+ [[ "$output" == *"read-only built-in field"* ]]
55+}
56+
57+@test "set: rejects reserved key 'cmd'" {
58+ "$ZMX" run test-reserved3 -d sleep 30
59+ wait_for_session test-reserved3
60+
61+ run "$ZMX" set test-reserved3 cmd=bad
62+ [ "$status" -ne 0 ]
63+ [[ "$output" == *"read-only built-in field"* ]]
64+}
65+
66+@test "set with empty value removes label" {
67+ "$ZMX" run test-unset -d sleep 30
68+ wait_for_session test-unset
69+
70+ run "$ZMX" set test-unset a=1 b=2
71+ run "$ZMX" set test-unset a=
72+
73+ run "$ZMX" get test-unset
74+ [ "$status" -eq 0 ]
75+ [[ "$output" != *"a=1"* ]]
76+ [[ "$output" == *"b=2"* ]]
77+}
78+
79+@test "clear: removes all labels" {
80+ "$ZMX" run test-clear -d sleep 30
81+ wait_for_session test-clear
82+
83+ run "$ZMX" set test-clear x=1 y=2
84+ run "$ZMX" clear test-clear
85+
86+ run "$ZMX" get test-clear
87+ [ "$status" -eq 0 ]
88+ [ -z "$output" ]
89+}
90+
91+@test "get: no session prints error" {
92+ run "$ZMX" get nonexistent
93+ [ "$status" -ne 0 ]
94+ [[ "$output" == *"not found"* ]]
95+}
96+
97+@test "get: no args prints error" {
98+ run env -u ZMX_SESSION "$ZMX" get
99+ [[ "$output" == *"SessionNameRequired"* ]]
100+}