diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-29 19:25:00 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-10-01 00:12:17 -0300 |
| commit | 57b30ba3e38153a4446626449b0fed5120da954c (patch) | |
| tree | 7b9381327a05791181a855bd8d4ba3cfb4df5301 /.agents | |
| parent | 0fd908eea63d04886b269438aa7529d3dd422256 (diff) | |
| download | pardes-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.md | 544 |
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. |
