--- name: pardes-9p description: Inspect and drive a running Pardes editor through its control filesystem, or exercise its panes, builtins, terminal input and rendered output in an isolated session. Use for Pardes interaction, plugin development and end-to-end debugging. --- # Pardes over 9P Pardes is driven by reading and writing files. Prefer ordinary file tools over a mount; reach for the Python client only when nothing is mounted, or when the work needs a fid held open. Do not build another wire client: project tooling stays in Zig. Run the examples from the repository root; paths below are relative to that root unless linked. ## Find the session through the mount This machine keeps a FUSE mount of every posted session under `/mnt/9p`: `/mnt/9p/pardes//` is one session's served root. Inside a pane, `$PARDES_9P` is that session's socket (`/run/user/1000/pardes-9p-.sock`), so its pid is the directory to use, and `$PARDES_PANE` is the calling pane's serial: ```sh m=/mnt/9p/pardes/$(basename "$PARDES_9P" .sock | sed 's/^pardes-9p-//') cat "$m/index" cat "$m/pane/$PARDES_PANE/body" ``` Entries for dead sessions stay listed and answer `Input/output error` on any access, so name the session you mean rather than globbing or taking the newest. `cat "$m/index"` is the cheapest liveness check, and `cat "$m/status"` reports the `pid`, `version` and `panes` of a session new enough to serve it. `/mnt/9p/pardes` is the registry of posted sessions, mounted once for the machine. It is not the per-pane kernel mount the `Tty9p` builtin makes, which gives one pane's shell `$PARDES_MOUNT`; see [docs/v9fs.md](../../../docs/v9fs.md) for that. Either mountpoint serves the same tree. A `9ns --unix SOCKET -- cmd` mount exists only inside `cmd`'s private namespace; to share one, use the registry (`9ns --mntgen`). A running session serves whatever binary started it. If a listing does not match this document, that session predates the change; restart it. ## The tree, and what to do with it ``` $m/README the served guide, worth reading first $m/index one line per pane: serial, kind (text|term|pdf|image), dirty flag, name $m/status pid, version, panes $m/look write a line = a right click on it at the active pane $m/exec write a line = a middle click: an editor command word, or a shell line $m/pane//pty/run write one line, read `exit N` + its output, or `busy` / `error ...`, on the same open: exec 3<>$m/pane/$n/pty/run; echo make >&3; cat <&3 (a fresh terminal: waits for its first prompt; if none comes, interrupt the read) $m/log recent events, then EOF: new|del|rename|save , msg (exec 3<>$m/log; echo follow >&3; cat <&3 waits for new ones; tail -f does not) $m/screen the rendered screen as JSON, frozen per open $m/listeners this session's dial addresses $m/focus the serial of the pane with the keyboard (empty while a column/workspace tag has it); echo a serial into it to move the keyboard (a folded pane stays folded) $m/ctl the settings, one a line as a write takes them; write a setting or a session builtin (Newcol, Dump, Theme x; Exit QUITS the editor, Kill [word...] stops the commands pardes typed into terminals; Exit and Restore refuse once per unsaved pane, `: Modified (Exit again to discard)`, and the same word again DISCARDS that text -- not a retry, unlike lock's `file in use`); a pane's builtins (Del, Save f, and the column words Delcol, Collapse, which act on that pane's column) go to $m/pane//ctl $m/commands every builtin: `Word`, `Word arg`, then `root` or `pane` (which ctl takes it) $m/pane/new open it to make a pane (a scratch named /+New), read names it; rmdir $m/pane/ closes it; closing a column's last pane leaves a +New there $m/pane//errors write-only: text appended to the +Errors pane of the pane's directory $m/os/ the host filesystem ``` The first field of an index row is a stable pane serial, not a slot or row number. A row is `serial kind dirty name`, so `row.split(maxsplit=3)` keeps a name with spaces intact. Re-read the index after anything that might open, reuse or close a pane. ```sh cat "$m/index" # which panes exist n=$(cat "$m/pane/new") # make one, take its serial printf 'text\n' > "$m/pane/$n/body" # append cat "$m/pane/$n/tag" # what its tagline offers echo notes.txt > "$m/pane/$n/name" # rename the buffer echo Save > "$m/pane/$n/exec" # save it (or to its ctl) echo Tty > "$m/pane/$n/ctl" # a terminal in its directory echo "/etc/hosts:3" > "$m/look"; cat "$m/look" # open a file, see where it landed rmdir "$m/pane/$n" # close it, dirty or not ``` A session may open its own mount from inside itself: a Look at `$m/anything` in the editor that serves `$m` is answered on the connection's task while the editor's own syscall waits. `/n/self/...` names the same tree without leaving the process. **Opening** `$m/pane/new` is what makes a pane, and reading the open file answers its serial — `/net/tcp/clone`'s mechanism. Each open makes another one, two reads of the same open file answer the same serial, and closing it leaves the pane. A pane is named by the serial the editor gives it, never by a name you choose. A *stat* makes nothing, which is the whole reason the allocation sits on open: `new` is listed in `$m/pane`, so `ls` shows it, and `ls -l`, `find` and anything else that stats every name a listing handed it stay inert. acme allocates on the walk instead and lets it land inside the new window, so `/dev/new/body` works in one step — it can afford that because a Plan 9 directory read carries every entry's stat and nothing walks. Under a kernel or FUSE mount that would be a pane per `ls -l`. Nothing else in the tree can be created or removed, and no read creates anything. `look` and `exec` are the editor's two clicks, one per line of a write, at the active pane from the root and at that pane from `$m/pane//look` and `$m/pane//exec`. Reading any of them answers the serials the last command made, or the pane it focused or acted on. A command that fails is reported in the editor, not as a write error, so inspect the resulting pane, index, message or screen; only a malformed line fails the write itself. A word no builtin knows (a typo included) is typed into a terminal -- the pane itself if it is a terminal at its prompt, else one in the pane's directory, made if need be -- and is not reported as an error. Every refused 9P write adds an `err : ` record to `$m/log`; through a mount the write itself only says `Invalid argument`. ## Edit through addresses, dot and the flag files For a file or scratch pane, with `pane=$m/pane/`: | Operation | Shell | Python client | |---|---|---| | Read text | `cat $pane/body` | `client.read(pane + '/body')` | | Append text | `echo text >> $pane/body` | `client.write(pane + '/body', b'text\n')` | | Replace all text | `echo text > $pane/body` | `client.write(pane + '/body', b'text\n', truncate=True)` | | Address a byte range | `echo '#0,#2' > $pane/addr` | `client.write(pane + '/addr', b'#0,#2')` | | Replace that range | `printf 'pub fn' > $pane/data` (`>>` too) | `client.write(pane + '/data', b'pub fn')` | | Delete that range | `: > $pane/data` | truncate `data` (open with OTRUNC) | A write leaves `addr` just past what it wrote, so a second `echo x > data` inserts after the first: write `addr` again before each replacement. Truncating `data` is pardes's own (acme ignores OTRUNC and always inserts). | Read the selection | `cat $pane/dot` (offsets), `cat $pane/sel` (text) | the same two reads | | Select the addressed range | `cp $pane/addr $pane/dot` | `client.write(pane + '/dot', client.read(pane + '/addr'))` | | Reload from disk | `echo get > $pane/ctl` | `client.write(pane + '/ctl', b'get\n')` | | Close it | `rmdir $pane` | `client.remove(pane)` | `addr`, `dot` and `limit` each read the pair of offsets they also accept, which is why copying one onto another is all that acme's `addr=dot`, `dot=addr` and `limit=addr` ever were; a write may also be an address expression (`#0,#5`, `/pattern/`, `2+1`), whose regexps are mvzr's searched as sam searches: `^`/`$` match at any line's start and end, `.` and `[^...]` never match a newline, the leftmost match wins (the first alternative there, not the longest), `/re/` wraps unless `limit` is set, and a search that backtracks past a step budget (about 300 ms) fails with `regular expression search took too long`. A failed address says why (`no match for regexp`, `address out of range`) and leaves no address: `data` refuses until the next good one, so a missed target is never written at the old one. Moving `dot` scrolls the pane to it. `limit` bounds a search and reads empty until set; truncate it to lift it. `dirty`, `mark` and `scroll` read `0` or `1` and take `0` or `1`: whether the buffer differs from its file, whether a write pushes an undo point, and whether a write scrolls. Truncating `tag` clears the part of the tag you may edit. Address state belongs to the pane, not to a client: it keeps the last range written until someone writes or truncates it, so writing an address and reading it back evaluates it, and two clients addressing the same pane will interfere. `$pane/ctl` reads acme's window status line — serial, tag length, body length, a reserved zero, the dirty flag, the width in cells, the font and the tab width, then `current` or `notcurrent` — and takes `get` (reload from disk), `lock`/`unlock`, and any builtin that acts on a pane (`Del`, `Save f`, `Collapse`). A `lock` another open holds fails at once with `file in use` (EBUSY): retry it. Session builtins and settings go to the root `ctl`, which reads back every setting in the syntax it takes. A ctl write is checked whole first and refused as `unknown control message "X"` (EINVAL) and the like, a required argument missing included (`wrong #args ... "Mount"`); then a line whose builtin reports an error fails the write with that error and the line (EIO), after the lines before it took effect. A `Save` on a scratch fails rather than prompt. The lock binds only clients that take it, and is held by the open that wrote it, so a shell holds an fd across the edit: `exec 3>$pane/ctl; echo lock >&3; ...; exec 3>&-`. Terminal panes have no file: writing their `body` sends child input, and truncation does not erase terminal history. ## The Python client, for what a shell cannot express Use the existing [Python client](../../../test/ninep.py) when there is no mount, or when the work needs a fid held open across several operations — `log`, `event` and `pty/data` are consuming queues whose reads park, and shell redirection cannot hold one open. ```sh PYTHONPATH=test python3 -B - "$PARDES_9P" <<'PY' import sys from ninep import Client with Client(sys.argv[1]) as client: print(client.read('/index').decode(), end='') print(client.read('/listeners').decode(), end='') PY ``` `Client` takes a raw Unix socket path, or `(numeric_ip, port)` for TCP; it does not parse Pardes dial strings or implement QUIC. It negotiates 9P2000, uses a five-second socket timeout, and closes on leaving `with`. Its paths are the served root: `/index`, `/pane/2/body`, `/os/...`. `/n/self`, `/n/os`, `/n/peer` and `/virtual` are editor Look paths, not server paths — Look `/virtual/src/pardes.zig` corresponds to reading `/src/pardes.zig`. For live terminal output or plugin events, use `open` / `read_fid` / `close`, not the read-until-EOF helper. `pty/data` captures output while held open; it is not a history replay. Both files are shared, consuming queues, not per-client broadcasts, so a slow reader loses older data. Holding `event` open intercepts that pane's Look and Exec clicks, so it is not a passive logger: for Look/Exec records whose offsets identify the intended text, forward the short record ` \n`, not the whole report with flags and text. Expansion and chord reports need explicit handling, and terminal-body events can carry text only in the report, which a short writeback cannot reproduce. Read the event implementation before building an interceptor. Close handles in `finally`, and disconnect after a socket timeout. The service shares four connection slots and 32 screen/terminal-history snapshot handles. A file's qid version is the pane's revision for `body`, `data` and `xdata`, so `stat` sees an edit land without reading the text; it stays zero elsewhere. Stat sizes are real, and for `log`, `event` and `pty/data` report the length of the record a read would answer — zero when nothing is waiting. ## Terminal input and screen observations Only terminal panes have `pty/`. Write keystroke bytes to `pty/data`, not `body`: `client.write(pane + '/pty/data', b'printf hello\r')` submits a shell command. For an interactive application, send its actual input bytes; `b'\x03'` is Ctrl-C, and Ctrl-U is `b'\x15'` where that application supports it. These go to the child terminal, not to Pardes key bindings. `pty/ctl` takes `winsize C R`, `sig INT` and `exec`, one per line. `client.screen()` returns `cols`, `rows`, `cursor`, `styles` and row-major `cells` of `[grapheme, style_index]`. Reconstruct rows using `cols`; resolve each cell's style through `styles` when checking highlighting, and compare colors and attributes rather than style-table indices or flattened text. Each screen open freezes one frame, and a terminal `body` freezes its history on its first read. `client.read` and `client.screen` reopen each time, so repeat the call for a fresh observation rather than polling a stale handle. Poll a specific condition with a deadline and a short delay, not a fixed long sleep. For large histories, measure whole-body reads separately from screen polling; write-to-observation timing includes RPC, rendering and polling. ## Exercise an isolated session Reuse [test/fs.py](../../../test/fs.py), which starts a private session with temporary configuration and cleans up its editor process. Pass an existing native binary, not a benchmark executable: ```sh PYTHONPATH=test python3 -B - /absolute/path/to/pardes <<'PY' from pathlib import Path import sys import tempfile from fs import session, new_pane, execute binary = str(Path(sys.argv[1]).resolve()) with tempfile.TemporaryDirectory(prefix='pardes-9p-skill-') as directory: with session(binary, Path(directory), 'skill') as (client, address): serial = new_pane(client, b'fn main() void {}\n') pane = f'/pane/{serial}' client.write(pane + '/name', b'probe.zig\n') client.write(pane + '/addr', b'#0,#2') client.write(pane + '/data', b'pub fn') assert client.read(pane + '/body') == b'pub fn main() void {}\n' execute(client, serial, 'Msg 9p-ready') frame = client.screen() assert '9p-ready' in ''.join(cell[0] for cell in frame['cells']) print('9P edit, builtin and screen checks passed') PY ``` `new_pane` opens `/pane/new` and reads the serial it answers; `execute` writes one line to a pane's `exec`. Both are in `test/fs.py`. For terminal tests, use `session(..., tty=True)` and read [test/agent_session.py](../../../test/agent_session.py) for bounded interactive driving; its readiness text and history threshold are application-specific, and session cleanup alone does not guarantee arbitrary grandchildren have exited. Use [test/snapshot.zig](../../../test/snapshot.zig) when editor key/mouse input or an independent terminal-rendering comparison matters; 9P screen inspection alone does not test physical input routing or the host renderer. Read [docs/fs.md](../../../docs/fs.md) for runtime mounts, TCP/QUIC listeners, Plan9port and Linux v9fs compatibility. Unix is always available; network listeners are opt-in and grant full session and OS-file access, so use isolated loopback listeners for tests. For event details or anything this page leaves open, read the implementation and its tests in [src/ninep/](../../../src/ninep/).