summaryrefslogtreecommitdiff
path: root/9harness/src/tree.zig
diff options
context:
space:
mode:
Diffstat (limited to '9harness/src/tree.zig')
-rw-r--r--9harness/src/tree.zig890
1 files changed, 879 insertions, 11 deletions
diff --git a/9harness/src/tree.zig b/9harness/src/tree.zig
index 6525e58..b6ff4a4 100644
--- a/9harness/src/tree.zig
+++ b/9harness/src/tree.zig
@@ -41,6 +41,7 @@ const fs = cloud9.fs;
const E = fs.E;
const Io = std.Io;
const linux = std.os.linux;
+pub const active = @import("active.zig");
// ---- comptime bounds ---------------------------------------------------------
@@ -84,9 +85,10 @@ pub const Top = enum(u8) {
hermes,
dsh,
skills,
+ active,
/// Directory entries of the root, in listing order.
- pub const listed = [_]Top{ .pid, .uptime, .claude, .codex, .omp, .hermes, .dsh, .skills };
+ pub const listed = [_]Top{ .pid, .uptime, .claude, .codex, .omp, .hermes, .dsh, .skills, .active };
pub fn fileName(t: Top) []const u8 {
return @tagName(t);
@@ -94,7 +96,7 @@ pub const Top = enum(u8) {
pub fn dir(t: Top) bool {
return switch (t) {
- .root, .claude, .codex, .omp, .hermes, .dsh, .skills => true,
+ .root, .claude, .codex, .omp, .hermes, .dsh, .skills, .active => true,
.pid, .uptime => false,
};
}
@@ -171,7 +173,82 @@ fn mountOwner(r: Root, rel_path: []const u8) Top {
// ---- node ids and the path table ---------------------------------------------
-pub const Kind = enum(u8) { top = 0, path = 1 };
+pub const Kind = enum(u8) { top = 0, path = 1, active = 2 };
+
+comptime {
+ // The derived view indexes the same pinned roots the mirror does.
+ for (std.enums.values(Root)) |r| {
+ if (!std.mem.eql(u8, @tagName(r), @tagName(@as(active.Kind, @enumFromInt(@intFromEnum(r))))))
+ @compileError("tree.Root and active.Kind must agree, index by index");
+ }
+}
+
+/// A node under `/active`. Everything but a harness directory names a
+/// slot of the live table and the generation it had when the id was
+/// minted, so an id outlives its process only long enough to answer
+/// ENOENT.
+pub const AFile = enum(u8) {
+ /// `/active/<harness>`
+ harness_dir,
+ /// `/active/<harness>/<pid>`
+ entry,
+ pid,
+ ppid,
+ started,
+ cwd,
+ name,
+ title,
+ session,
+ model,
+ via,
+ zmx,
+ status,
+ transcript,
+ /// `/active/<harness>/<pid>/agents`
+ agents,
+ /// `/active/<harness>/<pid>/agents/<name>`
+ agent,
+ agent_model,
+ agent_transcript,
+
+ /// The fields of one entry, in listing order. A field with no value
+ /// is not listed and does not resolve.
+ pub const entry_files = [_]AFile{
+ .pid, .ppid, .started, .cwd, .name, .title,
+ .session, .model, .via, .zmx, .status, .transcript,
+ };
+ pub const agent_files = [_]AFile{ .agent_model, .agent_transcript };
+
+ pub fn fileName(f: AFile) []const u8 {
+ return switch (f) {
+ .agent_model => "model",
+ .agent_transcript => "transcript",
+ else => @tagName(f),
+ };
+ }
+};
+
+/// A resolved `/active` node.
+pub const Act = struct {
+ file: AFile,
+ /// Only for `harness_dir`.
+ kind: active.Kind = .claude,
+ slot: u8 = 0,
+ gen: u8 = 0,
+ agent: u8 = 0,
+};
+
+pub fn activeNode(a: Act) u64 {
+ const serial: u48 = switch (a.file) {
+ .harness_dir => @intFromEnum(a.kind),
+ else => @as(u48, a.slot) | (@as(u48, a.gen) << 8) | (@as(u48, a.agent) << 16),
+ };
+ return @bitCast(Node{
+ .idx = @intFromEnum(a.file),
+ .kind = @intFromEnum(Kind.active),
+ .serial = serial,
+ });
+}
pub const Node = packed struct(u64) {
/// A Top index (kind == .top) or a Root index (kind == .path).
@@ -306,15 +383,71 @@ pub const Harness = struct {
list_dirs: [list_capacity]bool = undefined,
list_offs: [list_capacity]u32 = undefined,
+ // The derived view (`/active`). An empty `proc` base turns it off,
+ // which is the default: a test must opt in to reading a process
+ // tree, and never the live one.
+ live: active.Table = .{},
+ proc_buf: [base_capacity]u8 = @splat(0),
+ proc_len: u16 = 0,
+ home_buf: [base_capacity]u8 = @splat(0),
+ home_len: u16 = 0,
+ zmx_buf: [base_capacity]u8 = @splat(0),
+ zmx_len: u16 = 0,
+ runtime_buf: [base_capacity]u8 = @splat(0),
+ runtime_len: u16 = 0,
+ envp: ?cloud9.post.Env = null,
+ self_pid: u32 = 0,
+ btime: i64 = 0,
+ allow_move: bool = false,
+ act_text: [1024]u8 = undefined,
+ act_name: [64]u8 = undefined,
+ act_path: [active.path_capacity]u8 = undefined,
+
pub const InitOptions = struct {
io: Io,
pid: u32,
/// Base path of each root, or "" when the root is not pinned.
bases: [5][]const u8,
+ /// The proc filesystem `/active` reads. Empty — the default —
+ /// leaves the derived view out of the tree entirely.
+ proc: []const u8 = "",
+ /// $HOME, for the harnesses' store-slug spellings.
+ home: []const u8 = "",
+ /// The zmx binary a move execs, resolved to an absolute path by
+ /// the caller. Never client bytes.
+ zmx: []const u8 = "zmx",
+ /// $XDG_RUNTIME_DIR, where a move looks for the zmx session it
+ /// is about to create.
+ runtime: []const u8 = "",
+ /// The daemon's own environment block, handed to the harness a
+ /// move re-execs. Null leaves a moved agent with an empty one.
+ envp: ?cloud9.post.Env = null,
+ /// Whether writing `/active/<h>/<pid>/zmx` may move a session.
+ allow_move: bool = false,
};
pub fn init(h: *Harness, o: InitOptions) void {
h.* = .{ .io = o.io, .pid = o.pid, .started_sec = Io.Timestamp.now(o.io, .real).toSeconds() };
+ if (o.proc.len > 0 and o.proc.len <= base_capacity) {
+ @memcpy(h.proc_buf[0..o.proc.len], o.proc);
+ h.proc_len = @intCast(o.proc.len);
+ h.self_pid = o.pid;
+ h.btime = active.bootTime(o.io, o.proc);
+ }
+ if (o.home.len <= base_capacity) {
+ @memcpy(h.home_buf[0..o.home.len], o.home);
+ h.home_len = @intCast(o.home.len);
+ }
+ if (o.zmx.len > 0 and o.zmx.len <= base_capacity) {
+ @memcpy(h.zmx_buf[0..o.zmx.len], o.zmx);
+ h.zmx_len = @intCast(o.zmx.len);
+ }
+ if (o.runtime.len <= base_capacity) {
+ @memcpy(h.runtime_buf[0..o.runtime.len], o.runtime);
+ h.runtime_len = @intCast(o.runtime.len);
+ }
+ h.allow_move = o.allow_move;
+ h.envp = o.envp;
inline for (0..5) |i| {
const path = o.bases[i];
h.base_set[i] = path.len > 0 and path.len <= base_capacity;
@@ -388,6 +521,18 @@ pub const Harness = struct {
if (serialOf(slot, e.gen) != n.serial) return null;
return .{ .path = e };
},
+ .active => {
+ const f = std.enums.fromInt(AFile, n.idx) orelse return null;
+ if (f == .harness_dir) {
+ const k = std.enums.fromInt(active.Kind, @as(u8, @truncate(n.serial))) orelse return null;
+ return .{ .act = .{ .file = f, .kind = k } };
+ }
+ const slot: u8 = @truncate(n.serial);
+ const gen: u8 = @truncate(n.serial >> 8);
+ const agent: u8 = @truncate(n.serial >> 16);
+ const l = h.live.at(slot, gen) orelse return null;
+ return .{ .act = .{ .file = f, .kind = l.kind, .slot = slot, .gen = gen, .agent = agent } };
+ },
}
}
@@ -399,6 +544,7 @@ pub const Harness = struct {
pub const Target = union(enum) {
top: Top,
path: *Entry,
+ act: Act,
};
// ---- attributes ---------------------------------------------------------------
@@ -508,6 +654,7 @@ fn attrFor(h: *Harness, t: Target) ?fs.Attr {
.top => |top| {
switch (top) {
.root => return .{ .name = "/", .node = root, .dir = true, .mode = 0o555 },
+ .active => return .{ .name = "active", .node = topNode(.active), .dir = true, .mode = 0o555 },
.pid, .uptime => return .{
.name = top.fileName(),
.node = topNode(top),
@@ -530,6 +677,7 @@ fn attrFor(h: *Harness, t: Target) ?fs.Attr {
}
},
.path => |e| return statAttr(h, e),
+ .act => |a| return activeAttr(h, a),
}
}
@@ -544,6 +692,442 @@ fn attrOfMount(h: *Harness, r: Root, rel_path: []const u8) ?fs.Attr {
return statAttr(h, e);
}
+// ---- /active: the derived view -------------------------------------------------
+
+fn sourcesOf(h: *Harness) active.Sources {
+ var roots: [5][]const u8 = @splat("");
+ for (0..5) |i| {
+ if (h.base_set[i]) roots[i] = h.base_buf[i][0..h.base_len[i]];
+ }
+ return .{
+ .io = h.io,
+ .proc = h.proc_buf[0..h.proc_len],
+ .home = h.home_buf[0..h.home_len],
+ .roots = roots,
+ .self_pid = h.self_pid,
+ .btime = h.btime,
+ };
+}
+
+/// Rescans the process tree. Done on a listing of `/active` and on a
+/// lookup that misses, which is what makes an agent visible the moment
+/// it starts and gone the moment it exits.
+fn rescan(h: *Harness) void {
+ if (h.proc_len == 0) return;
+ h.live.scan(sourcesOf(h));
+}
+
+fn hasLive(h: *Harness, k: active.Kind) bool {
+ for (&h.live.slots) |*sl| {
+ if (sl.used and sl.live.kind == k) return true;
+ }
+ return false;
+}
+
+/// `<text>\n` staged where an answer can point at it.
+fn line(h: *Harness, text: []const u8) ?[]const u8 {
+ return std.fmt.bufPrint(&h.act_text, "{s}\n", .{text}) catch null;
+}
+
+/// The value of a field file, or null when this agent has none — an
+/// absent field is not listed and does not resolve, so the tree never
+/// answers a blank where it does not know.
+fn activeText(h: *Harness, a: Act) ?[]const u8 {
+ const l = h.live.at(a.slot, a.gen) orelse return null;
+ const src = sourcesOf(h);
+ var scratch: [active.text_capacity]u8 = undefined;
+ return switch (a.file) {
+ .pid => std.fmt.bufPrint(&h.act_text, "{d}\n", .{l.pid}) catch null,
+ .ppid => if (l.ppid == 0) null else std.fmt.bufPrint(&h.act_text, "{d}\n", .{l.ppid}) catch null,
+ .started => if (l.started == 0) null else std.fmt.bufPrint(&h.act_text, "{d}\n", .{l.started}) catch null,
+ .cwd => if (l.cwd.len == 0) null else line(h, l.cwd.slice()),
+ .session => if (l.session.len == 0) null else line(h, l.session.slice()),
+ .via => line(h, l.via.text()),
+ .name => line(h, active.registryField(src, l, "name", &scratch) orelse return null),
+ .status => line(h, active.registryField(src, l, "status", &scratch) orelse return null),
+ .title => line(h, active.headField(h.io, l.kind, l.transcript.slice(), .title, &scratch) orelse return null),
+ .model => line(h, active.headField(h.io, l.kind, l.transcript.slice(), .model, &scratch) orelse return null),
+ .agent_model => blk: {
+ const path = activePath(h, a) orelse break :blk null;
+ break :blk line(h, active.headField(h.io, l.kind, path, .model, &scratch) orelse return null);
+ },
+ // `zmx` is always there, so it can always be written to; empty
+ // means the agent runs outside zmx.
+ .zmx => if (active.zmxOf(src, l.pid, &scratch)) |v| line(h, v) else h.act_text[0..0],
+ else => null,
+ };
+}
+
+/// The absolute path behind a transcript-shaped file.
+fn activePath(h: *Harness, a: Act) ?[]const u8 {
+ const l = h.live.at(a.slot, a.gen) orelse return null;
+ switch (a.file) {
+ .transcript => return if (l.transcript.len == 0) null else l.transcript.slice(),
+ .agent_transcript, .agent_model => {
+ var ag: active.Agents = .{};
+ active.agentsOf(h.io, l, &ag);
+ return active.agentPath(l, &ag, a.agent, &h.act_path);
+ },
+ else => return null,
+ }
+}
+
+/// Opens an absolute path without following a symlink at the last
+/// component. These paths are derived from a pinned root or from the
+/// fd the harness itself holds, never from client bytes, but a name
+/// swapped underneath must still fail rather than redirect.
+fn openAbsNoFollow(path: []const u8) ?i32 {
+ if (path.len == 0 or path.len >= active.path_capacity) return null;
+ var z: [active.path_capacity]u8 = @splat(0);
+ @memcpy(z[0..path.len], path);
+ const rc = linux.open(@ptrCast(&z), .{
+ .ACCMODE = .RDONLY,
+ .NOFOLLOW = true,
+ .CLOEXEC = true,
+ .NONBLOCK = true,
+ }, 0);
+ if (linux.errno(rc) != .SUCCESS) return null;
+ return @intCast(rc);
+}
+
+fn activeAttr(h: *Harness, a: Act) ?fs.Attr {
+ switch (a.file) {
+ .harness_dir => {
+ if (!hasLive(h, a.kind)) return null;
+ return .{ .name = a.kind.text(), .node = activeNode(a), .dir = true, .mode = 0o555 };
+ },
+ .entry => {
+ const l = h.live.at(a.slot, a.gen) orelse return null;
+ const nm = std.fmt.bufPrint(&h.act_name, "{d}", .{l.pid}) catch return null;
+ return .{ .name = nm, .node = activeNode(a), .dir = true, .mode = 0o555 };
+ },
+ .agents => {
+ const l = h.live.at(a.slot, a.gen) orelse return null;
+ if (l.agent_dir.len == 0) return null;
+ return .{ .name = "agents", .node = activeNode(a), .dir = true, .mode = 0o555 };
+ },
+ .agent => {
+ const l = h.live.at(a.slot, a.gen) orelse return null;
+ var ag: active.Agents = .{};
+ active.agentsOf(h.io, l, &ag);
+ if (a.agent >= ag.count) return null;
+ const nm = ag.name(a.agent);
+ @memcpy(h.act_name[0..nm.len], nm);
+ return .{ .name = h.act_name[0..nm.len], .node = activeNode(a), .dir = true, .mode = 0o555 };
+ },
+ .transcript, .agent_transcript => {
+ const path = activePath(h, a) orelse return null;
+ const st = Io.Dir.statFile(.cwd(), h.io, path, .{ .follow_symlinks = false }) catch return null;
+ if (st.kind != .file) return null;
+ return .{
+ .name = a.file.fileName(),
+ .node = activeNode(a),
+ .size = st.size,
+ .mode = 0o444,
+ .mtime = @truncate(@as(u64, @bitCast(st.mtime.toSeconds()))),
+ };
+ },
+ else => {
+ const text = activeText(h, a) orelse return null;
+ // Only `zmx` is ever writable, and only when a move is allowed.
+ const mode: u16 = if (a.file == .zmx and h.allow_move) 0o644 else 0o444;
+ return .{ .name = a.file.fileName(), .node = activeNode(a), .size = text.len, .mode = mode };
+ },
+ }
+}
+
+/// The slot holding `pid` for this harness, if any.
+fn slotOfPid(h: *Harness, k: active.Kind, pid: u32) ?Act {
+ for (&h.live.slots, 0..) |*sl, i| {
+ if (!sl.used or sl.live.kind != k or sl.live.pid != pid) continue;
+ return .{ .file = .entry, .kind = k, .slot = @intCast(i), .gen = sl.gen };
+ }
+ return null;
+}
+
+fn activeLookup(h: *Harness, req: fs.Req, a: Act, name: []const u8) Answer {
+ switch (a.file) {
+ .harness_dir => {
+ const pid = std.fmt.parseInt(u32, name, 10) catch return fail(req.tag, E.NOENT);
+ var found = slotOfPid(h, a.kind, pid);
+ if (found == null) {
+ rescan(h);
+ found = slotOfPid(h, a.kind, pid);
+ }
+ const act = found orelse return fail(req.tag, E.NOENT);
+ return activeReply(h, req, act, name);
+ },
+ .entry => {
+ if (std.mem.eql(u8, name, "agents")) {
+ return activeReply(h, req, .{ .file = .agents, .kind = a.kind, .slot = a.slot, .gen = a.gen }, name);
+ }
+ for (AFile.entry_files) |f| {
+ if (!std.mem.eql(u8, f.fileName(), name)) continue;
+ return activeReply(h, req, .{ .file = f, .kind = a.kind, .slot = a.slot, .gen = a.gen }, name);
+ }
+ return fail(req.tag, E.NOENT);
+ },
+ .agents => {
+ const l = h.live.at(a.slot, a.gen) orelse return fail(req.tag, E.NOENT);
+ var ag: active.Agents = .{};
+ active.agentsOf(h.io, l, &ag);
+ const i = ag.indexOf(name) orelse return fail(req.tag, E.NOENT);
+ return activeReply(h, req, .{
+ .file = .agent,
+ .kind = a.kind,
+ .slot = a.slot,
+ .gen = a.gen,
+ .agent = @intCast(i),
+ }, name);
+ },
+ .agent => {
+ for (AFile.agent_files) |f| {
+ if (!std.mem.eql(u8, f.fileName(), name)) continue;
+ return activeReply(h, req, .{
+ .file = f,
+ .kind = a.kind,
+ .slot = a.slot,
+ .gen = a.gen,
+ .agent = a.agent,
+ }, name);
+ }
+ return fail(req.tag, E.NOENT);
+ },
+ else => return fail(req.tag, E.NOTDIR),
+ }
+}
+
+fn activeReply(h: *Harness, req: fs.Req, a: Act, name: []const u8) Answer {
+ const attr = activeAttr(h, a) orelse return fail(req.tag, E.NOENT);
+ var with_name = attr;
+ with_name.name = name;
+ return .{ .reply = .{ .tag = req.tag, .attr = with_name } };
+}
+
+fn activeReaddir(h: *Harness, req: fs.Req, a: Act) Answer {
+ var st: Staging = .{ .buf = &h.stage_buf, .skip = req.off };
+ switch (a.file) {
+ .harness_dir => {
+ for (&h.live.slots, 0..) |*sl, i| {
+ if (!sl.used or sl.live.kind != a.kind) continue;
+ var num: [24]u8 = undefined;
+ const nm = std.fmt.bufPrint(&num, "{d}", .{sl.live.pid}) catch continue;
+ st.add(activeNode(.{
+ .file = .entry,
+ .kind = a.kind,
+ .slot = @intCast(i),
+ .gen = sl.gen,
+ }), true, nm);
+ }
+ },
+ .entry => {
+ const l = h.live.at(a.slot, a.gen) orelse return fail(req.tag, E.NOENT);
+ for (AFile.entry_files) |f| {
+ const child: Act = .{ .file = f, .kind = a.kind, .slot = a.slot, .gen = a.gen };
+ if (activeAttr(h, child) == null) continue; // no value: not listed
+ st.add(activeNode(child), false, f.fileName());
+ }
+ if (l.agent_dir.len > 0) {
+ st.add(activeNode(.{ .file = .agents, .kind = a.kind, .slot = a.slot, .gen = a.gen }), true, "agents");
+ }
+ },
+ .agents => {
+ const l = h.live.at(a.slot, a.gen) orelse return fail(req.tag, E.NOENT);
+ var ag: active.Agents = .{};
+ active.agentsOf(h.io, l, &ag);
+ for (0..ag.count) |i| {
+ st.add(activeNode(.{
+ .file = .agent,
+ .kind = a.kind,
+ .slot = a.slot,
+ .gen = a.gen,
+ .agent = @intCast(i),
+ }), true, ag.name(i));
+ }
+ },
+ .agent => {
+ for (AFile.agent_files) |f| {
+ const child: Act = .{ .file = f, .kind = a.kind, .slot = a.slot, .gen = a.gen, .agent = a.agent };
+ if (activeAttr(h, child) == null) continue;
+ st.add(activeNode(child), false, f.fileName());
+ }
+ },
+ else => return fail(req.tag, E.NOTDIR),
+ }
+ return .{ .reply = .{ .tag = req.tag }, .bytes = h.stage_buf[0..st.len] };
+}
+
+fn activeRead(h: *Harness, req: fs.Req, a: Act) Answer {
+ switch (a.file) {
+ .harness_dir, .entry, .agents, .agent => return fail(req.tag, E.ISDIR),
+ .transcript, .agent_transcript => {
+ const path = activePath(h, a) orelse return fail(req.tag, E.NOENT);
+ const fd = openAbsNoFollow(path) orelse return fail(req.tag, E.NOENT);
+ const file: Io.File = .{ .handle = fd, .flags = .{ .nonblocking = false } };
+ defer file.close(h.io);
+ const st = file.stat(h.io) catch return fail(req.tag, E.IO);
+ if (st.kind == .directory) return fail(req.tag, E.ISDIR);
+ if (st.kind != .file) return fail(req.tag, E.PERM);
+ _ = linux.fcntl(fd, linux.F.SETFL, 0);
+ const want = @min(req.size, h.data_buf.len);
+ const n = file.readPositionalAll(h.io, h.data_buf[0..want], req.off) catch
+ return fail(req.tag, E.IO);
+ return .{ .reply = .{ .tag = req.tag }, .bytes = h.data_buf[0..n] };
+ },
+ else => {
+ const text = activeText(h, a) orelse return fail(req.tag, E.NOENT);
+ return window(req, text);
+ },
+ }
+}
+
+// ---- the move: writing a zmx session name into an agent's `zmx` ----------------
+
+/// How long a move waits, in 100ms ticks: for the agent to take the
+/// hangup, then to die outright, then for the new zmx session to post.
+const term_ticks: usize = 50;
+const kill_ticks: usize = 30;
+const post_ticks: usize = 100;
+
+fn napOneTick(h: *Harness) void {
+ h.io.sleep(.fromMilliseconds(100), .awake) catch {};
+}
+
+/// Is this name already a live zmx session? zmx posts each session into
+/// the registry, so the registry is the answer — no process scanning.
+fn zmxPosted(h: *Harness, name: []const u8) bool {
+ if (h.runtime_len == 0) return false;
+ var buf: [active.path_capacity]u8 = undefined;
+ const path = std.fmt.bufPrint(&buf, "{s}/9p/zmx/{s}", .{ h.runtime_buf[0..h.runtime_len], name }) catch return true;
+ return Io.Dir.statFile(.cwd(), h.io, path, .{ .follow_symlinks = true }) != error.FileNotFound;
+}
+
+fn procGone(h: *Harness, pid: u32) bool {
+ var buf: [active.path_capacity]u8 = undefined;
+ const path = std.fmt.bufPrint(&buf, "{s}/{d}", .{ h.proc_buf[0..h.proc_len], pid }) catch return false;
+ _ = Io.Dir.statFile(.cwd(), h.io, path, .{ .follow_symlinks = true }) catch return true;
+ return false;
+}
+
+/// SIGTERM, then SIGKILL, then give up. The agent's session was
+/// resolved before this ran, so whatever happens it has somewhere to
+/// come back to.
+fn killAndWait(h: *Harness, pid: u32) bool {
+ _ = linux.kill(@intCast(pid), .TERM);
+ for (0..term_ticks) |_| {
+ if (procGone(h, pid)) return true;
+ napOneTick(h);
+ }
+ _ = linux.kill(@intCast(pid), .KILL);
+ for (0..kill_ticks) |_| {
+ if (procGone(h, pid)) return true;
+ napOneTick(h);
+ }
+ return false;
+}
+
+/// `zmx run <name> -d <harness> <resume flag> <resume value>`, in the
+/// agent's own directory. Every word but `<name>` is fixed by the
+/// harness; `<name>` was checked against zmx's label charset before
+/// anything was killed. No byte a client wrote reaches `exec` as a
+/// command.
+fn spawnZmx(h: *Harness, l: *const active.Live, name: []const u8, resume_value: []const u8) bool {
+ if (h.zmx_len == 0) return false;
+ const harness_argv0: []const u8 = @tagName(l.kind);
+ const resume_flag: []const u8 = switch (l.kind) {
+ .codex => "resume",
+ else => "--resume",
+ };
+
+ // One buffer holds every NUL-terminated word; `argv` points into it.
+ var words: [8 * active.path_capacity]u8 = undefined;
+ var used: usize = 0;
+ var argv: [9]?[*:0]const u8 = @splat(null);
+ var n: usize = 0;
+ const parts = [_][]const u8{
+ h.zmx_buf[0..h.zmx_len], "run", name, "-d",
+ harness_argv0, resume_flag, resume_value,
+ };
+ for (parts) |w| {
+ if (used + w.len + 1 > words.len or n + 1 >= argv.len) return false;
+ @memcpy(words[used..][0..w.len], w);
+ words[used + w.len] = 0;
+ argv[n] = @ptrCast(&words[used]);
+ n += 1;
+ used += w.len + 1;
+ }
+
+ var cwd_z: [active.cwd_capacity + 1]u8 = @splat(0);
+ if (l.cwd.len >= cwd_z.len) return false;
+ @memcpy(cwd_z[0..l.cwd.len], l.cwd.slice());
+
+ const rc = linux.fork();
+ if (linux.errno(rc) != .SUCCESS) return false;
+ if (rc == 0) {
+ // The child: only async-signal-safe calls from here.
+ _ = linux.chdir(@ptrCast(&cwd_z));
+ const empty = [_:null]?[*:0]const u8{};
+ const envp: cloud9.post.Env = h.envp orelse &empty;
+ _ = linux.execve(argv[0].?, @ptrCast(&argv), envp);
+ linux.exit(127);
+ }
+ // Reap the forked `zmx`, which returns as soon as the session is up.
+ const child: i32 = @intCast(rc);
+ var status: u32 = 0;
+ for (0..post_ticks) |_| {
+ const w = linux.waitpid(child, &status, 1); // WNOHANG
+ if (w == @as(usize, @intCast(child))) break;
+ napOneTick(h);
+ }
+ return true;
+}
+
+/// A move: kill the agent and bring it back inside a zmx session of the
+/// name written. Refusals come before anything is destroyed.
+fn moveToZmx(h: *Harness, req: fs.Req, a: Act) Answer {
+ if (!h.allow_move) return fail(req.tag, E.PERM);
+ const name = std.mem.trim(u8, req.data, " \t\r\n");
+ if (!active.legalZmxName(name)) return fail(req.tag, E.INVAL);
+
+ const l = h.live.at(a.slot, a.gen) orelse return fail(req.tag, E.NOENT);
+ // Nothing is killed that has nowhere to come back to.
+ if (l.via == .none or l.session.len == 0) return fail(req.tag, E.PERM);
+ if (l.cwd.len == 0) return fail(req.tag, E.PERM);
+ const resume_value: []const u8 = switch (l.kind) {
+ // omp resumes by the transcript it wrote, the others by id.
+ .omp => l.transcript.slice(),
+ .claude, .codex, .hermes => l.session.slice(),
+ // dsh has no resume form worth guessing at.
+ .dsh => return fail(req.tag, E.PERM),
+ };
+ if (resume_value.len == 0) return fail(req.tag, E.PERM);
+
+ // Already there: setting a value it already has changes nothing.
+ var have: [active.text_capacity]u8 = undefined;
+ if (active.zmxOf(sourcesOf(h), l.pid, &have)) |current| {
+ if (std.mem.eql(u8, current, name)) {
+ return .{ .reply = .{ .tag = req.tag, .written = @intCast(req.data.len) } };
+ }
+ }
+ if (zmxPosted(h, name)) return fail(req.tag, E.EXIST);
+ // The slot could have gone stale between the scan and this write.
+ if (!active.stillAlive(sourcesOf(h), l)) return fail(req.tag, E.NOENT);
+
+ // Everything below this line destroys something.
+ var snapshot = l.*;
+ if (!killAndWait(h, snapshot.pid)) return fail(req.tag, E.IO);
+ if (!spawnZmx(h, &snapshot, name, resume_value)) return fail(req.tag, E.IO);
+ for (0..post_ticks) |_| {
+ if (zmxPosted(h, name)) {
+ rescan(h);
+ return .{ .reply = .{ .tag = req.tag, .written = @intCast(req.data.len) } };
+ }
+ napOneTick(h);
+ }
+ rescan(h);
+ return fail(req.tag, E.IO);
+}
+
// ---- dispatch ------------------------------------------------------------------
/// Answers one engine request. The caller holds `h.mutex` and replies
@@ -553,15 +1137,39 @@ pub fn handle(h: *Harness, req: fs.Req) Answer {
return switch (req.op) {
.lookup => lookup(h, req, t),
.getattr => attrReply(h, req.tag, t),
- .setattr => fail(req.tag, E.PERM),
+ .setattr => setattrReq(h, req, t),
.open => open(h, req, t),
.release => release(h, req, t),
.readdir => readdir(h, req, t),
.read => read(h, req, t),
- .write => fail(req.tag, E.PERM),
+ .write => writeReq(h, req, t),
};
}
+/// Truncation is how a shell's `>` opens a file before writing it. The
+/// `zmx` file has no length of its own — a write replaces the value —
+/// so on the one writable file a zero-length truncate is a no-op
+/// rather than a refusal. Everything else still answers EPERM.
+fn setattrReq(h: *Harness, req: fs.Req, t: Target) Answer {
+ switch (t) {
+ .act => |a| if (a.file == .zmx and h.allow_move and req.truncate) {
+ return .{ .reply = .{ .tag = req.tag } };
+ },
+ else => {},
+ }
+ return fail(req.tag, E.PERM);
+}
+
+/// The tree answers EPERM to every write but one: a zmx session name
+/// into a live agent's `zmx`, which moves it there.
+fn writeReq(h: *Harness, req: fs.Req, t: Target) Answer {
+ switch (t) {
+ .act => |a| if (a.file == .zmx) return moveToZmx(h, req, a),
+ else => {},
+ }
+ return fail(req.tag, E.PERM);
+}
+
fn attrReply(h: *Harness, tag: u64, t: Target) Answer {
const a = attrFor(h, t) orelse return fail(tag, E.NOENT);
return .{ .reply = .{ .tag = tag, .attr = a } };
@@ -586,7 +1194,7 @@ fn lookup(h: *Harness, req: fs.Req, t: Target) Answer {
// The engine asks for "." when cloning a fid; the reference is
// paid for path targets here like any other lookup result.
switch (t) {
- .top => return attrReply(h, req.tag, t),
+ .top, .act => return attrReply(h, req.tag, t),
.path => |e| {
e.refs += 1;
return lookupAttr(h, req, t, name);
@@ -618,6 +1226,16 @@ fn lookup(h: *Harness, req: fs.Req, t: Target) Answer {
const m = top.mirror().?;
return lookupBelow(h, req, m.root, m.rel, name);
},
+ .active => {
+ const k = blk: for (std.enums.values(active.Kind)) |k| {
+ if (std.mem.eql(u8, k.text(), name)) break :blk k;
+ } else return fail(req.tag, E.NOENT);
+ if (!hasLive(h, k)) {
+ rescan(h);
+ if (!hasLive(h, k)) return fail(req.tag, E.NOENT);
+ }
+ return activeReply(h, req, .{ .file = .harness_dir, .kind = k }, name);
+ },
.pid, .uptime => return fail(req.tag, E.NOTDIR),
},
.path => |e| {
@@ -627,6 +1245,7 @@ fn lookup(h: *Harness, req: fs.Req, t: Target) Answer {
} else return fail(req.tag, E.NOENT);
return lookupBelow(h, req, e.root, rel_path, name);
},
+ .act => |a| return activeLookup(h, req, a, name),
}
}
@@ -673,6 +1292,21 @@ fn lookupParent(h: *Harness, req: fs.Req, t: Target) Answer {
}
break :blk .{ .top = mountOwner(e.root, rel_path) };
},
+ .act => |a| switch (a.file) {
+ .harness_dir => .{ .top = .active },
+ .entry => .{ .act = .{ .file = .harness_dir, .kind = a.kind } },
+ .agents => .{ .act = .{ .file = .entry, .kind = a.kind, .slot = a.slot, .gen = a.gen } },
+ .agent => .{ .act = .{ .file = .agents, .kind = a.kind, .slot = a.slot, .gen = a.gen } },
+ .agent_model, .agent_transcript => .{ .act = .{
+ .file = .agent,
+ .kind = a.kind,
+ .slot = a.slot,
+ .gen = a.gen,
+ .agent = a.agent,
+ } },
+ // Every other file hangs directly off its entry.
+ else => .{ .act = .{ .file = .entry, .kind = a.kind, .slot = a.slot, .gen = a.gen } },
+ },
};
return attrReply(h, req.tag, parent);
}
@@ -687,17 +1321,26 @@ fn unref(e: *Entry) void {
fn open(h: *Harness, req: fs.Req, t: Target) Answer {
_ = attrFor(h, t) orelse return fail(req.tag, E.NOENT); // still there?
- // Read-only tree: any open that would write or truncate is refused.
+ // Read-only tree, with exactly one exception: the `zmx` file of a
+ // live agent, and only when the daemon was started to allow moves.
+ // Truncation is meaningless there (a write replaces the value), so
+ // it is accepted rather than refused, which is what `>` needs.
+ const writable = switch (t) {
+ .act => |a| a.file == .zmx and h.allow_move,
+ else => false,
+ };
const rw = req.omode & 3;
- if (rw == cloud9.owrite or rw == cloud9.ordwr) return fail(req.tag, E.PERM);
- if (req.omode & cloud9.otrunc != 0) return fail(req.tag, E.PERM);
+ if (!writable) {
+ if (rw == cloud9.owrite or rw == cloud9.ordwr) return fail(req.tag, E.PERM);
+ if (req.omode & cloud9.otrunc != 0) return fail(req.tag, E.PERM);
+ }
return .{ .reply = .{ .tag = req.tag, .handle = 1 } };
}
fn release(h: *Harness, req: fs.Req, t: Target) Answer {
_ = h;
switch (t) {
- .top => {},
+ .top, .act => {},
.path => |e| unref(e),
}
return .{ .reply = .{ .tag = req.tag } };
@@ -714,6 +1357,7 @@ fn read(h: *Harness, req: fs.Req, t: Target) Answer {
},
else => return fail(req.tag, E.ISDIR),
},
+ .act => |a| return activeRead(h, req, a),
.path => |e| {
// `O_NONBLOCK` so a fifo left in a harness root cannot park the
// daemon in `open`; the kind check below refuses it anyway.
@@ -792,6 +1436,13 @@ fn readdir(h: *Harness, req: fs.Req, t: Target) Answer {
}
},
.pid, .uptime => return fail(req.tag, E.NOTDIR),
+ .active => {
+ rescan(h);
+ for (std.enums.values(active.Kind)) |k| {
+ if (!hasLive(h, k)) continue;
+ st.add(activeNode(.{ .file = .harness_dir, .kind = k }), true, k.text());
+ }
+ },
.omp, .hermes, .dsh => {
const m = top.mirror().?;
if (h.base(m.root) == null) return fail(req.tag, E.NOENT);
@@ -814,6 +1465,7 @@ fn readdir(h: *Harness, req: fs.Req, t: Target) Answer {
.overflow => return fail(req.tag, E.NFILE),
}
},
+ .act => |a| return activeReaddir(h, req, a),
}
return .{ .reply = .{ .tag = req.tag }, .bytes = h.stage_buf[0..st.len] };
}
@@ -904,6 +1556,14 @@ const testing = std.testing;
/// ~/.claude or any other live harness root.
const Rig = struct {
dir: testing.TmpDir,
+ /// Build a fixture process tree under <home>/proc and point
+ /// `/active` at it. Off by default: a unit test must opt in to
+ /// reading a process tree, and it is never the live one.
+ with_proc: bool = false,
+ /// The pid the daemon believes it is, for the ancestry exclusion.
+ self_pid: u32 = 4242,
+ zmx: []const u8 = "zmx",
+ allow_move: bool = false,
path_buf: [std.fs.max_path_bytes]u8 = undefined,
home: []const u8 = undefined,
h: *Harness = undefined,
@@ -931,7 +1591,24 @@ const Rig = struct {
bases[i] = try std.fmt.bufPrint(&base_buf[i], "{s}/{s}", .{ rig.home, home_dirs[i] });
}
rig.harness_mem = undefined;
- rig.harness_mem.init(.{ .io = io, .pid = 4242, .bases = bases });
+ var proc_buf: [std.fs.max_path_bytes]u8 = undefined;
+ const proc_root: []const u8 = if (rig.with_proc)
+ try std.fmt.bufPrint(&proc_buf, "{s}/proc", .{rig.home})
+ else
+ "";
+ if (rig.with_proc) {
+ try Io.Dir.cwd().createDirPath(io, proc_root);
+ try rig.put("proc/stat", "cpu 1 2 3\nbtime 1000000\nprocesses 7\n");
+ }
+ rig.harness_mem.init(.{
+ .io = io,
+ .pid = rig.self_pid,
+ .bases = bases,
+ .proc = proc_root,
+ .home = rig.home,
+ .zmx = rig.zmx,
+ .allow_move = rig.allow_move,
+ });
rig.h = &rig.harness_mem;
}
@@ -964,6 +1641,58 @@ const Rig = struct {
fn lookupName(rig: *Rig, tag: u64, dir_node: u64, name: []const u8) Answer {
return handle(rig.h, .{ .tag = tag, .op = .lookup, .node = dir_node, .data = name });
}
+
+ /// Writes one fake process into the fixture `/proc`: the `stat`
+ /// line (with the start time in field 22, after a name that holds
+ /// the spaces and parentheses a real one can), the NUL-separated
+ /// `cmdline`, and a `cwd` symlink.
+ fn fakeProc(rig: *Rig, o: struct {
+ pid: u32,
+ ppid: u32 = 1,
+ comm: []const u8 = "x",
+ starttime: u64 = 5000,
+ argv: []const u8,
+ cwd: []const u8 = "",
+ }) !void {
+ var rel: [128]u8 = undefined;
+ var stat_text: [512]u8 = undefined;
+ var w = Io.Writer.fixed(&stat_text);
+ try w.print("{d} ({s}) S {d}", .{ o.pid, o.comm, o.ppid });
+ for (5..22) |i| try w.print(" {d}", .{i});
+ try w.print(" {d} 0 0\n", .{o.starttime});
+ try rig.put(try std.fmt.bufPrint(&rel, "proc/{d}/stat", .{o.pid}), w.buffered());
+ try rig.put(try std.fmt.bufPrint(&rel, "proc/{d}/cmdline", .{o.pid}), o.argv);
+
+ const target = if (o.cwd.len > 0) o.cwd else rig.home;
+ var link_buf: [std.fs.max_path_bytes]u8 = undefined;
+ const link = try std.fmt.bufPrintZ(&link_buf, "{s}/proc/{d}/cwd", .{ rig.home, o.pid });
+ Io.Dir.cwd().symLink(testing.io, target, link, .{}) catch {};
+ }
+
+ /// Removes a fake process, as an exit would.
+ fn reapProc(rig: *Rig, pid: u32) !void {
+ var buf: [std.fs.max_path_bytes]u8 = undefined;
+ const dir_path = try std.fmt.bufPrint(&buf, "{s}/proc/{d}", .{ rig.home, pid });
+ const d = try Io.Dir.openDirAbsolute(testing.io, dir_path, .{ .iterate = true });
+ var rb: [Io.Dir.Iterator.reader_buffer_len]u8 align(@alignOf(usize)) = undefined;
+ var it = Io.Dir.Reader.init(d, &rb);
+ var names: [8][64]u8 = undefined;
+ var lens: [8]usize = @splat(0);
+ var n: usize = 0;
+ while (n < names.len) {
+ const e = (it.next(testing.io) catch break) orelse break;
+ @memcpy(names[n][0..e.name.len], e.name);
+ lens[n] = e.name.len;
+ n += 1;
+ }
+ Io.Dir.close(d, testing.io);
+ for (0..n) |i| {
+ var one: [std.fs.max_path_bytes]u8 = undefined;
+ const path = try std.fmt.bufPrint(&one, "{s}/{s}", .{ dir_path, names[i][0..lens[i]] });
+ Io.Dir.deleteFileAbsolute(testing.io, path) catch {};
+ }
+ try Io.Dir.cwd().deleteDir(testing.io, dir_path);
+ }
};
const home_dirs = [5][]const u8{ ".claude", ".codex", ".omp", ".hermes", ".dsh" };
@@ -1327,3 +2056,142 @@ test "tree: a listing spanning several reads loses no entry" {
try testing.expect(reads > 1); // the listing really did span several reads
for (seen) |s| try testing.expect(s);
}
+
+test {
+ _ = active; // the derived view's own tests run with the tree's
+}
+
+// ---- /active: the derived view ------------------------------------------------
+
+/// Walks `/active` down to one agent's directory, answering its node.
+fn activeEntryNode(rig: *Rig, harness: []const u8, pid: []const u8) !u64 {
+ const act = rig.lookupName(90, root, "active");
+ try testing.expect(act.reply.status == .ok);
+ // A listing is what rescans, so it comes before the walk.
+ _ = handle(rig.h, .{ .tag = 91, .op = .readdir, .node = act.reply.attr.node, .off = 0, .size = msize });
+ const h_dir = rig.lookupName(92, act.reply.attr.node, harness);
+ try testing.expect(h_dir.reply.status == .ok);
+ const entry = rig.lookupName(93, h_dir.reply.attr.node, pid);
+ try testing.expect(entry.reply.status == .ok);
+ return entry.reply.attr.node;
+}
+
+fn activeField(rig: *Rig, entry: u64, name: []const u8) ![]const u8 {
+ const f = rig.lookupName(94, entry, name);
+ try testing.expect(f.reply.status == .ok);
+ const r = handle(rig.h, .{ .tag = 95, .op = .read, .node = f.reply.attr.node, .off = 0, .size = msize });
+ try testing.expect(r.reply.status == .ok);
+ return r.bytes;
+}
+
+test "active: a fixture process tree lists by harness and by pid" {
+ var rig: Rig = .{ .dir = undefined, .with_proc = true };
+ try rig.start();
+ defer rig.end();
+
+ // A Claude Code session, resolved the way the harness publishes it.
+ try rig.fakeProc(.{ .pid = 1001, .comm = "claude", .starttime = 5000, .argv = "claude\x00--print\x00" });
+ var rec: [512]u8 = undefined;
+ try rig.put(".claude/sessions/1001.json", try std.fmt.bufPrint(&rec,
+ \\{{"pid":1001,"sessionId":"sess-abc","cwd":"{s}",
+ \\ "name":"fixture-one","status":"busy","procStart":"5000"}}
+ , .{rig.home}));
+ var slug_buf: [512]u8 = undefined;
+ const slug = active.slugOf(.claude, rig.home, rig.home, &slug_buf).?;
+ var tr: [640]u8 = undefined;
+ try rig.put(
+ try std.fmt.bufPrint(&tr, ".claude/projects/{s}/sess-abc.jsonl", .{slug}),
+ "{\"type\":\"summary\",\"aiTitle\":\"Fixture Title\"}\n",
+ );
+
+ const entry = try activeEntryNode(&rig, "claude", "1001");
+ try testing.expectEqualStrings("1001\n", try activeField(&rig, entry, "pid"));
+ try testing.expectEqualStrings("sess-abc\n", try activeField(&rig, entry, "session"));
+ try testing.expectEqualStrings("registry\n", try activeField(&rig, entry, "via"));
+ try testing.expectEqualStrings("fixture-one\n", try activeField(&rig, entry, "name"));
+ try testing.expectEqualStrings("busy\n", try activeField(&rig, entry, "status"));
+ try testing.expectEqualStrings("Fixture Title\n", try activeField(&rig, entry, "title"));
+ // started is the boot time plus the process's own, in seconds.
+ try testing.expectEqualStrings("1000050\n", try activeField(&rig, entry, "started"));
+ // The transcript is served as the file it is, not as a field.
+ const t = rig.lookupName(96, entry, "transcript");
+ try testing.expect(t.reply.status == .ok);
+ const bytes = handle(rig.h, .{ .tag = 97, .op = .read, .node = t.reply.attr.node, .off = 0, .size = msize });
+ try testing.expect(std.mem.indexOf(u8, bytes.bytes, "Fixture Title") != null);
+ // A field this harness does not publish is absent, not blank.
+ try expectNoent(rig.lookupName(98, entry, "model"));
+}
+
+test "active: a daemon or helper is never listed as an agent" {
+ var rig: Rig = .{ .dir = undefined, .with_proc = true };
+ try rig.start();
+ defer rig.end();
+ // The shapes actually running on this machine.
+ try rig.fakeProc(.{ .pid = 1010, .comm = "python3", .argv = "python3\x00-m\x00hermes_cli.main\x00gateway\x00run\x00" });
+ try rig.fakeProc(.{ .pid = 1011, .comm = "omp", .argv = "omp\x00__omp_worker_daemon_broker\x00" });
+ try rig.fakeProc(.{ .pid = 1012, .comm = "claude", .argv = "claude\x00mcp-server\x00" });
+ // ...and one real session, so an empty answer cannot pass by default.
+ try rig.fakeProc(.{ .pid = 1013, .comm = "claude", .argv = "claude\x00" });
+
+ const act = rig.lookupName(1, root, "active");
+ const listing = handle(rig.h, .{ .tag = 2, .op = .readdir, .node = act.reply.attr.node, .off = 0, .size = msize });
+ try testing.expect(stageHas(listing.bytes, "claude"));
+ try testing.expect(!stageHas(listing.bytes, "hermes"));
+ try testing.expect(!stageHas(listing.bytes, "omp"));
+ const claude = rig.lookupName(3, act.reply.attr.node, "claude");
+ const pids = handle(rig.h, .{ .tag = 4, .op = .readdir, .node = claude.reply.attr.node, .off = 0, .size = msize });
+ try testing.expect(stageHas(pids.bytes, "1013"));
+ try testing.expect(!stageHas(pids.bytes, "1012")); // the mcp server
+}
+
+test "active: a pid reused by another process stops resolving" {
+ var rig: Rig = .{ .dir = undefined, .with_proc = true };
+ try rig.start();
+ defer rig.end();
+ try rig.fakeProc(.{ .pid = 1020, .comm = "claude", .starttime = 5000, .argv = "claude\x00" });
+ const entry = try activeEntryNode(&rig, "claude", "1020");
+ try testing.expect(handle(rig.h, .{ .tag = 5, .op = .getattr, .node = entry }).reply.status == .ok);
+
+ // The same pid, a different process: only the start time says so.
+ try rig.fakeProc(.{ .pid = 1020, .comm = "claude", .starttime = 9999, .argv = "claude\x00" });
+ const act = rig.lookupName(6, root, "active");
+ _ = handle(rig.h, .{ .tag = 7, .op = .readdir, .node = act.reply.attr.node, .off = 0, .size = msize });
+ try expectNoent(handle(rig.h, .{ .tag = 8, .op = .getattr, .node = entry }));
+ // The pid is still there — as the new process, under a new node.
+ const fresh = try activeEntryNode(&rig, "claude", "1020");
+ try testing.expect(fresh != entry);
+}
+
+test "active: a process that exits leaves the tree" {
+ var rig: Rig = .{ .dir = undefined, .with_proc = true };
+ try rig.start();
+ defer rig.end();
+ try rig.fakeProc(.{ .pid = 1030, .comm = "claude", .argv = "claude\x00" });
+ const entry = try activeEntryNode(&rig, "claude", "1030");
+ try testing.expect(handle(rig.h, .{ .tag = 9, .op = .getattr, .node = entry }).reply.status == .ok);
+
+ try rig.reapProc(1030);
+ const act = rig.lookupName(10, root, "active");
+ const listing = handle(rig.h, .{ .tag = 11, .op = .readdir, .node = act.reply.attr.node, .off = 0, .size = msize });
+ try testing.expect(!stageHas(listing.bytes, "claude"));
+ try expectNoent(handle(rig.h, .{ .tag = 12, .op = .getattr, .node = entry }));
+ try expectNoent(rig.lookupName(13, act.reply.attr.node, "claude"));
+}
+
+test "active: the daemon never lists the process tree it lives in" {
+ // 1041 is the daemon, its parent 1040 is a harness: showing it would
+ // let a client act on the tree serving it.
+ var rig: Rig = .{ .dir = undefined, .with_proc = true, .self_pid = 1041 };
+ try rig.start();
+ defer rig.end();
+ try rig.fakeProc(.{ .pid = 1040, .comm = "claude", .argv = "claude\x00" });
+ try rig.fakeProc(.{ .pid = 1041, .comm = "9harness", .ppid = 1040, .argv = "9harness\x00" });
+ try rig.fakeProc(.{ .pid = 1042, .comm = "claude", .argv = "claude\x00" });
+
+ const act = rig.lookupName(14, root, "active");
+ const claude = rig.lookupName(15, act.reply.attr.node, "claude");
+ try testing.expect(claude.reply.status == .ok);
+ const pids = handle(rig.h, .{ .tag = 16, .op = .readdir, .node = claude.reply.attr.node, .off = 0, .size = msize });
+ try testing.expect(stageHas(pids.bytes, "1042")); // an unrelated session lists
+ try testing.expect(!stageHas(pids.bytes, "1040")); // its own parent does not
+}