chore(vendor): import pi 1.3.0

Imported as a linear vendor commit so forge rebases do not replay the upstream root tree.

git-subtree-dir: third_party/pi

git-subtree-split: 71023aec1546d6d677a65669c5665c0927ce0dd0
This commit is contained in:
2026-10-15 17:58:48 -04:00
parent 719370c8f7
commit 6081e3e325
1743 changed files with 205460 additions and 131705 deletions

View File

@@ -0,0 +1,151 @@
# pi-env wire protocol
Version 1. The client starts `pi-env serve --token <hex>` (over `ssh`, or directly in tests) and talks to it over the
process's stdin and stdout. stderr is diagnostic text for logs only.
## Sync
Shell startup files may print to stdout before the daemon runs. The daemon's first output is the line
`PI-ENV <token>\n`, with the token from its command line. The client discards everything before that line. After it,
stdout carries only frames.
## Frames
Every frame, in both directions:
```text
u32 length bytes after this field
u8 type
u32 id request id; 0 for frames that belong to no request
u32 jsonLength
json UTF-8 JSON object, jsonLength bytes
bytes raw payload, the remaining length - 9 - jsonLength bytes
```
All integers are big-endian. A frame is at most 16 MiB; larger transfers are split by the client. A request whose JSON
does not parse gets an `EINVAL` error; framing errors end the connection.
| type | name | direction | meaning |
|---|---|---|---|
| 1 | request | client → daemon | `json.op` names the operation |
| 2 | result | daemon → client | success of request `id`; the request is finished |
| 3 | error | daemon → client | failure of request `id`; the request is finished |
| 4 | event | daemon → client | progress of a running request (`exec` and `pty` output, `watch` changes), not finishing it |
| 5 | cancel | client → daemon | cancel request `id`; see below |
| 6 | ping | both | liveness; the receiver ignores it |
Errors are `{ code, message, syscall?, path? }`. `code` is a Node-style error code (`ENOENT`, `EISDIR`,
`ERR_FS_EISDIR`, ...) or one of the execution codes `shell_unavailable`, `spawn_error`, `timeout`, `aborted`, `unknown`.
Messages are diagnostic; only codes are part of the contract.
Paths are absolute JSON strings; the client resolves relative paths, `~` and `file://` URLs before sending. Strings are
sent well-formed (lone surrogates become U+FFFD) and encoded as UTF-8, as Node encodes string paths and arguments. File
contents and output travel as raw payload bytes.
## Cancellation
A cancel names one request. The daemon registers each request before running it, so a cancel sent right after its
request is never lost. `exec` and `pty` kill the command's process group and finish with `aborted`; `{ mode: "kill" }` kills
without aborting, so the command finishes with the killed process's status, as `cleanup()` does. `scanLines` stops
between chunks and `listFiles` between entries, with `aborted`. `watch` stops and finishes with `{}`. Other operations
are short and finish normally.
## Scheduling
File operations run on a fixed pool of worker threads; `exec`, `pty` and `watch` run on their own threads. `ptyWrite`,
`ptyResize` and `ptyForeground` are answered in arrival order by the thread that reads frames, so keystrokes never
swap. Chunk writes to one handle run in arrival order. Results, errors, pings and `watch` events are sent before queued
command output, and results with payloads above 64 KiB queue behind output too, so a slow link delays bulk data, not
replies. Output of a command without an output window waits while more than 4 MiB of output is unsent, which slows the
command as a full pipe would.
## Liveness
Each side sends `ping` every 5 seconds. The daemon kills every process group it started and exits after 30 seconds
without receiving any bytes, or when stdin ends. A dropped connection therefore stops remote commands. The client
fails requests of a lost connection with `{ code: "unknown", lost: true }` and starts a new daemon on the next request;
handles belong to the daemon that opened them, so requests on them fail instead of reaching the new one.
## Operations
`hello { protocol }` → `{ protocol, version, os, arch, home, tmpdir, cwd, driveCwds, pid, features }`. The first
request. `version` is the npm package version the daemon shipped with; `os` and `arch` are Rust's `std::env::consts`
values (`linux`, `android`, `macos`, `windows`; `x86_64`, `aarch64`), which the client's `nodePlatform` maps to Node's
names; `tmpdir` follows the remote Node's `os.tmpdir()` rules; `cwd` and `driveCwds` (Windows' `=C:` variables) are
what Node's `path.resolve` falls back to for drive-relative paths. `features` lists operations added to protocol 1
after its first release (`listFiles`); older daemons omit the field and refuse those operations as unknown, so clients
check it first.
File operations take `{ path }` (and the listed fields) and return `{}` unless noted:
| op | fields | result |
|---|---|---|
| `lstat` | | `info` |
| `realpath` | | `{ path }` |
| `write` | `append`, `parents?` (default true), `keep?`, payload | creates missing parents like Node's recursive `mkdir`, opens like `writeFile` (`w`) or `appendFile` (`a`), writes the payload; with `keep`, `{ handle }` for `writeChunk` |
| `writeChunk` | `handle`, payload | appends to a kept write handle; after a failed chunk, later chunks fail with `EBADF` |
| `truncate` | `size` | |
| `fsync` | | |
| `rename` | `to` | |
| `mkdir` | `recursive` | |
| `rm` | `recursive`, `force` | Node `fs.rm` semantics |
| `mkdtemp` | (`path` is the prefix) | `{ path }` |
| `open` | `noFollow`, `mode?` | `{ handle, info }` for `openBinaryReader`: nonblocking, regular files only. With `mode: "read"`, Node's `open(path, "r")` of any file, blocking: `{ handle, stat }` or `{ handle, statError }` |
| `pread` | `handle`, `offset?`, `length` | payload; without `offset`, one read at the current position |
| `fstat` | `handle` | `info` |
| `scanLines` | `handle`, `startLine`, `endLine?` | `LineScan` |
| `opendir` | | `{ handle }` |
| `readdir` | `handle`, `max` | `{ entries, done }` |
| `close` | `handle` | |
`listFiles { path, hidden?, gitignore?, exclude?, maxEntries?, maxBytes?, timeoutMs? }` lists the tree below the
directory `path` (feature `listFiles`): files, directories and symbolic links, which are not followed; other kinds of
files are left out, and entries that cannot be read are skipped. The payload holds one record per entry: a kind byte
(`f` file, `d` directory, `l` symbolic link), the path relative to `path` with `/` separators as UTF-8 (names that are
not UTF-8 get replacement characters), and a NUL byte. Records come depth first, siblings in byte order of their
names. The result is `{ count, truncated }`. Entries named `.git` and the names in `exclude` are skipped with
everything below them. `hidden` (default true) includes names starting with `.` (and Windows' hidden files).
`gitignore` (default true) skips, inside git work trees, what the `.gitignore` files of `path`, its subdirectories and
its parents, `.git/info/exclude` and the global excludes file ignore, using the `ignore` crate of `fd` and ripgrep. The
listing stops with `truncated: true` at the first entry beyond `maxEntries` (default 50 000), beyond `maxBytes` of
payload (default 4 MiB, at most the frame limit less 64 KiB), or after `timeoutMs` (default 10 000). A missing `path`
fails with `ENOENT`, a file with `ENOTDIR`.
`info` is `{ name, kind, size, mtimeSec, mtimeNsec, dev, ino }` with `kind` one of `file`, `directory`, `symlink`, `other`.
`readdir` entries are `{ name, raw?, info }` or `{ name, raw?, error }` when the entry's `lstat` failed. Names are
decoded as UTF-8 with replacement characters, and lstat'ed under that name, as Node does; `raw` holds the original
bytes of a name that is not valid UTF-8, for sorting. Handles are numbers, valid until `close` or the end of the
connection; opening more than 4096 fails with `EMFILE`.
`exec { command | argv, cwd, env, inheritEnv, shellPath?, timeoutMs?, spill?, window? }` runs a shell string or an
argv array. While it runs, `event` frames `{ kind: "output", stream, skipped? }` carry decoded output text as UTF-8
payload. With `window { maxBytes, maxLines, minIntervalMs, bytesPerSecond }`, output is coalesced and paced like the
caller's commits, at most one output frame per command is unsent, and output followed by more than the window is
replaced by `skipped { bytes, newlines, endsWithNewline }` on the next event, which carries all output after it
(`ShellOutputInfo.skipped`). The result is `{ exitCode, spillPath? }`; failures are errors with codes `timeout` or
`aborted` (carrying `spillPath`), `shell_unavailable`, `spawn_error`, or `unknown` (spill failure).
`watch { targets, mode?, pollIntervalMs?, maxDirectories? }` runs Durable's `NodeFileWatcher` in the daemon. `targets`
are `{ path, recursive, exclude: { hidden, names } }`. Once coverage is established, an event `{ kind: "ready", mode }`
follows; then `{ kind: "change", paths }` or `{ kind: "change", overflow: true, mode: "polling" }` (native watching
failed), or `{ kind: "error", code, message }`, after which nothing follows. The request lasts until cancelled.
Failing to establish coverage is an error of the request.
`pty { argv?, cwd, env, inheritEnv, cols, rows }` runs a program in a new pseudo-terminal of `cols` × `rows` cells
(1 to 4096), as the leader of a new session with the terminal as its controlling terminal. Without `argv`, it runs
`SHELL` (from `env`, else the daemon's environment, else `/bin/sh`) as a login shell (`-` before its name). The
environment is built as for `exec`; set `TERM` in `env`. Once the program runs, an event `{ kind: "started", pid }`
comes first; then `{ kind: "output" }` events carry the terminal's raw output bytes as payload (not decoded, unlike
`exec`). Output waits while more than 4 MiB of output is unsent, like a full pipe. After the program exits, output is
collected until the terminal closes or 100 ms pass without output. The result is `{ exitCode }`. Not supported on
Windows yet (`ENOTSUP`).
While a `pty` request runs, `ptyWrite { target }` writes its payload to the terminal's input, and
`ptyResize { target, cols, rows }` changes its size (the program gets `SIGWINCH`); `target` is the id of the `pty`
request. Both return `{}` at once; input is queued and written in order, waiting while the program does not read it.
Once the terminal has ended, they fail with `EBADF`.
`ptyForeground { target }` reports the terminal's foreground process group when it is not the program's own:
`{ foreground: { pid, name } }`, where `pid` is the group's id and `name` its leader's process name (`null` if unknown),
or `{ foreground: null }` while the program itself is in front. For a shell this tells a running job (an editor, a
build) from the prompt; background jobs are not reported. It fails with `EBADF` once the terminal has ended.