summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-22 10:30:02 -0300
committerGabriel Schneider <[email protected]>2026-09-25 17:52:33 -0300
commitdf20863879fe2d83077534f4726a985ffc239def (patch)
tree3f13dfc786509d1e2c599894a8beea48695e253c
parenteb1a104f385f375a74319695e1a59f6f82e6384e (diff)
downloadcloud9-df20863879fe2d83077534f4726a985ffc239def.tar.gz
cloud9-df20863879fe2d83077534f4726a985ffc239def.zip
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.
-rw-r--r--9agents/build.zig (renamed from 9harness/build.zig)28
-rw-r--r--9agents/docs/DESIGN.md (renamed from 9harness/docs/DESIGN.md)112
-rw-r--r--9agents/src/active.zig (renamed from 9harness/src/active.zig)2
-rw-r--r--9agents/src/main.zig (renamed from 9harness/src/main.zig)65
-rw-r--r--9agents/src/tree.zig (renamed from 9harness/src/tree.zig)292
-rwxr-xr-x9agents/test/e2e.sh (renamed from 9harness/test/e2e.sh)83
-rw-r--r--build.zig16
-rw-r--r--build.zig.zon4
8 files changed, 453 insertions, 149 deletions
diff --git a/9harness/build.zig b/9agents/build.zig
index 4cf84d6..0a63d9b 100644
--- a/9harness/build.zig
+++ b/9agents/build.zig
@@ -1,12 +1,12 @@
-//! Build fragment for 9harness: the harness fs daemon (binary `9harness`),
+//! Build fragment for 9agents: the coding-agents fs daemon (binary `9agents`),
//! a read-only fresh-from-disk 9P2000 view of every harness's state,
-//! posted by default under the name `harness`. It is `@import`ed by the
+//! posted by default under the name `agents`. It is `@import`ed by the
//! root build.zig and called with the root builder, so every `b.path(...)`
-//! here is relative to the cloud9 root (hence the `9harness/` prefix),
+//! here is relative to the cloud9 root (hence the `9agents/` prefix),
//! every option is defined by the root and every step it registers lands
-//! in the root's step list under the `9harness` prefix.
+//! in the root's step list under the `9agents` prefix.
//!
-//! Steps: 9harness, 9harness-test, 9harness-itest.
+//! Steps: 9agents, 9agents-test, 9agents-itest.
const std = @import("std");
pub const Context = struct {
@@ -21,32 +21,32 @@ pub const Context = struct {
pub const Artifacts = struct {
exe: *std.Build.Step.Compile,
- /// `9harness-test`: the tree's unit tests (exclusions, path safety,
+ /// `9agents-test`: the tree's unit tests (exclusions, path safety,
/// node ids, vanishing files — all against a fake HOME).
test_step: *std.Build.Step,
- /// `9harness-itest`: test/e2e.sh (fixture roots + scratch registry,
- /// plus the real-registry end-to-end when the `harness` name is free).
+ /// `9agents-itest`: test/e2e.sh (fixture roots + scratch registry,
+ /// plus the real-registry end-to-end when the `agents` name is free).
itest_step: *std.Build.Step,
};
pub fn add(b: *std.Build, ctx: Context) Artifacts {
const mod = b.createModule(.{
- .root_source_file = b.path("9harness/src/main.zig"),
+ .root_source_file = b.path("9agents/src/main.zig"),
.target = ctx.target,
.optimize = ctx.optimize,
.imports = &.{.{ .name = "cloud9", .module = ctx.cloud9 }},
});
- const exe = b.addExecutable(.{ .name = "9harness", .root_module = mod });
+ const exe = b.addExecutable(.{ .name = "9agents", .root_module = mod });
const install = b.addInstallArtifact(exe, .{});
b.getInstallStep().dependOn(&install.step);
- b.step("9harness", "Build and install only the harness fs daemon").dependOn(&install.step);
+ b.step("9agents", "Build and install only the coding-agents fs daemon").dependOn(&install.step);
- const test_step = b.step("9harness-test", "Run the 9harness tree's unit tests (fake HOME; never the live roots)");
+ const test_step = b.step("9agents-test", "Run the 9agents tree's unit tests (fake HOME; never the live roots)");
test_step.dependOn(&b.addRunArtifact(b.addTest(.{ .root_module = mod })).step);
- const itest_step = b.step("9harness-itest", "Run 9harness/test/e2e.sh (posted name, reads, cmp of a transcript, mntgen mount, exclusions, live appends)");
+ const itest_step = b.step("9agents-itest", "Run 9agents/test/e2e.sh (posted name, reads, cmp of a transcript, mntgen mount, exclusions, live appends)");
const run = b.addSystemCommand(&.{"bash"});
- run.addFileArg(b.path("9harness/test/e2e.sh"));
+ run.addFileArg(b.path("9agents/test/e2e.sh"));
run.addArtifactArg(exe);
if (ctx.ns) |ns| run.addArtifactArg(ns);
itest_step.dependOn(&run.step);
diff --git a/9harness/docs/DESIGN.md b/9agents/docs/DESIGN.md
index 340f3da..8d399d3 100644
--- a/9harness/docs/DESIGN.md
+++ b/9agents/docs/DESIGN.md
@@ -1,6 +1,6 @@
-# 9harness design
+# 9agents design
-9harness is the harness fs daemon: a long-running 9P2000 server that
+9agents is the coding-agents fs daemon: a long-running 9P2000 server that
replaces the `zmxify` rc script's introspection half with a unified,
file-shaped, always-fresh view of every AI-agent harness's state on the
machine — Claude Code (`~/.claude`), Codex (`~/.codex`), omp (`~/.omp`),
@@ -13,13 +13,16 @@ One binary, hosted Linux only (it posts through `cloud9.post`). The
backend is `src/tree.zig`, a `cloud9.fs.Server` backend run by
`cloud9.serve.Runner`; the CLI is `src/main.zig`. Like its siblings
(9proc, 9ns) the build is a fragment in `build.zig` wired into the root
-behind `-D9harness` (steps `9harness`, `9harness-test`, `9harness-itest`;
+behind `-D9agents` (steps `9agents`, `9agents-test`, `9agents-itest`;
installed by the plain `zig build` next to the other programs).
## The tree (v1 contract)
```
/ read-only root (dr-xr-xr-x)
+/README the tree explained in place: what each entry is,
+ the /active fields, and the exclusions. Static text,
+ so it is the one fact needing no buffer.
/pid the daemon's pid, one line
/uptime seconds since start, one line
/claude/
@@ -101,6 +104,72 @@ names with `/` or NUL are refused before the filesystem is touched, `.`
and `..` are refused as components inside the walk, and `..` as a lookup
resolves through the backend's own parent map, never the kernel's.
+## Qids, and what the protocol says they mean
+
+Read out of the Plan 9 source (`~/05-genizah/principia-softwarica`), not
+recalled — the three rules below were all being broken.
+
+`intro(5)`: "The qid represents the server's unique identification for the
+file being accessed: two files on the same server hierarchy are the same if
+and only if their qids are the same. ... The path is an integer unique among
+all files in the hierarchy. If a file is deleted and recreated with the same
+name in the same directory, the old and new path components of the qids
+should be different. The version is a version number for a file; typically,
+it is incremented every time the file is modified."
+
+* **The qid path is the file's identity, not the server's bookkeeping.** The
+ backend's node id is a slot in the path table, and a slot freed by its last
+ release returns with a new generation — the guard that stops a forged node
+ id resolving. Reporting that as the qid path meant `9p stat` of one
+ unchanged file answered a *different* path every time (`...50000104`,
+ `...70000104`, `...90000104`), which by the rule above says "a different
+ file" on every walk. The qid path now comes from the file itself, the way
+ u9fs builds one — `qid.vers = st->st_mtime ^ (st->st_size << 8)` and a path
+ from the inode with the device mixed in (`u9fs.c`) — while the engine goes
+ on resolving by node id. `fs.Attr.path` exists for exactly this split and
+ the engine uses it for nothing else. The device is folded in because an
+ inode number is only unique within its filesystem and the five roots need
+ not share one.
+
+* **The qid version has to move when the file does.** It was 0 everywhere,
+ which tells every client that nothing this server holds ever changes —
+ precisely wrong for a tree whose whole point is transcripts that grow while
+ you read them. It is now `mtime ^ (size << 8)` for a file on disk (u9fs's
+ formula: the shift is what makes an append within one second still change
+ it) and a hash of the text for the fields this server derives rather than
+ reads, where `status` flipping `busy` to `idle` keeps its size and only the
+ content can carry the change.
+
+* **A directory's length is 0 on purpose.** `stat(5)`: "Directories and most
+ files representing devices have a conventional length of 0." That part of
+ the engine's "fidelity gap" note below is the convention, not a shortfall.
+
+## Errors are strings
+
+`error(5)` is `Rerror tag[2] ename[s]`: 9P2000 has no numeric error, and acme
+names its refusals in words (`Eperm[] = "permission denied"`, `Eexist[] =
+"file does not exist"`, `editors/acme/fsys.c`). A server that answers only
+bare errnos throws away the one channel the protocol gives it for explaining
+itself, and a move has seven different reasons to refuse that all used to
+arrive as one undifferentiated `EPERM`: moves not allowed, illegal name, no
+session resolved, no cwd, dsh has no resume form, nothing to resume, the name
+is taken. Each says so now, and so does the over-cap listing, which used to
+claim "Too many open files in system" — a true sentence about the wrong
+resource.
+
+The errno is still carried beside the text, because the reader may be a Linux
+mount rather than a person: 9ns maps the *text* back to an errno by
+case-insensitive substring, first match wins (`enameToErrno`, 9ns/src/nine.zig).
+So each message keeps the phrase that carries its errno — "not permitted" for
+EPERM, "invalid" for EINVAL, "exists" for EEXIST, "no such" for ENOENT — and
+avoids the ones that would silently retarget it: "permission" and "denied" map
+to EACCES, "read-only" to EROFS, and the bare substring "fid" to EBADF. That
+is why the read-only refusal reads "9agents serves a view, not a store:
+writing is not permitted" and not the more obvious wording.
+
+Opens refused by the mode bits never reach the backend at all: the engine
+checks `perm` first and answers `e_perm`, which is already acme's spelling.
+
## Backend shape
`Harness` (tree.zig) is the backend of `fs.Server(Harness, opts)` on
@@ -149,14 +218,14 @@ per-connection buffers; ~3 MB of tables, untouched pages cost nothing).
## CLI and process model
```
-usage: 9harness [--unix PATH | --tcp IP:PORT | --fd N] [--no-post]
+usage: 9agents [--unix PATH | --tcp IP:PORT | --fd N] [--no-post]
[--name NAME] [--root NAME=PATH]...
```
-- Default: post itself under the name `harness` via
+- Default: post itself under the name `agents` via
`serve.Runner.listenPosted`, so it appears at
- `$XDG_RUNTIME_DIR/9p/harness` and every interactive fish (self-wrapped
- in `9ns --mntgen`) sees it at `/mnt/9p/harness` with zero
+ `$XDG_RUNTIME_DIR/9p/agents` and every interactive fish (self-wrapped
+ in `9ns --mntgen`) sees it at `/mnt/9p/agents` with zero
configuration. `stop()` unposts; the stale-socket protocol covers a
killed daemon.
- `--unix`/`--tcp` listen forms (beside the post, or with `--no-post`),
@@ -167,8 +236,13 @@ usage: 9harness [--unix PATH | --tcp IP:PORT | --fd N] [--no-post]
descriptor N (socket activation, `9ns --spawn` handoffs), driving the
engine directly; it posts nothing.
- SIGTERM/SIGINT stop cleanly (unpost); SIGPIPE is ignored.
-- No daemonization in v1: run it in a zmx session, the zmx way —
- `zmx run harness -d 9harness`.
+- No daemonization in v1: the daemon never forks or detaches, so it is
+ run under something that supervises it. On this machine that is a
+ systemd user service (`~/.config/systemd/user/9agents.service`,
+ `Restart=always`, lingering on), which is what keeps `/mnt/9p/agents`
+ there across logouts and reboots; a restart reclaims the posted name
+ through the stale-socket protocol. For a throwaway instance the zmx
+ way still works — `zmx run agents -d 9agents`.
## Integration test plan (`test/e2e.sh`)
@@ -188,11 +262,11 @@ scratch `XDG_RUNTIME_DIR` registry, plan9port's `9p` as the client):
7. SIGTERM unposts the name.
Part B is the money shot, read-only against the real roots and the real
-`$XDG_RUNTIME_DIR/9p` — only when the `harness` name is free (a live
+`$XDG_RUNTIME_DIR/9p` — only when the `agents` name is free (a live
daemon owning the name skips it, not fails): the posted name in the real
registry, `9p` walks of the live `~/.claude`, a real transcript
byte-identical through the tree, the live `~/.claude/.credentials.json`
-unreachable, the `9ns --mntgen` mount of `/mnt/9p/harness` with
+unreachable, the `9ns --mntgen` mount of `/mnt/9p/agents` with
`head` of `/claude/history`, and the stop unposting the real name.
Unit tests (tree.zig) cover the exclusions (including the predicate
@@ -205,14 +279,14 @@ HOME under a test tmp dir, never the real harness roots.
## Verification
-- `zig build` (installs `9harness` beside 9ns, 9proc-demo, 9web).
+- `zig build` (installs `9agents` beside 9ns, 9proc-demo, 9web).
- `zig build test` — the library's 80 tests stay green.
-- `zig build 9harness-test` — the tree's unit tests.
-- `zig build 9harness-itest` — `test/e2e.sh` (fixture + real registry).
+- `zig build 9agents-test` — the tree's unit tests.
+- `zig build 9agents-itest` — `test/e2e.sh` (fixture + real registry).
- `zig build 9proc-check-freestanding` — the daemon is hosted-only but
must not break the root module.
- `zig build programs-test` / `programs-itest` — the umbrella steps,
- which include 9harness's.
+ which include 9agents's.
## /active: the derived view (v2)
@@ -274,7 +348,7 @@ what `/proc` and every harness's own registry agree on.
(`gateway`, `dashboard`, `mcp`, `mcp-server`, `app-server`,
`exec-server`, `serve`, `daemon`, `acp`, `ps`, `render`, `export`,
`__omp_worker_daemon_broker`) is a server or a helper, never a session.
-The daemon's own ancestry is excluded, so 9harness can never list or
+The daemon's own ancestry is excluded, so 9agents can never list or
act on the process tree it lives in.
Liveness is `/proc/<pid>` existing **and** its `stat` field 22
@@ -393,9 +467,9 @@ Named, because they are choices rather than oversights:
There is no replacement script, because the mount is the replacement:
```
-ls /mnt/9p/harness/active/*/* what is running
-cat /mnt/9p/harness/active/claude/345104/session what it is
-echo mine > /mnt/9p/harness/active/claude/345104/zmx
+ls /mnt/9p/agents/active/*/* what is running
+cat /mnt/9p/agents/active/claude/345104/session what it is
+echo mine > /mnt/9p/agents/active/claude/345104/zmx
zmx attach mine
```
diff --git a/9harness/src/active.zig b/9agents/src/active.zig
index fb55cf6..daaddc9 100644
--- a/9harness/src/active.zig
+++ b/9agents/src/active.zig
@@ -324,7 +324,7 @@ pub const Sources = struct {
/// 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
+ /// 9agents 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`.
diff --git a/9harness/src/main.zig b/9agents/src/main.zig
index fe0a07b..2ac9078 100644
--- a/9harness/src/main.zig
+++ b/9agents/src/main.zig
@@ -1,19 +1,21 @@
-//! 9harness: the harness fs daemon — a long-running 9P2000 server that
+//! 9agents: the coding-agents fs daemon — a long-running 9P2000 server that
//! mirrors every AI-agent harness's state (Claude Code, Codex, omp,
//! hermes, dsh + their skills) as one read-only, fresh-from-disk tree.
//! See docs/DESIGN.md for the tree contract and src/tree.zig for the tree.
//!
-//! By default it posts itself under the name `harness` with
-//! `serve.Runner.listenPosted`, so it appears at $XDG_RUNTIME_DIR/9p/harness
+//! By default it posts itself under the name `agents` with
+//! `serve.Runner.listenPosted`, so it appears at $XDG_RUNTIME_DIR/9p/agents
//! and every interactive fish (self-wrapped in `9ns --mntgen`) sees it at
-//! /mnt/9p/harness with zero configuration. `--unix`, `--tcp` and `--fd`
+//! /mnt/9p/agents with zero configuration. `--unix`, `--tcp` and `--fd`
//! are the other listen forms; `--no-post` serves without posting; the
//! five harness roots default to $HOME/.<name> and `--root NAME=PATH`
//! pins any of them elsewhere (the tests use fake homes).
//!
-//! v1 has no daemonization: run it in a zmx session, the zmx way:
+//! v1 has no daemonization: it never forks or detaches, so it is run under
+//! something that supervises it — a systemd user service with
+//! `Restart=always` (see 9agents.service), or a zmx session:
//!
-//! zmx run harness -d 9harness
+//! zmx run agents -d 9agents
//!
//! SIGTERM or SIGINT stops it cleanly (a posted name is unposted).
const std = @import("std");
@@ -29,11 +31,12 @@ const Io = std.Io;
const linux = std.os.linux;
const usage_text =
- \\usage: 9harness [--unix PATH | --tcp IP:PORT | --fd N] [--no-post]
+ \\usage: 9agents [--unix PATH | --tcp IP:PORT | --fd N] [--no-post]
\\ [--name NAME] [--root NAME=PATH]... [--proc DIR]
\\ [--allow-move] [--zmx PATH]
\\
\\A read-only, fresh-from-disk 9P2000 view of every harness's state:
+ \\ /README the tree, explained in place
\\ /pid /uptime daemon facts
\\ /claude/{projects,history,skills}
\\ /codex/{sessions,session-index,history}
@@ -41,9 +44,9 @@ const usage_text =
\\ /skills/{claude,codex,omp} the union skills view
\\ /active/<harness>/<pid>/ 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
- \\at /mnt/9p/harness. --no-post skips posting; --unix/--tcp add plain
+ \\By default the daemon posts itself under the name `agents`, so it is
+ \\dialable at $XDG_RUNTIME_DIR/9p/agents and mountable by 9ns --mntgen
+ \\at /mnt/9p/agents. --no-post skips posting; --unix/--tcp add plain
\\listeners beside the post; --fd N serves one 9P session over the
\\connected stream on descriptor N and posts nothing.
\\--root NAME=PATH pins one harness root (NAME: claude, codex, omp,
@@ -57,7 +60,7 @@ const usage_text =
\\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`.
+ \\Run it in a zmx session, the zmx way: `zmx run agents -d 9agents`.
\\
;
@@ -144,7 +147,7 @@ fn run(init: std.process.Init) !void {
var unix_path: []const u8 = "";
var tcp_addr: []const u8 = "";
var fd_no: i32 = -1;
- var post_name: []const u8 = "harness";
+ var post_name: []const u8 = "agents";
var no_post = false;
var base_overrides: [5]?[]const u8 = @splat(null);
var proc_root: []const u8 = "/proc";
@@ -165,7 +168,7 @@ fn run(init: std.process.Init) !void {
{
i += 1;
if (i >= args.len) {
- std.debug.print("9harness: {s} needs an argument\n{s}", .{ a, usage_text });
+ std.debug.print("9agents: {s} needs an argument\n{s}", .{ a, usage_text });
return error.Usage;
}
const v = args[i];
@@ -178,7 +181,7 @@ fn run(init: std.process.Init) !void {
} else if (std.mem.eql(u8, a, "--fd")) {
mode = .fd;
fd_no = std.fmt.parseInt(i32, v, 10) catch {
- std.debug.print("9harness: --fd: not a number: {s}\n", .{v});
+ std.debug.print("9agents: --fd: not a number: {s}\n", .{v});
return error.Usage;
};
} else if (std.mem.eql(u8, a, "--proc")) {
@@ -188,17 +191,17 @@ fn run(init: std.process.Init) !void {
} else if (std.mem.eql(u8, a, "--name")) {
post_name = v;
if (!post.legalName(post_name)) {
- std.debug.print("9harness: illegal post name: {s}\n", .{v});
+ std.debug.print("9agents: illegal post name: {s}\n", .{v});
return error.Usage;
}
} else {
const eq = std.mem.indexOfScalar(u8, v, '=') orelse {
- std.debug.print("9harness: --root NAME=PATH: {s}\n{s}", .{ v, usage_text });
+ std.debug.print("9agents: --root NAME=PATH: {s}\n{s}", .{ v, usage_text });
return error.Usage;
};
const name = v[0..eq];
const root = std.meta.stringToEnum(tree.Root, name) orelse {
- std.debug.print("9harness: unknown root {s} (claude, codex, omp, hermes, dsh)\n", .{name});
+ std.debug.print("9agents: unknown root {s} (claude, codex, omp, hermes, dsh)\n", .{name});
return error.Usage;
};
base_overrides[@intFromEnum(root)] = v[eq + 1 ..];
@@ -207,7 +210,7 @@ fn run(init: std.process.Init) !void {
std.debug.print("{s}", .{usage_text});
return;
} else {
- std.debug.print("9harness: unknown argument {s}\n{s}", .{ a, usage_text });
+ std.debug.print("9agents: unknown argument {s}\n{s}", .{ a, usage_text });
return error.Usage;
}
}
@@ -225,7 +228,7 @@ fn run(init: std.process.Init) !void {
var any = false;
for (bases) |b| any = any or b.len != 0;
if (!any) {
- std.debug.print("9harness: no harness roots (set $HOME or pass --root NAME=PATH)\n", .{});
+ std.debug.print("9agents: no harness roots (set $HOME or pass --root NAME=PATH)\n", .{});
return error.Usage;
}
@@ -236,7 +239,7 @@ fn run(init: std.process.Init) !void {
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", .{});
+ std.debug.print("9agents: --allow-move needs zmx on $PATH (or --zmx PATH)\n", .{});
return error.Usage;
}
@@ -252,10 +255,10 @@ fn run(init: std.process.Init) !void {
.envp = envp,
});
if (allow_move) {
- std.debug.print("9harness: moves allowed — a write to /active/<h>/<pid>/zmx re-execs that agent under {s}\n", .{zmx_abs});
+ std.debug.print("9agents: moves allowed — a write to /active/<h>/<pid>/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});
+ std.debug.print("9agents: --no-post needs a listen form (--unix, --tcp or --fd)\n{s}", .{usage_text});
return error.Usage;
}
@@ -263,7 +266,7 @@ fn run(init: std.process.Init) !void {
switch (mode) {
.fd => {
- std.debug.print("9harness: serving one session on fd {d}\n", .{fd_no});
+ std.debug.print("9agents: serving one session on fd {d}\n", .{fd_no});
try serveFd(io, fd_no);
return;
},
@@ -277,29 +280,29 @@ fn run(init: std.process.Init) !void {
if (!no_post) {
runner_mem.listenPosted(envp, post_name, 16) catch |err| {
if (err == error.AlreadyPosted) {
- std.debug.print("9harness: the name `{s}` is already posted by a live server\n", .{post_name});
+ std.debug.print("9agents: the name `{s}` is already posted by a live server\n", .{post_name});
return err;
}
- std.debug.print("9harness: cannot post as {s}: {t}\n", .{ post_name, err });
+ std.debug.print("9agents: cannot post as {s}: {t}\n", .{ post_name, err });
return err;
};
posted_something = true;
var pbuf: [transport.sun_path_len]u8 = undefined;
const path = post.registryPath(envp, post_name, &pbuf) catch "";
- std.debug.print("9harness: posted as {s} at {s}\n", .{ post_name, path });
+ std.debug.print("9agents: posted as {s} at {s}\n", .{ post_name, path });
}
switch (mode) {
.unix => {
_ = try runner_mem.listen(.{ .unix = try arena.dupeZ(u8, unix_path) }, 16);
- std.debug.print("9harness: listening on {s}\n", .{unix_path});
+ std.debug.print("9agents: listening on {s}\n", .{unix_path});
},
.tcp => {
const addr = Io.net.IpAddress.parseLiteral(tcp_addr) catch {
- std.debug.print("9harness: bad --tcp address: {s}\n", .{tcp_addr});
+ std.debug.print("9agents: bad --tcp address: {s}\n", .{tcp_addr});
return error.Usage;
};
const bound = try runner_mem.listen(.{ .tcp = addr }, 16);
- std.debug.print("9harness: listening on tcp!{f}\n", .{bound});
+ std.debug.print("9agents: listening on tcp!{f}\n", .{bound});
},
else => {},
}
@@ -307,14 +310,14 @@ fn run(init: std.process.Init) !void {
for (bases) |b| {
if (b.len != 0) pinned += 1;
}
- std.debug.print("9harness: serving ({d} roots pinned, {d} bytes of tables)\n", .{ pinned, @sizeOf(tree.Harness) });
+ std.debug.print("9agents: serving ({d} roots pinned, {d} bytes of tables)\n", .{ pinned, @sizeOf(tree.Harness) });
// The runner's tasks drive themselves; the main thread only waits for
// a stop signal (SIGTERM/SIGINT) and then unposts via stop().
while (!stop_requested.load(.acquire)) {
io.sleep(.fromMilliseconds(250), .awake) catch break;
}
- std.debug.print("9harness: stopping\n", .{});
+ std.debug.print("9agents: stopping\n", .{});
}
const root_dirs = [5][]const u8{ "claude", "codex", "omp", "hermes", "dsh" };
diff --git a/9harness/src/tree.zig b/9agents/src/tree.zig
index b6ff4a4..ed0699c 100644
--- a/9harness/src/tree.zig
+++ b/9agents/src/tree.zig
@@ -1,6 +1,7 @@
//! The harness file tree served over 9P2000: a unified, read-only,
//! fresh-from-disk view of every AI-agent harness's state on the machine.
//!
+//! /README what the tree is and how to read it
//! /pid /uptime daemon facts, one line each
//! /claude/ projects/ (mirror of ~/.claude/projects),
//! history (~/.claude/history.jsonl),
@@ -86,18 +87,21 @@ pub const Top = enum(u8) {
dsh,
skills,
active,
+ readme,
/// Directory entries of the root, in listing order.
- pub const listed = [_]Top{ .pid, .uptime, .claude, .codex, .omp, .hermes, .dsh, .skills, .active };
+ pub const listed = [_]Top{ .readme, .pid, .uptime, .claude, .codex, .omp, .hermes, .dsh, .skills, .active };
pub fn fileName(t: Top) []const u8 {
- return @tagName(t);
+ // The only name that is not its tag: a README announces itself in
+ // the listing, and lowercase would bury it among the harness dirs.
+ return if (t == .readme) "README" else @tagName(t);
}
pub fn dir(t: Top) bool {
return switch (t) {
.root, .claude, .codex, .omp, .hermes, .dsh, .skills, .active => true,
- .pid, .uptime => false,
+ .pid, .uptime, .readme => false,
};
}
@@ -359,6 +363,25 @@ fn fail(tag: u64, e: u16) Answer {
return .{ .reply = fs.Reply.fail(tag, e) };
}
+/// A refusal that says why. 9P2000 has no numeric error — error(5) is
+/// `Rerror tag[2] ename[s]`, a string — and acme names its refusals in words
+/// ("permission denied", "file does not exist", fsys.c). A server that only
+/// answers bare errnos throws away the one channel the protocol gives it for
+/// explaining itself, which matters most here: a move has seven different
+/// reasons to say no, and EPERM tells the user none of them.
+///
+/// `errno` is still carried, because the reader may be a Linux mount rather
+/// than a person: 9ns maps the *text* back to an errno by case-insensitive
+/// substring (enameToErrno in 9ns/src/nine.zig, first match wins). So each
+/// message below is worded to keep the phrase that carries its errno —
+/// "not permitted" for EPERM, "invalid" for EINVAL, "exists" for EEXIST,
+/// "no such" for ENOENT — and to avoid phrases that would silently retarget
+/// it ("permission" and "denied" map to EACCES, "read-only" to EROFS, and
+/// the bare substring "fid" to EBADF).
+fn failWhy(tag: u64, e: u16, ename: []const u8) Answer {
+ return .{ .reply = .{ .tag = tag, .status = .err, .errno = e, .ename = ename } };
+}
+
/// The daemon's state. One instance serves every connection; `handle` is
/// called with `mutex` held (the serve handler in main.zig holds it across
/// handle and reply, because `bytes` point into the shared buffers).
@@ -606,24 +629,99 @@ fn fdOf(rc: usize) ?i32 {
/// is never served: it is the one escape hatch around the pinned roots.
/// `O_PATH` opens the name itself, so a fifo or device never blocks and
/// never has its open side effects run.
-fn statIn(h: *Harness, r: Root, rel_path: []const u8) ?Io.Dir.Stat {
+/// A stat plus the file's identity on disk.
+const Stat = struct {
+ st: Io.Dir.Stat,
+ /// The qid path to report, or 0 when the kernel would not say.
+ ///
+ /// intro(5) is strict about what a qid path means: "The path is an
+ /// integer unique among all files in the hierarchy. If a file is deleted
+ /// and recreated with the same name in the same directory, the old and
+ /// new path components of the qids should be different" — and two files
+ /// are the same file if and only if their qids match. So the path has to
+ /// be a property of the *file*, stable for as long as it exists.
+ ///
+ /// The backend's own node id cannot supply that: it is a slot in the path
+ /// table, and a slot freed by its last release comes back with a new
+ /// generation (the guard that stops a forged node id resolving). A client
+ /// that walks, stats and clunks — `9p stat` does exactly this — saw a
+ /// different qid path for the same unchanged file every single time, and
+ /// by the protocol's own rule that means a different file.
+ ///
+ /// So this is u9fs's answer instead, from the same fstat the attr already
+ /// needs:
+ ///
+ /// memmove(p, &st->st_ino, ...); *(dev_t*)q ^= st->st_dev; -- u9fs.c
+ ///
+ /// The device is mixed in because an inode number is only unique within
+ /// its filesystem, and the five pinned roots need not share one. The
+ /// engine keeps resolving requests by node id, which still recycles; only
+ /// the qid the client sees is stabilised (fs.Attr.path exists for exactly
+ /// this split, and the engine uses it for nothing else).
+ qid_path: u64,
+};
+
+fn statIn(h: *Harness, r: Root, rel_path: []const u8) ?Stat {
const fd = openIn(h, r, rel_path, .{ .PATH = true, .CLOEXEC = true }) orelse return null;
const f: Io.File = .{ .handle = fd, .flags = .{ .nonblocking = false } };
defer f.close(h.io);
const st = f.stat(h.io) catch return null;
if (st.kind == .sym_link) return null; // never served: an escape hatch
- return st;
+ return .{ .st = st, .qid_path = identityOf(fd) };
+}
+
+/// (dev, ino) of an open descriptor, folded into one integer. 0 when statx
+/// declines to answer, which the caller reads as "fall back to the node id".
+fn identityOf(fd: i32) u64 {
+ var sx: linux.Statx = undefined;
+ const want: linux.STATX = .{ .INO = true };
+ const rc = linux.statx(fd, "", linux.AT.EMPTY_PATH, want, &sx);
+ if (linux.errno(rc) != .SUCCESS or !sx.mask.INO) return 0;
+ const dev = (@as(u64, sx.dev_major) << 32) | sx.dev_minor;
+ // The inode carries the entropy, so it stays unshifted and the device is
+ // folded over its top bits: two roots on one filesystem keep distinct
+ // inodes, and the same inode on two filesystems stops colliding.
+ return sx.ino ^ (dev *% 0x9E37_79B9_7F4A_7C15);
+}
+
+/// The qid version of a file on disk, the way u9fs derives it:
+///
+/// qid.vers = st->st_mtime ^ (st->st_size << 8); -- u9fs.c
+///
+/// intro(5) makes the qid the server's identity for a file — "two files on
+/// the same server hierarchy are the same if and only if their qids are the
+/// same" — and the version the part that "is incremented every time the file
+/// is modified". A server that leaves it 0 is telling every client that
+/// nothing it serves ever changes, which is exactly wrong for this tree: its
+/// whole point is transcripts that grow while you read them. The size shift
+/// is what makes an append within the same second still change the version,
+/// and a rewrite that keeps the size change it through the mtime.
+fn qidVers(mtime_sec: i64, size: u64) u32 {
+ return @truncate(@as(u64, @bitCast(mtime_sec)) ^ (size << 8));
+}
+
+/// The qid version of text this server derives rather than reads: there is no
+/// mtime behind it, so the content itself is the only honest version.
+fn textVers(text: []const u8) u32 {
+ return @truncate(std.hash.Wyhash.hash(0, text));
}
fn statAttr(h: *Harness, e: *Entry) ?fs.Attr {
- const st = statIn(h, e.root, e.rel()) orelse return null;
+ const got = statIn(h, e.root, e.rel()) orelse return null;
+ const st = got.st;
+ const mtime = st.mtime.toSeconds();
+ const size: u64 = if (st.kind == .directory) 0 else st.size;
return .{
.name = basename(e.rel()),
.node = h.entryNode(e),
.dir = st.kind == .directory,
- .size = if (st.kind == .directory) 0 else st.size,
+ // stat(5): "Directories and most files representing devices have a
+ // conventional length of 0."
+ .size = size,
.mode = if (st.kind == .directory) 0o555 else 0o444,
- .mtime = @truncate(@as(u64, @bitCast(st.mtime.toSeconds()))),
+ .mtime = @truncate(@as(u64, @bitCast(mtime))),
+ .version = qidVers(mtime, size),
+ .path = if (got.qid_path != 0) got.qid_path else null,
};
}
@@ -632,8 +730,44 @@ fn basename(rel_path: []const u8) []const u8 {
return rel_path;
}
+/// Served as `/README`: what the tree is and how to read it. Static, so it
+/// is the one "fact" that needs no buffer — `factText` returns it directly.
+const readme_text =
+ \\9agents - every coding agent's state on this machine, as files.
+ \\Read-only: writes answer EPERM. Nothing is cached, so a transcript
+ \\grows while you cat it and a new session appears as soon as its
+ \\file lands.
+ \\
+ \\ README this file
+ \\ pid uptime this daemon's pid; seconds since it started
+ \\ active/ what is running right now
+ \\ claude/ projects/ history skills/
+ \\ codex/ sessions/ session-index history
+ \\ omp/ hermes/ dsh/ each harness's own directory, raw
+ \\ skills/ claude/ codex/ omp/, in one place
+ \\
+ \\active/<harness>/<pid>/ holds pid ppid started cwd name title
+ \\session via status transcript zmx.
+ \\ session its session id, empty when it did not resolve
+ \\ via how that id was found: registry, fd, dir or none
+ \\ status only harnesses that publish one have it
+ \\ transcript the live .jsonl; its mtime is the last activity
+ \\ zmx the zmx session it runs in, empty outside zmx
+ \\
+ \\ ls active/*/* what is running
+ \\ cat active/claude/345104/session what it is
+ \\ tail -f active/claude/345104/transcript
+ \\
+ \\Credentials are excluded by name and never listed, at any depth;
+ \\symlinks are never served. Writing a name into an agent's zmx file
+ \\moves it into that zmx session, and only when the daemon was
+ \\started with --allow-move; otherwise that write answers EPERM too.
+ \\
+;
+
/// A fact's text, rendered on demand. `buf` should be a small stack buffer.
fn factText(h: *Harness, fact: Top, buf: []u8) []const u8 {
+ if (fact == .readme) return readme_text; // static: outlives any buffer
var w = Io.Writer.fixed(buf);
switch (fact) {
.pid => w.print("{d}\n", .{h.pid}) catch {},
@@ -655,7 +789,7 @@ fn attrFor(h: *Harness, t: Target) ?fs.Attr {
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 .{
+ .pid, .uptime, .readme => return .{
.name = top.fileName(),
.node = topNode(top),
.size = factText(h, top, &fact_buf).len,
@@ -670,9 +804,15 @@ fn attrFor(h: *Harness, t: Target) ?fs.Attr {
.omp, .hermes, .dsh => {
// The harness dir IS its mirror: stat the target.
const m = top.mirror().?;
- const st = statIn(h, m.root, m.rel) orelse return null;
- if (st.kind != .directory) return null;
- return .{ .name = top.fileName(), .node = topNode(top), .dir = true, .mode = 0o555 };
+ const got = statIn(h, m.root, m.rel) orelse return null;
+ if (got.st.kind != .directory) return null;
+ return .{
+ .name = top.fileName(),
+ .node = topNode(top),
+ .dir = true,
+ .mode = 0o555,
+ .path = if (got.qid_path != 0) got.qid_path else null,
+ };
},
}
},
@@ -819,19 +959,29 @@ fn activeAttr(h: *Harness, a: Act) ?fs.Attr {
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;
+ const mtime = st.mtime.toSeconds();
return .{
.name = a.file.fileName(),
.node = activeNode(a),
.size = st.size,
.mode = 0o444,
- .mtime = @truncate(@as(u64, @bitCast(st.mtime.toSeconds()))),
+ .mtime = @truncate(@as(u64, @bitCast(mtime))),
+ .version = qidVers(mtime, st.size),
};
},
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 };
+ return .{
+ .name = a.file.fileName(),
+ .node = activeNode(a),
+ .size = text.len,
+ .mode = mode,
+ // Derived, not read: `status` flipping busy->idle keeps its
+ // size, so only the text can carry the change.
+ .version = textVers(text),
+ };
},
}
}
@@ -1085,22 +1235,22 @@ fn spawnZmx(h: *Harness, l: *const active.Live, name: []const u8, resume_value:
/// 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);
+ if (!h.allow_move) return failWhy(req.tag, E.PERM, "moves are not permitted: start 9agents with --allow-move");
const name = std.mem.trim(u8, req.data, " \t\r\n");
- if (!active.legalZmxName(name)) return fail(req.tag, E.INVAL);
+ if (!active.legalZmxName(name)) return failWhy(req.tag, E.INVAL, "invalid zmx name: letters, digits, dot, dash and underscore only");
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);
+ if (l.via == .none or l.session.len == 0) return failWhy(req.tag, E.PERM, "no session resolved: not permitted, there would be nothing to resume");
+ if (l.cwd.len == 0) return failWhy(req.tag, E.PERM, "no working directory known for this agent: not permitted");
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),
+ .dsh => return failWhy(req.tag, E.PERM, "dsh has no resume form: moving it is not permitted"),
};
- if (resume_value.len == 0) return fail(req.tag, E.PERM);
+ if (resume_value.len == 0) return failWhy(req.tag, E.PERM, "nothing to resume from: not permitted");
// Already there: setting a value it already has changes nothing.
var have: [active.text_capacity]u8 = undefined;
@@ -1109,14 +1259,14 @@ fn moveToZmx(h: *Harness, req: fs.Req, a: Act) Answer {
return .{ .reply = .{ .tag = req.tag, .written = @intCast(req.data.len) } };
}
}
- if (zmxPosted(h, name)) return fail(req.tag, E.EXIST);
+ if (zmxPosted(h, name)) return failWhy(req.tag, E.EXIST, "a live zmx session of that name already exists");
// The slot could have gone stale between the scan and this write.
- if (!active.stillAlive(sourcesOf(h), l)) return fail(req.tag, E.NOENT);
+ if (!active.stillAlive(sourcesOf(h), l)) return failWhy(req.tag, E.NOENT, "the agent exited before the move began: no such process");
// 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);
+ if (!killAndWait(h, snapshot.pid)) return failWhy(req.tag, E.IO, "the agent did not exit when signalled; nothing was started");
+ if (!spawnZmx(h, &snapshot, name, resume_value)) return failWhy(req.tag, E.IO, "could not start zmx; the agent has already been stopped");
for (0..post_ticks) |_| {
if (zmxPosted(h, name)) {
rescan(h);
@@ -1125,7 +1275,7 @@ fn moveToZmx(h: *Harness, req: fs.Req, a: Act) Answer {
napOneTick(h);
}
rescan(h);
- return fail(req.tag, E.IO);
+ return failWhy(req.tag, E.IO, "zmx did not post the session in time; the agent may still return");
}
// ---- dispatch ------------------------------------------------------------------
@@ -1157,7 +1307,7 @@ fn setattrReq(h: *Harness, req: fs.Req, t: Target) Answer {
},
else => {},
}
- return fail(req.tag, E.PERM);
+ return failWhy(req.tag, E.PERM, "9agents serves a view, not a store: writing is not permitted");
}
/// The tree answers EPERM to every write but one: a zmx session name
@@ -1167,7 +1317,7 @@ fn writeReq(h: *Harness, req: fs.Req, t: Target) Answer {
.act => |a| if (a.file == .zmx) return moveToZmx(h, req, a),
else => {},
}
- return fail(req.tag, E.PERM);
+ return failWhy(req.tag, E.PERM, "9agents serves a view, not a store: writing is not permitted");
}
fn attrReply(h: *Harness, tag: u64, t: Target) Answer {
@@ -1236,7 +1386,7 @@ fn lookup(h: *Harness, req: fs.Req, t: Target) Answer {
}
return activeReply(h, req, .{ .file = .harness_dir, .kind = k }, name);
},
- .pid, .uptime => return fail(req.tag, E.NOTDIR),
+ .pid, .uptime, .readme => return fail(req.tag, E.NOTDIR),
},
.path => |e| {
const rel_path = e.rel();
@@ -1331,8 +1481,8 @@ fn open(h: *Harness, req: fs.Req, t: Target) Answer {
};
const rw = req.omode & 3;
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);
+ if (rw == cloud9.owrite or rw == cloud9.ordwr) return failWhy(req.tag, E.PERM, "9agents serves a view, not a store: writing is not permitted");
+ if (req.omode & cloud9.otrunc != 0) return failWhy(req.tag, E.PERM, "truncation is not permitted here");
}
return .{ .reply = .{ .tag = req.tag, .handle = 1 } };
}
@@ -1351,7 +1501,7 @@ fn release(h: *Harness, req: fs.Req, t: Target) Answer {
fn read(h: *Harness, req: fs.Req, t: Target) Answer {
switch (t) {
.top => |top| switch (top) {
- .pid, .uptime => {
+ .pid, .uptime, .readme => {
const text = factText(h, top, &fact_buf);
return window(req, text);
},
@@ -1435,7 +1585,7 @@ fn readdir(h: *Harness, req: fs.Req, t: Target) Answer {
st.add(h.entryNode(e), a.dir, m.name);
}
},
- .pid, .uptime => return fail(req.tag, E.NOTDIR),
+ .pid, .uptime, .readme => return fail(req.tag, E.NOTDIR),
.active => {
rescan(h);
for (std.enums.values(active.Kind)) |k| {
@@ -1450,7 +1600,7 @@ fn readdir(h: *Harness, req: fs.Req, t: Target) Answer {
.ok => {},
.gone => return fail(req.tag, E.NOENT),
.failed => return fail(req.tag, E.IO),
- .overflow => return fail(req.tag, E.NFILE),
+ .overflow => return failWhy(req.tag, E.NFILE, "directory has more entries than this server can list at once"),
}
},
},
@@ -1462,7 +1612,7 @@ fn readdir(h: *Harness, req: fs.Req, t: Target) Answer {
.ok => {},
.gone => return fail(req.tag, E.NOENT),
.failed => return fail(req.tag, E.IO),
- .overflow => return fail(req.tag, E.NFILE),
+ .overflow => return failWhy(req.tag, E.NFILE, "directory has more entries than this server can list at once"),
}
},
.act => |a| return activeReaddir(h, req, a),
@@ -1499,7 +1649,7 @@ fn listDir(h: *Harness, st: *Staging, r: Root, dir_rel: []const u8) Listing {
var child_buf: [rel_capacity + 256]u8 = undefined;
const child_rel = joinRel(&child_buf, dir_rel, entry.name) orelse continue;
const cst = statIn(h, r, child_rel) orelse continue;
- kind = cst.kind;
+ kind = cst.st.kind;
}
if (kind == .sym_link) continue; // never served
// A cap reached is an error, never a short listing: a directory
@@ -1725,11 +1875,34 @@ test "tree: facts at the root" {
// The root lists the facts and the five harness dirs.
const listing = handle(rig.h, .{ .tag = 6, .op = .readdir, .node = root, .off = 0, .size = msize });
try testing.expect(listing.reply.status == .ok);
- for ([_][]const u8{ "pid", "uptime", "claude", "codex", "omp", "hermes", "dsh", "skills" }) |name| {
+ for ([_][]const u8{ "README", "pid", "uptime", "claude", "codex", "omp", "hermes", "dsh", "skills" }) |name| {
try testing.expect(stageHas(listing.bytes, name));
}
}
+test "tree: README is served at the root, whole and read-only" {
+ var rig: Rig = .{ .dir = undefined };
+ try rig.start();
+ defer rig.end();
+
+ const rd = rig.lookupName(1, root, "README");
+ try testing.expect(rd.reply.status == .ok);
+ try testing.expect(!rd.reply.attr.dir);
+ try testing.expectEqual(readme_text.len, rd.reply.attr.size);
+
+ // Read it back whole and compare: a truncated or empty README would
+ // otherwise pass every check above.
+ const got = handle(rig.h, .{ .tag = 2, .op = .read, .node = rd.reply.attr.node, .off = 0, .size = msize });
+ try testing.expect(got.reply.status == .ok);
+ try testing.expectEqualStrings(readme_text, got.bytes);
+
+ // It is a file, and it is read-only like everything else here.
+ const as_dir = handle(rig.h, .{ .tag = 3, .op = .readdir, .node = rd.reply.attr.node, .off = 0, .size = msize });
+ try testing.expectEqual(E.NOTDIR, as_dir.reply.errno);
+ const wr = handle(rig.h, .{ .tag = 4, .op = .write, .node = rd.reply.attr.node, .off = 0, .size = 1 });
+ try testing.expectEqual(E.PERM, wr.reply.errno);
+}
+
fn stageHas(staged: []const u8, name: []const u8) bool {
var i: usize = 0;
while (i + 10 <= staged.len) {
@@ -1852,6 +2025,49 @@ test "tree: paths compose only from the pinned roots" {
try testing.expect(rig.lookupName(8, topNode(.pid), "x").reply.errno == E.NOTDIR);
}
+test "tree: a file's qid path survives release, and its version tracks content" {
+ var rig: Rig = .{ .dir = undefined };
+ try rig.start();
+ defer rig.end();
+ try rig.put(".claude/projects/p1/session-x.jsonl", "{}\n");
+
+ const projects = rig.lookupName(1, topNode(.claude), "projects");
+ const dir = rig.lookupName(2, projects.reply.attr.node, "p1");
+ const first = rig.lookupName(3, dir.reply.attr.node, "session-x.jsonl");
+ try testing.expect(first.reply.status == .ok);
+ const qid_path = first.reply.attr.path.?;
+ const version = first.reply.attr.version;
+
+ // Give every reference back, so the table slot is free to recycle.
+ for ([_]u64{ first.reply.attr.node, dir.reply.attr.node, projects.reply.attr.node }) |n| {
+ _ = handle(rig.h, .{ .tag = 4, .op = .release, .node = n });
+ }
+
+ // Walk to it again from scratch. intro(5): two files are the same file if
+ // and only if their qids are the same — so the same unchanged file must
+ // come back with the same qid path, whatever the node id does. Before the
+ // qid path came from the file itself, the recycled slot's new generation
+ // made this a different file on every walk.
+ const p2 = rig.lookupName(5, topNode(.claude), "projects");
+ const d2 = rig.lookupName(6, p2.reply.attr.node, "p1");
+ const again = rig.lookupName(7, d2.reply.attr.node, "session-x.jsonl");
+ try testing.expect(again.reply.status == .ok);
+ try testing.expectEqual(qid_path, again.reply.attr.path.?);
+ try testing.expectEqual(version, again.reply.attr.version);
+
+ // Two different files never share a qid path.
+ try rig.put(".claude/projects/p1/session-y.jsonl", "{}\n");
+ const other = rig.lookupName(8, d2.reply.attr.node, "session-y.jsonl");
+ try testing.expect(other.reply.attr.path.? != qid_path);
+
+ // And a change to the content moves the version, which is what tells a
+ // caching client the transcript it is holding has grown.
+ try rig.put(".claude/projects/p1/session-x.jsonl", "{}\n{\"more\":1}\n");
+ const grown = rig.lookupName(9, d2.reply.attr.node, "session-x.jsonl");
+ try testing.expectEqual(qid_path, grown.reply.attr.path.?);
+ try testing.expect(grown.reply.attr.version != version);
+}
+
test "tree: node ids — same file equal, distinct files differ, ids recycle" {
var rig: Rig = .{ .dir = undefined };
try rig.start();
@@ -1950,7 +2166,7 @@ comptime {
// Every static mount target must survive the exclusion rules; the
// daemon's own skeleton may never be filtered out from under it.
for (named_mounts) |m| {
- if (excludedPath(m.mount.rel)) @compileError("a 9harness mount target is excluded by name");
+ if (excludedPath(m.mount.rel)) @compileError("a 9agents mount target is excluded by name");
}
}
@@ -2185,7 +2401,7 @@ test "active: the daemon never lists the process tree it lives in" {
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 = 1041, .comm = "9agents", .ppid = 1040, .argv = "9agents\x00" });
try rig.fakeProc(.{ .pid = 1042, .comm = "claude", .argv = "claude\x00" });
const act = rig.lookupName(14, root, "active");
diff --git a/9harness/test/e2e.sh b/9agents/test/e2e.sh
index 254d999..fa41bef 100755
--- a/9harness/test/e2e.sh
+++ b/9agents/test/e2e.sh
@@ -1,8 +1,8 @@
#!/usr/bin/env bash
-# End-to-end suite for 9harness: the read-only, fresh-from-disk 9P view of
+# End-to-end suite for 9agents: the read-only, fresh-from-disk 9P view of
# every harness's state.
#
-# Usage: bash 9harness/test/e2e.sh <9harness> [<9ns>] (zig build 9harness-itest)
+# Usage: bash 9agents/test/e2e.sh <9agents> [<9ns>] (zig build 9agents-itest)
#
# Part A always runs, entirely on fixtures: a fake home with fixture roots
# pinned by --root, a scratch XDG_RUNTIME_DIR registry, plan9port's 9p as
@@ -12,20 +12,20 @@
# visible at once, writes answer EPERM, and --unix/--fd listen forms work.
#
# Part B (the money shot) runs against the REAL roots and the REAL
-# registry only when the `harness` name is free, and only reads: the posted
+# registry only when the `agents` name is free, and only reads: the posted
# name in /run/user/<uid>/9p, 9p walks of the live ~/.claude, a real
# transcript byte-identical through the tree, the 9ns --mntgen mount of
-# /mnt/9p/harness, and the live ~/.claude/.credentials.json unreachable.
+# /mnt/9p/agents, and the live ~/.claude/.credentials.json unreachable.
# It is skipped (not failed) when a live daemon already owns the name.
#
# Exit 0 on success (or when the machine cannot run a part), 1 on failure.
set -u
-H9=$(realpath "${1:?path to 9harness}")
+H9=$(realpath "${1:?path to 9agents}")
HARNESS_TMP_XDG="${XDG_RUNTIME_DIR:-}"
[ $# -ge 2 ] && [ -n "$2" ] && NS=$(realpath "$2")
P9P=/usr/lib/plan9/bin/9p
-TMP=$(mktemp -d "${TMPDIR:-/tmp}/9harness.XXXXXX")
+TMP=$(mktemp -d "${TMPDIR:-/tmp}/9agents.XXXXXX")
PIDS=()
FAILED=0
PASSED=0
@@ -33,6 +33,10 @@ PASSED=0
cleanup() {
for p in "${PIDS[@]:-}"; do [ -n "$p" ] && kill "$p" 2>/dev/null; done
rm -rf "$TMP"
+ # part B posts into the *real* registry under a scratch name; a crash
+ # between the post and the unpost would otherwise leave a socket there.
+ [ -n "${REAL_SOCKET:-}" ] && rm -f "$REAL_SOCKET"
+ return 0
}
trap cleanup EXIT
@@ -106,51 +110,51 @@ mkfile "$FX/dsh/.credentials.yaml" 'STAY OUT'
start_daemon --root "claude=$FX/claude" --root "codex=$FX/codex" \
--root "omp=$FX/omp" --root "hermes=$FX/hermes" --root "dsh=$FX/dsh" \
- --name harness
-wait_posted "$REG/harness" || { fail "the daemon posts as harness" "$(cat "$TMP/daemon.log")"; exit 1; }
+ --name agents
+wait_posted "$REG/agents" || { fail "the daemon posts as agents" "$(cat "$TMP/daemon.log")"; exit 1; }
# 1. The posted name is listed.
-expect_contains "posted name listed in the registry" harness "$(ls "$REG")"
+expect_contains "posted name listed in the registry" agents "$(ls "$REG")"
# 2. The roots walk; a real transcript reads back byte-identical.
-expect_contains "ls / shows the roots" claude "$(p9 "$REG/harness" ls /)"
-expect_contains "ls / shows codex" codex "$(p9 "$REG/harness" ls /)"
-expect_contains "ls / shows skills" skills "$(p9 "$REG/harness" ls /)"
-p9 "$REG/harness" read /claude/projects/-tmp-proj/session-abc.jsonl > "$TMP/via-9p.jsonl" 2>"$TMP/read.err" \
+expect_contains "ls / shows the roots" claude "$(p9 "$REG/agents" ls /)"
+expect_contains "ls / shows codex" codex "$(p9 "$REG/agents" ls /)"
+expect_contains "ls / shows skills" skills "$(p9 "$REG/agents" ls /)"
+p9 "$REG/agents" read /claude/projects/-tmp-proj/session-abc.jsonl > "$TMP/via-9p.jsonl" 2>"$TMP/read.err" \
|| fail "transcript read through the tree" "$(cat "$TMP/read.err")"
cmp -s "$TMP/via-9p.jsonl" "$FX/claude/projects/-tmp-proj/session-abc.jsonl" \
&& pass "transcript byte-identical through the tree (cmp)" \
|| fail "transcript byte-identical through the tree (cmp)" "differs"
-expect_eq "history file reads" "$(cat "$FX/claude/history.jsonl")" "$(p9 "$REG/harness" read /claude/history)"
+expect_eq "history file reads" "$(cat "$FX/claude/history.jsonl")" "$(p9 "$REG/agents" read /claude/history)"
expect_eq "skills union mirrors the harness tree" "$(cat "$FX/claude/skills/revu/SKILL.md")" \
- "$(p9 "$REG/harness" read /skills/claude/revu/SKILL.md)"
+ "$(p9 "$REG/agents" read /skills/claude/revu/SKILL.md)"
# 3. A credentials-shaped file is unreachable anywhere in the tree.
-PROJ_LS=$(p9 "$REG/harness" ls /claude/projects/-tmp-proj)
+PROJ_LS=$(p9 "$REG/agents" ls /claude/projects/-tmp-proj)
for bad in .credentials.json auth.json token.bin; do
expect_missing "credentials-shaped $bad not listed" "$bad" "$PROJ_LS"
- p9 "$REG/harness" read "/claude/projects/-tmp-proj/$bad" >/dev/null 2>&1 \
+ p9 "$REG/agents" read "/claude/projects/-tmp-proj/$bad" >/dev/null 2>&1 \
&& fail "credentials-shaped $bad unreachable" "read succeeded" \
|| pass "credentials-shaped $bad unreachable"
done
-expect_missing "hermes auth.json not listed" auth.json "$(p9 "$REG/harness" ls /hermes)"
-expect_missing "omp config.yml not listed" config.yml "$(p9 "$REG/harness" ls /omp)"
-expect_missing "dsh .credentials.yaml not listed" credentials.yaml "$(p9 "$REG/harness" ls /dsh)"
+expect_missing "hermes auth.json not listed" auth.json "$(p9 "$REG/agents" ls /hermes)"
+expect_missing "omp config.yml not listed" config.yml "$(p9 "$REG/agents" ls /omp)"
+expect_missing "dsh .credentials.yaml not listed" credentials.yaml "$(p9 "$REG/agents" ls /dsh)"
sleep 0.3
-expect_contains "hermes logs still served" logs "$(p9 "$REG/harness" ls /hermes)"
+expect_contains "hermes logs still served" logs "$(p9 "$REG/agents" ls /hermes)"
# 3b. A file at the very top of a mirror root: its relative path is the
# bare name, the one case a join onto an empty directory path gets wrong.
-expect_contains "a file at the top of a mirror root is listed" state.db "$(p9 "$REG/harness" ls /hermes)"
+expect_contains "a file at the top of a mirror root is listed" state.db "$(p9 "$REG/agents" ls /hermes)"
expect_eq "a file at the top of a mirror root reads back" "$(cat "$FX/hermes/state.db")" \
- "$(p9 "$REG/harness" read /hermes/state.db)"
+ "$(p9 "$REG/agents" read /hermes/state.db)"
# 3c. A directory bigger than the listing caps fails loudly. A short
# listing is indistinguishable from a small directory, so the daemon must
# never answer one: the read errors and the client sees it.
mkdir -p "$FX/dsh/wide"
seq 1 1100 | while read -r i; do : > "$FX/dsh/wide/f$(printf %05d "$i")"; done
-if p9 "$REG/harness" ls /dsh/wide > "$TMP/wide.out" 2>"$TMP/wide.err"; then
+if p9 "$REG/agents" ls /dsh/wide > "$TMP/wide.out" 2>"$TMP/wide.err"; then
fail "an over-cap directory fails instead of truncating" "listed $(wc -l < "$TMP/wide.out") entries"
else
pass "an over-cap directory fails instead of truncating ($(head -c 80 "$TMP/wide.err"))"
@@ -161,17 +165,17 @@ rm -rf "$FX/dsh/wide"
printf '{"type":"assistant","message":"third line"}\n' >> "$FX/claude/projects/-tmp-proj/session-abc.jsonl"
expect_eq "append after start is visible immediately" \
"$(cat "$FX/claude/projects/-tmp-proj/session-abc.jsonl")" \
- "$(p9 "$REG/harness" read /claude/projects/-tmp-proj/session-abc.jsonl)"
+ "$(p9 "$REG/agents" read /claude/projects/-tmp-proj/session-abc.jsonl)"
mkdir -p "$FX/claude/projects/-tmp-proj2"
mkfile "$FX/claude/projects/-tmp-proj2/session-new.jsonl" '{"new":true}
'
-expect_contains "new session dir appears at once" -tmp-proj2 "$(p9 "$REG/harness" ls /claude/projects)"
+expect_contains "new session dir appears at once" -tmp-proj2 "$(p9 "$REG/agents" ls /claude/projects)"
# 5. The facts and the write refusal.
-DAEMON_PID_TEXT=$(p9 "$REG/harness" read /pid)
+DAEMON_PID_TEXT=$(p9 "$REG/agents" read /pid)
expect_eq "pid fact answers the daemon's pid" "$DAEMON_PID" "$DAEMON_PID_TEXT"
-[ -n "$(p9 "$REG/harness" read /uptime)" ] && pass "uptime fact answers" || fail "uptime fact answers" "empty"
-printf 'x' | p9 "$REG/harness" write /pid >/dev/null 2>&1 \
+[ -n "$(p9 "$REG/agents" read /uptime)" ] && pass "uptime fact answers" || fail "uptime fact answers" "empty"
+printf 'x' | p9 "$REG/agents" write /pid >/dev/null 2>&1 \
&& fail "write answers EPERM" "write succeeded" \
|| pass "write answers EPERM"
@@ -206,7 +210,7 @@ fi
kill "$DAEMON_PID" 2>/dev/null
wait "$DAEMON_PID" 2>/dev/null
sleep 0.2
-[ -S "$REG/harness" ] && fail "SIGTERM unposts the name" "socket still there" || pass "SIGTERM unposts the name"
+[ -S "$REG/agents" ] && fail "SIGTERM unposts the name" "socket still there" || pass "SIGTERM unposts the name"
# ============================================================================
echo "# part C: /active, the derived view"
@@ -294,14 +298,21 @@ echo "# part B: the real roots, the real registry (read-only)"
# ============================================================================
export XDG_RUNTIME_DIR="${HARNESS_TMP_XDG:-/run/user/$(id -u)}"
REAL_REG="$XDG_RUNTIME_DIR/9p"
-REAL_SOCKET="$REAL_REG/harness"
+# A scratch name, not `agents`: the whole point of part B is to read the real
+# roots through the real registry, and that has nothing to do with which name
+# the tree is posted under. Taking `agents` would mean this suite could only
+# run while the machine's own 9agents.service was stopped — so it would either
+# be skipped on any machine that actually uses the daemon, or fight it.
+REAL_NAME="agents-itest-$$"
+REAL_SOCKET="$REAL_REG/$REAL_NAME"
if [ -e "$REAL_SOCKET" ]; then
- echo "SKIP: a live daemon already owns the posted name `harness`"
+ # $$ collided with a leftover socket from a crashed run of this suite.
+ echo "SKIP: scratch name $REAL_NAME is already taken"
else
HOME_DIR=$(getent passwd "$(id -u)" | cut -d: -f6)
- start_daemon --name harness
+ start_daemon --name "$REAL_NAME"
if wait_posted "$REAL_SOCKET"; then
- expect_contains "posted name listed in the real registry" harness "$(ls "$REAL_REG")"
+ expect_contains "posted name listed in the real registry" "$REAL_NAME" "$(ls "$REAL_REG")"
expect_contains "ls / shows the claude root" claude "$(p9 "$REAL_SOCKET" ls /)"
# A real transcript, byte-identical through the tree.
REAL_T=$(ls "$HOME_DIR/.claude/projects"/*/*.jsonl 2>/dev/null | head -n 1 || true)
@@ -323,10 +334,10 @@ else
expect_missing "no credentials leaked into /claude" credentials "$CLAUDE_LS"
# The money shot: the mntgen mount every interactive fish sees.
if [ -n "$NS" ]; then
- OUT=$(timeout 60 "$NS" --mntgen -- sh -c 'ls /mnt/9p/harness && head -c 200 /mnt/9p/harness/claude/history' 2>"$TMP/mntgen.err")
+ OUT=$(timeout 60 "$NS" --mntgen -- sh -c "ls /mnt/9p/$REAL_NAME && head -c 200 /mnt/9p/$REAL_NAME/claude/history" 2>"$TMP/mntgen.err")
RC=$?
if [ $RC -eq 0 ]; then
- expect_contains "mntgen mount lists the harness tree" claude "$OUT"
+ expect_contains "mntgen mount lists the agents tree" claude "$OUT"
expect_contains "mntgen mount reads claude/history" claude "$OUT"
else
fail "mntgen money shot (exit $RC)" "$(cat "$TMP/mntgen.err")"
diff --git a/build.zig b/build.zig
index 795d8e2..c5d662f 100644
--- a/build.zig
+++ b/build.zig
@@ -156,7 +156,7 @@ pub fn build(b: *std.Build) void {
const is_linux = target.result.os.tag == .linux;
const want_9proc = b.option(bool, "9proc", "Build the 9proc library module and demo (default: target is Linux)") orelse is_linux;
const want_9ns = b.option(bool, "9ns", "Build the 9ns FUSE mount CLI (default: target is Linux; Linux only)") orelse is_linux;
- const want_9harness = b.option(bool, "9harness", "Build the harness fs daemon (default: target is Linux; Linux only)") orelse is_linux;
+ const want_9agents = b.option(bool, "9agents", "Build the coding-agents fs daemon (default: target is Linux; Linux only)") orelse is_linux;
if (want_9ns and !is_linux) {
std.debug.print("error: -D9ns=true needs a Linux target (got {s})\n", .{@tagName(target.result.os.tag)});
std.process.exit(1);
@@ -207,21 +207,21 @@ pub fn build(b: *std.Build) void {
programs_itest.dependOn(p.itest_step);
}
- // The harness fs daemon: a read-only 9P view of every harness's
- // state, posted by default as `harness` (Linux only: it posts into
+ // The coding-agents fs daemon: a read-only 9P view of every harness's
+ // state, posted by default as `agents` (Linux only: it posts into
// $XDG_RUNTIME_DIR/9p through cloud9.post).
- if (want_9harness and !is_linux) {
- std.debug.print("error: -D9harness=true needs a Linux target (got {s})\n", .{@tagName(target.result.os.tag)});
+ if (want_9agents and !is_linux) {
+ std.debug.print("error: -D9agents=true needs a Linux target (got {s})\n", .{@tagName(target.result.os.tag)});
std.process.exit(1);
}
- const harness_build = @import("9harness/build.zig");
- const harness: ?harness_build.Artifacts = if (want_9harness) harness_build.add(b, .{
+ const agents_build = @import("9agents/build.zig");
+ const agents: ?agents_build.Artifacts = if (want_9agents) agents_build.add(b, .{
.target = target,
.optimize = optimize,
.cloud9 = module,
.ns = if (ns) |p| p.exe else null,
}) else null;
- if (harness) |p| {
+ if (agents) |p| {
programs_test.dependOn(p.test_step);
programs_itest.dependOn(p.itest_step);
}
diff --git a/build.zig.zon b/build.zig.zon
index d042fe3..b275168 100644
--- a/build.zig.zon
+++ b/build.zig.zon
@@ -7,11 +7,11 @@
// published package cannot build: build.zig @imports each program's
// build fragment unconditionally, so a program missing from this
// list is a FileNotFound for every consumer, while building fine
- // from a checkout. 9harness was exactly that until a pardes build
+ // from a checkout. 9agents was exactly that until a pardes build
// caught it.
.paths = .{
"build.zig", "build.zig.zon", "src", "web",
"test", "docs", "README.md",
- "9ns", "9proc", "9harness",
+ "9ns", "9proc", "9agents",
},
}