diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-21 16:49:20 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-09-21 16:49:20 -0300 |
| commit | dddd556accea6b6ea7802cd3f622f8b3cf8eb43f (patch) | |
| tree | 64a1cc34d44f9be6ec266d852fdef6f5b842d004 /9harness/src/active.zig | |
| parent | b4db588dd5b92d647b661c2dc17b40925af92348 (diff) | |
| download | cloud9-dddd556accea6b6ea7802cd3f622f8b3cf8eb43f.tar.gz cloud9-dddd556accea6b6ea7802cd3f622f8b3cf8eb43f.zip | |
9harness: /active, and zmxify as a write to a file
The mirror answers what files exist. /active answers what is running:
one directory per live agent, normalized across harnesses, fields as
small text files, synthesized per request.
/active/claude/345104/{pid,cwd,session,via,name,status,title,
model,started,zmx,transcript,agents/}
This is /proc's shape, and deliberately: a directory per object named
by pid under a directory per harness, rather than a compound
`claude-345104` that would make you parse a name to recover a field
that is already the directory above it. There is no `updated` file —
that is the mtime of `transcript`, which stat already carries.
Each harness is asked in its own terms, and the route is reported in
`via` so a wrong guess is visible rather than silent. Claude Code
publishes sessions/<pid>.json itself, with procStart as a pid-reuse
guard, so nothing there is guessed. omp and dsh are found by the
transcript they hold open, omp falling back to the store named after
its cwd. codex's rollout file carries the session id in its *name*, so
its sqlite is never opened. hermes is the one gap and needs none: its
sessions live only in sqlite, and the only hermes processes that run
are the gateway and the dashboard, which are not sessions.
Liveness is /proc/<pid> plus a matching start time: a pid alone is not
an identity. The daemon never lists its own ancestry, so it cannot show
or act on the tree serving the request.
The write path, and why it is a file and not a ctl: writing a zmx
session name into an agent's `zmx` moves it there. The file means which
zmx session this agent lives in, and writing makes that true. A ctl
taking verbs is the ordinary Plan 9 spelling, and an executable script
served in the tree is the spelling zmx's own `attach` uses, but a
script that shells out to a local binary lies over a remote mount — it
would run against a session that is not on the client's machine. A
write is served where the authority is.
Every refusal comes before anything is destroyed: the name must be
zmx's label charset, unused by a live session, and the agent's session
must have resolved, because nothing is killed that has nowhere to come
back to. The command is fixed per harness and no client byte reaches
exec. It is off unless --allow-move: this is the one place the tree is
not read-only, and anything that can mount it could otherwise kill an
agent.
9harness/zmxify replaces the 307-line rc script. It parses no /proc,
opens no fd table and queries no database; it lists /active, offers the
rows to fzf and writes the chosen name. It no longer excludes the
caller's own session, which the old one had to: that script did the
killing itself, so killing its own parent lost the session it was
rescuing. The daemon completes the kill and the re-exec whether or not
the client is still connected — verified by hanging up immediately
after sending the write — so zmxifying the terminal you are sitting in
now works, which is the common case.
--proc DIR is the fixture seam: the scan, the liveness guard, the
exclusions and the ancestry rule are unit-tested against a fake process
tree, never the live one.
Suites: 87/87 root, 48/48 9ns, 26/26 9harness (+5 for the view),
60/60 9proc, 144/144 programs-test, 51+88 9ns integration, 44/0
9harness end-to-end (+16, including a real move against a fake harness
and a fake zmx), 29/0 9proc debug, 213/0 9ns adversarial, freestanding
green.
Diffstat (limited to '9harness/src/active.zig')
| -rw-r--r-- | 9harness/src/active.zig | 1006 |
1 files changed, 1006 insertions, 0 deletions
diff --git a/9harness/src/active.zig b/9harness/src/active.zig new file mode 100644 index 0000000..0a1a60d --- /dev/null +++ b/9harness/src/active.zig @@ -0,0 +1,1006 @@ +//! The derived view behind `/active`: one record per live agent, +//! normalized across harnesses. +//! +//! v1 of the tree mirrors files. This module answers a different +//! question — *what is running right now* — by reading `/proc` and then +//! asking each harness in its own terms which stored session the +//! process is writing. That ladder (`fd` -> `dir` -> `db`, with the +//! route named so a wrong guess is visible) is `~/.local/bin/zmxify`'s, +//! ported here so the knowledge lives somewhere tested. +//! +//! Everything here composes paths from the proc root, a pinned harness +//! root and a pid. No client byte reaches the filesystem, and the proc +//! root is a parameter (`--proc`) so the scan, the liveness guard and +//! the exclusions are testable against a fixture tree. +const std = @import("std"); +const Io = std.Io; + +// ---- comptime bounds --------------------------------------------------------- + +/// Live agents held at once. A machine running more than this many +/// interactive harnesses at the same time is not the case to optimize. +pub const max_live: usize = 32; +/// Subagents listed under one agent. +pub const max_agents: usize = 32; +/// Longest directory a process may run in. +pub const cwd_capacity: usize = 512; +/// Longest absolute path this module composes (proc root, harness root). +pub const path_capacity: usize = 1024; +/// Longest session id, title, name or model answered. +pub const text_capacity: usize = 160; +/// `<harness>-<pid>`. +pub const name_capacity: usize = 32; +/// Bytes of a `/proc` file or a transcript head read at once. +pub const probe_capacity: usize = 8192; + +/// The harnesses this view knows how to recognize. +/// The order is `tree.Root`'s, and `tree.zig` asserts that at comptime: +/// the two enums index the same pinned roots, so a mismatch would hand +/// one harness another's base path. +pub const Kind = enum(u8) { + claude, + codex, + omp, + hermes, + dsh, + + pub fn text(k: Kind) []const u8 { + return @tagName(k); + } +}; + +/// How a session was resolved — reported, so a wrong guess is visible +/// rather than silent. `none` means the process is listed with what +/// `/proc` knows and nothing more (codex and hermes keep their sessions +/// in sqlite, which this round does not read). +pub const Via = enum(u8) { + none, + registry, + fd, + dir, + + pub fn text(v: Via) []const u8 { + return @tagName(v); + } +}; + +/// A fixed-capacity byte buffer. Everything this module remembers is +/// bounded at comptime, like the rest of the daemon. +fn Buf(comptime n: usize) type { + return struct { + bytes: [n]u8 = undefined, + len: u16 = 0, + + const Self = @This(); + + pub fn set(b: *Self, v: []const u8) void { + const take = @min(v.len, n); + @memcpy(b.bytes[0..take], v[0..take]); + b.len = @intCast(take); + } + + pub fn slice(b: *const Self) []const u8 { + return b.bytes[0..b.len]; + } + + pub fn clear(b: *Self) void { + b.len = 0; + } + }; +} + +/// One live agent. +pub const Live = struct { + kind: Kind = .claude, + pid: u32 = 0, + ppid: u32 = 0, + /// `/proc/<pid>/stat` field 22. Two processes can share a pid over + /// time; they cannot share a pid and a start time, so this is what + /// makes a remembered slot safe to trust later. + starttime: u64 = 0, + /// Unix seconds, from the process's start time against boot time. + started: i64 = 0, + via: Via = .none, + name: Buf(name_capacity) = .{}, + cwd: Buf(cwd_capacity) = .{}, + session: Buf(text_capacity) = .{}, + /// Absolute path of the session transcript, empty when unresolved. + transcript: Buf(path_capacity) = .{}, + /// Directory holding the subagent transcripts, empty when the + /// harness has no such layout. + agent_dir: Buf(path_capacity) = .{}, +}; + +// ---- pure helpers: the part that carries zmxify's knowledge ------------------- + +/// A command line word that marks a daemon, a server or a helper — a +/// process wearing the harness's name that is not an interactive +/// session and must never be listed or acted on. +const not_session = [_][]const u8{ + "__omp_worker_daemon_broker", + "acp", + "mcp", + "mcp-server", + "app-server", + "exec-server", + "gateway", + "dashboard", + "serve", + "daemon", + "ps", + "render", + "export", +}; + +/// The last path component of `p`. +pub fn basename(p: []const u8) []const u8 { + if (std.mem.lastIndexOfScalar(u8, p, '/')) |i| return p[i + 1 ..]; + return p; +} + +/// Iterates a `/proc/<pid>/cmdline`: NUL-separated argv, usually with a +/// trailing NUL. An empty cmdline (a kernel thread) yields nothing. +pub const Argv = struct { + rest: []const u8, + + pub fn init(cmdline: []const u8) Argv { + return .{ .rest = cmdline }; + } + + pub fn next(a: *Argv) ?[]const u8 { + while (a.rest.len > 0 and a.rest[0] == 0) a.rest = a.rest[1..]; + if (a.rest.len == 0) return null; + const end = std.mem.indexOfScalar(u8, a.rest, 0) orelse a.rest.len; + const word = a.rest[0..end]; + a.rest = a.rest[@min(end + 1, a.rest.len)..]; + return word; + } +}; + +/// The harness a command line runs, if any. `argv[0]`'s basename names +/// it, except for hermes, which runs as a python module; any word from +/// `not_session` disqualifies the whole command line. +pub fn harnessOf(cmdline: []const u8) ?Kind { + var it: Argv = .init(cmdline); + const argv0 = it.next() orelse return null; + const exe_name = basename(argv0); + + var found: ?Kind = null; + if (std.mem.eql(u8, exe_name, "claude")) { + found = .claude; + } else if (std.mem.eql(u8, exe_name, "omp")) { + found = .omp; + } else if (std.mem.eql(u8, exe_name, "codex")) { + found = .codex; + } else if (std.mem.eql(u8, exe_name, "hermes")) { + found = .hermes; + } else if (std.mem.eql(u8, exe_name, "dsh")) { + found = .dsh; + } else if (std.mem.startsWith(u8, exe_name, "python") or std.mem.startsWith(u8, exe_name, "pypy")) { + var py: Argv = .init(cmdline); + while (py.next()) |w| { + if (std.mem.endsWith(u8, w, "hermes") or std.mem.endsWith(u8, w, "hermes_cli.main")) { + found = .hermes; + break; + } + } + } + if (found == null) return null; + + var words: Argv = .init(cmdline); + while (words.next()) |w| { + for (not_session) |bad| if (std.mem.eql(u8, w, bad)) return null; + } + return found; +} + +/// Field `n` (1-based) of a `/proc/<pid>/stat` line. Field 2 is the +/// executable name in parentheses and may itself hold spaces and +/// parentheses, so the fields are counted from the *last* `)`, never by +/// splitting the whole line. Only fields from 3 on are reachable, which +/// is all any caller wants. +pub fn statField(stat: []const u8, n: usize) ?u64 { + if (n < 3) return null; + const close = std.mem.lastIndexOfScalar(u8, stat, ')') orelse return null; + var rest = stat[close + 1 ..]; + var field: usize = 3; // state, the first field after the name + while (true) { + while (rest.len > 0 and rest[0] == ' ') rest = rest[1..]; + if (rest.len == 0) return null; + const end = std.mem.indexOfScalar(u8, rest, ' ') orelse rest.len; + if (field == n) return std.fmt.parseInt(u64, rest[0..end], 10) catch null; + rest = rest[end..]; + field += 1; + } +} + +/// The process's start time in clock ticks since boot. Two processes can +/// share a pid over time; they cannot share a pid and a start time, so +/// this is what makes a remembered slot safe to trust later. +pub fn startTimeOf(stat: []const u8) ?u64 { + return statField(stat, 22); +} + +/// The parent pid. +pub fn parentOf(stat: []const u8) ?u32 { + const v = statField(stat, 4) orelse return null; + return std.math.cast(u32, v); +} + +/// The value of `"key"` in a small flat JSON object: a quoted string +/// without its quotes, or a bare token (a number, `true`, `null`). +/// Nested objects are not searched, which is what the callers want — +/// these are the harnesses' own one-level session records. +pub fn jsonField(text: []const u8, key: []const u8) ?[]const u8 { + var quoted_buf: [64]u8 = undefined; + if (key.len + 2 > quoted_buf.len) return null; + quoted_buf[0] = '"'; + @memcpy(quoted_buf[1 .. 1 + key.len], key); + quoted_buf[1 + key.len] = '"'; + const quoted = quoted_buf[0 .. key.len + 2]; + + var from: usize = 0; + while (std.mem.indexOfPos(u8, text, from, quoted)) |at| { + from = at + quoted.len; + var rest = text[from..]; + while (rest.len > 0 and (rest[0] == ' ' or rest[0] == '\t')) rest = rest[1..]; + if (rest.len == 0 or rest[0] != ':') continue; + rest = rest[1..]; + while (rest.len > 0 and (rest[0] == ' ' or rest[0] == '\t')) rest = rest[1..]; + if (rest.len == 0) return null; + if (rest[0] == '"') { + const end = std.mem.indexOfScalar(u8, rest[1..], '"') orelse return null; + return rest[1 .. 1 + end]; + } + if (rest[0] == '{' or rest[0] == '[') continue; // not a leaf; keep looking + const end = std.mem.indexOfAny(u8, rest, ",}\n\r \t") orelse rest.len; + if (end == 0) return null; + return rest[0..end]; + } + return null; +} + +/// The session id in a transcript's file name. omp writes +/// `<timestamp>_<uuid>.jsonl`, so the id is what follows the last `_`. +/// codex writes `rollout-<timestamp>-<uuid>.jsonl`, where the +/// timestamp holds dashes too, so the id is the last 36 bytes rather +/// than anything found by splitting. Claude Code writes +/// `<uuid>.jsonl`, all of it the id. +pub fn sessionIdOf(kind: Kind, file_name: []const u8) []const u8 { + var stem = file_name; + if (std.mem.endsWith(u8, stem, ".jsonl")) stem = stem[0 .. stem.len - ".jsonl".len]; + switch (kind) { + .omp => if (std.mem.lastIndexOfScalar(u8, stem, '_')) |i| return stem[i + 1 ..], + .codex => { + const uuid_len = "01a0bcd2-5653-7cc0-aa36-676f6467a72a".len; + if (stem.len >= uuid_len) return stem[stem.len - uuid_len ..]; + }, + else => {}, + } + return stem; +} + +/// The store directory a harness derives from a working directory. +/// omp strips `$HOME` and turns every `/` into `-`; Claude Code turns +/// every character that is not a letter or a digit into `-`, over the +/// whole path. Returns the slug written into `out`. +pub fn slugOf(kind: Kind, cwd: []const u8, home: []const u8, out: []u8) ?[]const u8 { + var path = cwd; + if (kind == .omp and home.len > 0 and std.mem.startsWith(u8, path, home)) { + path = path[home.len..]; + } + if (path.len > out.len) return null; + for (path, 0..) |c, i| { + out[i] = switch (kind) { + .omp => if (c == '/') '-' else c, + else => if (std.ascii.isAlphanumeric(c)) c else '-', + }; + } + return out[0..path.len]; +} + +/// Is `name` a zmx session name? zmx allows only these bytes in a +/// label, and the move path never composes anything else. +pub fn legalZmxName(name: []const u8) bool { + if (name.len == 0 or name.len > 64) return false; + for (name) |c| { + if (!std.ascii.isAlphanumeric(c) and c != '.' and c != '_' and c != '-') return false; + } + return true; +} + + +// ---- reading /proc and the harness stores ------------------------------------ + +/// Where the view reads from. Every path below is composed from these +/// and a pid; nothing here is ever built from a client's bytes. +pub const Sources = struct { + io: Io, + /// The proc filesystem root — `/proc`, or a fixture tree under test. + proc: []const u8, + /// $HOME, for the store-slug spellings. + home: []const u8, + /// The pinned harness roots, indexed by `@intFromEnum(Kind)`; an + /// empty base means that harness is unreachable. + roots: [5][]const u8 = @splat(""), + /// The daemon's own pid: it and its ancestors are never listed, so + /// 9harness can neither show nor act on the tree it lives in. Zero + /// disables the check (fixtures have no ancestry). + self_pid: u32 = 0, + /// Seconds between the epoch and boot, from `/proc/stat`'s `btime`. + /// Zero when it could not be read; `started` is then zero too. + btime: i64 = 0, +}; + +/// USER_HZ: the unit of `/proc/<pid>/stat`'s time fields. Linux reports +/// them in hundredths of a second whatever CONFIG_HZ is. +const user_hz: u64 = 100; + +fn readSmall(io: Io, path: []const u8, buf: []u8) ?[]const u8 { + const out = Io.Dir.readFile(.cwd(), io, path, buf) catch return null; + return out; +} + +fn linkOf(io: Io, path: []const u8, buf: []u8) ?[]const u8 { + const n = Io.Dir.readLinkAbsolute(io, path, buf) catch return null; + return buf[0..n]; +} + +fn statOf(io: Io, path: []const u8) ?Io.Dir.Stat { + return Io.Dir.statFile(.cwd(), io, path, .{ .follow_symlinks = true }) catch null; +} + +fn mtimeOf(io: Io, path: []const u8) i64 { + const st = statOf(io, path) orelse return 0; + return st.mtime.toSeconds(); +} + +/// `parts` joined with `/` into `buf`, or null when they do not fit. +fn join(buf: []u8, parts: []const []const u8) ?[]const u8 { + var n: usize = 0; + for (parts, 0..) |p, i| { + if (i > 0) { + if (n + 1 > buf.len) return null; + buf[n] = '/'; + n += 1; + } + if (n + p.len > buf.len) return null; + @memcpy(buf[n..][0..p.len], p); + n += p.len; + } + return buf[0..n]; +} + +/// `<root>` of a harness, or null when it is not pinned. +fn rootOf(s: Sources, k: Kind) ?[]const u8 { + const base = s.roots[@intFromEnum(k)]; + return if (base.len == 0) null else base; +} + +/// The directory a harness keeps its session transcripts under. +fn sessionRoot(s: Sources, k: Kind, buf: []u8) ?[]const u8 { + const base = rootOf(s, k) orelse return null; + return switch (k) { + // The omp root is ~/.omp and its mirror hangs off agent/. + .omp => join(buf, &.{ base, "agent", "sessions" }), + .claude => join(buf, &.{ base, "projects" }), + .dsh => join(buf, &.{ base, "sessions" }), + // codex names each rollout after its session, so the file it + // holds open is the whole answer — no sqlite needed. + .codex => join(buf, &.{ base, "sessions" }), + // hermes keeps its sessions only in sqlite, and the only hermes + // processes that run are the gateway and the dashboard, which + // are never sessions. Nothing to resolve. + .hermes => null, + }; +} + +// ---- the table ---------------------------------------------------------------- + +pub const Slot = struct { + used: bool = false, + /// Bumped whenever the slot comes to hold a different process, so a + /// node id minted for the old one stops resolving. + gen: u8 = 0, + seen: bool = false, + live: Live = .{}, +}; + +pub const Table = struct { + slots: [max_live]Slot = @splat(.{}), + + /// The slot holding this (pid, starttime), if any. + fn slotOfPid(t: *Table, pid: u32, starttime: u64) ?*Slot { + for (&t.slots) |*sl| { + if (sl.used and sl.live.pid == pid and sl.live.starttime == starttime) return sl; + } + return null; + } + + fn freeSlot(t: *Table) ?*Slot { + for (&t.slots) |*sl| if (!sl.used) return sl; + return null; + } + + /// The live record at `index`, when its generation still matches. + pub fn at(t: *Table, index: usize, gen: u8) ?*Live { + if (index >= t.slots.len) return null; + const sl = &t.slots[index]; + if (!sl.used or sl.gen != gen) return null; + return &sl.live; + } + + pub fn indexOf(t: *Table, l: *const Live) usize { + const base = @intFromPtr(&t.slots[0]); + return (@intFromPtr(l) - @sizeOf(Slot) + @sizeOf(Slot) - @offsetOf(Slot, "live") - base) / @sizeOf(Slot); + } + + /// Rescans `/proc`. Slots keep their index and generation while the + /// same process is still there, so a handle opened before the scan + /// keeps working; a slot that comes to hold a different process + /// bumps its generation and the old node ids stop resolving. + pub fn scan(t: *Table, s: Sources) void { + for (&t.slots) |*sl| sl.seen = false; + + var anc: [64]u32 = @splat(0); + const anc_n = ancestry(s, &anc); + + var buf: [path_capacity]u8 = undefined; + if (Io.Dir.openDirAbsolute(s.io, s.proc, .{ .iterate = true })) |dir| { + defer Io.Dir.close(dir, s.io); + var rb: [Io.Dir.Iterator.reader_buffer_len]u8 align(@alignOf(usize)) = undefined; + var it = Io.Dir.Reader.init(dir, &rb); + while (true) { + const entry = (it.next(s.io) catch break) orelse break; + const pid = std.fmt.parseInt(u32, entry.name, 10) catch continue; + var skip = false; + for (anc[0..anc_n]) |a| if (a == pid) { + skip = true; + }; + if (skip) continue; + t.consider(s, pid, &buf); + } + } else |_| {} + + for (&t.slots) |*sl| { + if (sl.used and !sl.seen) { + sl.used = false; + sl.gen +%= 1; + } + } + } + + /// Looks at one pid and takes it if it is an agent. + fn consider(t: *Table, s: Sources, pid: u32, buf: []u8) void { + var num: [24]u8 = undefined; + const pid_text = std.fmt.bufPrint(&num, "{d}", .{pid}) catch return; + var dir_buf: [path_capacity]u8 = undefined; + const pid_dir = join(&dir_buf, &.{ s.proc, pid_text }) orelse return; + + var stat_buf: [probe_capacity]u8 = undefined; + const stat_path = join(buf, &.{ pid_dir, "stat" }) orelse return; + const stat = readSmall(s.io, stat_path, &stat_buf) orelse return; + const starttime = startTimeOf(stat) orelse return; + + var cmd_buf: [probe_capacity]u8 = undefined; + const cmd_path = join(buf, &.{ pid_dir, "cmdline" }) orelse return; + const cmdline = readSmall(s.io, cmd_path, &cmd_buf) orelse return; + const kind = harnessOf(cmdline) orelse return; + + // Already known and still the same process: keep the slot and + // everything resolved for it. + if (t.slotOfPid(pid, starttime)) |sl| { + sl.seen = true; + return; + } + + const sl = t.freeSlot() orelse return; // full: the rest simply do not list + sl.* = .{ .used = true, .gen = sl.gen +% 1, .seen = true, .live = .{} }; + const l = &sl.live; + l.kind = kind; + l.pid = pid; + l.ppid = parentOf(stat) orelse 0; + l.starttime = starttime; + l.started = if (s.btime == 0) 0 else s.btime + @as(i64, @intCast(starttime / user_hz)); + + var name_buf: [name_capacity]u8 = undefined; + l.name.set(std.fmt.bufPrint(&name_buf, "{s}-{d}", .{ kind.text(), pid }) catch kind.text()); + + const cwd_path = join(buf, &.{ pid_dir, "cwd" }) orelse return; + var cwd_buf: [cwd_capacity]u8 = undefined; + if (linkOf(s.io, cwd_path, &cwd_buf)) |cwd| l.cwd.set(cwd); + + resolveSession(l, s, pid_dir); + } +}; + +/// The daemon's own pid and every parent of it, so the tree it lives in +/// is never listed. Returns how many were written. +fn ancestry(s: Sources, out: []u32) usize { + if (s.self_pid == 0) return 0; + var n: usize = 0; + var pid = s.self_pid; + var buf: [path_capacity]u8 = undefined; + var stat_buf: [probe_capacity]u8 = undefined; + while (pid > 1 and n < out.len) { + out[n] = pid; + n += 1; + var num: [24]u8 = undefined; + const pid_text = std.fmt.bufPrint(&num, "{d}", .{pid}) catch break; + const path = join(&buf, &.{ s.proc, pid_text, "stat" }) orelse break; + const stat = readSmall(s.io, path, &stat_buf) orelse break; + const parent = parentOf(stat) orelse break; + if (parent == 0 or parent == pid) break; + pid = parent; + } + return n; +} + +/// Asks the harness, in its own terms, which stored session this +/// process is writing. Leaves `via = .none` when it cannot tell: the +/// view never invents a session it did not find. +fn resolveSession(l: *Live, s: Sources, pid_dir: []const u8) void { + switch (l.kind) { + .claude => resolveClaude(l, s), + .omp => resolveOmp(l, s, pid_dir), + .dsh, .codex => resolveByFd(l, s, pid_dir), + // hermes has no per-session file to find. + .hermes => {}, + } +} + +/// Claude Code maintains `sessions/<pid>.json` itself, so there is +/// nothing to guess — only to check. `procStart` must match the +/// process's start time, or the record belongs to a dead predecessor +/// that happened to share the pid. +fn resolveClaude(l: *Live, s: Sources) void { + const base = rootOf(s, .claude) orelse return; + var buf: [path_capacity]u8 = undefined; + var num: [24]u8 = undefined; + const file = std.fmt.bufPrint(&num, "{d}.json", .{l.pid}) catch return; + const path = join(&buf, &.{ base, "sessions", file }) orelse return; + var text_buf: [probe_capacity]u8 = undefined; + const text = readSmall(s.io, path, &text_buf) orelse return; + + const proc_start = jsonField(text, "procStart") orelse return; + const claimed = std.fmt.parseInt(u64, proc_start, 10) catch return; + if (claimed != l.starttime) return; // a record left by a reused pid + + const sid = jsonField(text, "sessionId") orelse return; + if (sid.len == 0 or sid.len > text_capacity) return; + l.session.set(sid); + l.via = .registry; + + var slug_buf: [cwd_capacity]u8 = undefined; + const slug = slugOf(.claude, l.cwd.slice(), s.home, &slug_buf) orelse return; + var tr_buf: [path_capacity]u8 = undefined; + var file_buf: [text_capacity + 8]u8 = undefined; + const tr_name = std.fmt.bufPrint(&file_buf, "{s}.jsonl", .{sid}) catch return; + const tr = join(&tr_buf, &.{ base, "projects", slug, tr_name }) orelse return; + if (statOf(s.io, tr) != null) l.transcript.set(tr); +} + +/// omp: the transcript it holds open, else the store directory named +/// after its working directory. +fn resolveOmp(l: *Live, s: Sources, pid_dir: []const u8) void { + resolveByFd(l, s, pid_dir); + if (l.via == .none) resolveOmpByDir(l, s); + if (l.transcript.len == 0) return; + const tr = l.transcript.slice(); + l.session.set(sessionIdOf(.omp, basename(tr))); + // The subagents are sibling files in the directory named after the + // session file, one `<Agent>.jsonl` each. + if (std.mem.endsWith(u8, tr, ".jsonl")) { + l.agent_dir.set(tr[0 .. tr.len - ".jsonl".len]); + } +} + +fn resolveOmpByDir(l: *Live, s: Sources) void { + var root_buf: [path_capacity]u8 = undefined; + const root = sessionRoot(s, .omp, &root_buf) orelse return; + var slug_buf: [cwd_capacity]u8 = undefined; + const slug = slugOf(.omp, l.cwd.slice(), s.home, &slug_buf) orelse return; + var dir_buf: [path_capacity]u8 = undefined; + const dir_path = join(&dir_buf, &.{ root, slug }) orelse return; + var newest_buf: [path_capacity]u8 = undefined; + const newest = newestUnder(s.io, dir_path, ".jsonl", &newest_buf) orelse return; + l.transcript.set(newest); + l.via = .dir; +} + +/// The newest entry of `dir_path` whose name ends in `suffix`, as a +/// full path written into `out`. +fn newestUnder(io: Io, dir_path: []const u8, suffix: []const u8, out: []u8) ?[]const u8 { + const dir = Io.Dir.openDirAbsolute(io, dir_path, .{ .iterate = true }) catch return null; + defer Io.Dir.close(dir, io); + var rb: [Io.Dir.Iterator.reader_buffer_len]u8 align(@alignOf(usize)) = undefined; + var it = Io.Dir.Reader.init(dir, &rb); + var best: i64 = -1; + var best_len: usize = 0; + var probe: [path_capacity]u8 = undefined; + while (true) { + const entry = (it.next(io) catch break) orelse break; + if (!std.mem.endsWith(u8, entry.name, suffix)) continue; + const path = join(&probe, &.{ dir_path, entry.name }) orelse continue; + const when = mtimeOf(io, path); + if (when <= best) continue; + if (path.len > out.len) continue; + @memcpy(out[0..path.len], path); + best = when; + best_len = path.len; + } + return if (best_len == 0) null else out[0..best_len]; +} + +/// The route that needs no knowledge of how a harness names its store: +/// whatever session file the process is holding open. The newest wins, +/// so a harness that keeps an old transcript open for reading does not +/// outvote the one it is writing. +fn resolveByFd(l: *Live, s: Sources, pid_dir: []const u8) void { + var root_buf: [path_capacity]u8 = undefined; + const root = sessionRoot(s, l.kind, &root_buf) orelse return; + + var fd_buf: [path_capacity]u8 = undefined; + const fd_dir_path = join(&fd_buf, &.{ pid_dir, "fd" }) orelse return; + const fd_dir = Io.Dir.openDirAbsolute(s.io, fd_dir_path, .{ .iterate = true }) catch return; + defer Io.Dir.close(fd_dir, s.io); + var rb: [Io.Dir.Iterator.reader_buffer_len]u8 align(@alignOf(usize)) = undefined; + var it = Io.Dir.Reader.init(fd_dir, &rb); + + var best: i64 = -1; + var best_path: [path_capacity]u8 = undefined; + var best_len: usize = 0; + while (true) { + const entry = (it.next(s.io) catch break) orelse break; + var link_path_buf: [path_capacity]u8 = undefined; + const link_path = join(&link_path_buf, &.{ fd_dir_path, entry.name }) orelse continue; + var target_buf: [path_capacity]u8 = undefined; + const target = linkOf(s.io, link_path, &target_buf) orelse continue; + if (!std.mem.startsWith(u8, target, root)) continue; + if (target.len <= root.len or target[root.len] != '/') continue; + if (!sessionFile(l.kind, target[root.len + 1 ..])) continue; + const when = mtimeOf(s.io, target); + if (when <= best or target.len > best_path.len) continue; + @memcpy(best_path[0..target.len], target); + best = when; + best_len = target.len; + } + if (best_len == 0) return; + const found = best_path[0..best_len]; + l.transcript.set(found); + l.via = .fd; + if (l.kind == .codex) l.session.set(sessionIdOf(.codex, basename(found))); + if (l.kind == .dsh) { + // ~/.dsh/sessions/<cwd-slug>/session-<uuid>/session.jsonl.zstd: + // the id is the directory the transcript sits in. + const rel = found[root.len + 1 ..]; + var parts = std.mem.splitScalar(u8, rel, '/'); + _ = parts.next(); + if (parts.next()) |id| l.session.set(id); + } +} + +/// Is this path, relative to the harness's session root, one of its +/// session transcripts rather than a subagent's or something else? +fn sessionFile(kind: Kind, rel: []const u8) bool { + var depth: usize = 0; + var parts = std.mem.splitScalar(u8, rel, '/'); + while (parts.next()) |_| depth += 1; + return switch (kind) { + // <slug>/<file>.jsonl exactly: a third component is a subagent. + .omp => depth == 2 and std.mem.endsWith(u8, rel, ".jsonl"), + .claude => depth == 2 and std.mem.endsWith(u8, rel, ".jsonl"), + .dsh => depth == 3 and std.mem.startsWith(u8, rel, "-"), + // YYYY/MM/DD/rollout-<when>-<uuid>.jsonl + .codex => depth == 4 and std.mem.endsWith(u8, rel, ".jsonl") and + std.mem.startsWith(u8, basename(rel), "rollout-"), + .hermes => false, + }; +} + + +// ---- fields derived when they are read --------------------------------------- + +/// The value of `key` in a NUL-separated environment block, as +/// `/proc/<pid>/environ` stores it. +pub fn envValue(environ: []const u8, key: []const u8) ?[]const u8 { + var it: Argv = .init(environ); + while (it.next()) |pair| { + if (pair.len <= key.len) continue; + if (!std.mem.startsWith(u8, pair, key)) continue; + if (pair[key.len] != '=') continue; + return pair[key.len + 1 ..]; + } + return null; +} + +/// The title key each harness writes into the head of a transcript. +fn titleKey(k: Kind) []const u8 { + return switch (k) { + .claude => "aiTitle", + else => "title", + }; +} + +/// A field from the first few records of a transcript, where the +/// harnesses put the session's title and the model it runs. Only the +/// head is read: these records are written when the session opens. +pub fn headField(io: Io, kind: Kind, transcript: []const u8, which: enum { title, model }, out: []u8) ?[]const u8 { + if (transcript.len == 0) return null; + var head: [probe_capacity]u8 = undefined; + const text = readSmall(io, transcript, &head) orelse return null; + const key = switch (which) { + .title => titleKey(kind), + .model => "model", + }; + const v = jsonField(text, key) orelse return null; + if (v.len == 0 or v.len > out.len) return null; + @memcpy(out[0..v.len], v); + return out[0..v.len]; +} + +/// The zmx session a process runs inside, from its environment. +pub fn zmxOf(s: Sources, pid: u32, out: []u8) ?[]const u8 { + var num: [24]u8 = undefined; + const pid_text = std.fmt.bufPrint(&num, "{d}", .{pid}) catch return null; + var path_buf: [path_capacity]u8 = undefined; + const path = join(&path_buf, &.{ s.proc, pid_text, "environ" }) orelse return null; + var env_buf: [probe_capacity]u8 = undefined; + const env = readSmall(s.io, path, &env_buf) orelse return null; + const v = envValue(env, "ZMX_SESSION") orelse return null; + if (v.len == 0 or v.len > out.len) return null; + @memcpy(out[0..v.len], v); + return out[0..v.len]; +} + +/// A field of Claude Code's own session record, which is the only +/// harness that publishes a name and a status. +pub fn registryField(s: Sources, l: *const Live, key: []const u8, out: []u8) ?[]const u8 { + if (l.kind != .claude) return null; + const base = rootOf(s, .claude) orelse return null; + var num: [24]u8 = undefined; + const file = std.fmt.bufPrint(&num, "{d}.json", .{l.pid}) catch return null; + var path_buf: [path_capacity]u8 = undefined; + const path = join(&path_buf, &.{ base, "sessions", file }) orelse return null; + var text_buf: [probe_capacity]u8 = undefined; + const text = readSmall(s.io, path, &text_buf) orelse return null; + // The record must still describe this process, not a reused pid. + const proc_start = jsonField(text, "procStart") orelse return null; + const claimed = std.fmt.parseInt(u64, proc_start, 10) catch return null; + if (claimed != l.starttime) return null; + const v = jsonField(text, key) orelse return null; + if (v.len == 0 or v.len > out.len) return null; + @memcpy(out[0..v.len], v); + return out[0..v.len]; +} + +/// Is this process still the one the slot remembers? A pid alone is not +/// an identity: the kernel reuses them. +pub fn stillAlive(s: Sources, l: *const Live) bool { + var num: [24]u8 = undefined; + const pid_text = std.fmt.bufPrint(&num, "{d}", .{l.pid}) catch return false; + var path_buf: [path_capacity]u8 = undefined; + const path = join(&path_buf, &.{ s.proc, pid_text, "stat" }) orelse return false; + var stat_buf: [probe_capacity]u8 = undefined; + const stat = readSmall(s.io, path, &stat_buf) orelse return false; + const now = startTimeOf(stat) orelse return false; + return now == l.starttime; +} + +/// Seconds between the epoch and boot, from `/proc/stat`'s `btime` +/// line. Zero when it cannot be read. +pub fn bootTime(io: Io, proc: []const u8) i64 { + var path_buf: [path_capacity]u8 = undefined; + const path = join(&path_buf, &.{ proc, "stat" }) orelse return 0; + var buf: [probe_capacity]u8 = undefined; + const text = readSmall(io, path, &buf) orelse return 0; + var lines = std.mem.splitScalar(u8, text, '\n'); + while (lines.next()) |line| { + if (!std.mem.startsWith(u8, line, "btime ")) continue; + return std.fmt.parseInt(i64, std.mem.trim(u8, line["btime ".len..], " \r"), 10) catch 0; + } + return 0; +} + + +// ---- subagents ---------------------------------------------------------------- + +/// Longest subagent name answered. +pub const agent_name_capacity: usize = 64; + +/// The subagents of one session: omp writes each as `<Name>.jsonl` in a +/// directory named after the session file. Sorted, so a listing that +/// spans several reads keeps its cursor. +pub const Agents = struct { + names: [max_agents][agent_name_capacity]u8 = undefined, + lens: [max_agents]u8 = @splat(0), + count: usize = 0, + + pub fn name(a: *const Agents, i: usize) []const u8 { + if (i >= a.count) return ""; + return a.names[i][0..a.lens[i]]; + } + + pub fn indexOf(a: *const Agents, want: []const u8) ?usize { + for (0..a.count) |i| if (std.mem.eql(u8, a.name(i), want)) return i; + return null; + } +}; + +pub fn agentsOf(io: Io, l: *const Live, out: *Agents) void { + out.count = 0; + const dir_path = l.agent_dir.slice(); + if (dir_path.len == 0) return; + const dir = Io.Dir.openDirAbsolute(io, dir_path, .{ .iterate = true }) catch return; + defer Io.Dir.close(dir, io); + var rb: [Io.Dir.Iterator.reader_buffer_len]u8 align(@alignOf(usize)) = undefined; + var it = Io.Dir.Reader.init(dir, &rb); + while (out.count < max_agents) { + const entry = (it.next(io) catch break) orelse break; + if (!std.mem.endsWith(u8, entry.name, ".jsonl")) continue; + const stem = entry.name[0 .. entry.name.len - ".jsonl".len]; + if (stem.len == 0 or stem.len > agent_name_capacity) continue; + @memcpy(out.names[out.count][0..stem.len], stem); + out.lens[out.count] = @intCast(stem.len); + out.count += 1; + } + // Insertion sort: the list is at most `max_agents` long and already + // nearly ordered, and this keeps the struct allocation-free. + var i: usize = 1; + while (i < out.count) : (i += 1) { + var j = i; + while (j > 0 and std.mem.order(u8, out.name(j), out.name(j - 1)) == .lt) : (j -= 1) { + std.mem.swap([agent_name_capacity]u8, &out.names[j], &out.names[j - 1]); + std.mem.swap(u8, &out.lens[j], &out.lens[j - 1]); + } + } +} + +/// The transcript path of subagent `i`, written into `out`. +pub fn agentPath(l: *const Live, a: *const Agents, i: usize, out: []u8) ?[]const u8 { + if (i >= a.count) return null; + var name_buf: [agent_name_capacity + 8]u8 = undefined; + const file = std.fmt.bufPrint(&name_buf, "{s}.jsonl", .{a.name(i)}) catch return null; + return join(out, &.{ l.agent_dir.slice(), file }); +} + +// ---- unit tests --------------------------------------------------------------- + +const testing = std.testing; + +/// A `/proc/<pid>/cmdline`: NUL-separated argv, written out literally so +/// the tests read the way the kernel stores it. +fn cmd(comptime words: []const []const u8) []const u8 { + comptime var out: []const u8 = ""; + inline for (words) |w| out = out ++ w ++ "\x00"; + return out; +} + +test "active: a command line names its harness, or nothing" { + try testing.expectEqual(Kind.claude, harnessOf(cmd(&.{ "claude", "--dangerously-skip-permissions" })).?); + try testing.expectEqual(Kind.omp, harnessOf(cmd(&.{"omp"})).?); + try testing.expectEqual(Kind.codex, harnessOf(cmd(&.{ "/usr/bin/codex", "resume" })).?); + try testing.expectEqual(Kind.dsh, harnessOf(cmd(&.{"/home/goblin/.local/bin/dsh"})).?); + // hermes runs as a python module, named by a later word. + try testing.expectEqual(Kind.hermes, harnessOf(cmd(&.{ "/venv/bin/python", "-m", "hermes_cli.main" })).?); + // Not a harness at all. + try testing.expect(harnessOf(cmd(&.{ "bash", "-c", "claude" })) == null); + try testing.expect(harnessOf("") == null); + try testing.expect(harnessOf("\x00\x00") == null); +} + +test "active: a daemon or helper wearing a harness name is never a session" { + // The live machine's own gateway and dashboard, which must not list. + try testing.expect(harnessOf(cmd(&.{ "python3", "-m", "hermes_cli.main", "gateway", "run" })) == null); + try testing.expect(harnessOf(cmd(&.{ "python3", "/x/hermes-dashboard", "dashboard" })) == null); + try testing.expect(harnessOf(cmd(&.{ "claude", "mcp-server" })) == null); + try testing.expect(harnessOf(cmd(&.{ "omp", "__omp_worker_daemon_broker" })) == null); + try testing.expect(harnessOf(cmd(&.{ "codex", "app-server" })) == null); + // A directory that merely contains the word is still a session: the + // exclusion matches a whole argument, never a substring. + try testing.expectEqual(Kind.claude, harnessOf(cmd(&.{ "claude", "--cwd", "/home/goblin/serve-me" })).?); +} + +test "active: starttime is counted from the last ')' in a stat line" { + // Fields: pid (comm) state ppid ... starttime is field 22. + var line: [512]u8 = undefined; + var w = Io.Writer.fixed(&line); + try w.print("345104 (claude) S 344980", .{}); + for (5..22) |i| try w.print(" {d}", .{i * 10}); + try w.print(" 1625063 rest of the line", .{}); + try testing.expectEqual(@as(u64, 1625063), startTimeOf(w.buffered()).?); + + // A process whose name holds spaces and parentheses — the reason + // the fields are never counted from the start of the line. + var nasty: [512]u8 = undefined; + var nw = Io.Writer.fixed(&nasty); + try nw.print("42 (we i)rd (name) ) R 1", .{}); + for (5..22) |i| try nw.print(" {d}", .{i}); + try nw.print(" 999 x", .{}); + try testing.expectEqual(@as(u64, 999), startTimeOf(nw.buffered()).?); + + try testing.expect(startTimeOf("no parens here") == null); + try testing.expect(startTimeOf("1 (short) S 2 3") == null); +} + +test "active: json fields come out of a harness's own session record" { + // The shape Claude Code writes to ~/.claude/sessions/<pid>.json. + const rec = + \\{ + \\ "pid": 345104, + \\ "sessionId": "1f9a74f7-b04f-4092-8b98-df11316e03c0", + \\ "cwd": "/home/goblin", + \\ "version": "2.1.278", + \\ "peerFeatures": ["notify_idle", "artifact_yield"], + \\ "name": "goblin-e5", + \\ "status": "busy", + \\ "procStart": "1625063" + \\} + ; + try testing.expectEqualStrings("345104", jsonField(rec, "pid").?); + try testing.expectEqualStrings("1f9a74f7-b04f-4092-8b98-df11316e03c0", jsonField(rec, "sessionId").?); + try testing.expectEqualStrings("/home/goblin", jsonField(rec, "cwd").?); + try testing.expectEqualStrings("goblin-e5", jsonField(rec, "name").?); + try testing.expectEqualStrings("busy", jsonField(rec, "status").?); + try testing.expectEqualStrings("1625063", jsonField(rec, "procStart").?); + // An array or object value is skipped, not half-parsed. + try testing.expect(jsonField(rec, "peerFeatures") == null); + try testing.expect(jsonField(rec, "absent") == null); + // A key that is a prefix of another must not match it. + try testing.expect(jsonField("{\"sessionIdent\":\"x\"}", "sessionId") == null); +} + +test "active: session ids and store slugs follow each harness's spelling" { + try testing.expectEqualStrings( + "01a0c46a-7a06-7676-aef1-add9d9340c83", + sessionIdOf(.omp, "2026-09-21T14-41-47-526Z_01a0c46a-7a06-7676-aef1-add9d9340c83.jsonl"), + ); + try testing.expectEqualStrings( + "42898558-2fa0-41f2-a2c6-1357e5cf6836", + sessionIdOf(.claude, "42898558-2fa0-41f2-a2c6-1357e5cf6836.jsonl"), + ); + // codex's timestamp holds dashes too, so the id is the last 36 + // bytes and never what splitting on `-` would give. + try testing.expectEqualStrings( + "01a0bcd2-5653-7cc0-aa36-676f6467a72a", + sessionIdOf(.codex, "rollout-2026-09-20T00-18-16-01a0bcd2-5653-7cc0-aa36-676f6467a72a.jsonl"), + ); + + var buf: [256]u8 = undefined; + // omp strips $HOME and keeps everything but the slashes. + try testing.expectEqualStrings( + "-00-projects-0x4200.cafe-cloud9", + slugOf(.omp, "/home/goblin/00-projects/0x4200.cafe/cloud9", "/home/goblin", &buf).?, + ); + // Claude Code dashes every byte that is not a letter or a digit. + try testing.expectEqualStrings( + "-home-goblin-00-projects-0x4200-cafe-cloud9", + slugOf(.claude, "/home/goblin/00-projects/0x4200.cafe/cloud9", "/home/goblin", &buf).?, + ); + // A path longer than the buffer is refused, never truncated into a + // slug that would name the wrong store. + var tiny: [4]u8 = undefined; + try testing.expect(slugOf(.omp, "/home/goblin/somewhere", "/home/goblin", &tiny) == null); +} + +test "active: a zmx session name is checked before it is ever used" { + try testing.expect(legalZmxName("harness")); + try testing.expect(legalZmxName("claude-cloud9.2_x")); + try testing.expect(!legalZmxName("")); + try testing.expect(!legalZmxName("has space")); + try testing.expect(!legalZmxName("../escape")); + try testing.expect(!legalZmxName("semi;colon")); + try testing.expect(!legalZmxName("nul\x00byte")); + try testing.expect(!legalZmxName("x" ** 65)); +} + +test "active: an environment block answers by key, not by prefix" { + const env = "PATH=/usr/bin\x00ZMX_SESSION=harness\x00HOME=/home/goblin\x00"; + try testing.expectEqualStrings("harness", envValue(env, "ZMX_SESSION").?); + try testing.expectEqualStrings("/home/goblin", envValue(env, "HOME").?); + try testing.expect(envValue(env, "ZMX") == null); // a prefix is not the key + try testing.expect(envValue(env, "NOPE") == null); + try testing.expect(envValue("", "HOME") == null); + // An entry with no value is a value of zero length, not a miss. + try testing.expectEqualStrings("", envValue("EMPTY=\x00", "EMPTY").?); +} |
