--- 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 Check `$NINE_MOUNT` first. `9ns --mntgen` sets it for every process it starts (an interactive shell on this machine runs inside one), so a set `$NINE_MOUNT` means the posted-9P registry is mounted there, usually `/mnt/9p`, and each running editor is a directory `$NINE_MOUNT/pardes//` (a `--detach=NAME` session's is `pardes/NAME/`). Inside a pane, `$PARDES_9P` is that session's socket (`/run/user/1000/pardes-9p-.sock`), which names the directory either way, and `$PARDES_PANE` is the calling pane's serial: ```sh [ -n "$NINE_MOUNT" ] || echo 'no 9P mount: use the Python client below' s=${PARDES_9P##*/pardes-9p-}; m=$NINE_MOUNT/pardes/${s%.sock} # under `9ns --unix SOCKET -- cmd` the session is the mount itself: m=$NINE_MOUNT cat "$m/index" cat "$m/pane/$PARDES_PANE/body" ``` From there the editor is files: `cat`, `echo >`, `ls` and shell scripts are the whole interface, and nothing below needs more than they do. 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. `$NINE_MOUNT/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, and there `$NINE_MOUNT` is the session's own root (`$NINE_MOUNT/index`), not a registry; 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, column serial $m/status pid, version, panes $m/look write a line = a right click on it at the active pane (file:/re/, file:#n, :/re/ select by address, as acme's look, from the file's dot: file:0/re/ for the first match; file:N selects the line; a miss changes nothing, logs err, and look reads back empty) $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 , run , exit , send , dump|restore , err : (exec 3<>$m/log; echo follow >&3; cat <&3 waits for new ones; tail -f does not; while read -r line <&3; do ...; done loses nothing) $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 makes an empty column, Dump, Theme x; Exit QUITS the editor, Kill [word...] stops the commands pardes started (command panes, lines it typed into shells), a word matching a command line's first word; Exit and Restore refuse once, naming every unsaved pane, `, : Modified (Exit again to discard them all)`, and the same word again DISCARDS that text -- not a retry, unlike lock's `file in use`; Kill signals only the foreground job, so of `sleep 30; echo done` the echo still runs; Joincol needs a column to the right of the keyboard's); a pane's builtins (Del, Save f, Collapse, which folds that pane, and the column word Delcol, which closes 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/layout one line per column: serial index x width current|notcurrent empty|full pane-serials...; active $m/tag the workspace tag (> replaces, >> appends, one line); $m/col//tag a column's (serials stay, as panes' do) $m/col//ctl Delcol, Joincol, New, Tty on that column; col//exec a word as a click in its tag; rmdir col/ closes an empty column $m/pane/new open it to make a pane (a scratch named /+New), read names it; it goes in the ACTIVE column (the one last typed or clicked in, or Newcol's), filling it if empty, else taking the bottom half of its last pane (ctl `Placement pardes`: the old rules); rmdir $m/pane/ closes it; a column's last pane leaves the column EMPTY (focus reads empty, the log says only del), and the session's LAST pane QUITS it $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 a command line: written at a terminal at its prompt it is typed into that shell; from anywhere else it runs as a command pane, a terminal whose child is the root ctl's `Shell` (fish unless set) running `-c` the line in the pane's directory, which ends showing `exit N` (a typo: `exit 127`) and logs `run ` and `exit ` -- `exec` reads back its serial, so follow `log` for the exit. The directory's next command reuses a finished command pane, below what it showed. A terminal bound as a REPL (`Repl python` on its ctl) takes the middle clicks made on a `.py` body instead, but never a 9P write: a script sends code by writing the REPL pane's `pty/data`, and runs a command from such a file with `Exec ` or the tag. A bound terminal's `ctl` line ends with its id (`python-a`); an event record written back from such a file's body goes to its REPL, as the click would. `Repl -` unbinds; a bare `Repl` says the binding. With several REPLs bound for a language an exec asks which, logged `ask repl a b` (Del's side from the keyboard is `ask del k j`): answer with `echo 'answer a' > $m/pane//ctl`, or `answer -` to send nothing. Multi-line code written to `pty/data` should be a bracketed paste, `\e[200~\e[201~`, then, in a separate write once the REPL has echoed the paste (Python 3.13+ takes a `\r` read with the paste as part of it, even for one line), `\r`, and a second `\r` when the code's last line is indented (a `def` or `for` body): one Enter leaves such a block open. Sent line by line, a blank line ends a Python block, and Python 3.14's REPL auto-indents each line it is typed. 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/`: Rename everywhere, or any sam edit, is one write: `echo 'Edit ,x/foo/c/bar/' > $pane/ctl`. `Edit` takes sam's command language (acme's): addresses, `x y g v c a i d s p = m t u` and `{ }` (commands in braces one to a line, so from exec or a tag, not a one-line ctl write). Its changes are one undo step, and one that fails changes nothing and fails the write with acme's words (`Edit: no substitution`), logged as `err`. A block goes in one write to the pane's `ctl`, the root `ctl` (the active pane) or `exec`: an `Edit` line takes the lines after it until its `{` closes or its `a`/`c`/`i` text ends with `.`. bash's builtin `printf` writes line by line, so use a heredoc or `env printf`: ```sh cat > $pane/ctl <<'END' Edit ,x/area_of/{ i/[/ a/]/ } END env printf 'Edit ,x/foo/{\ni//\n}\n' > $pane/ctl ``` `p` and `=` print to the directory's `+Errors`. Not there: `b B D e r w f X Y`, `< | >`, and `\1`-`\9` in `s`. | 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` (refused once while there are unsaved edits; again discards) | `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`, or pardes's `12:5`: line 12, byte column 5, composing as `12:5,14: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). In a pattern with `\n`, `^` works only first (`^def .*\n` finds every def line) and `$` only just before a `\n`; anywhere else the pattern is refused, not silently unmatched. `^` inside an alternation (`^def|^ `) holds only where the search starts: fine in `x` over lines or `g`, not mid-line. An expression is evaluated from the current address (the last one written, or just past the last `data` write): `.` is that address, not the selection, `/re/` searches on from its end and wraps unless `limit` is set, `?re?` or `-/re/` searches back, `#100,#50` fails `addresses out of order`, and a search that backtracks past a step budget (about 300 ms) fails with `regular expression search gave up, ...`. 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 only the end of a forward search, as in acme, and reads empty until set; truncate it to lift it. In `12:5` the column counts bytes from 1 and clamps past the end of the line; a line past the end is `address out of range`. A refused write repeated the same way is one `err` line counted, `(x4)`. `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. `tag` reads the path, then the text you may edit: `> tag` replaces that text (default words too), `>> tag` appends to it. Address state belongs to the pane, not to a client: it keeps the last range written until someone writes another, so writing an address and reading it back evaluates it, and two clients addressing the same pane will interfere. Neither an open nor a `>` resets it (acme resets it on the first open): each `echo /re/ > addr` searches on from the last address, so a find-and-replace loop advances. Write `0` to start from the top. The search wraps, so stop a find-all loop when the address comes back to where it began, or set `limit`. `$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 -- and lines written to that pane's own `look`/`exec`, or to the root's while it has the keyboard, as `F` records at `0 0` with the text, and clicks in a terminal's body, also at `0 0` -- so it is not a passive logger: a helper holding `event` that writes its own pane's exec gets its command back as a record; run it through `ctl` or write the record back. To have a record done, write it back: the short form ` \n` acts on that range's text, and the whole record as read acts on its text when the range is empty (the only way for a record at `0 0`). Chord reports need explicit handling. A record is ` \n` and its text may hold newlines: read `n` bytes of text, never up to the next newline (acme counts runes; pardes counts bytes, as all its offsets are). A Restore puts a new editor under every client: the Restore write is answered, then every connection is hung up (their fids name the old editor's panes); dial again, and the new log names the restored panes then `restore ` -- the authority, since a slow client may see the cut before the answer. A 9ns older than cloud9 2a7137c could fail the Restore write with ECONNRESET although the Restore went ahead; trust the log. `Dump` writes `pardes--