From 074f113fa95a875a6bf17196f8e46e69806a247c Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Wed, 30 Sep 2026 23:04:26 -0300 Subject: Scripting over 9P has one chapter, and the served README and the 9P skill shrink to a summary, the traps and a pointer to it Co-Authored-By: Claude Opus 5.5 --- docs/typ/scripting.typ | 153 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 docs/typ/scripting.typ (limited to 'docs/typ') diff --git a/docs/typ/scripting.typ b/docs/typ/scripting.typ new file mode 100644 index 00000000..a00ee612 --- /dev/null +++ b/docs/typ/scripting.typ @@ -0,0 +1,153 @@ +// Scripting pardes over 9P: finding the session, the recipes, and the +// traps. Every file's full semantics are in the reference; this is the +// five-minute path to using them. +#import "style.typ": key, keys, btn, chord, word, tag, addr, file, cmd, doc, pairs + +Every session serves its panes, columns and tags as files, as acme does: +`cat`, `echo >` and `ls` are the whole interface, and a program that opens +files is an extension. The reference (#doc("fs")) has every file; this +chapter is how to reach them and what to do with them. + += Finding the session + +Each session listens on a Unix socket, +`$XDG_RUNTIME_DIR/pardes-9p-.sock` (else under +`~/.local/state/pardes`), `` being the pid, the `--detach=NAME` or +`--9p=NAME`, and posts it in the 9P registry as +`$XDG_RUNTIME_DIR/9p/pardes/`. Shells in its panes get `PARDES_PID` +(the editor's pid), `PARDES_9P` (the socket) and `PARDES_PANE` (their +pane's serial). + +Two clients do the rest. `9ns` comes from cloud9 +(`git.sr.ht/~gbrls/cloud9`; its `zig build` installs `9ns` on Linux) and +mounts a 9P tree through FUSE in a private namespace, no root needed; +`9p` is plan9port's, and reads and writes without a mount. + +#cmd("9ns --mntgen # every posted session, under $NINE_MOUNT") +#cmd("9ns --unix \"$PARDES_9P\" -- sh -c 'cat \"$NINE_MOUNT/index\"' # one session, for that command") +#cmd("9p -a \"unix!$PARDES_9P\" read index # no mount") + +Under `9ns --mntgen` find this pane's session from its socket: + +#cmd("[ -n \"$NINE_MOUNT\" ] || echo 'no 9P mount: use 9p or the Python client'") +#cmd("s=${PARDES_9P##*/pardes-9p-}; m=$NINE_MOUNT/pardes/${s%.sock}") +#cmd("cat \"$m/index\"") + +Under `9ns --unix` the session is `$NINE_MOUNT` itself (`m=$NINE_MOUNT`), +and in a #word("Tty9p") terminal (#key("SPC n 9")) it is `$PARDES_MOUNT`. +A dead session's registry entry stays listed and answers `Input/output error`: name the session, never glob. A running session serves the binary +that started it. + +#word("Tty9p") opens a terminal with the session kernel-mounted (Linux +v9fs): it asks for your sudo password in the pane, mounts the socket in a +private mount namespace and starts your shell as you, with +`PARDES_MOUNT` set. Only that shell sees the mount, and each takes one of +the session's 16 connections. It needs the `9p` and `9pnet_fd` kernel +modules and the `pardes-v9fs` helper the build installs beside `pardes`. + += Recipes + +The paths below are under `$m`; `p=$m/pane/$n` for a pane. + +#pairs( + [look around], [#cmd("cat $m/index\ncat $m/layout\ncat $m/focus\ncat $m/commands")], + [open a file at a place], [#cmd("echo \"$PWD/main.zig:120\" > $m/look; cat $m/look # where it went\necho 'main.zig:0/fn main/' > $m/look # the first match")], + [make, fill, name, save], [#cmd("n=$(cat $m/pane/new) # one pane per open\nprintf 'hello\\n' > $m/pane/$n/body # > replaces, >> appends\necho \"$PWD/notes.txt\" > $m/pane/$n/name\necho Save > $m/pane/$n/ctl\nrmdir $m/pane/$n # close it")], + [say something], [#cmd("echo 'Msg hello' > $m/exec")], + [replace everywhere], [#cmd("echo 'Edit ,x/foo/c/bar/' > $p/ctl && grep -c foo $p/body")], + [replace one match], [#cmd("echo /old/ > $p/addr && printf new > $p/data")], + [delete line 3], [#cmd("echo 3 > $p/addr; : > $p/data")], + [select line 3, read it], [#cmd("echo 3 > $p/addr; cp $p/addr $p/dot; cat $p/sel")], + [run a command], [#cmd("echo 'make test' > $m/exec; c=$(cat $m/exec) # its pane\ngrep \"^exit $c \" $m/log | tail -1 # exit c N")], + [run at a prompt], [#cmd("t=$(awk '$2==\"term\"{print $1; exit}' $m/index)\nexec 3<>$m/pane/$t/pty/run; echo ls >&3; cat <&3; exec 3<&-")], + [type, interrupt], [#cmd("printf 'q' > $m/pane/$t/pty/data # \\r Enter, \\x03 Ctrl-C\necho 'sig INT' > $m/pane/$t/pty/ctl")], + [ask the language server], [#cmd("echo /myFunc/ > $p/addr; echo dot=addr > $p/ctl\necho Hover > $p/exec # also Rename new, Symbols")], + [follow what happens], [#cmd("exec 3<>$m/log; echo 'follow new' >&3\ntimeout 30 cat <&3; exec 3<&-")], +) + +`follow` without `new` replays the ring first. #file("pty/run") answers +`busy: is running` when a program holds the terminal: talk to it +through #file("pty/data") instead. + +== Event helpers + +Holding a pane's #file("event") open takes its #btn("B2") and #btn("B3") +clicks: they arrive as acme's records instead of acting, so the pane's tag +can carry a helper's own words, and writing a record back has pardes do +it. The keyboard is never taken, and the clicks come back when the helper +closes the file. One open at a time; the record format is in the reference +(#doc("fs", section: "event")). + +== Waiting for an edit + +`pardes --wait FILE` in a pane's shell returns when the pane showing FILE +is closed (#doc("tags", section: "editor")): `EDITOR='pardes --wait'` makes +pardes the editor of every program run there. + += Traps + +- A refused write says only `Invalid argument` or `Input/output error`; the + reason is the `err` record in the log (`tail -1 $m/log`). Only writes + log: a refused open, truncation or `rmdir` has its errno alone. +- #file("addr") belongs to the pane, not to you, and moves on: each + `/re/` searches from the last address, a #file("data") write leaves it + just past what it wrote, and a read moves it too. Write #file("addr") + before each replacement, `0` to start at the top. A failed address + leaves none, and #file("data") refuses until you write one. +- An Edit `x` that matches nothing succeeds: check the result + (`grep -c foo body`), not the exit status. +- Each open of #file("pane/new") makes a pane. `ls`, `stat` and `find` + never do. +- Through a mount, bash's `printf 'a\nb\n' > ctl` arrives one write per + line, so a block that should be refused whole runs its good lines before + the bad one fails. Send a block as one write (`cat block > $p/ctl`), and + end every write whose result matters with a newline. +- plan9port's `9p write` opens with OTRUNC: `echo x | 9p write pane/3/body` replaces the whole body. Append with `>>` through a mount. +- `tail -f log` never sees anything new: write `follow` on the open you + read. Through a FUSE mount bash's `read -t` cannot time out: wrap the + loop in `timeout`. +- Settings and session words go to #file("/ctl"), pane words + (#word("Undo"), #word("Save"), #word("Del")) to #file("pane//ctl"); + the wrong one is refused, naming the right one. +- #word("Exit"), #word("Restore"), #word("Del") and `get` refuse once over + unsaved text; the same word again discards. +- A word no builtin knows runs as a shell command: a typo ends `exit 127`. +- `lock` needs a held file descriptor: + `exec 3>$p/ctl; echo lock >&3; ...; exec 3>&-`. +- Names in #file("index") may hold blanks: split a row with + `rsplit(maxsplit=1)` first, and read #file("index") again after anything + that opens or closes panes. +- A #word("Restore") hangs up every connection: dial again and restart the + mount. + += No mount: the Python client + +`test/ninep.py` in the source tree speaks 9P itself, for when nothing is +mounted or a fid must stay open (#file("event"), #file("pty/data"), a +followed #file("log")). Its paths are the served root: + +```python +import sys +from ninep import Client # PYTHONPATH=test +with Client(sys.argv[1]) as c: # a socket path, or (ip, port) + 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}') +``` + +`client.screen()` returns the parsed #file("/screen"). + += An isolated session + +Never experiment on a session someone is using. Strip every `PARDES_*` +variable first, or a file argument goes to the session you are inside; +then `pardes --detach=NAME &` with its own `HOME` and `XDG_*` directories, +and mount it with `9ns --mntgen` (the session is `$NINE_MOUNT/pardes/NAME`) +or `9ns --unix $XDG_RUNTIME_DIR/pardes-9p-NAME.sock -- sh`. Kill it when +done. From the source tree, `test/fs.py`'s `session()` starts a private +session and cleans it up, and `test/agent_session.py` drives terminals. -- cgit v1.3