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             }
+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+}