--- 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 , dump|restore , err : (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 started (command panes, lines it typed into shells); 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`; 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/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 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 `$SHELL -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. 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). 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 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 -- 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. `Dump` writes `pardes--