# 9harness design 9harness is the harness 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`), hermes (`~/.hermes`) and dsh (`~/.dsh`), plus their skills. Everything is read-only and read live from disk at request time; nothing is cached, so a session transcript grows as its harness writes it and a new session appears as soon as its file lands. 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`; installed by the plain `zig build` next to the other programs). ## The tree (v1 contract) ``` / read-only root (dr-xr-xr-x) /pid the daemon's pid, one line /uptime seconds since start, one line /claude/ projects/ mirror of ~/.claude/projects//: one dir per project, its *.jsonl session transcripts as plain readable files, memory/ subdirs included history ~/.claude/history.jsonl (global prompt history) skills/ mirror of ~/.claude/skills /codex/ sessions/ mirror of ~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl session-index ~/.codex/session_index.jsonl history ~/.codex/history.jsonl /omp/ mirror of ~/.omp/agent (history.db, agent.db, models.db, ... served as raw readable blobs; sqlite parsing is a later step — v1 is files) /hermes/ mirror of ~/.hermes (state.db raw, logs/) /dsh/ mirror of ~/.dsh (profiles/, storages/, ... whatever it holds) /skills/ union view: plain dirs claude/, codex/, omp/ mirroring each harness's skills dir; a harness without one simply has no entry ``` Mirroring is lazy: nothing is walked at startup. A lookup, getattr or readdir stats and lists the real files at request time. Unknown files and directories appear as themselves, readable. A file that vanishes between lookup and read answers ENOENT cleanly; a file that appears is visible on the next request. Writes, creates, removes and setattrs answer EPERM (create/remove/wstat are not declared as backend features, so the engine refuses them itself; the write and setattr ops answer `E.PERM`, as does an open for write). Modes are reported read-only regardless of the real bits: directories `0o555`, files `0o444`. Sizes and mtimes are real. The engine's known fidelity gap applies: directory records carry fixed modes and length 0, so `9p ls -l` shows placeholders; `9p stat` is the accurate one. ## The exclusions (the security boundary) The daemon must never become a credential reader for anything that mounts it. The rule is absolute — `excluded()` in tree.zig, unit-tested — and applies to every path component of every subtree, by lookup *and* by readdir, so an excluded name neither resolves nor lists: - names containing `credentials`, `token`, `auth` or `secret` (case-insensitive substrings; "auth" also hides "author-notes" — the price of never guessing wrong); - names ending `.key`, `.pem` or `.env`; - the config/settings files of the mirrored harnesses, which may embed API keys: `settings.json`, `settings.yaml`, `settings.local.json`, `config.yml`, `config.yaml`, `config.toml`, `models.yml`, `models.yaml` (Claude Code's settings.json is not in the tree anyway: /claude serves only projects/, skills/ and history.jsonl); - `.ssh`, and `id_rsa`/`id_dsa`/`id_ecdsa`/`id_ed25519` prefixes. Symlinks are never served, at any depth: they are an escape hatch around the root pinning (a symlink out of `~/.claude` would make the daemon read outside the five roots). A symlink answers ENOENT and is not listed. Path resolution is the mechanism that makes that true, and it is not string composition. `openIn` walks from the pinned base one component at a time, each opened with `O_NOFOLLOW`: the intermediate components with `O_PATH|O_DIRECTORY` (so a component swapped for a symlink fails with ENOTDIR rather than redirecting the walk) and the last with the caller's flags. Every stat, read and readdir goes through it. Composing `/` and opening that in one call is what the first version did, and it was wrong in a way a test could not see: only the final component's symlink was refused, so a name replaced between the walk and the read — the plain TOCTOU — served bytes from outside every root, and a directory above it could redirect the whole path at any time. `O_PATH` also means a fifo or device left in a harness root is stat'd without its open ever blocking; a read refuses anything that is not a regular file. The base itself comes from $HOME or `--root NAME=PATH` at startup and is resolved normally (it is configuration, and may legitimately be a link). Relative paths are composed only from remembered (root, rel) pairs plus single-segment, engine-vetted names. Client bytes never form a path: 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. ## Backend shape `Harness` (tree.zig) is the backend of `fs.Server(Harness, opts)` on `serve.Runner` (main.zig): allocation-free request paths, comptime caps, `Io` passed explicitly, static memory in .bss (the path table and the per-connection buffers; ~3 MB of tables, untouched pages cost nothing). - **Node ids** pack `Node{idx: u8, kind: u8, serial: u48}` in the u64, zmx-style. Static skeleton nodes (facts, the harness dirs, skills) are `kind = .top`; every mirrored file is `kind = .path` with `serial = (slot, generation)` of its table entry. Two walks of the same file find the same entry, so their node ids — and qid paths — are equal; an entry freed by its last release takes a new generation, so a stale forged node id never resolves. - **The path table** remembers at most 4096 remembered (root, rel) pairs, each with a Wyhash of the pair for dedupe and a reference count. The backend declares `features = .{ .references = true }`, so the engine asks lookups for `.` too and pays every reference back with exactly one `release`; an entry's refcount reaching zero frees it. Readdir records name entries without references (a listing does not pin), so a later walk of the same name finds the same entry and the same node id. A full table answers `E.NFILE` — on a lookup, and on a listing that cannot name all its entries (see Readdir). - **Fresh reads**: a read opens the file, `readPositionalAll`s the window at the request's offset, and closes it — every read is against the live file, and the engine handles offsets, so `cat` of a 5 MB transcript through a mntgen mount works. - **Readdir** collects a directory's surviving names, sorts them for cross-read cursor stability, and stages the engine's records (`node:u64le dir:u8 len:u8 name`), skipping `req.off` records. The comptime caps (1024 names, 128 KiB of name bytes, the path table) are **loud**: a directory that would not fit answers `E.NFILE` instead of a short listing, because a short listing cannot be told apart from a small directory and is therefore a wrong answer, not a limitation. The staging buffer filling is different and normal — it ends that read at the last record that fit, and the next read continues from there; staging stops at the first record that does not fit rather than packing a later, shorter one behind it, which would drop that entry from the listing entirely. A directory that changes between the reads of one listing may shift its cursor — the fresh-tree trade, accepted in v1. - **Locking**: one mutex spans `handle` and the reply (the answer's bytes point into shared staging buffers), so the backend is serialized across connections. v1 accepts this: it is an introspection fs, not a throughput service. ## CLI and process model ``` usage: 9harness [--unix PATH | --tcp IP:PORT | --fd N] [--no-post] [--name NAME] [--root NAME=PATH]... ``` - Default: post itself under the name `harness` 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 configuration. `stop()` unposts; the stale-socket protocol covers a killed daemon. - `--unix`/`--tcp` listen forms (beside the post, or with `--no-post`), `--name` to post under another name, `--root NAME=PATH` to pin any of the five roots elsewhere (defaults are `$HOME/.`; the tests use fake homes and never the live roots). - `--fd N` serves exactly one 9P session over the connected stream on 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`. ## Integration test plan (`test/e2e.sh`) Part A runs entirely on fixtures (a fake home pinned by `--root`, a scratch `XDG_RUNTIME_DIR` registry, plan9port's `9p` as the client): 1. the posted name is listed in the registry; 2. `9p ls /` shows the roots; a fixture transcript reads back byte-identical (`cmp` against the source file); the skills union mirrors the harness tree; 3. credentials-shaped files are unreachable by both listing and reading, at several depths, in every root; 4. a file appended after the daemon started is visible immediately, and a new session directory appears in the next listing; 5. the pid fact answers the daemon's pid; writes answer EPERM; 6. the `--unix` and `--fd` listen forms serve; 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 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 `head` of `/claude/history`, and the stop unposting the real name. Unit tests (tree.zig) cover the exclusions (including the predicate itself, spelled out), path-composition safety (slashes, NULs, symlinks, the `..` chain, NOTDIR), node id packing (same file equal, distinct files differ, the union identity, id recycling through release), the vanishing file (ENOENT on lookup, getattr and read), EPERM on write/setattr/open-for-write, and live visibility — all against a fake HOME under a test tmp dir, never the real harness roots. ## Verification - `zig build` (installs `9harness` 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 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. ## /active: the derived view (v2) v1 mirrors files. `/active` is the first *semantic* layer: one directory per live agent, normalized across harnesses, so `ls /active` answers "what is running right now" whatever wrote it. It is the discovery half of `~/.local/bin/zmxify`, whose resolution ladder it ports. ``` /active/ claude/ 345104/ pid 345104 ppid 344980 started 1790013029 cwd /home/goblin name goblin-e5 title the session's title session 1f9a74f7-... model claude-opus-5[1m] via registry zmx harness (read-write, below) status busy only where the harness publishes one transcript the live .jsonl, growing as it writes agents/ /{model,transcript} omp/ 155574/ ... ``` A harness directory holds one directory per live process of it, named by pid. `/proc` spells it that way and so does this: a compound `claude-345104` would make you parse a name to recover `harness`, which is already the directory above it, and `/active/omp/*` would not glob. There is no `updated` file. The last write to a session is the mtime of `transcript`, which `stat` already carries; serving it again as its own file would be the same fact twice, and the copy is the one that goes stale. These are zmxify's picker columns — pid, harness, dir, age, zmx, via, session, title — as files, which is the point: the script stops scanning `/proc`, reading fds and querying sqlite itself and just reads the tree. **`status` is a promise only some harnesses make.** It is the harness's own word for what it is doing, and only Claude Code publishes one (`busy`, in `sessions/.json`); for every other harness the file is simply absent, the way a `skills` mount with no target is absent. Deriving one from the process's `/proc` state would answer a different question (`S` means "not on a CPU this instant", not "waiting for you"). For every other harness the honest activity signal is the mtime of `transcript`. `ls /active/*/*/status` tells you who publishes the stronger answer. An entry is named `-`: unique, stable for the process's life, and it sorts by harness. The pid is the identity because it is what `/proc` and every harness's own registry agree on. ### Finding the agents `/proc` is scanned for a process whose `argv[0]` basename is `omp`, `claude`, `codex`, `hermes` or `dsh`, or a python running `hermes_cli.main`. A command line carrying one of the daemon words (`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 act on the process tree it lives in. Liveness is `/proc/` existing **and** its `stat` field 22 (starttime) matching the one remembered for the slot. A pid that has been reused is a different process and does not list; a corpse never lists. ### Resolving the session Each harness is asked in its own terms — the `fd -> dir -> db` ladder zmxify worked out, with the route named in `via` so a wrong guess is visible rather than silent: | harness | route | `via` | |---|---|---| | claude | `~/.claude/sessions/.json`, which the harness maintains itself: sessionId, cwd, name, status, version, and `procStart` as a pid-reuse guard | `registry` | | omp | the transcript it holds open under `~/.omp/agent/sessions/`, newest first; else the store directory named after its cwd with `/` becoming `-` | `fd`, `dir` | | dsh | the `session-/session.jsonl.zstd` it holds open; zstd, so there is no title | `fd` | | codex | the rollout it holds open — `sessions/YYYY/MM/DD/rollout--.jsonl`, whose *name* carries the session id, so its sqlite is never opened | `fd` | | hermes | nothing to resolve: see below | `none` | A resolved session is checked against the process's cwd before it is believed (the head of an omp transcript carries `"cwd"`). A process whose session does not resolve still appears, with everything `/proc` knows and an empty `session`: the view never pretends to know what it does not, and a move on it is refused. codex's id is the last 36 bytes of the rollout's name, not what splitting on `-` gives — the timestamp in front of it holds dashes too. **hermes is the one harness with no answer here, and it needs none.** Its sessions live only in `state.db`, with no per-session file to find; but the only hermes processes that run are the gateway and the dashboard, and both are daemon-shaped, so neither is a session anything should move. zmxify resolved a hermes id from sqlite and then declined to touch the process holding it, for the same reason. If an interactive hermes ever exists, this is the gap, and it is the one place sqlite would buy something. ### Freshness A slot remembers only what identifies the agent: harness, pid, starttime, cwd, session id and transcript path. Every *field* is re-derived from disk when it is read, so `status` is never stale and a transcript grows under `cat`. `/proc` is rescanned on a readdir of `/active` and on a lookup that misses, not on every read. ### `zmx`: the write path, and why it is not a ctl Writing a zmx session name into `/active//zmx` moves that agent into a zmx session of that name: the zmxify action half, as a file. Reading `zmx` gives the `ZMX_SESSION` of the process, empty when it runs outside zmx. Writing sets it. The file means *which zmx session this agent lives in*, and writing a name makes that true — state, not a verb channel. A `ctl` taking words would be the ordinary Plan 9 spelling (`/proc/n/ctl`), and an executable `zmxify` script served in the tree would be the zmx `attach` spelling, but a script that shells out to a local binary is a lie 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, so it survives being mounted from anywhere. What a write does, in order, refusing before it destroys anything: 1. The name must be zmx's label charset (`[A-Za-z0-9._-]`) and unused by a live session, else `EEXIST`. 2. The agent's session must have resolved, else `EPERM`. Nothing is killed that has nowhere to come back to — zmxify's invariant. 3. `SIGTERM`, then `SIGKILL` after 5s, giving up at 12s with `EIO`. 4. `fork`, `chdir` to the agent's cwd, `exec zmx run -d` with a **fixed argv per harness** (`claude --resume `, `omp --resume `, `codex resume `, `hermes --resume `). No client byte ever reaches `exec`: the only thing the client supplies is the session name, and it is validated first. 5. The write returns once `$XDG_RUNTIME_DIR/9p/zmx/` appears (zmx self-posts), or `EIO` on timeout. The agent comes back under a new pid, so the old `/active/` is gone and a new entry takes its place with `zmx` reading the new name. The window between the kill and the exec is the same exposure zmxify has always had, and it is why step 2 comes first. **This is the tree's first write path, and it is an escalation**: a client that can write this file can kill the user's agents and cause a process to be spawned. It is therefore off unless `--allow-move` is given, and the read-only daemon stays the default. Everything else in the tree still answers `EPERM` to writes. ### Where this is not Plan 9 Named, because they are choices rather than oversights: * **`via` describes how the server found out**, not something true of the process. That is diagnostics in the interface. It stays because a resolution that guesses wrong silently is worse than a wart — it is why zmxify's picker showed the route too. * **A write to `zmx` does not change the object, it replaces it.** The process is killed and another starts under a new pid, so the entry written to disappears. `/proc/n/ctl` accepting `kill` is at least honest about being a verb with a consequence. The file still beats an executable served in the tree, which would lie outright over a remote mount, so it stays — with this paragraph. * **A move blocks the whole daemon** for as long as the kill and the restart take, because one mutex spans `handle` and the reply. A Plan 9 server keeps answering other fids meanwhile. The engine already has parked replies and Tflush for exactly this; the move does not use them yet, and that is the first thing to revisit. * **`/active` lives inside the mirror** rather than being its own posted service. "What files exist" and "what is running" are different jobs, and a stricter reading would separate them; they share the pinned roots and the parsing glue, so they share a daemon. ### Replacing zmxify `9harness/zmxify` is the whole script now: it lists `/active`, offers the rows to fzf, and writes the chosen name into the agent's `zmx`. It parses no `/proc`, opens no fd table and queries no database — the 307 lines that did become a `cat` of a few files, and the knowledge they held now lives in a daemon with tests around it. What the script still decides is which rows to offer and what to call the new session. It skips an agent already under zmx — that is what this moves things *into* — and one whose session did not resolve, which the daemon would refuse anyway. A free name is the caller's business too, and the registry already answers which are taken. What it does **not** skip is the caller's own session, and that is the whole point of the daemon owning the action. The old script excluded its own ancestry because it did the killing itself: kill your own parent and the script dies before it can re-exec, losing the session it was rescuing. Now the script's only job is to get the `Twrite` out. The daemon kills, re-execs and waits for the new session to post, and it completes all of that whether or not the client is still there — verified by hanging up immediately after sending the write and watching the move land anyway. So zmxifying the terminal you are sitting in works; the shell drops, and `zmx attach ` is printed before the write, because there may be no script left to print it after. ### Testability `--proc DIR` overrides `/proc` the way `--root NAME=PATH` overrides a harness root, so the scan, the liveness guard, the resolution ladder and the exclusions are unit-tested against a fixture process tree and never against the live machine. The move itself is exercised end-to-end against a fake harness in `test/e2e.sh`. ## Out of scope for v1 (documented, not hidden) - Write paths (create/write/setattr stay EPERM), resume/attach automation (the zmxify re-exec half), sqlite parsing (omp/hermes sessions stay raw blobs), cross-session search, any caching or invalidation layer. - Streaming/parked reads (a read answers at once; the transcripts are plain files), and union-directory semantics beyond the plain /skills/ dirs.