summaryrefslogtreecommitdiff
path: root/.agents
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-29 19:25:00 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:12:17 -0300
commit57b30ba3e38153a4446626449b0fed5120da954c (patch)
tree7b9381327a05791181a855bd8d4ba3cfb4df5301 /.agents
parent0fd908eea63d04886b269438aa7529d3dd422256 (diff)
downloadpardes-57b30ba3e38153a4446626449b0fed5120da954c.tar.gz
pardes-57b30ba3e38153a4446626449b0fed5120da954c.zip
The docs and the 9P skill say each fact once, in the file that owns it, and say only what a live session does
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Diffstat (limited to '.agents')
-rw-r--r--.agents/skills/pardes-9p/SKILL.md544
1 files changed, 116 insertions, 428 deletions
diff --git a/.agents/skills/pardes-9p/SKILL.md b/.agents/skills/pardes-9p/SKILL.md
index 1d32ce7a..7cdca1a0 100644
--- a/.agents/skills/pardes-9p/SKILL.md
+++ b/.agents/skills/pardes-9p/SKILL.md
@@ -1,486 +1,174 @@
---
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.
+description: Inspect and drive a running Pardes editor through its 9P 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.
+Pardes is files: `cat`, `echo >`, `ls` and shell scripts are the whole
+interface. [docs/fs.md](../../../docs/fs.md) is the reference for every
+file; this page is recipes. Paths are relative to the repository root.
-## 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/<pid>/` (a `--detach=NAME`
-session's is `pardes/NAME/`; `pardes --detach=NAME &` runs the editor as
-that process, so `$!` is its pid, and `$m/status`'s first line, `pid <n>`,
-says the same). Inside a pane, `$PARDES_9P` is that session's
-socket (`/run/user/1000/pardes-9p-<pid or NAME>.sock`), which names the
-directory either way, and `$PARDES_PANE` is the calling pane's serial:
+## Find the session
```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.
-
-`pardes FILE` run in a pane opens FILE in that session and returns at once;
-`pardes --wait FILE` returns when that pane is deleted (1 if the session goes
-away), which is what `EDITOR='pardes --wait'` needs (`GIT_EDITOR` follows it).
+`$NINE_MOUNT` is set by `9ns --mntgen` (usually `/mnt/9p`). `$PARDES_9P` is
+the session socket of the pane you run in, `$PARDES_PANE` that pane's
+serial. Under `9ns --unix SOCK -- cmd` the session is the mount itself:
+`m=$NINE_MOUNT`. In a `Tty9p` shell: `m=$PARDES_MOUNT`. A dead session's
+entry answers `Input/output error`: name the session, never glob. A running
+session serves the binary that started it.
-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.
+## Look around
-`$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`).
+```sh
+cat "$m/index" # serial kind dirty name column, a pane a line
+cat "$m/layout" # columns, then `active <serial>`
+cat "$m/focus" # the pane with the keyboard
+cat "$m/README" # the one-screen guide
+cat "$m/commands" # every builtin and where it goes
+```
-A running session serves whatever binary started it. If a listing does not
-match this document, that session predates the change; restart it.
+Parse an index row with `rsplit(maxsplit=1)` first: names may hold blanks.
+Re-read the index after anything that opens or closes panes.
-## The tree, and what to do with it
+## Open a file, go to a place
+```sh
+echo "$PWD/main.zig:120" > "$m/look"; cat "$m/look" # serial it landed in
+echo "main.zig:0/fn main/" > "$m/look" # first match; without 0, the next after dot
```
-$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/<n>/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 <serial> <name>, msg <serial|-> <text>,
- run <serial> <line>, exit <serial> <N|?>, send <from> <to> <repl-id>,
- ask <serial> <what> <choices>, answer <serial> <choice|->, changed <serial>
- [reloaded|deleted] (under unsaved edits; a clean reload; gone from disk),
- unsaved <serial> <name> (each pane a refusal is about), newcol|delcol <serial>, restored|restoredcol <old> <new>,
- dump|restore <path>, err <serial|-> <file>: <why>
- (exec 3<>$m/log; echo follow >&3; cat <&3 replays what is there, then waits for new
- ones; `echo follow new` waits for new ones only; tail -f does not wait;
- while read -r line <&3; do ...; done loses nothing within a session (through
- FUSE, `read -t` never times out: use `timeout N` around the loop): a Restore
- hangs every connection up, so dial again, and restart a 9ns mount)
-$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;
- `size <cols> <rows>` sizes a --detach session no frontend is attached to (160x50 before)
- (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,
- logging `unsaved <serial> <name>` for each unsaved pane, then failing the write with
- `<name>: Modified (Exit again to discard)` for one or `4 unsaved panes: Modified (Exit
- again to discard)` for more (Restore's says `Restore again`); on screen the list stays
- in the one +Unsaved pane under a short notice (`3 unsaved panes — Exit again to discard`); a second refusal names only the panes edited since the last,
- as acme's does, and the same word again with nothing edited since DISCARDS them all -- not a retry, unlike lock's `file in use`;
- Kill stops a command pane's whole line, `&` jobs included; of a line typed into a
- shell only the foreground job, and the shell decides the rest (of `sleep 30; echo done`
- bash and fish both run the echo); Joincol folds the keyboard's column into the one
- on its right, its panes going below that column's own, in order, and needs such a column);
- 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/<n>/ctl
-$m/commands every builtin: `Word`, `Word arg`, then `root`, `pane` or `both` (which ctl takes
- it), a setting's values comma-joined, then ` -- ` and one sentence of what it does
-$m/recent files opened lately, closed ones too, most recent first: `open|closed <path>`;
- lost a file you closed? read this, or run Recent and look at its row to reopen it
-$m/layout one line per column (16 columns at most: Newcol past that fails, no space for a column): serial index x width current|notcurrent empty|full pane-serials...; active <serial>
-$m/tag the workspace tag (> replaces, >> appends, one line); $m/col/<serial>/tag a column's (serials stay, as panes' do)
-$m/tagexec a word as a click in the workspace tag (pane/<n>/tagexec: in that pane's tag); every exec
- file reads back the serials its last write touched
-$m/col/<serial>/ctl Delcol, Joincol, New, Tty on that column; col/<serial>/exec a word as a click in its tag;
- rmdir col/<serial> closes an empty column
-$m/pane/new open it to make a pane (a scratch named <dir>/+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);
- a session holds 64 panes (16 on the board): past that, every route that opens one
- fails with `no space for a pane: 64 max` (ENOSPC), and so does one whose column
- has no room (each pane keeps its tag and 2 rows: `no space for a pane in that column`); rmdir $m/pane/<n> 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/<n>/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 column`, the last word the pane's
-column serial, so `head, col = row.rsplit(maxsplit=1)` then
-`serial, kind, dirty, name = head.split(maxsplit=3)` keeps a name with spaces
-intact. Re-read the index after anything that might open,
-reuse or close a pane.
+A miss opens nothing, reads back empty and logs an `err <serial> look: ...`
+line; the write itself succeeds. `L:C` columns are bytes.
+
+## Make, fill, name and save 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" # replace its text (> truncates)
-printf 'more\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
+n=$(cat "$m/pane/new") # each open makes one: read it once
+printf 'hello\n' > "$m/pane/$n/body" # > replaces the text
+printf 'more\n' >> "$m/pane/$n/body" # >> appends
+echo "$PWD/notes.txt" > "$m/pane/$n/name"
+echo Save > "$m/pane/$n/ctl"
+rmdir "$m/pane/$n" # close it, saved 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.
+## Edit text
-**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.
+```sh
+p=$m/pane/$n
+echo 'Edit ,x/foo/c/bar/' > $p/ctl # sam edit, one undo step
+echo '/old/' > $p/addr; printf 'new' > $p/data # replace the next match
+echo 0 > $p/addr # back to the top
+echo '3' > $p/addr; : > $p/data # delete line 3
+cp $p/addr $p/dot; cat $p/sel # select it, read the selection
+```
-`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/<n>/look` and
-`$m/pane/<n>/exec`. Reading any of them answers the serials the last command
-made, or the pane it focused or acted on: an open's own last write's, or, on
-an open that never wrote, the session's last. With other clients about,
-write and read one open (`exec 3<>$m/look; echo x >&3; cat <&3; exec 3<&-`).
-A read is a stream: once read, a second read on the same fd gives EOF (on
-an open that wrote, until its next write); open again, or seek to 0, to read
-it again. Each command line runs once whole, however a mount cuts a big write; a
-write's last line with no newline runs with it (and fails it), unless the
-write filled its 9P message and may go on; an Edit block still open when the
-file closes fails there (an `err`: ``unmatched `{'``, or an a/c/i text with no `.`
-line), changing nothing. One rule: a builtin that fails, whether through a
-ctl (a pane's pty/ctl `exec` too), look, exec, tagexec or a column's exec,
-fails the write (EINVAL for a malformed line, ENOENT for what is not there --
-a directory gone, a Find or Grep with no hit -- else EIO or an errno that
-fits) and logs one `err` with the reason, no `msg`. A look that finds nothing is no failure: it answers
-nothing and logs one `err`. A command line run in a command pane is judged
-by its `exit` record. 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` ($SHELL, else /bin/sh, unless set)
-running `-c` the line in the pane's directory, which ends showing `exit N` (a typo: `exit 127`) and logs `run
-<serial> <line>` and `exit <serial> <N|?>` -- `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 <text>` 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 <serial> repl a b`
-(Del's side from the keyboard is `ask <serial> del k j`): answer with
-`echo 'answer a' > $m/pane/<serial>/ctl`, or `answer -` to send nothing (the
-log says `answer <serial> a|-`). Kill does not stop what a REPL runs (it was
-typed, not started by pardes): `echo 'sig INT' > $m/pane/<repl>/pty/ctl`
-interrupts it. A range of a `.py` pane goes to its REPL by writing the event
-record `MX<q0> <q1>` to that pane's `event`. A chorded exec's record (flag 8)
-is followed by two, its argument and where it came from; write all three back
-as read (one write or three) and it runs once with its argument. `Tty`'s argument is a shell
-(`Tty fish`), and `Tty` on a pane's ctl opens a new terminal pane. After a
-Restore, panes have new serials (`restored <old> <new>` in the log), a
-command pane shows how it ended, `exit N` (`exit ?` if it was still running when
-dumped), and does not run again, and REPLs are unbound. Multi-line code
-written to `pty/data` should be a bracketed paste, `\e[200~<code>\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`.
-A paste of one line needs that one `\r`; a paste of more than one line
-needs a second `\r` unless the pasted code ends in a newline (one Enter
-leaves a multi-line input at `...`). A middle click, or an event
-write-back, sends what it needs by itself. 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 <serial|->
-<file>: <why>` record to `$m/log`; through a mount the write itself only says
-`Invalid argument`. Only writes log one: a refused open or truncation
-(an OTRUNC open, as `data`'s after a failed `addr`), create or remove is
-its error alone, as are a write to `pane/new` (`permission denied`) and a
-write on a fid opened read-only (`bad use of fid`). Errors are words, never
-a C errno string. A look miss quotes what was written: `no match for "zzq:#3"`.
+`addr` searches on from the last address (it is the pane's, shared by all
+clients); after a `data` write it sits just past the text, so write `addr`
+before each replacement. A failed `addr` leaves none and `data` refuses:
+check the write's status. An Edit block of several lines goes in one open:
+`printf 'Edit ,x/foo/{\ni/</\na/>/\n}\n' > $p/ctl`.
-## Edit through addresses, dot and the flag files
+## Run a command
-For a file or scratch pane, with `pane=$m/pane/<serial>`:
+```sh
+echo 'make test' > "$m/exec"; c=$(cat "$m/exec") # a command pane; its serial
+grep "^exit $c " "$m/log" | tail -1 # `exit <serial> <N>` once done
+```
-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`; an `x` that finds nothing is no failure
-(sam's), so `,x/zzz/c/bar/` with no `zzz` succeeds silently. A block goes to the pane's `ctl`, the
-root `ctl` (the active pane) or `exec` on one open, in one write or several
-(bash's `printf` writes line by line): an `Edit` line takes the lines after it
-until its `{` closes or its `a`/`c`/`i` text ends with `.`, and runs then. `sel`
-reads the selected text, and a write to it replaces the selection:
+At a terminal's prompt, run and get the status and output in one go:
```sh
-cat > $pane/ctl <<'END'
-Edit ,x/area_of/{
-i/[/
-a/]/
-}
-END
-env printf 'Edit ,x/foo/{\ni/</\na/>/\n}\n' > $pane/ctl
+t=$(awk '$2=="term"{print $1; exit}' "$m/index")
+exec 3<>"$m/pane/$t/pty/run"; echo 'ls' >&3; cat <&3; exec 3<&- # `exit N`, then output
+printf 'q' > "$m/pane/$t/pty/data" # raw keystrokes (\r Enter, \x03 Ctrl-C)
+echo 'sig INT' > "$m/pane/$t/pty/ctl" # interrupt
```
- `p` and `=` print to the directory's
-`+Errors`. Not there: `b B D e r w f X Y`, `< | >`, and `\1`-`\9` in `s`.
-
-> **sam gotchas** (as sam does them, pardes too)
-> - `$-1` is the last line when the text ends in a newline (`a\nb\n`: `b\n`),
-> but the one before it when it does not (`a\nb`: `$` is on `b`, so `a\n`).
-> - With a final newline, the line after the last is the empty place at the
-> end, not an error: `N+1` at the last line is `#<len>,#<len>`. Without one
-> (`a\nb`), the last line runs to the end and `N+1` is out of range.
-> - `^` and `$` match at the text's end too: `/^/` from the end finds the
-> empty place after a final newline.
-> - `2,1` is no error: it is `#<start of 2>,#<end of 1>`, an empty range at
-> line 2's start (only a range whose end is before its start is refused).
-> - `y` yields the stretch before the first match too, empty if the text
-> starts with one: `,y/a/` on `abc` gives `` and `bc`.
-> - `c/&/` puts a literal `&`; only `s` expands `&` to the match.
-> - `line:col` columns count bytes from 1: `12:0` is refused
-> (`a column counts from 1`); a tool's character column is the same only
-> on an ASCII line.
-| 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) |
+`busy: <program> is running` means the prompt is not free: a program holds
+the terminal, so talk to it through `pty/data`.
-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. The
-writes of one open of `data` are one undo step (a multi-line `printf` too);
-to make a loop's writes one, `echo 1 > mark`
-(an undo point now), `echo 0 > mark`, the writes, then `echo 1 > mark`.
-A `+New` scratch counts as unsaved (and holds up Exit, Restore, Del) only
-once it holds 100 bytes or more. A read
-of `data` or `xdata` moves `addr` past what it read, as in acme.
-Truncating `data` is pardes's own (acme ignores OTRUNC and always inserts).
-plan9port's `9p write` always opens with OTRUNC, so `… | 9p write pane/body`
-replaces the whole body: append with `>>` through a mount instead.
-| 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)` |
+## Follow what happens
-`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`; a Recent, +Search or Jumplist row's `12:5-14:2` is taken too,
-through 14:2 inclusive), 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. `^def|^ ` finds lines starting either way
-(every branch anchored; a mix like `^def|x` is refused). `[éa-z]` and
-`[à-ÿ]` match those runes (a range up to 256 runes); `[^é]` and a wider
-range are refused. 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 one search that runs past its step budget (about 300 ms; each search of
-an Edit `x` has its own) fails with `regular expression search took too
-much time, gave up`. A failed address says
-why (`no match for regexp`, `address out of range`) and leaves no address:
-`addr` reads empty and `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)`.
+```sh
+exec 3<>"$m/log"; echo 'follow new' >&3
+timeout 30 cat <&3 # one record a line, as they come
+exec 3<&-
+```
-`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.
+`follow` (without `new`) replays the ring first. `tail -f` sees nothing new.
+Through FUSE `read -t` never times out: use `timeout`. After a `Restore`
+every connection is cut: dial again and restart the mount.
-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,
-isdir (0), the dirty flag, the width in cells, the font, the tab width, the
-undo flag and the redo flag, as acme's fields — then `current` or
-`notcurrent` — and takes `get` (reload from disk),
-`lock`/`unlock`, and any builtin that acts on a pane (`Del`, `Save f`,
-`Collapse`, `Undo`/`Redo`: 256 steps; with none left they say so, and the
-write succeeds). 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, or an errno that fits: ENOENT for a missing pane, file or dump), 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>&-`.
+## Traps
-Terminal panes have no file: writing their `body` sends child input, and
-truncation does not erase terminal history.
+- A refused write says only `Invalid argument` or `Input/output error`; the
+ reason is the `err` line in `$m/log` (`tail -1 "$m/log"`).
+- Only writes log: a refused open, truncation (`> data` after a failed
+ `addr`) or `rmdir` has its errno alone.
+- Settings and session words go to `$m/ctl`; pane words (`Undo`, `Save`,
+ `Del`) to `$m/pane/<n>/ctl`. The wrong one is refused naming the right one.
+- `Exit`, `Restore`, `Del`, `get` refuse once over unsaved text; the same word
+ again discards.
+- A word no builtin knows is a shell command (`exit 127` if a typo).
+- `lock` needs a held fd: `exec 3>$p/ctl; echo lock >&3; ...; exec 3>&-`.
+- plan9port `9p write` truncates: it replaces a whole `body`.
-## The Python client, for what a shell cannot express
+## No mount: the Python client
-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.
+Use [test/ninep.py](../../../test/ninep.py) when nothing is mounted, or when
+a fid must stay open (`event`, `pty/data`, a followed `log`). Its paths are
+the served root (`/index`, `/pane/2/body`).
```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='')
+with Client(sys.argv[1]) as c:
+ print(c.read('/index').decode(), end='')
+ n = int(c.read('/pane/new'))
+ c.write(f'/pane/{n}/body', b'hello\n', truncate=True)
+ fid = c.open(f'/pane/{n}/event', 0) # hold event: clicks come here
+ c.write(f'/pane/{n}/exec', b'Msg hi\n')
+ print(c.read_fid(fid, 0, 4096)) # b'FX0 0 1 6 Msg hi\n'
+ c.close(fid)
+ c.remove(f'/pane/{n}')
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
-`<origin><action><q0> <q1>\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
-`<origin><action><q0> <q1> <flag> <n> <text>\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). Every address lands on a
-rune boundary, never inside one and never widened to a grapheme cluster: `#n`
-or `line:col` inside a rune snaps back to its start, a match covers the runes
-it touches, and a combining mark or a CRLF's `\r` is addressable alone.
-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 has a `new` for each restored pane, then
-`restore <path>`, then `restored <old> <new>` for each pane and
-`restoredcol <old> <new>` for each column -- `restore <path>` 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-<date>-<time>.zon`, the time in UTC, under
-`DumpDir` (`$XDG_DATA_HOME/pardes`, else `~/.local/share/pardes`) and logs
-`dump <path>`. A relative `Restore <path>` is looked for in `DumpDir` first,
-then in the directory pardes started in; a bare `Restore` takes the last dump
-this session wrote (none yet: refused, name one).
-Read the event implementation before building an interceptor. Close handles in
-`finally`, and disconnect after a socket timeout. The service shares sixteen
-connection slots (the next client's version gets `too many connections`)
-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: for `event` and `pty/data` the length of the record a read
-would answer (zero when nothing is waiting), for `log` the whole ring, the text
-an open would freeze now.
-
-## Terminal input and screen observations
+`Client` takes a Unix socket path or `(ip, port)`; `client.screen()` returns
+the parsed `/screen`. For `event` records and writing them back, see
+[fs.md#event](../../../docs/fs.md#event).
-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` (restart the shell; a directory that is gone fails ENOENT), one per line. `pty/status` reads one line: the pty's cols,
-rows and busy (1 while a command runs or text is typed at the prompt).
+## An isolated session
-`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:
+Never experiment on a session someone is using. Build a binary with
+`zig build install -Dplatform=tty` (a bare `zig build`, or `--prefix
+zig-out`, installs over `~/.local/bin`). `test/fs.py` starts a private
+session and cleans it up:
```sh
-PYTHONPATH=test python3 -B - /absolute/path/to/pardes <<'PY'
+PYTHONPATH=test python3 -B - "$(realpath zig-out/bin/pardes)" <<'PY'
+import sys, tempfile
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')
+with tempfile.TemporaryDirectory(prefix='pardes-9p-') as d:
+ with session(sys.argv[1], Path(d), 'probe') as (client, address):
+ n = new_pane(client, b'fn main() void {}\n')
+ execute(client, n, 'Msg ready')
+ assert 'ready' in ''.join(c[0] for c in client.screen()['cells'])
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/).
+By hand: `pardes --detach=NAME &` with its own `HOME` and `XDG_*`
+directories, then `9ns --mntgen` (the session is `$NINE_MOUNT/pardes/NAME`)
+or `9ns --unix $XDG_RUNTIME_DIR/pardes-9p-NAME.sock -- sh`. Strip every
+`PARDES_*` variable first, or a file argument goes to the session you are
+inside. `session(..., tty=True)` and
+[test/agent_session.py](../../../test/agent_session.py) drive terminals.