diff options
| author | Gabriel Schneider <[email protected]> | 2026-09-21 20:07:43 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-10-01 00:12:14 -0300 |
| commit | 9070942b29bd10dddcdecdb0e88ba0fb40608467 (patch) | |
| tree | fa1fc5984c7847c52bf4e78d277586edce2d8325 /.agents | |
| parent | 0122e94fb37422085325f2dc78ca015051ce5f91 (diff) | |
| download | pardes-9070942b29bd10dddcdecdb0e88ba0fb40608467.tar.gz pardes-9070942b29bd10dddcdecdb0e88ba0fb40608467.zip | |
Plan 9 idiom for the control filesystem, and the regressions a624a56 left
The 9P tree stops being a command language wearing a filesystem. /new
created a pane as a side effect of a *read*; it is now Tcreate in /pane,
with Tremove to close, which cloud9's engine has always supported and the
editor never declared: tree.zig now says
`features = .{ .create = true, .remove = true }`. Eleven pane ctl verbs
become files that can be read as well as written -- dot, limit, dirty,
mark, scroll, look, exec -- leaving ctl with `get`, the one verb no file
would say better. Root /ctl splits into a read-only /status and the
/look and /exec files whose write IS the click. stat carries real sizes
where it used to answer 0, and qid versions track a pane's revision, so a
client can poll for change without re-reading the body.
Commit a624a56 moved raw-tty keys to an early-return branch that knew only
Ctrl-B and bare Escape, and in the same edit deleted the paste branch below
it. That cost Shift-Escape (the unconditional way out of tty mode) and both
paste chords: Ctrl-V and Ctrl-Shift-V reached the child as keystrokes, so an
agent CLI running in a pane took Ctrl-V for its image-paste binding and
answered "No image found in clipboard". Both are restored, with tests.
Nested detection was not subtly broken but deleted: 60367d8 removed
nested.zig's process-ancestry walk and left "am I inside pardes" derived from
PARDES_FORWARD_LOOK, which read "0" both for --nested and for "the listener
did not come up". PARDES_PID now answers that question on its own, checked
with kill(pid, 0); PARDES_9P and PARDES_PANE answer how to reach it; the flag
is gone. The posted-9P registry also self-heals now -- a session that aborts
cannot unlink its own socket, so posting sweeps entries whose target refuses
a connection, symlinks only and on a definite ECONNREFUSED only.
Elsewhere: tty scrolling is sticky-bottom, following new output only from
the last row, with typing and entering raw mode snapping back to live; the
boot layouts are a Boot enum instead of a chain of ifs, and the bare tty
startup (Boot.tty, which main.zig names) opens an empty text pane under the
shell while tests keep Boot.tty_shell; builtins announce themselves on the
message row under a Verbose setting that is on by default; Config prints
each setting the way you would type it back, so WindowOpacity 70 rather than
"WindowOpacity: 70%"; LocationsConfig opens its window only when called bare;
every tagline puts the word that closes the thing last, and a column now
outlives its panes -- closing the last one leaves an empty pane, and only
Delcol, newly on the column tagline, takes the column away.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
Diffstat (limited to '.agents')
| -rw-r--r-- | .agents/skills/pardes-9p/SKILL.md | 259 |
1 files changed, 159 insertions, 100 deletions
diff --git a/.agents/skills/pardes-9p/SKILL.md b/.agents/skills/pardes-9p/SKILL.md index 09dee666..2cea4d4a 100644 --- a/.agents/skills/pardes-9p/SKILL.md +++ b/.agents/skills/pardes-9p/SKILL.md @@ -1,127 +1,184 @@ --- name: pardes-9p -description: Inspect and drive a running Pardes editor over 9P, 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 through its control filesystem. +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 -Use the existing [Python client](../../../test/ninep.py) for ad hoc interaction -and functional tests. 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 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. -## Connect and identify panes +## Find the session through the mount -Every native session opens a Unix socket. Inside a pane, `PARDES_9P` names the -socket and `PARDES_PANE` is that pane's serial. Outside Pardes, find -`pardes-9p-*.sock` under `$XDG_RUNTIME_DIR`, or `~/.local/state/pardes` when -that variable is unset. Select the intended session explicitly; do not assume -the newest socket is the right one. Names come from `--9p=name`, the detached -session name, or the process ID. +This machine keeps a FUSE mount of every posted session under `/mnt/9p`: +`/mnt/9p/pardes/<pid>/` is one session's served root. Inside a pane, +`$PARDES_9P` is that session's socket (`/run/user/1000/pardes-9p-<pid>.sock`), +so its pid is the directory to use, and `$PARDES_PANE` is the calling pane's +serial: ```sh -PYTHONPATH=test python3 -B - "$PARDES_9P" <<'PY' -import sys -from ninep import Client +m=/mnt/9p/pardes/$(basename "$PARDES_9P" .sock | sed 's/^pardes-9p-//') +cat "$m/index" +cat "$m/pane/$PARDES_PANE/body" +``` -with Client(sys.argv[1]) as client: - print(client.read('/self/index').decode(), end='') - print(client.read('/self/listeners').decode(), end='') -PY +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 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/log one record per read: new|del|rename|save <serial> <name>; reads park +$m/screen the rendered screen as JSON, frozen per open +$m/listeners this session's dial addresses +$m/pane/ mkdir opens a pane; rmdir <serial> closes it +$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 +mkdir "$m/pane/x"; n=$(awk 'END{print $1}' "$m/index") # open 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 +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 ``` -`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`. +The name `mkdir` asks for is ignored: a pane is named by the serial the editor +gives it, and because `/index` is ordered by serial its last row is the pane +just made. Nothing else in the tree can be created or removed, and no read +creates anything, so `ls`, `stat` and `find` over the whole tree are inert. + +`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. 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. + +## Edit through addresses, dot and the flag files + +For a file or scratch pane, with `pane=$m/pane/<serial>`: -The first field of each index row is a stable pane serial, not a slot or row -number. The tag starts after five numeric fields; use `row.split(maxsplit=5)` -to preserve spaces. Inspect `/self/pane/<serial>/tag`, `body`, or directory -entries before choosing a target. Re-read the index after actions that might -open, reuse or close panes. +| 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 | `echo 'pub fn' > $pane/data` | `client.write(pane + '/data', b'pub fn')` | +| 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)` | -Wire paths start with `/self` or `/os`. `/n/self`, `/n/os`, `/n/peer` and -`/virtual` belong to editor Look paths, not the server root. For example, -Look `/virtual/src/pardes.zig` corresponds to reading `/self/src/pardes.zig`. +`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`). Moving `dot` scrolls the pane to it. `limit` bounds a +search and reads empty until set; truncate it to lift it. -## Edit, Look and execute +`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. -For a file or scratch pane, with a connected `client` and a confirmed -`serial`, let `pane = f'/self/pane/{serial}'`. Terminal body writes instead -send child input; truncation does not erase terminal history. +Address state belongs to the pane, not to a client: opening `addr` resets it, +so 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 — and takes the +one verb `get`. -| Operation | Client call | -|---|---| -| Read text | `client.read(pane + '/body')` | -| Append text | `client.write(pane + '/body', b'text\n')` | -| Replace all text | `client.write(pane + '/body', b'text\n', truncate=True)` | -| Select a byte range | `client.write(pane + '/addr', b'#0,#2')` | -| Replace that range | `client.write(pane + '/data', b'pub fn')` | -| Show the addressed selection | `client.write(pane + '/ctl', b'dot=addr\n')` | -| Look from this pane | `client.write(pane + '/ctl', b'look /virtual/src/pardes.zig:10\n')` | -| Save | `client.write(pane + '/ctl', b'put\n')` | -| Reload | `client.write(pane + '/ctl', b'get\n')` | -| Close, refusing dirty text | `client.write(pane + '/ctl', b'del\n')` | +Terminal panes have no file: writing their `body` sends child input, and +truncation does not erase terminal history. -`delete` force-closes, discarding unsaved text. `get` replaces edits with file -contents. Use those only when that loss is intended. Reading `/self/new/ctl` -creates a scratch pane and returns its serial as the first field; it is not -an observational read. +## The Python client, for what a shell cannot express -`ctl` is not a builtin interpreter. Execute builtins through a body Exec event -on an owned scratch pane: +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. -```python -from fs import new_pane, execute +```sh +PYTHONPATH=test python3 -B - "$PARDES_9P" <<'PY' +import sys +from ninep import Client -control = new_pane(client, b'') -execute(client, control, 'Msg 9p-ready') +with Client(sys.argv[1]) as client: + print(client.read('/index').decode(), end='') + print(client.read('/listeners').decode(), end='') +PY ``` -`execute` overwrites that pane's body with the command, then writes -`MX0 <UTF-8-byte-length>\n` to its `event` file. Keep the control pane separate -from user text. The same helper can run `Mini path`, `Mount peer dial`, and -`Unmount peer`; commands resolve relative to the control pane's directory. -A successful event write acknowledges dispatch, not completion: inspect the -resulting pane, message or screen for success. +`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`. -Opening `addr` resets its range. To inspect the current selection, open -`addr` first, write `addr=dot\n` to `ctl`, then use `read_fid` on that already -open handle. Do not replace that last step with `client.read`, which reopens -and resets it. Address state is shared by the pane, not private to a client. +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, so it is not a passive logger: for +Look/Exec records whose offsets identify the intended text, forward the short +record `<origin><action><q0> <q1>\n`, not the whole report with flags and text. +Expansion and chord reports need explicit handling, and terminal-body events +can carry text only in the report, which a short writeback cannot reproduce. +Read the event implementation before building an interceptor. Close handles in +`finally`, and disconnect after a socket timeout. The service shares four +connection slots 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, and for `log`, `event` and `pty/data` report the length of +the record a read would answer — zero when nothing is waiting. ## Terminal input and screen observations 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 inputs go to the child terminal, not Pardes editor key bindings. +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`, one per line. -`client.screen()` returns `cols`, `rows`, `cursor`, `styles`, and row-major +`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. Compare colors -and attributes, not just style-table indices or flattened text. - -Each screen open freezes one frame. A terminal `body` freezes history on its -first read. `client.read` and `client.screen` reopen each time; use repeated -calls for fresh observations. Do not poll a stale handle. Poll a specific -condition with a deadline and a short delay, rather than a fixed long sleep. -For large histories, measure whole-body reads separately from screen polling; -9P write-to-observation timing includes RPC, rendering and polling overhead. +each cell's style through `styles` when checking highlighting, and compare +colors and attributes rather than style-table indices or flattened text. -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; slow readers can lose older data. Holding `event` open -intercepts Look/Exec clicks, so it is not a passive logger. For Look/Exec -records whose offsets identify the intended file/tag text, forward the short -record `<origin><action><q0> <q1>\n`, not the entire report with flags and text. -Expansion/chord reports need explicit handling; terminal-body events can have -empty ranges with text carried only in the report, so short writeback cannot -reproduce them. Read the event implementation before building an interceptor. -Close handles in `finally`; disconnect after a socket timeout. The service -shares four connection slots and 32 screen/terminal-history snapshot handles. +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 @@ -140,22 +197,23 @@ 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'/self/pane/{serial}' - client.write(pane + '/ctl', b'name probe.zig\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' - control = new_pane(client, b'') - execute(client, control, 'Msg 9p-ready') + 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') PY ``` -For terminal tests, use `session(..., tty=True)` and read +`new_pane` is `mkdir` plus a read of the index; `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; +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 @@ -163,6 +221,7 @@ 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/OS-file access. Use isolated -loopback listeners for tests. For less common control verbs or event details, -read their implementation and tests in [src/fs.zig](../../../src/fs.zig). +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/). |
