summaryrefslogtreecommitdiff
path: root/9agents/docs/DESIGN.md
blob: 1530153c1118dba3c28644c8ce6b33df6f83e935 (plain) (blame)
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
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
# 9agents design

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`),
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 `-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/
  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.

## 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
`serve.Runner` (main.zig): allocation-free request paths, comptime caps,
`Io` passed explicitly, static memory: the harness (the path table,
~3 MB) in .bss, and the two large tables in anonymous mappings of their
own, `MAP_NORESERVE` and `MADV_NOHUGEPAGE`, so they are address space
until used — the runner (~160 MB: 16 connections, each with a fid table
of 32768) and the chat indexes (~100 MB). A kernel mount holds a fid for
every inode the kernel caches, and `ls -l` of one long chat caches
thousands, so the fid table has to be large; the engine sets itself up in
place (`initIn`) and writes a fid slot only when a client first uses it.
Huge pages are refused because each connection's and each index's header
sits in its own 2 MB stretch, and the first write to one would back all
of it: with them, the idle daemon was 79 MB; without, 11 MB.

- **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: 9agents [--unix PATH | --tcp IP:PORT | --fd N] [--no-post]
               [--name NAME] [--root NAME=PATH]...
```

- Default: post itself under the name `agents` via
  `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/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`),
  `--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: 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`)

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 `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/agents` 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 `9agents` beside 9ns, 9proc-demo, 9web).
- `zig build test` — the library's 80 tests stay green.
- `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 9agents'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}
      chat/    00000000-user 00000001-assistant 00000002-bash ...   (claude, codex; below)
  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 9agents 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; else the thread whose writer lock it holds (below) | `fd`, `lock` |
| 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

There is no replacement script, because the mount is the replacement:

```
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
```

`~/.local/bin/zmxify` was 307 lines of rc because there was no
filesystem to ask: it scanned `/proc`, classified command lines, read
fd tables and queried two sqlite databases to work out what the four
lines above now read. All of that moved into the daemon, where it is
tested. Writing a wrapper back on top would only re-derive what the
tree already says, and would become a second, worse interface beside
the real one.

Two behaviours the old script had are worth knowing, because they are
now the caller's to arrange and both are one line: pick a session name
nothing holds (the registry, `$XDG_RUNTIME_DIR/9p/zmx`, says which are
taken) and `zmx attach` afterwards.

A third is gone rather than moved. The old script refused to act on
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. The daemon kills, re-execs and waits for the new session
to post, and completes all of it whether or not the client is still
connected — verified by hanging up immediately after sending the write
and watching the move land anyway. So moving the terminal you are
sitting in works, which is the common case; the shell drops, and you
reattach with the name you wrote.

**When the fds are closed, codex's locks name its session.** A daemon
started inside a user namespace — anything launched from a terminal that
`9ns` wraps (`unshare -U`, mapping only the user's uid) — cannot read
`/proc/<pid>/fd`, `cwd` or `environ` of a process outside that namespace:
the kernel's ptrace access check fails across it, whatever the uid. The
systemd service runs in the initial namespace and the fd route works
there; `zmx run agents -d 9agents` from a terminal does not, and the fd
route finds nothing. What stays visible is the locks:
codex holds a write `flock` on `~/.codex/thread-writer-locks/<thread>.lock`
for every thread it is writing, and `/proc/locks` is world-readable and
names the holder's pid and the locked file's inode. So the `lock` route
reads the pid's `FLOCK WRITE` lines, matches their inodes against the lock
directory, and turns each thread id into its rollout: the id is a UUIDv7,
whose first 48 bits are the milliseconds it was minted at, so the
`YYYY/MM/DD` directory is the UTC day or one either side of it (the
rollouts are named in local time). A process holds its own thread and its
subagents' (multi-agent v2 runs them in-process); its own is the one whose
first record says `"thread_source":"user"`, and a process holding several
(a `/new`) resolves to the newest.

The inodes are compared without the device. On btrfs `/proc/locks` prints
the superblock's device (`00:37`) and `stat` the subvolume's anonymous one
(`0:56`), so the pair never agrees; every candidate is in one directory,
where the inode alone is unique.

### chat/: the conversation, one file per message

```
/active/claude/345104/chat/
  00000000-user  00000001-assistant  00000002-bash  00000003-bash-result
  00000004-thinking  00000005-read  00000006-read-result  00000007-system ...
```

`chat/` is the agent's transcript read as messages: one file per message,
named `<id>-<kind>`. The id is the message's position in the transcript,
counted from 0 and written as 8 digits, so a plain `ls` lists them in the
order they were said. Transcripts are append-only, so an id never moves
and a message written later takes the next one. The kind says who is
speaking, normalized across harnesses:

| kind | claude | codex |
|---|---|---|
| `user` | a `user` record's text; a prompt typed while the model worked (`queued_command`, origin human) | `message`, role user |
| `assistant` | an `assistant` text block | `message`, role assistant |
| `thinking` | a thinking block, when it is not redacted to an empty string | a `reasoning` summary, when there is one |
| *tool* (`bash`, `read`, `shell`, `apply_patch`, ...) | `tool_use`: name, then input | `function_call`, `custom_tool_call`, `*_call` (`web_search`, `local_shell`): name, then arguments |
| *tool*`-result` | `tool_result`, matched to its call by `tool_use_id` | `*_output`, matched by `call_id` |
| `tool-call`, `tool-result` | a call whose tool has no usable name, and a result whose call is not known | the same |
| `system` | `isMeta` or compact-summary user text, `system` records, queued prompts not from a person | `message`, role developer or system; user-role text codex writes itself (AGENTS.md, `<environment_context>`, goals, plugin lists) |
| `agent` | — | `agent_message`: the sending agent's path, then its text |

A tool call is named after its tool: lowercased, with anything but
letters, digits, `_`, `.` and `-` turned into `-`, and cut at 48 bytes. A
tool whose name would read as another kind (`user`, `tool-result`,
anything ending `-result`) is named `tool-call` instead, so a name never
lies about who spoke. Its result is found through the call's id, among the
last 256 calls; results follow their calls closely, even when a model runs
calls in parallel. The index keeps each chat's distinct tool names (up to
255) and gives every message a one-byte reference to one.

Everything else in a transcript is the harness's bookkeeping (attachments,
mode changes, titles, token counts, snapshots, and codex's `event_msg`
copies of what the `response_item`s already say) and is not a message.
Claude Code writes each content block of a reply as its own record, and
each is its own message here too. A subagent's thread (`isSidechain`) is
not this chat.

The file is the message as text: JSON strings unescaped, each part on its
own line, a newline added where the text lacks one, and an image as
`[image]`. A tool call is the tool's name and then its input, which stays
JSON — it is data the model wrote, not prose. A block of a kind this does
not know is served as its JSON rather than dropped. The mtime of a message
is the timestamp its record carries, so `ls -lt` shows when it was said.

A name has one spelling: the id is always 8 digits, so `1-user`,
`000000001-user`, `00000001-assistant` for a user message, or a bare `1`
do not resolve, and every message answers to exactly one path.
Only complete lines count: a record the harness is half-way through
writing waits for its newline, which is what keeps ids from being handed
out twice.

**The index, and why it is not a cache.** Finding message N means knowing
where every message before it is, which is a scan of the transcript, and
codex rollouts reach 200 MB. So each transcript gets an index
(`src/chat.zig`): per message, the spans of the file that render it — an
offset, a length, and how to decode it. It holds positions, never bytes:
every read still reads the file. It is keyed by the file's (dev, ino) and
checked against it on every request; a transcript that grew is indexed
from where the index stopped. Anything else is another file and is
indexed again from the start: one that shrank, one born again under a
reused inode (its birth time differs), and one rewritten in place to the
same size or longer, which keeps its inode and only its bytes can give
away — so the index also keeps a hash of the first and the last 64 bytes
it covered, and checks both. Eight transcripts are indexed at once,
reused least recently used first, in a pool of its own mapped
`MAP_NORESERVE`: ~100 MB of address space, none of it memory until an
index writes it.

A string's output offset does not map onto its input offset, so a read
from the middle of a long message would have to decode everything before
it, and reading a message front to back would cost the square of its
length. The pool remembers where the last read stopped — the input offset
of the last escape or run it began, and the output offset that produced —
and the next read of the same message resumes there.

The first read of a chat pays for the scan: a 17 MB Claude Code
transcript takes ~150 ms in a Debug build, the largest codex rollout on
this machine (228 MB) ~2 s in Debug and ~130 ms in ReleaseFast. That time
is spent under the daemon's one mutex, like a move. After that, a request
costs a stat and whatever the transcript grew by.

The caps are loud, as everywhere else: an index holds 262144 messages (the
longest rollout on this machine has 32k), and a transcript with more
answers the listing with an error rather than a list that stops early.

### 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.
- `chat/` for sessions that are not running: it hangs off `/active`
  entries only. The mirrors serve a transcript as a file, and Claude
  Code already keeps a `<session>/` directory beside each transcript for
  its subagents, so a chat there needs a name of its own.
- Chats of subagents (omp's `agents/`, codex's in-process threads) and of
  omp, hermes and dsh.
- A session changed inside a running process (`/clear` in Claude Code,
  `/new` in codex): the `/active` entry keeps the transcript it resolved
  when it first resolved one. (An agent that resolved nothing is asked
  again on every scan, since a harness seen in its first instant may not
  have written its record or taken its lock yet.)