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
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
|
---
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
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` forks, its parent
returning at once, so the running editor's pid is not `$!`: read it from
`$m/status`, whose first line is `pid <n>`). 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:
```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.
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.
`$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`).
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, 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`); 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/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.
```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: 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 last
line with no newline runs when the open closes, and an Edit block still open
then 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`.
## Edit through addresses, dot and the flag files
For a file or scratch pane, with `pane=$m/pane/<serial>`:
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`. 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:
```sh
cat > $pane/ctl <<'END'
Edit ,x/area_of/{
i/[/
a/]/
}
END
env printf 'Edit ,x/foo/{\ni/</\na/>/\n}\n' > $pane/ctl
```
`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) |
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. Each
`data` write is one undo step; 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).
| 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)` |
`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`), 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. `^` inside an alternation (`^def|^ `) holds
only where the search starts: fine in `x` over lines or `g`, not mid-line. 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 a search that backtracks
past a step budget (about 300 ms) fails with `regular expression search
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)`.
`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.
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,
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`, `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>&-`.
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 -- 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
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).
`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/).
|