From dddd556accea6b6ea7802cd3f622f8b3cf8eb43f Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Mon, 21 Sep 2026 16:49:20 -0300 Subject: 9harness: /active, and zmxify as a write to a file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/.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/ 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. --- 9harness/src/main.zig | 61 +++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 59 insertions(+), 2 deletions(-) (limited to '9harness/src/main.zig') diff --git a/9harness/src/main.zig b/9harness/src/main.zig index 2355a4f..fe0a07b 100644 --- a/9harness/src/main.zig +++ b/9harness/src/main.zig @@ -30,7 +30,8 @@ const linux = std.os.linux; const usage_text = \\usage: 9harness [--unix PATH | --tcp IP:PORT | --fd N] [--no-post] - \\ [--name NAME] [--root NAME=PATH]... + \\ [--name NAME] [--root NAME=PATH]... [--proc DIR] + \\ [--allow-move] [--zmx PATH] \\ \\A read-only, fresh-from-disk 9P2000 view of every harness's state: \\ /pid /uptime daemon facts @@ -38,6 +39,7 @@ const usage_text = \\ /codex/{sessions,session-index,history} \\ /omp /hermes /dsh full mirrors, raw \\ /skills/{claude,codex,omp} the union skills view + \\ /active/// what is running right now \\ \\By default the daemon posts itself under the name `harness`, so it is \\dialable at $XDG_RUNTIME_DIR/9p/harness and mountable by 9ns --mntgen @@ -46,6 +48,14 @@ const usage_text = \\connected stream on descriptor N and posts nothing. \\--root NAME=PATH pins one harness root (NAME: claude, codex, omp, \\hermes, dsh) somewhere other than $HOME/.; repeatable. + \\--proc DIR reads the process tree somewhere other than /proc (for + \\tests); --proc "" leaves /active out of the tree entirely. + \\--allow-move lets a write to /active///zmx move that agent + \\into a zmx session of that name: the daemon kills it and re-execs + \\the harness under zmx with its session resumed. Off by default, + \\because it is the one place the tree is not read-only, and anything + \\that can mount it can then kill an agent. --zmx PATH names the + \\binary a move runs (default: zmx, found on $PATH). \\ \\Run it in a zmx session, the zmx way: `zmx run harness -d 9harness`. \\ @@ -98,6 +108,19 @@ fn serveReq(ctx: ?*anyopaque, conn: *Runner.Conn, req: fs.Req) void { h.mutex.unlock(h.io); } +/// `name` found on `path_env`, as an absolute path in the arena. +fn onPath(io: Io, arena: std.mem.Allocator, path_env: []const u8, name: []const u8) ![]const u8 { + var it = std.mem.splitScalar(u8, path_env, ':'); + while (it.next()) |dir_path| { + if (dir_path.len == 0) continue; + const candidate = try std.fmt.allocPrint(arena, "{s}/{s}", .{ dir_path, name }); + const st = Io.Dir.statFile(.cwd(), io, candidate, .{ .follow_symlinks = true }) catch continue; + if (st.kind != .file) continue; + return candidate; + } + return error.NotFound; +} + // ---- the CLI -------------------------------------------------------------------- const Mode = enum { posted, unix, tcp, fd }; @@ -124,14 +147,20 @@ fn run(init: std.process.Init) !void { var post_name: []const u8 = "harness"; var no_post = false; var base_overrides: [5]?[]const u8 = @splat(null); + var proc_root: []const u8 = "/proc"; + var zmx_path: []const u8 = "zmx"; + var allow_move = false; var i: usize = 1; while (i < args.len) : (i += 1) { const a = args[i]; if (std.mem.eql(u8, a, "--no-post")) { no_post = true; + } else if (std.mem.eql(u8, a, "--allow-move")) { + allow_move = true; } else if (std.mem.eql(u8, a, "--unix") or std.mem.eql(u8, a, "--tcp") or std.mem.eql(u8, a, "--fd") or std.mem.eql(u8, a, "--name") or + std.mem.eql(u8, a, "--proc") or std.mem.eql(u8, a, "--zmx") or std.mem.eql(u8, a, "--root")) { i += 1; @@ -152,6 +181,10 @@ fn run(init: std.process.Init) !void { std.debug.print("9harness: --fd: not a number: {s}\n", .{v}); return error.Usage; }; + } else if (std.mem.eql(u8, a, "--proc")) { + proc_root = v; + } else if (std.mem.eql(u8, a, "--zmx")) { + zmx_path = v; } else if (std.mem.eql(u8, a, "--name")) { post_name = v; if (!post.legalName(post_name)) { @@ -196,7 +229,31 @@ fn run(init: std.process.Init) !void { return error.Usage; } - harness_mem.init(.{ .io = io, .pid = @intCast(linux.getpid()), .bases = bases }); + // A move execs zmx by absolute path: the daemon resolves it once, + // at startup, so nothing about $PATH matters when the write lands. + const zmx_abs = if (std.mem.indexOfScalar(u8, zmx_path, '/') != null) + zmx_path + else + onPath(io, arena, post.getenv(envp, "PATH") orelse "", zmx_path) catch zmx_path; + if (allow_move and std.mem.indexOfScalar(u8, zmx_abs, '/') == null) { + std.debug.print("9harness: --allow-move needs zmx on $PATH (or --zmx PATH)\n", .{}); + return error.Usage; + } + + harness_mem.init(.{ + .io = io, + .pid = @intCast(linux.getpid()), + .bases = bases, + .proc = proc_root, + .home = home orelse "", + .zmx = zmx_abs, + .runtime = post.getenv(envp, "XDG_RUNTIME_DIR") orelse "", + .allow_move = allow_move, + .envp = envp, + }); + if (allow_move) { + std.debug.print("9harness: moves allowed — a write to /active///zmx re-execs that agent under {s}\n", .{zmx_abs}); + } if (no_post and mode == .posted) { std.debug.print("9harness: --no-post needs a listen form (--unix, --tcp or --fd)\n{s}", .{usage_text}); return error.Usage; -- cgit v1.3