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
......@@ -4,6 +4,16 @@ Use spec: https://common-changelog.org/
44
55 ## Staged
66
7+### Added
8+
9+- Label system for sessions:
10+ - `zmx set <name> k=v ...` to attach key=value labels to live sessions
11+ - `zmx set <name> key=` to remove a specific label (empty value = delete)
12+ - `zmx get <name>` to read labels from a session
13+ - `zmx get <name> key` to print a single value
14+ - `zmx clear <name>` to remove all labels
15+ - `zmx list` now shows labels by default as tab-separated fields
16+
717 ### Fixed
818
919 - `zmx run` will now detect heredocs and add the completion marker to a newline
+24, -1
......@@ -83,7 +83,11 @@ Commands:
8383 [p]rint <name> <text...> Inject text into session display
8484 [wr]ite <name> <file_path> Write stdin to file_path through the session
8585 [d]etach Detach all clients (ctrl+\\ for current client)
86- [l]ist|ls [--short] List active sessions
86+ [l]ist|ls [--short|--where k=v] List active sessions
87+ [g]et <name> Get session labels
88+ set <name> k=v ... Set session labels
89+ [un]set <name> key ... Remove session labels
90+ [cl]ear <name> Clear all session labels
8791 [k]ill <name>... [--force] Kill session and all attached clients
8892 [hi]story <name> [--vt|--html] Output session scrollback
8993 [w]ait <name>... Wait for session tasks to complete
......@@ -286,6 +290,25 @@ zmx k tests # kills d.tests
286290 zmx wait # suspends until all tasks prefixed with "d." are complete
287291 ```
288292
293+## label inheritance
294+
295+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`.
296+
297+To control which labels are inherited, set `ZMX_INHERIT_LABELS`:
298+
299+```bash
300+# Inherit all labels (default)
301+zmx a child
302+
303+# Inherit no labels
304+export ZMX_INHERIT_LABELS=
305+zmx a child
306+
307+# Inherit only specific labels
308+export ZMX_INHERIT_LABELS=project,team
309+zmx a child
310+```
311+
289312 ## philosophy
290313
291314 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
......@@ -37,6 +37,7 @@ pub fn build(b: *std.Build) void {
3737 // on PATH" (true even via the CLT stub), which pulls in the iOS SDK at
3838 // configure time and breaks builds without full Xcode.
3939 .@"emit-xcframework" = false,
40+ .@"emit-macos-app" = false,
4041 });
4142 exe_mod.addImport(
4243 "ghostty-vt",
......@@ -73,6 +74,7 @@ pub fn build(b: *std.Build) void {
7374 .optimize = optimize,
7475 .@"emit-lib-vt" = true,
7576 .@"emit-xcframework" = false,
77+ .@"emit-macos-app" = false,
7678 });
7779 test_module.addImport(
7880 "ghostty-vt",
......@@ -126,6 +128,7 @@ pub fn build(b: *std.Build) void {
126128 .optimize = .ReleaseSafe,
127129 .@"emit-lib-vt" = true,
128130 .@"emit-xcframework" = false,
131+ .@"emit-macos-app" = false,
129132 })) |release_dep| {
130133 release_mod.addImport("ghostty-vt", release_dep.module("ghostty-vt"));
131134 }
+1, -0
......@@ -3,6 +3,7 @@ set -euo pipefail
33
44 export ZMX_SESSION_PREFIX="${ZMX_SESSION_PREFIX:-ci.zmx.}"
55 EVENT="${PICI_EVENT:-manual}"
6+BRANCH="${PICI_BRANCH:-tmp}"
67
78 echo "running ci event=${EVENT} session=${ZMX_SESSION_PREFIX}"
89
+24, -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 wait tail completions version help"
35+ \\ local commands="attach run send print write detach list kill history get set clear 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|wait|tail)
43+ \\ attach|run|send|print|write|kill|history|get|set|clear|wait|tail)
4444 \\ local sessions=$(zmx list --short 2>/dev/null | tr '\n' ' ')
4545 \\ COMPREPLY=($(compgen -W "$sessions" -- "$cur"))
4646 \\ ;;
......@@ -86,6 +86,9 @@ const zsh_completions =
8686 \\ 'wait:Wait for session tasks to complete'
8787 \\ 'tail:Follow session output'
8888 \\ 'completions:Shell completion scripts'
89+ \\ 'get:Get session labels'
90+ \\ 'set:Set session labels'
91+ \\ 'clear:Clear all session labels'
8992 \\ 'version:Show version'
9093 \\ 'help:Show help message'
9194 \\ )
......@@ -93,7 +96,7 @@ const zsh_completions =
9396 \\ ;;
9497 \\ args)
9598 \\ case $words[2] in
96- \\ attach|a|kill|k|run|r|send|s|print|p|write|wr|history|hi|wait|w|tail|t)
99+ \\ attach|a|kill|k|run|r|send|s|print|p|write|wr|history|get|g|set|clear|hi|wait|w|tail|t)
97100 \\ _zmx_sessions
98101 \\ ;;
99102 \\ completions|c)
......@@ -145,10 +148,13 @@ const fish_completions =
145148 \\complete -c zmx -n "__fish_is_nth_token 1" -a tail -d 'Follow session output'
146149 \\complete -c zmx -n "__fish_is_nth_token 1" -a completions -d 'Shell completions (bash, zsh, fish, nu)'
147150 \\complete -c zmx -n "__fish_is_nth_token 1" -a version -d 'Show version'
151+ \\complete -c zmx -n "__fish_is_nth_token 1" -a get -d 'Get session labels'
152+ \\complete -c zmx -n "__fish_is_nth_token 1" -a set -d 'Set session labels'
153+ \\complete -c zmx -n "__fish_is_nth_token 1" -a clear -d 'Clear all session labels'
148154 \\complete -c zmx -n "__fish_is_nth_token 1" -a help -d 'Show help message'
149155 \\
150156 \\# Complete session names and shells
151- \\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'
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'
152158 \\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'
153159 \\
154160 \\complete -c zmx -n "__fish_is_nth_token 2; and __fish_seen_subcommand_from c completions" -a 'bash zsh fish nu' -d Shell
......@@ -157,6 +163,7 @@ const fish_completions =
157163 \\complete -c zmx -n "__fish_seen_subcommand_from r run" -s d -d 'Detach from the calling terminal; use `wait` to track its status'
158164 \\complete -c zmx -n "__fish_seen_subcommand_from r run" -l fish -d 'Required when the session runs fish shell'
159165 \\complete -c zmx -n "__fish_seen_subcommand_from l list" -l short -d 'Short output'
166+ \\complete -c zmx -n "__fish_seen_subcommand_from l list" -l where -d 'Filter by label (key=value)' -r
160167 \\complete -c zmx -n "__fish_seen_subcommand_from k kill" -l force -d 'Force kill'
161168 \\complete -c zmx -n "__fish_seen_subcommand_from hi history" -l vt -d 'History format for escape sequences'
162169 \\complete -c zmx -n "__fish_seen_subcommand_from hi history" -l html -d 'History format for escape sequences'
......@@ -210,5 +217,18 @@ const nu_completions =
210217 \\export extern "zmx tail" [...sessions: string@"nu-complete zmx sessions"]
211218 \\export extern "zmx version" []
212219 \\export extern "completions" [shell: string@"nu-complete zmx complete"]
220+ \\export extern "zmx get" [
221+ \\ name?: string@"nu-complete zmx sessions"
222+ \\]
223+ \\
224+ \\export extern "zmx set" [
225+ \\ name?: string@"nu-complete zmx sessions"
226+ \\ ...pairs: string
227+ \\]
228+ \\
229+ \\export extern "zmx clear" [
230+ \\ name?: string@"nu-complete zmx sessions"
231+ \\]
232+ \\
213233 \\export extern "zmx help" []
214234 ;
+81, -13
......@@ -18,6 +18,10 @@ pub const Tag = enum(u8) {
1818 Switch = 11,
1919 Write = 12,
2020 TaskComplete = 13,
21+ LabelGet = 14,
22+ LabelSet = 15,
23+ LabelClear = 16,
24+ LabelData = 17,
2125 // Non-exhaustive: this enum comes off the wire via bytesToValue and
2226 // @enumFromInt, so out-of-range values (14-255) are representable
2327 // rather than UB. Switches must handle `_` (unknown tag).
......@@ -213,6 +217,13 @@ const SessionProbeError = error{
213217 const SessionProbeResult = struct {
214218 fd: i32,
215219 info: Info,
220+ labels: ?[]const u8,
221+ alloc: std.mem.Allocator,
222+
223+ pub fn deinit(self: *const SessionProbeResult) void {
224+ if (self.labels) |lbl| self.alloc.free(lbl);
225+ posix.close(self.fd);
226+ }
216227 };
217228
218229 pub fn probeSession(
......@@ -224,6 +235,7 @@ pub fn probeSession(
224235 errdefer posix.close(fd);
225236
226237 send(fd, .Info, "") catch return error.Unexpected;
238+ send(fd, .LabelGet, "") catch {};
227239
228240 var poll_fds = [_]posix.pollfd{.{ .fd = fd, .events = posix.POLL.IN, .revents = 0 }};
229241 const poll_result = posix.poll(&poll_fds, timeout_ms) catch return error.Unexpected;
......@@ -237,19 +249,43 @@ pub fn probeSession(
237249 const n = sb.read(fd) catch return error.Unexpected;
238250 if (n == 0) return error.Unexpected;
239251
240- while (sb.next()) |msg| {
241- if (msg.header.tag == .Info) {
242- if (msg.payload.len != @sizeOf(Info)) return error.InfoSizeMismatch;
243- return .{
244- .fd = fd,
245- .info = std.mem.bytesToValue(Info, msg.payload[0..@sizeOf(Info)]),
246- };
252+ var info_result: ?Info = null;
253+ var labels: ?[]const u8 = null;
254+ errdefer if (labels) |lbl| alloc.free(lbl);
255+
256+ while (true) {
257+ if (sb.next()) |msg| {
258+ if (msg.header.tag == .Info) {
259+ if (msg.payload.len != @sizeOf(Info)) return error.InfoSizeMismatch;
260+ info_result = std.mem.bytesToValue(Info, msg.payload[0..@sizeOf(Info)]);
261+ }
262+ if (msg.header.tag == .LabelData) {
263+ labels = alloc.dupe(u8, msg.payload) catch null;
264+ }
265+
266+ if (info_result != null and labels != null) break;
267+ continue;
247268 }
269+
270+ // No complete message available, wait for more data
271+ const more = posix.poll(&poll_fds, 50) catch break;
272+ if (more == 0) break;
273+ const n_read = sb.read(fd) catch break;
274+ if (n_read == 0) break;
275+ }
276+
277+ if (info_result) |info| {
278+ return .{
279+ .fd = fd,
280+ .info = info,
281+ .labels = labels,
282+ .alloc = alloc,
283+ };
248284 }
249285 return error.Unexpected;
250286 }
251287
252-// WIRE PROTOCOL FREEZE — read before "fixing" any test below.
288+// WIRE PROTOCOL FREEZE: read before "fixing" any test below.
253289 //
254290 // Changing these constants does not fix the test; it breaks every
255291 // running daemon for every user until they `pkill -f zmx`.
......@@ -264,14 +300,46 @@ test "Info wire size is frozen" {
264300
265301 test "Tag wire values are frozen" {
266302 inline for (.{
267- .{ Tag.Input, 0 }, .{ Tag.Output, 1 }, .{ Tag.Resize, 2 },
268- .{ Tag.Detach, 3 }, .{ Tag.DetachAll, 4 }, .{ Tag.Kill, 5 },
269- .{ Tag.Info, 6 }, .{ Tag.Init, 7 }, .{ Tag.History, 8 },
270- .{ Tag.Run, 9 }, .{ Tag.Ack, 10 }, .{ Tag.Switch, 11 },
271- .{ Tag.Write, 12 }, .{ Tag.TaskComplete, 13 },
303+ .{ Tag.Input, 0 }, .{ Tag.Output, 1 }, .{ Tag.Resize, 2 },
304+ .{ Tag.Detach, 3 }, .{ Tag.DetachAll, 4 }, .{ Tag.Kill, 5 },
305+ .{ Tag.Info, 6 }, .{ Tag.Init, 7 }, .{ Tag.History, 8 },
306+ .{ Tag.Run, 9 }, .{ Tag.Ack, 10 }, .{ Tag.Switch, 11 },
307+ .{ Tag.Write, 12 }, .{ Tag.TaskComplete, 13 }, .{ Tag.LabelGet, 14 },
308+ .{ Tag.LabelSet, 15 }, .{ Tag.LabelClear, 16 }, .{ Tag.LabelData, 17 },
272309 }) |p| try std.testing.expectEqual(@as(u8, p[1]), @intFromEnum(p[0]));
273310 }
274311
312+pub fn roundTripForTag(
313+ alloc: std.mem.Allocator,
314+ socket_path: []const u8,
315+ request_tag: Tag,
316+ payload: []const u8,
317+ expected_tag: Tag,
318+) SessionProbeError![]u8 {
319+ const timeout_ms = 1000;
320+ const fd = try connectSession(socket_path);
321+ defer posix.close(fd);
322+
323+ send(fd, request_tag, payload) catch return error.Unexpected;
324+
325+ var poll_fds = [_]posix.pollfd{.{ .fd = fd, .events = posix.POLL.IN, .revents = 0 }};
326+ const poll_result = posix.poll(&poll_fds, timeout_ms) catch return error.Unexpected;
327+ if (poll_result == 0) return error.Timeout;
328+
329+ var sb = SocketBuffer.init(alloc) catch return error.Unexpected;
330+ defer sb.deinit();
331+
332+ const n = sb.read(fd) catch return error.Unexpected;
333+ if (n == 0) return error.Unexpected;
334+
335+ while (sb.next()) |msg| {
336+ if (msg.header.tag == expected_tag) {
337+ return alloc.dupe(u8, msg.payload) catch return error.Unexpected;
338+ }
339+ }
340+ return error.Unexpected;
341+}
342+
275343 test "zeroed Info has no stack garbage in wire bytes" {
276344 var info = std.mem.zeroes(Info);
277345 info.clients_len = 3;
+145, -0
......@@ -0,0 +1,145 @@
1+const std = @import("std");
2+
3+pub const LabelError = error{
4+ LabelKeyEmpty,
5+ LabelKeyInvalidChar,
6+ LabelValueInvalidChar,
7+ LabelKeyReservedName,
8+};
9+
10+const reserved_keys = [_][]const u8{ "name", "start_dir", "cmd" };
11+
12+fn isAlnum(c: u8) bool {
13+ return (c >= 'a' and c <= 'z') or
14+ (c >= 'A' and c <= 'Z') or
15+ (c >= '0' and c <= '9');
16+}
17+
18+pub fn assertLabel(key: []const u8, value: []const u8) LabelError!void {
19+ if (key.len == 0) {
20+ return LabelError.LabelKeyEmpty;
21+ }
22+
23+ for (reserved_keys) |rk| {
24+ if (std.mem.eql(u8, key, rk)) return error.LabelKeyReservedName;
25+ }
26+
27+ for (key) |ch| {
28+ if (!isAlnum(ch) and ch != '-' and ch != '_' and ch != '.') {
29+ return LabelError.LabelKeyInvalidChar;
30+ }
31+ }
32+
33+ for (value) |ch| {
34+ if (!isAlnum(ch) and ch != '-' and ch != '_' and ch != '.') {
35+ return LabelError.LabelValueInvalidChar;
36+ }
37+ }
38+}
39+
40+pub fn labelsToU8(alloc: std.mem.Allocator, labels: std.StringHashMapUnmanaged([]u8)) ![]u8 {
41+ var out = std.ArrayList(u8).empty;
42+ var keys = std.ArrayList([]const u8).empty;
43+ defer keys.deinit(alloc);
44+
45+ var it = labels.iterator();
46+ while (it.next()) |entry| {
47+ try keys.append(alloc, entry.key_ptr.*);
48+ }
49+ std.mem.sort([]const u8, keys.items, {}, struct {
50+ fn lessThan(_: void, a: []const u8, b: []const u8) bool {
51+ return std.mem.order(u8, a, b) == .lt;
52+ }
53+ }.lessThan);
54+
55+ var idx: usize = 1;
56+ for (keys.items) |key| {
57+ defer idx += 1;
58+ const value = labels.get(key).?;
59+ try out.appendSlice(alloc, key);
60+ try out.append(alloc, '=');
61+ try out.appendSlice(alloc, value);
62+ if (idx < keys.items.len) {
63+ try out.append(alloc, ' ');
64+ }
65+ }
66+ return out.toOwnedSlice(alloc);
67+}
68+
69+pub const LabelIterator = struct {
70+ labels: []const u8,
71+ idx: usize = 0,
72+
73+ const LabelKeyValue = struct {
74+ key: []const u8,
75+ value: []const u8,
76+ };
77+
78+ pub fn init(labels: []const u8) LabelIterator {
79+ return .{
80+ .labels = labels,
81+ };
82+ }
83+
84+ pub fn next(self: *LabelIterator) ?LabelKeyValue {
85+ const labels = self.labels;
86+ while (self.idx < labels.len) {
87+ var eql_idx = self.idx;
88+ // scan to '=' char
89+ while (eql_idx < labels.len and labels[eql_idx] != '=') eql_idx += 1;
90+ if (eql_idx == labels.len) break;
91+
92+ var space_idx = eql_idx + 1;
93+ // scan to ' ' char
94+ while (space_idx < labels.len and labels[space_idx] != ' ') space_idx += 1;
95+
96+ const kv = LabelKeyValue{
97+ .key = labels[self.idx..eql_idx],
98+ .value = labels[eql_idx + 1 .. space_idx],
99+ };
100+ // move the pointer so next() will start where it left off
101+ self.idx = if (space_idx < labels.len) space_idx + 1 else labels.len;
102+ return kv;
103+ }
104+
105+ return null;
106+ }
107+};
108+
109+pub fn getLabelValueFromPairs(single_kv: []const u8, labels: []const u8) error{LabelKeyNotFound}![]const u8 {
110+ var iter = LabelIterator.init(labels);
111+ while (iter.next()) |kv| {
112+ if (std.mem.eql(u8, single_kv, kv.key)) {
113+ return kv.value;
114+ }
115+ }
116+ return error.LabelKeyNotFound;
117+}
118+
119+test "getLabelValueFromPairs" {
120+ try std.testing.expect(std.mem.eql(u8, "zmx", try getLabelValueFromPairs("project", "project=zmx env=prd")));
121+ try std.testing.expect(std.mem.eql(u8, "zmx", try getLabelValueFromPairs("project", "env=prd status=done project=zmx")));
122+ try std.testing.expectError(error.LabelKeyNotFound, getLabelValueFromPairs("sha", "env=prd status=done project=zmx"));
123+}
124+
125+test "assertLabel" {
126+ try assertLabel("key", "");
127+ try assertLabel("1337", "");
128+ try assertLabel("key.key_key-key", "");
129+ try std.testing.expectError(error.LabelKeyEmpty, assertLabel("", "value"));
130+ try std.testing.expectError(error.LabelKeyInvalidChar, assertLabel("key key", ""));
131+ try std.testing.expectError(error.LabelKeyInvalidChar, assertLabel("key:key", ""));
132+ try std.testing.expectError(error.LabelKeyInvalidChar, assertLabel("key/key", ""));
133+
134+ try assertLabel("key", "");
135+ try assertLabel("key", "1337");
136+ try assertLabel("key", "value");
137+ try assertLabel("key", "value.value_value-value");
138+ try std.testing.expectError(error.LabelValueInvalidChar, assertLabel("key", "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+
142+ try std.testing.expectError(error.LabelKeyReservedName, assertLabel("name", "dev"));
143+ try std.testing.expectError(error.LabelKeyReservedName, assertLabel("start_dir", "dev"));
144+ try std.testing.expectError(error.LabelKeyReservedName, assertLabel("cmd", "dev"));
145+}
+267, -12
......@@ -8,6 +8,7 @@ const completions = @import("completions.zig");
88 const util = @import("util.zig");
99 const cross = @import("cross.zig");
1010 const socket = @import("socket.zig");
11+const label = @import("label.zig");
1112
1213 pub const version = build_options.version;
1314 pub const ghostty_version = build_options.ghostty_version;
......@@ -46,6 +47,25 @@ const SessionMatch = struct {
4647 }
4748 };
4849
50+fn resolveSessionOrEnv(alloc: std.mem.Allocator, session_name: ?[]const u8) ![]const u8 {
51+ const sesh_env = socket.getSeshNameFromEnv();
52+ const raw = if (session_name) |name|
53+ if (std.mem.eql(u8, name, ".")) blk: {
54+ if (sesh_env.len > 0) break :blk sesh_env;
55+ var buf: [4096]u8 = undefined;
56+ var w = std.fs.File.stderr().writer(&buf);
57+ w.interface.print("error: \".\" requires ZMX_SESSION (are you inside a zmx session?)\n", .{}) catch {};
58+ w.interface.flush() catch {};
59+ return error.SessionNameRequired;
60+ } else name
61+ else if (sesh_env.len > 0)
62+ sesh_env
63+ else {
64+ return error.SessionNameRequired;
65+ };
66+ return socket.getSeshName(alloc, raw);
67+}
68+
4969 fn parseSessionArg(alloc: std.mem.Allocator, raw: []const u8) !SessionMatch {
5070 if (raw.len > 0 and raw[raw.len - 1] == '*') {
5171 const name = try socket.getSeshName(alloc, raw[0 .. raw.len - 1]);
......@@ -67,6 +87,10 @@ fn drainSignalPipe() void {
6787 }
6888 }
6989
90+fn detectHelp(arg: []const u8) bool {
91+ return (std.mem.eql(u8, arg, "--help") or std.mem.eql(u8, arg, "-h"));
92+}
93+
7094 pub fn main() !void {
7195 // use c_allocator to avoid "reached unreachable code" panic in DebugAllocator when forking
7296 const alloc = std.heap.c_allocator;
......@@ -99,13 +123,39 @@ pub fn main() !void {
99123 return help();
100124 } else if (std.mem.eql(u8, cmd, "list") or std.mem.eql(u8, cmd, "l") or std.mem.eql(u8, cmd, "ls")) {
101125 var short = false;
102- if (args.next()) |arg| {
103- if (std.mem.eql(u8, arg, "--help") or std.mem.eql(u8, arg, "-h")) {
104- return help();
105- }
106- short = std.mem.eql(u8, arg, "--short");
126+ while (args.next()) |arg| {
127+ if (detectHelp(arg)) return help();
128+ if (std.mem.eql(u8, arg, "--short")) short = true;
107129 }
108130 return list(&cfg, short);
131+ } else if (std.mem.eql(u8, cmd, "get") or std.mem.eql(u8, cmd, "g")) {
132+ const sesh_name = args.next() orelse return error.SessionNameRequired;
133+ if (detectHelp(sesh_name)) return help();
134+ const sesh = try resolveSessionOrEnv(alloc, sesh_name);
135+ defer alloc.free(sesh);
136+ const single_kv = args.next() orelse "";
137+ return labelGet(&cfg, sesh, single_kv);
138+ } else if (std.mem.eql(u8, cmd, "set")) {
139+ const sesh_name = args.next() orelse return error.SessionNameRequired;
140+ if (detectHelp(sesh_name)) return help();
141+ const sesh = try resolveSessionOrEnv(alloc, sesh_name);
142+ defer alloc.free(sesh);
143+
144+ var kvs = std.ArrayList(u8).empty;
145+ defer kvs.deinit(alloc);
146+ var first = true;
147+ while (args.next()) |arg| {
148+ if (!first) try kvs.append(alloc, ' ');
149+ try kvs.appendSlice(alloc, arg);
150+ first = false;
151+ }
152+ return labelSet(&cfg, sesh, kvs.items);
153+ } else if (std.mem.eql(u8, cmd, "clear")) {
154+ const sesh_name = args.next() orelse return error.SessionNameRequired;
155+ if (detectHelp(sesh_name)) return help();
156+ const sesh = try resolveSessionOrEnv(alloc, sesh_name);
157+ defer alloc.free(sesh);
158+ return labelClear(&cfg, sesh);
109159 } else if (std.mem.eql(u8, cmd, "completions") or std.mem.eql(u8, cmd, "c")) {
110160 const arg = args.next() orelse return;
111161 if (std.mem.eql(u8, arg, "--help") or std.mem.eql(u8, arg, "-h")) {
......@@ -187,9 +237,9 @@ pub fn main() !void {
187237 while (args.next()) |arg| {
188238 if (std.mem.startsWith(u8, arg, "-d")) {
189239 detached = true;
190- continue;
240+ } else {
241+ try cmd_args_raw.append(alloc, arg);
191242 }
192- try cmd_args_raw.append(alloc, arg);
193243 }
194244 const clients = try std.ArrayList(*Client).initCapacity(alloc, 10);
195245
......@@ -604,6 +654,7 @@ const Daemon = struct {
604654 cfg: *Cfg,
605655 alloc: std.mem.Allocator,
606656 clients: std.ArrayList(*Client),
657+ labels: std.StringHashMapUnmanaged([]u8) = .empty,
607658 // This control which client is the leader. The leader controls terminal state and
608659 // cols/rows of session.
609660 leader_client_fd: ?i32,
......@@ -630,10 +681,64 @@ const Daemon = struct {
630681
631682 pub fn deinit(self: *Daemon) void {
632683 self.clients.deinit(self.alloc);
684+ var it = self.labels.iterator();
685+ while (it.next()) |entry| {
686+ self.alloc.free(entry.key_ptr.*);
687+ self.alloc.free(entry.value_ptr.*);
688+ }
689+ self.labels.deinit(self.alloc);
633690 self.pty_write_buf.deinit(self.alloc);
634691 self.alloc.free(self.socket_path);
635692 }
636693
694+ fn handleLabelGet(self: *Daemon, client: *Client) !void {
695+ const out = try label.labelsToU8(self.alloc, self.labels);
696+ defer self.alloc.free(out);
697+ try ipc.appendMessage(self.alloc, &client.write_buf, .LabelData, out);
698+ client.has_pending_output = true;
699+ }
700+
701+ fn handleLabelSet(self: *Daemon, client: *Client, labels: []const u8) !void {
702+ std.log.info("handle label set payload={s}", .{labels});
703+
704+ var kvs = label.LabelIterator.init(labels);
705+ while (kvs.next()) |kv| {
706+ if (kv.value.len == 0) {
707+ if (self.labels.fetchRemove(kv.key)) |existing| {
708+ self.alloc.free(existing.key);
709+ self.alloc.free(existing.value);
710+ }
711+ continue;
712+ }
713+
714+ const owned_key = try self.alloc.dupe(u8, kv.key);
715+ errdefer self.alloc.free(owned_key);
716+ const owned_value = try self.alloc.dupe(u8, kv.value);
717+ errdefer self.alloc.free(owned_value);
718+ if (try self.labels.fetchPut(self.alloc, owned_key, owned_value)) |existing| {
719+ // fetchPut does NOT replace the key in the map, the old
720+ // key pointer stays. So free the new (unused) key and the
721+ // old value.
722+ self.alloc.free(owned_key);
723+ self.alloc.free(existing.value);
724+ }
725+ }
726+
727+ try ipc.appendMessage(self.alloc, &client.write_buf, .Ack, "");
728+ client.has_pending_output = true;
729+ }
730+
731+ fn handleLabelClear(self: *Daemon, client: *Client) !void {
732+ var it = self.labels.iterator();
733+ while (it.next()) |entry| {
734+ self.alloc.free(entry.key_ptr.*);
735+ self.alloc.free(entry.value_ptr.*);
736+ }
737+ self.labels.clearRetainingCapacity();
738+ try ipc.appendMessage(self.alloc, &client.write_buf, .Ack, "");
739+ client.has_pending_output = true;
740+ }
741+
637742 pub fn shutdown(self: *Daemon) void {
638743 std.log.info("shutting down daemon session={s}", .{self.session_name});
639744 self.running = false;
......@@ -762,6 +867,7 @@ const Daemon = struct {
762867 /// ensureSession "upserts" a session by checking if the unix socket exists already.
763868 /// If not it creates one and spawns the daemon.
764869 fn ensureSession(self: *Daemon) !EnsureSessionResult {
870+ std.log.info("ensure session session={s}", .{self.session_name});
765871 var dir = try std.fs.openDirAbsolute(self.cfg.socket_dir, .{});
766872 defer dir.close();
767873
......@@ -889,6 +995,7 @@ const Daemon = struct {
889995 }
890996
891997 try daemonLoop(self, server_sock_fd, pty_fd);
998+ std.log.info("daemon loop shutdown", .{});
892999 return .{ .created = true, .is_daemon = true };
8931000 }
8941001 posix.close(server_sock_fd);
......@@ -1287,7 +1394,10 @@ fn help() !void {
12871394 \\ [p]rint <name> <text...> Inject text into session display
12881395 \\ [wr]ite <name> <file_path> Write stdin to file_path through the session
12891396 \\ [d]etach Detach all clients (ctrl+\\ for current client)
1290- \\ [l]ist|ls [--short] List active sessions
1397+ \\ [l]ist|ls [--short|--where k=v] List active sessions
1398+ \\ [g]et <name> Get session labels
1399+ \\ set <name> k=v ... Set session labels (k= to remove)
1400+ \\ [cl]ear <name> Clear all session labels
12911401 \\ [k]ill <name>... [--force] Kill session and all attached clients
12921402 \\ [hi]story <name> [--vt|--html] Output session scrollback
12931403 \\ [w]ait <name>... Wait for session tasks to complete
......@@ -1383,6 +1493,20 @@ fn help() !void {
13831493 \\ zmx wait dev
13841494 \\ zmx wait dev other
13851495 \\
1496+ \\Labels:
1497+ \\ Attach key=value labels to live sessions for discovery and
1498+ \\ filtering. Labels are in-memory and scoped to session lifetime.
1499+ \\
1500+ \\ Examples:
1501+ \\ zmx set dev project=zmx env=dev
1502+ \\ zmx set dev project= # unset a label
1503+ \\ zmx set . status=fail # "." resolves to current session
1504+ \\ zmx get dev
1505+ \\ zmx get dev project
1506+ \\ zmx set next "$(zmx get prev)" # set labels from other session
1507+ \\ zmx list | grep project=zmx
1508+ \\ zmx clear dev
1509+ \\
13861510 \\Environment variables:
13871511 \\ SHELL Default shell for new sessions
13881512 \\ ZMX_DIR Socket directory (priority 1)
......@@ -1747,7 +1871,6 @@ fn list(cfg: *Cfg, short: bool) !void {
17471871 const current_session = socket.getSeshNameFromEnv();
17481872 var buf: [4096]u8 = undefined;
17491873 var stdout = std.fs.File.stdout().writer(&buf);
1750-
17511874 var sessions = try util.get_session_entries(alloc, cfg.socket_dir);
17521875 defer {
17531876 for (sessions.items) |session| {
......@@ -1768,6 +1891,12 @@ fn list(cfg: *Cfg, short: bool) !void {
17681891 std.mem.sort(util.SessionEntry, sessions.items, {}, util.SessionEntry.lessThan);
17691892
17701893 for (sessions.items) |session| {
1894+ if (session.is_error) {
1895+ try util.writeSessionLine(&stdout.interface, session, short, current_session);
1896+ try stdout.interface.flush();
1897+ continue;
1898+ }
1899+
17711900 try util.writeSessionLine(&stdout.interface, session, short, current_session);
17721901 try stdout.interface.flush();
17731902 }
......@@ -1782,6 +1911,7 @@ fn detachAll(cfg: *Cfg) !void {
17821911 std.log.err("ZMX_SESSION env var not found: are you inside a zmx session?", .{});
17831912 return;
17841913 }
1914+ std.log.info("detach all session={s}", .{session_name});
17851915
17861916 var dir = try std.fs.openDirAbsolute(cfg.socket_dir, .{});
17871917 defer dir.close();
......@@ -1804,6 +1934,7 @@ fn detachAll(cfg: *Cfg) !void {
18041934 }
18051935
18061936 fn kill(cfg: *Cfg, session_name: []const u8, force: bool) !void {
1937+ std.log.info("kill session={s}", .{session_name});
18071938 var gpa = std.heap.GeneralPurposeAllocator(.{}){};
18081939 defer _ = gpa.deinit();
18091940 const alloc = gpa.allocator();
......@@ -1865,6 +1996,116 @@ fn kill(cfg: *Cfg, session_name: []const u8, force: bool) !void {
18651996 try w.interface.flush();
18661997 }
18671998
1999+fn printLabelError(session_name: []const u8, err: anyerror) noreturn {
2000+ var buf: [4096]u8 = undefined;
2001+ var w = std.fs.File.stderr().writer(&buf);
2002+ switch (err) {
2003+ error.Timeout => w.interface.print(
2004+ "error: session \"{s}\" does not support labels (daemon too old?)\n",
2005+ .{session_name},
2006+ ) catch {},
2007+ error.ConnectionRefused, error.Unexpected => w.interface.print(
2008+ "error: session \"{s}\" not found or unresponsive\n",
2009+ .{session_name},
2010+ ) catch {},
2011+ else => w.interface.print(
2012+ "error: {s}\n",
2013+ .{@errorName(err)},
2014+ ) catch {},
2015+ }
2016+ w.interface.flush() catch {};
2017+ std.process.exit(1);
2018+}
2019+
2020+fn labelGet(cfg: *Cfg, session_name: []const u8, single_kv: []const u8) !void {
2021+ std.log.info("label get session={s}", .{session_name});
2022+ var gpa = std.heap.GeneralPurposeAllocator(.{}){};
2023+ defer _ = gpa.deinit();
2024+ const alloc = gpa.allocator();
2025+
2026+ const socket_path = socket.getSocketPath(alloc, cfg.socket_dir, session_name) catch |err| switch (err) {
2027+ error.NameTooLong => return socket.printSessionNameTooLong(session_name, cfg.socket_dir),
2028+ error.OutOfMemory => return err,
2029+ };
2030+ defer alloc.free(socket_path);
2031+
2032+ const payload = ipc.roundTripForTag(alloc, socket_path, .LabelGet, "", .LabelData) catch |err| {
2033+ printLabelError(session_name, err);
2034+ };
2035+ defer alloc.free(payload);
2036+
2037+ var buf: [4096]u8 = undefined;
2038+ var stdout = std.fs.File.stdout().writer(&buf);
2039+ if (single_kv.len == 0) {
2040+ try stdout.interface.print("{s}", .{payload});
2041+ try stdout.interface.flush();
2042+ return;
2043+ }
2044+
2045+ const val = try label.getLabelValueFromPairs(single_kv, payload);
2046+ try stdout.interface.print("{s}", .{val});
2047+ try stdout.interface.flush();
2048+}
2049+
2050+fn labelSet(cfg: *Cfg, session_name: []const u8, labels: []const u8) !void {
2051+ std.log.info("label set session={s}", .{session_name});
2052+ var gpa = std.heap.GeneralPurposeAllocator(.{}){};
2053+ defer _ = gpa.deinit();
2054+ const alloc = gpa.allocator();
2055+
2056+ var kvs = label.LabelIterator.init(labels);
2057+ while (kvs.next()) |kv| {
2058+ label.assertLabel(kv.key, kv.value) catch |err| {
2059+ var buf: [4096]u8 = undefined;
2060+ var w = std.fs.File.stderr().writer(&buf);
2061+ const msg = "error: key-value kvs can only contain [a-z, A-Z, 0-9, -_.] characters";
2062+ switch (err) {
2063+ error.LabelKeyEmpty => {
2064+ w.interface.print("error: label key cannot be empty\n", .{}) catch {};
2065+ },
2066+ error.LabelKeyReservedName => {
2067+ w.interface.print("error: \"{s}\" is a read-only built-in field\n", .{kv.key}) catch {};
2068+ },
2069+ error.LabelKeyInvalidChar => {
2070+ w.interface.print("{s}: key=[{s}]\n", .{ msg, kv.key }) catch {};
2071+ },
2072+ error.LabelValueInvalidChar => {
2073+ w.interface.print("{s}: value=[{s}]\n", .{ msg, kv.value }) catch {};
2074+ },
2075+ }
2076+ w.interface.flush() catch {};
2077+ std.process.exit(1);
2078+ };
2079+ }
2080+
2081+ const socket_path = socket.getSocketPath(alloc, cfg.socket_dir, session_name) catch |err| switch (err) {
2082+ error.NameTooLong => return socket.printSessionNameTooLong(session_name, cfg.socket_dir),
2083+ error.OutOfMemory => return err,
2084+ };
2085+ defer alloc.free(socket_path);
2086+
2087+ _ = ipc.roundTripForTag(alloc, socket_path, .LabelSet, labels, .Ack) catch |err| {
2088+ printLabelError(session_name, err);
2089+ };
2090+}
2091+
2092+fn labelClear(cfg: *Cfg, session_name: []const u8) !void {
2093+ std.log.info("label clear session={s}", .{session_name});
2094+ var gpa = std.heap.GeneralPurposeAllocator(.{}){};
2095+ defer _ = gpa.deinit();
2096+ const alloc = gpa.allocator();
2097+
2098+ const socket_path = socket.getSocketPath(alloc, cfg.socket_dir, session_name) catch |err| switch (err) {
2099+ error.NameTooLong => return socket.printSessionNameTooLong(session_name, cfg.socket_dir),
2100+ error.OutOfMemory => return err,
2101+ };
2102+ defer alloc.free(socket_path);
2103+
2104+ _ = ipc.roundTripForTag(alloc, socket_path, .LabelClear, "", .Ack) catch |err| {
2105+ printLabelError(session_name, err);
2106+ };
2107+}
2108+
18682109 /// Fetch terminal history from a session socket, returning it as an allocated
18692110 /// string. Caller owns the returned memory and must free it.
18702111 fn fetchHistory(
......@@ -1872,6 +2113,7 @@ fn fetchHistory(
18722113 cfg: *Cfg,
18732114 session_name: []const u8,
18742115 ) ![]const u8 {
2116+ std.log.info("fetch history session={s}", .{session_name});
18752117 const socket_path = socket.getSocketPath(alloc, cfg.socket_dir, session_name) catch |err| switch (err) {
18762118 error.NameTooLong => {
18772119 socket.printSessionNameTooLong(session_name, cfg.socket_dir);
......@@ -1930,6 +2172,7 @@ fn fetchHistory(
19302172 }
19312173
19322174 fn history(cfg: *Cfg, session_name: []const u8, format: util.HistoryFormat) !void {
2175+ std.log.info("history session={s}", .{session_name});
19332176 var gpa = std.heap.GeneralPurposeAllocator(.{}){};
19342177 defer _ = gpa.deinit();
19352178 const alloc = gpa.allocator();
......@@ -1991,6 +2234,7 @@ fn switchSesh(daemon: *Daemon, current_sesh: []const u8) !void {
19912234 // we want daemon.session_name because that's the session name the user provided during zmx attach
19922235 // instead of the name of the session they are currently inside of.
19932236 const next_session = daemon.session_name;
2237+ std.log.info("switch session cur={s} next={s}", .{ current_sesh, next_session });
19942238
19952239 const socket_path = socket.getSocketPath(daemon.alloc, daemon.cfg.socket_dir, current_sesh) catch |err| switch (err) {
19962240 error.NameTooLong => return socket.printSessionNameTooLong(current_sesh, daemon.cfg.socket_dir),
......@@ -2170,7 +2414,7 @@ fn writeFile(daemon: *Daemon, file_path: []const u8) !void {
21702414 return;
21712415 };
21722416
2173- defer posix.close(result.fd);
2417+ defer result.deinit();
21742418
21752419 // Build wire payload: [u32 path len][path bytes][file content]
21762420 var wire_buf = try std.ArrayList(u8).initCapacity(
......@@ -2206,6 +2450,7 @@ fn writeFile(daemon: *Daemon, file_path: []const u8) !void {
22062450 }
22072451
22082452 fn send(cfg: *Cfg, session_name: []const u8, socket_path: []const u8, text_parts: [][]const u8, tag: ipc.Tag) !void {
2453+ std.log.info("send session={s}", .{session_name});
22092454 const alloc = std.heap.c_allocator;
22102455 var buf: [4096]u8 = undefined;
22112456 var w = std.fs.File.stdout().writer(&buf);
......@@ -2259,7 +2504,7 @@ fn send(cfg: *Cfg, session_name: []const u8, socket_path: []const u8, text_parts
22592504 try w.interface.flush();
22602505 return;
22612506 };
2262- defer posix.close(probe_result.fd);
2507+ defer probe_result.deinit();
22632508
22642509 ipc.send(probe_result.fd, tag, payload.items) catch |err| switch (err) {
22652510 error.ConnectionResetByPeer, error.BrokenPipe => return,
......@@ -2372,6 +2617,7 @@ const ClientResult = struct {
23722617 /// clientLoop sends ipc commands to its corresponding daemon. It uses poll() as its non-blocking
23732618 /// mechanism. It will send stdin to the daemon and receive stdout from the daemon.
23742619 fn clientLoop(client_sock_fd: i32) !ClientResult {
2620+ std.log.info("client loop fd={d}", .{client_sock_fd});
23752621 // use c_allocator to avoid "reached unreachable code" panic in DebugAllocator when forking
23762622 const alloc = std.heap.c_allocator;
23772623 defer posix.close(client_sock_fd);
......@@ -2461,11 +2707,13 @@ fn clientLoop(client_sock_fd: i32) !ClientResult {
24612707 if (n > 0) {
24622708 // Check for detach sequences (ctrl+\ as first byte or Kitty escape sequence)
24632709 if (util.isCtrlBackslash(buf[0..n])) {
2710+ std.log.info("detach key detected", .{});
24642711 try ipc.appendMessage(alloc, &sock_write_buf, .Detach, "");
24652712 } else {
24662713 try ipc.appendMessage(alloc, &sock_write_buf, .Input, buf[0..n]);
24672714 }
24682715 } else {
2716+ std.log.info("eof stdin", .{});
24692717 // EOF on stdin
24702718 return ClientResult{ .kind = .detach, .session_name = null };
24712719 }
......@@ -2483,6 +2731,7 @@ fn clientLoop(client_sock_fd: i32) !ClientResult {
24832731 return err;
24842732 };
24852733 if (n == 0) {
2734+ std.log.info("server closed connection", .{});
24862735 // Server closed connection
24872736 return ClientResult{ .kind = .detach, .session_name = null };
24882737 }
......@@ -2506,6 +2755,7 @@ fn clientLoop(client_sock_fd: i32) !ClientResult {
25062755 );
25072756 },
25082757 .Switch => {
2758+ std.log.info("switch session", .{});
25092759 return ClientResult{ .kind = .switch_session, .session_name = try alloc.dupe(u8, msg.payload) };
25102760 },
25112761 else => {},
......@@ -2519,6 +2769,7 @@ fn clientLoop(client_sock_fd: i32) !ClientResult {
25192769 const n = posix.write(client_sock_fd, sock_write_buf.items) catch |err| blk: {
25202770 if (err == error.WouldBlock) break :blk 0;
25212771 if (err == error.ConnectionResetByPeer or err == error.BrokenPipe) {
2772+ std.log.info("connection reset or broken pipe", .{});
25222773 return ClientResult{ .kind = .detach, .session_name = null };
25232774 }
25242775 return err;
......@@ -2540,6 +2791,7 @@ fn clientLoop(client_sock_fd: i32) !ClientResult {
25402791 }
25412792
25422793 if (poll_fds.items[1].revents & (posix.POLL.HUP | posix.POLL.ERR | posix.POLL.NVAL) != 0) {
2794+ std.log.info("poll hup|err|nval", .{});
25432795 return ClientResult{ .kind = .detach, .session_name = null };
25442796 }
25452797 }
......@@ -2798,9 +3050,12 @@ fn daemonLoop(daemon: *Daemon, server_sock_fd: i32, pty_fd: i32) !void {
27983050 break :daemon_loop;
27993051 },
28003052 .Info => try daemon.handleInfo(client),
3053+ .LabelGet => try daemon.handleLabelGet(client),
3054+ .LabelSet => try daemon.handleLabelSet(client, msg.payload),
3055+ .LabelClear => try daemon.handleLabelClear(client),
28013056 .History => try daemon.handleHistory(client, &term, msg.payload),
28023057 .Run => try daemon.handleRun(client, msg.payload),
2803- .Ack, .TaskComplete => {},
3058+ .Ack, .TaskComplete, .LabelData => {},
28043059 .Write => try daemon.handleWrite(client, msg.payload),
28053060 _ => std.log.warn(
28063061 "ignoring unknown IPC tag={d}",
+1, -0
......@@ -3,4 +3,5 @@ comptime {
33 _ = @import("util.zig");
44 _ = @import("socket.zig");
55 _ = @import("ipc.zig");
6+ _ = @import("label.zig");
67 }
+19, -1
......@@ -3,6 +3,7 @@ const posix = std.posix;
33 const ghostty_vt = @import("ghostty-vt");
44 const ipc = @import("ipc.zig");
55 const socket = @import("socket.zig");
6+const label = @import("label.zig");
67 const testing = std.testing;
78
89 pub const SessionEntry = struct {
......@@ -13,6 +14,7 @@ pub const SessionEntry = struct {
1314 error_name: ?[]const u8,
1415 cmd: ?[]const u8 = null,
1516 cwd: ?[]const u8 = null,
17+ labels: ?[]const u8 = null,
1618 created_at: u64,
1719 task_ended_at: ?u64,
1820 task_exit_code: ?u8,
......@@ -21,6 +23,7 @@ pub const SessionEntry = struct {
2123 alloc.free(self.name);
2224 if (self.cmd) |cmd| alloc.free(cmd);
2325 if (self.cwd) |cwd| alloc.free(cwd);
26+ if (self.labels) |l| alloc.free(l);
2427 }
2528
2629 pub fn lessThan(_: void, a: SessionEntry, b: SessionEntry) bool {
......@@ -32,6 +35,7 @@ pub fn get_session_entries(
3235 alloc: std.mem.Allocator,
3336 socket_dir: []const u8,
3437 ) !std.ArrayList(SessionEntry) {
38+ std.log.info("get session entries socket_dir={s}", .{socket_dir});
3539 var dir = try std.fs.openDirAbsolute(socket_dir, .{ .iterate = true });
3640 defer dir.close();
3741 var iter = dir.iterate();
......@@ -60,6 +64,7 @@ pub fn get_session_entries(
6064 .created_at = 0,
6165 .task_exit_code = 1,
6266 .task_ended_at = 0,
67+ .labels = "",
6368 });
6469 // Only clean up when the daemon is definitively gone. A busy
6570 // daemon can miss the probe timeout; deleting its socket
......@@ -69,7 +74,7 @@ pub fn get_session_entries(
6974 }
7075 continue;
7176 };
72- posix.close(result.fd);
77+ defer result.deinit();
7378
7479 // Extract cmd and cwd from the fixed-size arrays. Lengths come
7580 // off the wire (u16 range), so clamp to the actual array size.
......@@ -79,11 +84,17 @@ pub fn get_session_entries(
7984 alloc.dupe(u8, result.info.cmd[0..cmd_len]) catch null
8085 else
8186 null;
87+
8288 const cwd: ?[]const u8 = if (cwd_len > 0)
8389 alloc.dupe(u8, result.info.cwd[0..cwd_len]) catch null
8490 else
8591 null;
8692
93+ const labels = if (result.labels) |lbl|
94+ alloc.dupe(u8, lbl) catch null
95+ else
96+ null;
97+
8798 try sessions.append(alloc, .{
8899 .name = name,
89100 .pid = result.info.pid,
......@@ -92,6 +103,7 @@ pub fn get_session_entries(
92103 .error_name = null,
93104 .cmd = cmd,
94105 .cwd = cwd,
106+ .labels = labels,
95107 .created_at = result.info.created_at,
96108 .task_ended_at = result.info.task_ended_at,
97109 .task_exit_code = result.info.task_exit_code,
......@@ -739,6 +751,12 @@ pub fn writeSessionLine(
739751 }
740752 }
741753 }
754+ if (session.labels) |labels| {
755+ var kvs = label.LabelIterator.init(labels);
756+ while (kvs.next()) |kv| {
757+ try writer.print("\t{s}={s}", .{ kv.key, kv.value });
758+ }
759+ }
742760 try writer.print("\n", .{});
743761 }
744762
+99, -0
......@@ -0,0 +1,99 @@
1+#!/usr/bin/env bats
2+# Label tests for zmx.
3+
4+load test_helper
5+
6+# ============================================================================
7+# Label CRUD
8+# ============================================================================
9+
10+@test "set/get: round-trips labels" {
11+ "$ZMX" run test-labels -d sleep 30
12+ wait_for_session test-labels
13+
14+ run "$ZMX" set test-labels project=zmx env=dev
15+ [ "$status" -eq 0 ]
16+
17+ run "$ZMX" get test-labels
18+ [ "$status" -eq 0 ]
19+ [[ "$output" == *"env=dev"* ]]
20+ [[ "$output" == *"project=zmx"* ]]
21+}
22+
23+@test "set: updates existing label" {
24+ "$ZMX" run test-update -d sleep 30
25+ wait_for_session test-update
26+
27+ run "$ZMX" set test-update status=busy
28+ [ "$status" -eq 0 ]
29+ run "$ZMX" set test-update status=done
30+ [ "$status" -eq 0 ]
31+
32+ run "$ZMX" get test-update
33+ [ "$status" -eq 0 ]
34+ [[ "$output" == *"status=done"* ]]
35+ [[ "$output" != *"status=busy"* ]]
36+}
37+
38+@test "set: rejects reserved key 'name'" {
39+ "$ZMX" run test-reserved -d sleep 30
40+ wait_for_session test-reserved
41+
42+ run "$ZMX" set test-reserved name=bad
43+ [ "$status" -ne 0 ]
44+ [[ "$output" == *"read-only built-in field"* ]]
45+}
46+
47+@test "set: rejects reserved key 'start_dir'" {
48+ "$ZMX" run test-reserved2 -d sleep 30
49+ wait_for_session test-reserved2
50+
51+ run "$ZMX" set test-reserved2 start_dir=/tmp
52+ [ "$status" -ne 0 ]
53+ [[ "$output" == *"read-only built-in field"* ]]
54+}
55+
56+@test "set: rejects reserved key 'cmd'" {
57+ "$ZMX" run test-reserved3 -d sleep 30
58+ wait_for_session test-reserved3
59+
60+ run "$ZMX" set test-reserved3 cmd=bad
61+ [ "$status" -ne 0 ]
62+ [[ "$output" == *"read-only built-in field"* ]]
63+}
64+
65+@test "set with empty value removes label" {
66+ "$ZMX" run test-unset -d sleep 30
67+ wait_for_session test-unset
68+
69+ run "$ZMX" set test-unset a=1 b=2
70+ run "$ZMX" set test-unset a=
71+
72+ run "$ZMX" get test-unset
73+ [ "$status" -eq 0 ]
74+ [[ "$output" != *"a=1"* ]]
75+ [[ "$output" == *"b=2"* ]]
76+}
77+
78+@test "clear: removes all labels" {
79+ "$ZMX" run test-clear -d sleep 30
80+ wait_for_session test-clear
81+
82+ run "$ZMX" set test-clear x=1 y=2
83+ run "$ZMX" clear test-clear
84+
85+ run "$ZMX" get test-clear
86+ [ "$status" -eq 0 ]
87+ [ -z "$output" ]
88+}
89+
90+@test "get: no session prints error" {
91+ run "$ZMX" get nonexistent
92+ [ "$status" -ne 0 ]
93+ [[ "$output" == *"not found"* ]]
94+}
95+
96+@test "get: no args prints error" {
97+ run env -u ZMX_SESSION "$ZMX" get
98+ [[ "$output" == *"SessionNameRequired"* ]]
99+}