From df20863879fe2d83077534f4726a985ffc239def Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Tue, 22 Sep 2026 10:30:02 -0300 Subject: Rename 9harness to 9agents; README, file-backed qids, worded errors - 9harness/ becomes 9agents/ (build option -D9agents, package paths). - 9agents serves /README, reports qid paths from the file's (dev, ino) and qid versions that move with the file, and names its refusals. --- 9harness/src/active.zig | 1006 ----------------------------------------------- 1 file changed, 1006 deletions(-) delete mode 100644 9harness/src/active.zig (limited to '9harness/src/active.zig') diff --git a/9harness/src/active.zig b/9harness/src/active.zig deleted file mode 100644 index fb55cf6..0000000 --- a/9harness/src/active.zig +++ /dev/null @@ -1,1006 +0,0 @@ -//! 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; -/// `-`. -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, and cannot be moved: hermes is the -/// only harness that always lands here. -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//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//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//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 -/// `_.jsonl`, so the id is what follows the last `_`. -/// codex writes `rollout--.jsonl`, where the -/// timestamp holds dashes too, so the id is the last 36 bytes rather -/// than anything found by splitting. Claude Code writes -/// `.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//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]; -} - -/// `` 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/.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 `.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//session-/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) { - // /.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--.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//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 `.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//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/.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").?); -} -- cgit v1.3