1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
|
---
name: pardes-9p
description: Inspect and drive a running Pardes editor through its control filesystem, or exercise its panes, builtins, terminal input and rendered output in an isolated session. Use for Pardes interaction, plugin development and end-to-end debugging.
---
# Pardes over 9P
Pardes is driven by reading and writing files. Prefer ordinary file tools over
a mount; reach for the Python client only when nothing is mounted, or when the
work needs a fid held open. Do not build another wire client: project tooling
stays in Zig. Run the examples from the repository root; paths below are
relative to that root unless linked.
## Find the session through the mount
This machine keeps a FUSE mount of every posted session under `/mnt/9p`:
`/mnt/9p/pardes/<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
m=/mnt/9p/pardes/$(basename "$PARDES_9P" .sock | sed 's/^pardes-9p-//')
cat "$m/index"
cat "$m/pane/$PARDES_PANE/body"
```
Entries for dead sessions stay listed and answer `Input/output error` on any
access, so name the session you mean rather than globbing or taking the newest.
`cat "$m/index"` is the cheapest liveness check, and `cat "$m/status"` reports
the `pid`, `version` and `panes` of a session new enough to serve it.
`/mnt/9p/pardes` is the registry of posted sessions, mounted once for the
machine. It is not the per-pane kernel mount the `Tty9p` builtin makes, which
gives one pane's shell `$PARDES_MOUNT`; see [docs/v9fs.md](../../../docs/v9fs.md)
for that. Either mountpoint serves the same tree.
A 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/new open it to make a pane, read names it; rmdir $m/pane/<n> 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
n=$(cat "$m/pane/new") # make one, take its serial
printf 'text\n' > "$m/pane/$n/body" # append
cat "$m/pane/$n/tag" # what its tagline offers
echo notes.txt > "$m/pane/$n/name" # rename the buffer
echo Save > "$m/pane/$n/exec" # save it
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
```
**Opening** `$m/pane/new` is what makes a pane, and reading the open file
answers its serial — `/net/tcp/clone`'s mechanism. Each open makes another one,
two reads of the same open file answer the same serial, and closing it leaves
the pane. A pane is named by the serial the editor gives it, never by a name
you choose.
A *stat* makes nothing, which is the whole reason the allocation sits on open:
`new` is listed in `$m/pane`, so `ls` shows it, and `ls -l`, `find` and
anything else that stats every name a listing handed it stay inert. acme
allocates on the walk instead and lets it land inside the new window, so
`/dev/new/body` works in one step — it can afford that because a Plan 9
directory read carries every entry's stat and nothing walks. Under a kernel or
FUSE mount that would be a pane per `ls -l`. Nothing else in the tree can be
created or removed, and no read creates anything.
`look` and `exec` are the editor's two clicks, one per line of a write, at the
active pane from the root and at that pane from `$m/pane/<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>`:
| 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)` |
`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.
`dirty`, `mark` and `scroll` read `0` or `1` and take `0` or `1`: whether the
buffer differs from its file, whether a write pushes an undo point, and whether
a write scrolls. Truncating `tag` clears the part of the tag you may edit.
Address state belongs to the pane, not to a client: it keeps the last range
written until someone writes or truncates it, so writing an address and reading
it back evaluates it, and two clients addressing the same pane will interfere.
`$pane/ctl` reads acme's window status line — serial, tag length, body length,
a reserved zero, the dirty flag, the width in cells, the font and the tab
width — and takes the one verb `get`.
Terminal panes have no file: writing their `body` sends child input, and
truncation does not erase terminal history.
## The Python client, for what a shell cannot express
Use the existing [Python client](../../../test/ninep.py) when there is no
mount, or when the work needs a fid held open across several operations —
`log`, `event` and `pty/data` are consuming queues whose reads park, and shell
redirection cannot hold one open.
```sh
PYTHONPATH=test python3 -B - "$PARDES_9P" <<'PY'
import sys
from ninep import Client
with Client(sys.argv[1]) as client:
print(client.read('/index').decode(), end='')
print(client.read('/listeners').decode(), end='')
PY
```
`Client` takes a raw Unix socket path, or `(numeric_ip, port)` for TCP; it does
not parse Pardes dial strings or implement QUIC. It negotiates 9P2000, uses a
five-second socket timeout, and closes on leaving `with`. Its paths are the
served root: `/index`, `/pane/2/body`, `/os/...`. `/n/self`, `/n/os`, `/n/peer`
and `/virtual` are editor Look paths, not server paths — Look
`/virtual/src/pardes.zig` corresponds to reading `/src/pardes.zig`.
For live terminal output or plugin events, use `open` / `read_fid` / `close`,
not the read-until-EOF helper. `pty/data` captures output while held open; it
is not a history replay. Both files are shared, consuming queues, not
per-client broadcasts, so a slow reader loses older data. Holding `event` open
intercepts that pane's Look and Exec clicks, 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 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
`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:
```sh
PYTHONPATH=test python3 -B - /absolute/path/to/pardes <<'PY'
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')
PY
```
`new_pane` walks to `/pane/new` and reads the serial off the directory it
lands on; `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/).
|