// Second draft. The first is superseded; what changed and why is recorded // entry by entry in `docs/registry.typ`, and every §-to-entry link below is // live. Build: typst compile docs/9p.typ docs/9p.pdf #let app = "PaRDeS" #set page( paper: "us-letter", margin: (x: 1.5in, y: 1.2in), numbering: "1", number-align: center, ) #set text(font: "Libertinus Serif", size: 11pt, lang: "en") #set par(justify: true, leading: 0.62em, first-line-indent: 1.5em) #show raw: set text(font: "DejaVu Sans Mono", size: 8.5pt) #show raw.where(block: true): block.with(inset: (left: 1.5em, y: 0.4em), width: 100%) #set table(stroke: none, inset: 0.35em) #set heading(numbering: "1.") #show heading: it => { set text(size: 11pt, weight: "bold") v(1.1em, weak: true) block(counter(heading).display() + h(0.6em) + it.body) v(0.4em, weak: true) } #show heading.where(level: 1): it => { set text(size: 11pt, weight: "bold") v(1.4em, weak: true) block(counter(heading).display() + h(0.6em) + it.body) v(0.5em, weak: true) } /// A registry cross-reference. Every contestable claim in this note is an /// entry over there, with its evidence and its losing arguments attached. #let r(id) = text(size: 9pt)[#raw(id)] #align(center)[ #v(0.5em) #text(size: 15pt)[A File Interface for an Editor and Terminal Multiplexer] #v(0.9em) #text(size: 10.5pt, style: "italic")[architecture draft, second revision] #v(0.4em) #text(size: 10pt)[#datetime.today().display("[year]-[month]-[day]")] ] #v(1.2em) *Historical proposal, superseded by `docs/fs.md`.* Pardes now serves 9P by default over Unix sockets, with optional TCP and QUIC. FUSE support and the old examples have been removed. The arguments and source references below describe the earlier implementation, not the current interface. #block(inset: (x: 2.5em))[ #set text(size: 10pt) #set par(first-line-indent: 0em) #text(weight: "bold")[ABSTRACT] #h(0.8em) #app is an editor with attached terminals. It runs as a daemon; clients attach to it. It already serves acme's control filesystem, over FUSE, on Linux only. This note adds 9P as that filesystem's second transport, and as a carrier for the display protocol it already has. The program becomes a file server that needs no kernel, so the tree reaches macOS, a browser tab, a microcontroller and another machine, none of which FUSE can reach. The same program is also a 9P client, which is how it edits a file on a board it cannot mount. We describe the interface, what it costs — measured against two shipping implementations rather than estimated — and, at greater length, the parts we do not build. The first draft of this note proposed replacing FUSE and replacing the wire protocol. Both are wrong, for reasons that turn out to be cheap to state and expensive to discover. ] = What this is for One sentence, because every other section is downstream of it. *PaRDeS has no way to ask anything a question.* It can ask the kernel, and that is all. Two running instances can do exactly two things to each other: shout — `nested.sendLook` formats one line, connects, writes, returns, and never reads, and its own test asserts that nothing comes back — or *become*, which is what `Attach` does, and `Attach` is not a connection but a replacement: on a successful handshake the frontend reaps its pane shells, closes its watches, unmounts its filesystem, deinitialises the core, and turns into a thin client of somebody else's. Nothing in the tree reads `PARDES_FS`. `Event` has no variant for a peer. The 21-method host vtable has no peer method. Reading another session's text is possible only by shelling out to `cat` a path derived from a pid you must already know, on Linux, if that session was started with `--fs` — and impossible against a detached session, which serves no filesystem at all. 9P is a request/response protocol that crosses machines. That property is the one thing none of the three private mechanisms can be extended to have, and every item on the wanted list is the same feature wearing different clothes: scripting from macOS, driving a session over a network, reading the board's memory, chaining one instance to another without destroying either. All of it is *being able to ask, and get an answer back*. That is the justification, and it is the only one. In particular it is not simplification: §12 puts the honest figure at between 750 and 1,050 lines of net growth, and the measurement is in the registry, not in a hope. What does fall is the number of ideas — four framings become two, three schemes for "which instance?" become two, two version negotiations become one, and six concepts currently duplicated across 799 lines become one copy each. Fewer ideas, more lines. Anyone selling this as "simpler" has not counted. = What changed, and why this draft exists The first draft argued that 9P should replace two things: the FUSE mount, and the private protocol on the daemon's socket. Neither survives contact with the sources. It cannot replace the mount. Linux grades mount privilege by a per-filesystem flag, and 9P and FUSE are on opposite sides of it. `mount_capable` falls back to `capable(CAP_SYS_ADMIN)` — root in the *initial* namespace — for any filesystem that does not set `FS_USERNS_MOUNT`; FUSE sets it, v9fs does not. So `mount -t 9p` costs real root, a user namespace does not help, and `trans=fd` does not help because the syscall is checked before the transport is consulted. The 281 lines in `src/fuse.zig` that fork a setuid helper and receive a descriptor over `SCM_RIGHTS` are not overhead; they buy an unprivileged pathname, which 9P cannot buy at any price on Linux. The usual escape, `9pfuse`, is FUSE again in someone else's process — which is exactly what `ad`, the one editor that already took this route, ships #cite(). #r("9P-13") It cannot replace the wire either, because the wire's payload is a compressed cell grid and 9P has no server-initiated message. But the interesting half of that finding is that 9P *can carry* the wire unchanged, and that this is how remote display has actually shipped for thirty years #cite(). #r("9P-12") What is left is better than what was proposed, and smaller. = Two layers The first draft's mistakes are almost all one layer's property asserted about the other, so the layers are worth naming before anything else. *Layer 1 is the control tree.* Request and response, nine operations, a pure transaction over the core: `handle(p, req) Reply` in `src/acmefs.zig`, whose ABI already records that "FUSE opcodes, 9P messages and a unit test all reduce to these". It is transport-neutral today and unit-tested with no transport at all. *Layer 2 is the display protocol.* Nineteen message tags in `src/detached/wire.zig` carrying run-length-encoded cell grids and input, one message per frame, pushed by the server to N frontends that share one screen. They share a machine and nothing else. The tree is not private and not undocumented — it is acme(4) #cite(). The wire is both, and deliberately: it is a codec, and its cost is measured at seven lines per new fact, which is cheap for what it does. 9P is Layer 1's second transport and Layer 2's carrier. It is never a re-encoding of either. #r("9P-1") = The interface A session is a tree. Windows are numbered directories under it. ``` / ctl session control; read lists windows index one line per window: id, tag, flags new/ walking here creates a window 7/ ctl commands in; id and state out tag the tag line body the text addr an address data read and write at addr event the interesting one; see below errors writes appear in the error window pty/ data the stream ctl winsize, raw, cooked, sig, exec status dimensions, mode, exit status ``` This is acme's tree plus `pty/`, minus the plan9 compatibility stubs. Four conventions matter, and the first draft got three of them wrong. *Offsets are honoured everywhere they mean something.* `body`, `tag`, `addr`, `ctl`, `index` and `rdsel` are seekable files and are read at the offset the message carries. acme does the same, and its entire byte-to-rune cache exists to serve a non-zero offset on `body`. Only `data` and `event` ignore offsets, and only because they are positioned by `addr` and by the queue respectively. All writes ignore offsets: a write to `body` appends, which acme(4) specifies. A server that ignored offsets on `body` would make `cat`, `wc`, `diff` and `tail` return the first chunk forever, which is the opposite of the whole point. *Directory reads carry a cookie, not an index.* 9P requires a directory read at offset zero or at exactly the byte offset where the previous read ended, and the reply must contain whole `stat` entries; anything else is `Ebadoffset`. The core counts entries, so the transport keeps a per-fid byte cursor and the one entry that did not fit. This is the only genuine offset discontinuity between FUSE and 9P, and it is about forty lines. #r("9P-5") *The address belongs to the window.* The first draft claimed per-fid and attributed it to acme; acme keeps `Range addr` in `struct Window`, #app keeps it per pane, and `ad` keeps it per buffer. Per-fid is a real improvement — two scripts could then address one window without colliding — but it is a proposal, not a restatement, and it is the only thing in this note that asks the core to grow state. It is argued on its own merits or not at all. #r("9P-6") *`event` is opened by a client, not by a fid.* One controller per window is right; enforcing it per fid is not, because a client that opens the file twice is not two controllers. `ad` reaches the same conclusion and scopes `QTEXCL` to a connection. #r("9P-7") = Events A window handles its own mouse and keyboard until a program opens `event`. From then on it ships them out: origin, action, the character range, the text. The program reads a record, decides, and writes it back to accept it; or handles it itself and stays silent. This is the whole extension mechanism and it already works — `examples/acmefs/` has four programs that use it, one of which puts words #app has never heard of into a tag and makes them execute. There are no callbacks and no plugin API. A program that wants to redefine what the right button does opens a file and reads it. A read blocks. The core does not: it answers `again` — nothing consumed, ask me later — and the transport holds the request. That inversion is what lets a single-threaded core serve a filesystem at all, and it is worth stating plainly because the prior art does not have it. `ad` spends three to four threads per connection and then serialises all of them behind one mutex and a blocking round trip into the editor thread. #app answers a blocked `event` read in twenty-one nanoseconds and allocates nothing. = Terminals A pty is a file interface wearing the wrong clothes. Everything one wants to do to it is an `ioctl`, and 9P has none in any dialect. They become writes to `pty/ctl`: #v(0.3em) #align(center)[ #set text(size: 9.5pt) #table( columns: 2, align: left, column-gutter: 2em, row-gutter: 0.3em, [`TIOCSWINSZ`], [`winsize 80 24`], [`TCSETS`], [`raw`, `cooked`, `echo off`], [`kill`], [`sig INT`], [`TIOCGWINSZ`], [read `status`], [spawn], [`exec /bin/sh`], ) ] #v(0.3em) `pty/data` is the stream. A write is input to the process; a read blocks until there is output. On exit a read returns zero bytes rather than an error, because clients already know what end of file means. Two things about this section that the first draft did not say. It has *nothing to do with 9P*: `push_spawn` and `push_pty_resize` are effects the core already has, so `exec` and `winsize` are existing capabilities acquiring a name, and `sig INT` is the only new one. Build it in the FUSE tree first and the 9P transport inherits it. And it has *no prior art anywhere*: acme has no pty files, `ad` has no terminal surface at all. Being first is a reason to keep it to three files and stop. #r("9P-8") Every keystroke in raw mode is a round trip. On a local socket this does not matter. Over a slow link it matters a great deal, which is why Plan 9's terminal programs do line editing locally and send whole lines. Raw mode should be entered deliberately and left promptly. = Layering: how 9P and the wire meet Six arrangements were costed. The finding that decides it is that the cost is almost the same in all of them: a base-9P2000 codec, a dispatcher onto the existing nine operations, and a fid table come to roughly 970 lines that appear in every option. Layering moves about 150 lines either way. So this is not a cost question. It is a question of reach. #v(0.3em) #align(center)[ #set text(size: 9pt) #table( columns: (auto, auto, auto, 1fr), align: (left, right, right, left), stroke: (y: 0.4pt), table.header([], [msgs/frame], [msgs/key], [what decides it]), [O1 9P replaces all], [2], [4], [64 park slots needed against 32; a round trip per frame], [O2 two listeners], [1], [2], [no routing origin for the 9P descriptor], [O3 9P inside the wire], [1], [2], [*works over the board's UART unchanged*], [O4 wire inside 9P], [2], [4], [+34 B on a 56 B diff, or +61%], [O5 side by side], [1], [2], [cheapest, but cannot reach the board], [O6 frontend serves], [2], [4], [core would write and match tags inside `update`], ) ] #v(0.3em) That table is superseded, and the correction is the most important thing in this note. It was written believing 9P has no way to push a frame, so every arrangement that carried frames over 9P paid a round trip. The premise is wrong — not because 9P can push, but because *the pushing end should be the client.* Plan 9 has shipped this for thirty years and it needs no invention. `drawterm` dials out to a cpu server, writes a shell script, and then calls `exportfs(fd, fd)`: the dialer becomes the *server* on the socket it dialed. The script the remote runs is `mount -nc /fd/0 /mnt/term`, and the remote is the *client*. When the remote application draws, the bytes cross as the application's `Twrite` to `/dev/draw/N/data`, and the terminal — the machine with the screen — executes them. One descriptor, roles fixed per side, data flowing both ways, no server-initiated message anywhere. So the rule is one role per connection, not one role per message: #v(0.3em) #align(center)[ #set text(size: 9.5pt) #table( columns: (auto, auto, 1fr), align: (left, left, left), column-gutter: 1.2em, row-gutter: 0.3em, [*display*], [frontend serves], [core `Twrite`s frames to `screen`, blocking-`Tread`s `input`], [*session*], [core serves], [scripts and other instances walk `7/body`, `event`, `ctl`], [*devices*], [board serves], [core reads `mem/`, `gpio/`, `prof`], ) ] #v(0.3em) Three connections, one role each; the program contains both halves and uses whichever the link calls for. Do *not* build a link that carries both roles at once. 9P permits it — T-messages are even and R-messages odd, so a stream is self-demuxing — but nothing has ever done it, and it buys a `Tversion` ordering hazard in which each side must answer the peer's version while awaiting its own. Two numbers make the display path credible. Chunking is a non-issue: payload per message is 131,072 bytes at Linux's default, so every real frame — 20 B, a 56 B keystroke diff, a 6,298 B full board frame, a 27 KB desktop frame — is one `Twrite`, and the 1.7 MB worst case is thirteen. And the core need not block: tags are allocated per outstanding request with no in-order reply requirement, which is why `exportfs` can answer out of order at all. Where `exportfs` needs a slave process per blocked request, PaRDeS needs none — `Status.again` and the park table are the same thing done single-threaded. Copy the file discipline rather than inventing one: `/dev/draw/N/data` is a write-only batched binary command stream with an exclusive `ctl`, and `/dev/mouse` is an exclusive single-reader file whose read blocks until something happens. That is `screen` and `input` already designed, and it is the shape `event` already has. Frames are never re-represented. Whatever carries them carries `encodeFrame` bytes; the existing five-byte length prefix already makes such a message a legal 9P payload. The counter-example is worth keeping in view: Plan 9's `/dev/screen` is an offset-addressed raster a client polls, with no change notification, which is precisely why nobody ever ran a remote rio through it. One scope limit, measured and not negotiable. This applies to *remote* displays only. Turning the local host vtable into a tree costs every shell more than it saves — tty and gui about +204 lines each, macOS +164, and the web shell +142 lines of Zig plus roughly 500 of JavaScript, because its `present` today writes a flat cell array that JavaScript reads straight out of wasm memory and 9P would make that an RLE stream needing a decoder and a codec. The board is worse in kind: its host is the same process behind a function pointer, so a message pair per frame is overhead against a direct call. `host.VTable` is already the abstraction — the design document says the surface *is* the abstraction and refuses a layer over the shells — so a 9P host is one more implementation of that vtable, chosen when the display is on the other end of a wire, and never for the window in front of you. #r("9P-19") #r("9P-23") = The client half The same program is a 9P client. This is not symmetry for its own sake: it is how one instance shows another instance's windows, and how it edits a file on a machine it cannot mount. The client half needs no mount, and after §1 that is no longer one advantage among several — it is the whole of what 9P buys that we cannot otherwise have. To edit a file on the board, #app walks to `board/fs/etc/config`, opens, reads, edits in a buffer, writes, and clunks. There is no path on the local machine, no kernel involvement, and no privilege. That works on macOS, which has no 9P in the kernel and no control filesystem at all today; it works in a browser tab, which has no filesystem at all; it works on the board, which has no sockets; and it works when the operator is not root, which on Linux is now the interesting case. A mount is still worth offering, because `grep`, `make` and the compiler take filenames and a filename is the one thing a client library cannot produce. But on Linux that mount is `src/fuse.zig`, which we keep, or it is `9pfuse`, which is `src/fuse.zig` written by someone else. It is not `mount -t 9p` unless the operator is root and chooses to be. = The board The microcontroller is the case that motivated this note, and both the first draft and its review were wrong about it in opposite directions. The first draft said the board runs "a 9P server and nothing else". It runs the whole editor, as firmware, with two of twenty-one host methods filled. The review said a 9P server would not fit, quoting nine kilobytes of free heap. That number is two refactors stale: since the shadow grids were reduced to one cell, the heap reports around 336 KB free and the binding constraint moved to the 240 KiB of low memory that holds `.bss`. #r("FIX-2") Costed from real components, a 9P server on this board is: #v(0.3em) #align(center)[ #set text(size: 9.5pt) #table( columns: (auto, auto, 1fr), align: (left, right, left), column-gutter: 1.2em, row-gutter: 0.3em, [two `msize` buffers], [8,192 B], [u9fs uses three; a minimal server needs two], [fid table, fixed array], [512 B], [32 entries × 16 B, linear scan ≈0.4 µs], [codec scratch], [128 B], [one `stat`, `ERRMAX`], [#text(weight: "bold")[RAM total]], [#text(weight: "bold")[8,832 B]], [2.5% of free heap], [flash, 9P-only image], [≈39 KiB], [2.5% of a 1.5 MB partition], ) ] #v(0.3em) The 4,096-byte floor that sizes those buffers is imposed by the Linux kernel and by nothing else; Plan 9, plan9port and #app's own client all accept a 512-byte `msize`, which would halve them. And a 4,096-byte reassembly buffer already exists on this exact serial line, with the property measured: that much in a single write arrives intact. So it fits, easily. What does not work is having it both ways: the editor owns the one UART bidirectionally and the board's header exposes no second one, so a 9P server on the P4 is a *second firmware image*, not a second role for this one. The build already expresses that shape — the on-die test suite is a separate executable with its own image and flash steps in fifty-four lines, and it links no editor object. The board is an editor or a filesystem, and saying so is better than implying both. #r("9P-11") Before quoting any latency for it, raise the console to 921600 baud. It is one divider write on the existing crystal, the routine already exists, and it takes a warm read of a small file from 10.8 ms to 1.35 ms. #r("BOARD-1") The tree such an image would serve is not invented. The board already exposes its whole address space, its pin header and one pad toggle — as *acme words a human types into a tag*, capped at four kilobytes per command because of the console, with the answer landing in an output pane. Nothing about it is machine-readable and nothing is remote. `mem/`, `gpio/pinout` and `prof` are backed by functions that exist today; four more files need one new exported symbol each; four have no implementation at all. A 9P tree is that capability with names instead of verbs, and the inventory above is the honest scope. = What we do not build The temptation in this design is to keep going. Each of the following was considered and declined. *A window system.* #app draws in one window. Panes are ours; surfaces are not. Becoming a Wayland compositor would buy the ability to put someone else's program in a pane, and cost input handling, buffer management, output hotplug, scaling, clipboard, and a permanent maintenance obligation against a moving protocol. We do not want someone else's program in a pane. *A namespace.* Per-process namespaces, union directories, `bind`, and walks that cross mount points are what make Plan 9's implementation large #cite(). The distinction is worth defending, because the first union directory brings the rest. *An aggregate.* The first draft's longest technical section described a prefix router that re-exports attached instances, and its own final section concluded the aggregate is not worth building before a second machine exists. That verdict is right and also covers the client half of it. It also understates the work: a proxied `Twalk` cannot be answered until the remote `Rwalk` arrives, and the core may not block, so it is not a fid map — it is per-tag continuations, tag remapping, flush forwarding and fid invalidation on connection death, in a daemon that is one `poll(2)` with no worker pool. Deferred with a named precondition rather than described as easy. #r("9P-10") *A file server.* Where a host's files must be exported, we run `u9fs` or `diod` and attach as a client #cite(). Real filesystems are where the difficulty in 9P actually lives: stable qids, `..` clamped at the export root, symlinks that escape, identity mapping, and the full `wstat` surface. Our own trees are synthetic, so we invent every file and there are no cases we did not choose. Note that `u9fs` serves its requests from a strictly serial loop and its flush handler is a `break`, so it is a reference for the tree and not for §10. *Authentication.* Not now, and not by us. The Unix socket is protected by the permissions on the socket. Where a network is involved, the connection is tunnelled. Linux's client never sends `Tauth` at all, and Plan 9's `mount` only does so without `-N`, so a server that refuses it is not an obstacle to either. Real authentication is `Tauth` or TLS, and it can be added without changing the tree. = Protocol notes We serve base 9P2000 #cite(). Plan 9 mounts it natively, plan9port's `9p` speaks it, and Linux mounts it with `version=9p2000` #cite(). The following are the traps, in the order they will bite. Each is cheap once known and each has been shipped wrongly by someone. *Set `qid.version` to zero on every file.* This is the 9P equivalent of FUSE's `FOPEN_DIRECT_IO` and it is server-side, not advice to the operator: Linux's client disables both read and write caching for any file whose qid version is zero, whatever the cache mode, unless `ignoreqv` is passed explicitly. A synthetic tree of live editor state has no business being cached, and this is how we say so. The first draft's "mount with `cache=none`" was unnecessary; its claim that the default message size is small was simply wrong, since the default is 128 KiB and the cap is 1 MB. #r("9P-3") *Clamp every reply to the client's `count`.* An `Rread` longer than the count asked for is a hard `EIO` in the Linux client, not a truncation. This has no FUSE analogue, which is why nobody looks for it, and `ad` gets it wrong on exactly the file we care about. The existing rule that a read too small for one event record is refused rather than split is what makes the clamp safe on `event`. #r("9P-17") *Decide the error ABI deliberately.* `Rerror` in base 9P2000 is a string, and Linux recovers an errno by exact match against a fixed table; a miss is not `EIO` but `ESERVERFAULT`, which userspace prints as "Unknown error 526". `ad` emits nineteen prose strings and not one of them is in that table. Either emit the exact strings Linux knows, or serve 9P2000.u and send the number. The second also restores `statfs`, which base 9P2000 does not have and which the core's operation set includes. #r("9P-4") *`Tflush` is a park-table lookup.* The common failure is not omitting it — it is implementing the message and still hanging, which is what `ad` does: correct `Rflush` ordering in thirty-nine lines, and a filesystem hook that defaults to doing nothing. #app is structurally better placed than either reference, because the thirty-two-slot park table is already keyed per outstanding request and already answers `FUSE_INTERRUPT` the same way. Implement flush against the table, not against a callback, and the bug is unrepresentable. #r("9P-16") *Serve requests concurrently.* Several tags may be outstanding on one connection and `event` reads block by design. This does not require threads: it requires that a blocked request be parked rather than waited on, which is what the core's `again` already means. = Cost, and the order to build in The honest figure is 1,600 to 1,900 lines of server plus around 500 of tests. This is measured, not estimated: `ad`'s 9P2000 server core is 2,321 lines (codec 704, stat and mode 437, session and fid and flush 615, loop and handlers 474) and `u9fs` is 1,838 with an 805-line codec. Two independent implementations agree on codec ≈750 and server logic ≈1,700. Roughly 970 of those lines are identical under every layering, so none of the estimate is at risk from §6. #r("9P-15") What comes off against it has now been counted too, and it is less than hoped. Six concepts are genuinely duplicated across the existing mechanisms — endpoint-path derivation in six copies, directory create-and-vet in six, stale sweeping in three, bind/listen/accept in three, "which instance?" in three, and version negotiation in two — 799 lines in total, of which about 330 collapses to one copy. Add `nested.zig`'s socket half and its socket tests, 518 more, and the deletion is *≈850 lines*. What cannot be deleted is ≈7,700: `src/fuse.zig` by §1, `wire.zig` because frames stay frames, the daemon's host half, the 158-line ancestor walk that answers a question 9P has no message for, and every kernel interface that was never a protocol choice — inotify, the pty, the subprocess pipes, `mkstemp`. *Net: between 750 and 1,050 lines of growth.* It breaks the house rule that a change leaves the file it touches no longer than it found it, §1 forbids retiring `src/fuse.zig` in exchange, and there is no arithmetic that rescues it. The growth is justified by reach and by §0's reply, or it is not justified. One cost is still unpriced and should be before any code is written. `wire.zig` chooses every protocol tag in one file so that reordering a core enum is a compile error, and a test asserts that exactly eleven client tags exist and that the other 250 bytes are refused, so a frontend built before a change cannot forge a pane's output into a session built after it. 9P's codec validates 9P; it cannot validate that a byte string is a legal keystroke. An exhaustive switch checked by the compiler becomes a runtime string parse with nothing to bind to. The likely answer is to generate the `ctl` grammar from the same enum so the compile error survives as a parser generator, but nobody has tried it. #r("9P-20") #r("9P-21") The order matters more than the total. Each step is stated with what it is, where it lands, what becomes possible that was not, and how you know it worked. Steps 1 to 3 are independent of each other and of 9P; step 4 needs step 2; step 6 needs step 5. Any prefix of this list is a reasonable place to stop. == Step 1 — let a detached session be scripted A daemon started with `--detach=work` holds the core, and today it serves no control filesystem at all: `src/detached/server.zig:566-570` records that it has no `push_fs_reply` "because this process mounted no /dev/fuse". It simply never calls `fs_service.start`. Nothing prevents it. The change is a `Source.fuse` variant in the poll union, an `fs` field, the `fs_service.start` call, `fsReply` copied verbatim from `src/tty/tty.zig`, one vtable entry, a pollfd and its dispatch arm, and the `drain` call. About sixteen lines. It is *better* in the daemon than in the desktop hosts: those need a background thread to notice a request, and the daemon's own `poll(2)` already watches everything, so it can watch `/dev/fuse` too and the thread disappears. After: `pardes --detach=work` and then `ls $XDG_RUNTIME_DIR/pardes//` works, and every script in `examples/acmefs/` drives a daemon it previously could not see. Proof is those four programs and the existing filesystem snapshots, run against a detached session instead of a local one. #r("9P-14") == Step 2 — name the transport seam `fs_service.drain` and `step` take a `*fuse.Fs`, but they only ever call three methods on it: `retry()`, `next()` and `reply()`. Replace the concrete pointer with a context pointer and a three-function vtable, at the three call sites in `tty.zig` and `gui.zig`. Forty lines changed, none added, `fuse.Fs` is the first implementor, and nothing observable changes. This is the whole preparation for a second transport, and it is worth doing on its own merits: if 9P is never written, the seam is documented in code rather than in a comment. #r("9P-2") == Step 3 — make terminals scriptable Add `pty/data`, `pty/ctl` and `pty/status` to each pane's directory in `src/acmefs.zig`, with `ctl` taking `winsize 80 24`, `raw`, `cooked`, `echo off`, `sig INT` and `exec /bin/sh`. Today a script can write into a terminal that already exists and read its rendered scrollback, and that is all — it cannot start one, resize one, or signal one. Two of those are free: `push_spawn` and `push_pty_resize` are already effects the core emits (`src/host.zig:80-82`), so `exec` and `winsize` are existing capabilities acquiring a name. Only `sig INT` is new work; there is no `kill` anywhere in `host_io.zig`. This has nothing to do with 9P. It lands in the FUSE tree, works immediately, and any later transport inherits it. Keep it to three files: acme has no pty files and neither does `ad`, so there is no prior art to be wrong about, which is a reason for restraint rather than ambition. #r("9P-8") == Step 4 — serve the existing tree over 9P A new `src/ninep.zig`: base 9P2000 over a unix socket, serving exactly the tree `src/acmefs.zig` already defines. No client, no aggregation, no `aname`, and no new files — step 3 already added the only ones wanted. Implement `Tversion`, `Tattach`, `Twalk`, `Topen`, `Tread`, `Twrite`, `Tclunk`, `Tstat`, `Twstat` and `Tflush`, and answer `Rerror` to `Tauth`, `Tcreate` and `Tremove`. Lift the park table and its `retry`/`take`/`freeSlot`/`findSlot` helpers out of `src/fuse.zig` unchanged — `Status.again` means the same thing to both transports. What is genuinely new is the codec, the fid table, a per-fid directory cursor, and `stat` marshalling. Every trap in §11 applies here and nowhere else. After: macOS and the browser have a control filesystem for the first time, and a script on another machine can drive a session over TCP. On Linux the FUSE mount stays, because `mount -t 9p` needs root and `fusermount3` does not — the two are complementary, not competing. Proof is the existing snapshots run through the 9P transport unchanged, and `examples/acmefs/` run through `9pfuse`. #r("9P-15") == Step 5 — become a 9P client The other half, and the one that moved up the list, because it is the mechanism for everything the note is for. A server lets pardes be *talked to*; a client is how pardes *asks*. It is a state machine over a byte stream — walk, open, read, write, clunk — with no kernel, no mount and no privilege, and the protocol layer is freestanding-safe: no allocator, no threads, no sockets, so it runs on the board's UART and in the browser as readily as on a socket. After: `Look board/fs/etc/config` opens a file that lives on the microcontroller, in a pane, with no mount anywhere. And `Attach` gains the sibling it has always needed — one that *connects* instead of replacing. Today `Attach` deinitialises the local core and turns the process into a thin frontend; the new word leaves both cores alive and lets each walk the other's tree. That is the "chaining" that is currently impossible. #r("9P-22") == Step 6 — let a remote display serve the core A frontend exports `screen`, `input`, `snarf` and `ctl`; the core dials it and becomes its client, writing frames to `screen` and taking a blocking read on `input`. This is `drawterm` exactly, and the file discipline should be copied rather than invented: a write-only batched command stream with an exclusive `ctl`, and an exclusive single-reader input file whose read blocks. The frame bytes are `encodeFrame` output verbatim — the codec does not change, only what carries it. *Remote displays only.* The window in front of you keeps the direct vtable call, because a tree costs every local shell more than it saves. What this actually unlocks: the ESP32-P4 stops being a shrunken pardes — an 809 KB image running a subset of the editor in 384 KB of RAM — and becomes a terminal for a full core running on the workstation. Roughly 39 KB of firmware, and it gains language servers, tree-sitter and PDF because those now run somewhere that can afford them. Raise the console to 921600 baud first, or every measurement will be eight times worse than it needs to be. #r("9P-19") #r("BOARD-1") = Status The tree, the event protocol, the pty control language and the layering are settled enough to implement, and the registry holds the arguments that settled them. Three questions remain genuinely open: whether the address becomes a per-fid property and the core grows state to hold it #r("9P-6"); which error ABI we adopt #r("9P-4"); and whether the parked second core on the board could own a 9P server so the board need not choose between being an editor and being a filesystem #r("9P-11"). Steps one through three are worth doing whatever the answer to the rest is, which is the best property this plan has. #v(1.5em) #line(length: 30%, stroke: 0.5pt) #v(0.5em) #set text(size: 9.5pt) #set par(first-line-indent: 0em) #bibliography( title: none, full: false, ("refs.yml"), )