1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
|
# 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/<project-dir>/: 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
`<base>/<rel>` 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/.<name>`; 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/ <name>/{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/<pid>.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 `<harness>-<pid>`: 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/<pid>` 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/<pid>.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-<uuid>/session.jsonl.zstd` it holds open; zstd, so there is no title | `fd` |
| codex | the rollout it holds open — `sessions/YYYY/MM/DD/rollout-<when>-<uuid>.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/<id>/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 <name> -d` with a
**fixed argv per harness** (`claude --resume <sid>`,
`omp --resume <transcript>`, `codex resume <sid>`,
`hermes --resume <sid>`). 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/<name>` appears
(zmx self-posts), or `EIO` on timeout.
The agent comes back under a new pid, so the old `/active/<id>` 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 <name>` 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/<harness> dirs.
|