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
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
|
---
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 `9ns --unix SOCKET -- cmd`
mount exists only inside `cmd`'s private namespace; to share one, use the
registry (`9ns --mntgen`).
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/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>
(exec 3<>$m/log; echo follow >&3; cat <&3 waits for new ones; tail -f does not)
$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
(Newcol, Dump, Theme x; Exit QUITS the editor, Kill [word...] stops the
commands pardes typed into terminals; Exit and Restore refuse once per
unsaved pane, `<name>: Modified (Exit again to discard)`, and the same
word again DISCARDS that text -- not a retry, unlike lock's `file in use`); a pane's builtins (Del, Save f,
and the column words Delcol, Collapse, which act on that pane's column) go to
$m/pane/<n>/ctl
$m/commands every builtin: `Word`, `Word arg`, then `root` or `pane` (which ctl takes it)
$m/pane/new open it to make a pane (a scratch named <dir>/+New), read names it;
rmdir $m/pane/<n> closes it; closing a column's last pane leaves a +New there
$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`, 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 (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
```
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.
**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. A word no builtin
knows (a typo included) is typed into a terminal -- the pane itself if it is a
terminal at its prompt, else one in the pane's directory, made if need be --
and is not reported as an error. Every refused 9P write adds an `err <serial|->
<file>: <why>` record to `$m/log`; through a mount the write itself only says
`Invalid argument`.
## 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 | `printf 'pub fn' > $pane/data` (`>>` too) | `client.write(pane + '/data', b'pub fn')` |
| Delete that range | `: > $pane/data` | truncate `data` (open with OTRUNC) |
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.
Truncating `data` is pardes's own (acme ignores OTRUNC and always inserts).
| 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`), 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), `/re/` wraps unless `limit` is set, and a search that backtracks
past a step budget (about 300 ms) fails with `regular expression search
took too long`. A failed address says
why (`no match for regexp`, `address out of range`) and leaves no address:
`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 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, then `current` or `notcurrent` — and takes `get` (reload from disk),
`lock`/`unlock`, and any builtin that acts on a pane (`Del`, `Save f`,
`Collapse`). 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), 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>&-`.
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` 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/).
|