v1.1.0Pi v1.1.0 源码分析Analysis of Pi v1.1.0 · 2026-10-07

envenv

Pi Durable 的远程执行环境(经 SSH 部署的小型守护进程)Remote execution environments for Pi Durable: a small daemon deployed over SSH and its ExecutionEnv client

InfraInfraInfra

← → 翻页 · F 全屏 · O 目录 · T 明暗 · L 语言← → navigate · F fullscreen · O overview · T theme · L language

图解 Pi Agent · Pi v1.1.0Pi Agent, Illustrated · Pi v1.1.001
01 · 两半:TypeScript 客户端与 Rust 守护进程01 · Two halves: a TypeScript client and a Rust daemonInfra

两半:TypeScript 客户端与 Rust 守护进程Two halves: a TypeScript client and a Rust daemon

pi-env: a 2,073-line TypeScript client + a 4,027-line Rust daemon本地:Durable worker 所在的机器Local: where the Durable worker runssrc/*.ts远端:工具真正运行的机器Remote: where the tools actually rundaemon/src/*.rs包与平台Package and platforms939remote-env.tsRemoteExecutionEnv:实现 Durable 的 ExecutionEnvRemoteExecutionEnv: a Durable ExecutionEnv530ssh.ts主机密钥 · 探测平台 · 部署 · 启动host keys · detect · deploy · launch384connection.tsConnection:帧、请求 id、心跳、重连Connection: frames, request ids, ping, restart158watch.tsRemoteWatcher:断线后重新打开RemoteWatcher: reopens after a lost link34errors.tstoFileError:错误码映射toFileError: error-code mapping28index.ts公开 APIpublic API516main.rsserve:同步行、调度、工作线程serve: sync line, scheduling, workers778watch.rsNodeFileWatcher 的移植a port of NodeFileWatcher483exec.rs命令、进程组、超时、溢出文件commands, process groups, timeouts, spill269window.rs输出窗口与节流output window and pacing473fs.rs · scan.rs文件操作 · 行扫描file ops · line scans988sys/unix.rs · sys/windows.rs平台差异platform specifics@earendil-works/pi-envchord ^1.1.0pi-durable ^1.1.0LinuxmacOSAndroid (Termux)Windowsx86-64arm64sshstdin/stdout随包附带 bin/pi-env-{platform}-{arch}/pi-envships bin/pi-env-{platform}-{arch}/pi-envREADME:Durable worker、存储与凭据留在本地README: the Durable worker, storage and credentials stay local标签 = 行数tag = lines本地 TypeScriptlocal TypeScript远端 Rustremote Rust请求requests结果与事件results & events

支持的远端:Linux、macOS、Android(Termux)与 Windows,x86-64 与 arm64。Windows 上字符串命令像 NodeExecutionEnv 那样通过 Git Bash 运行,argv 命令直接运行。Supported remotes: Linux, macOS, Android (Termux) and Windows, on x86-64 and arm64. On Windows, string commands run through Git Bash as NodeExecutionEnv runs them; argv commands run directly.

图解 Pi Agent · Pi v1.1.0Pi Agent, Illustrated · Pi v1.1.002
02 · 分工与语义02 · Split of work and semanticsInfra

分工与语义Split of work and semantics

Split of work: results must match NodeExecutionEnv on the remote machine客户端(TypeScript,本地)Client (TypeScript, local)守护进程(Rust,远端)Daemon (Rust, remote)路径解析Path resolution~(远端 home)、file://、相对 cwd;Windows 驱动器相对路径~ (remote home), file://, relative to cwd; Windows drive-relative取消检查点Abort checkpoints每个方法的检查点与 NodeExecutionEnv 相同each method's checkpoints, as NodeExecutionEnv错误码映射Error-code mappingENOENT → not_found …(toFileError)ENOENT → not_found … (toFileError)readFile 的分块与解码readFile chunking & decodingStringDecoder,保留 BOMStringDecoder, BOM keptwriteFile512 KiB 一块,每块前检查取消512 KiB writes, abort check before each行读取器Line reader定位读取 + Durable 的 StreamDecoderpositional reads + Durable's StreamDecoder超时与结果优先级Timeouts & result precedence回调错误 > 超时 > 取消 > 溢出失败 > 退出码callback error > timeout > abort > spill failure > exit code系统调用System callslstat、open、pread、rename、rm …lstat, open, pread, rename, rm …进程Processes每条命令一个新会话(进程组)a new session (process group) per command溢出文件Spill filespi-output-<uuid>.log,在新的 tmp- 目录里pi-output-<uuid>.log in a fresh tmp- directory行扫描Line scansscanLines:在文件旁边数行scanLines: counted next to the file输出解码Output decoding每个流独立,WHATWG UTF-8(encoding_rs)per stream, WHATWG UTF-8 (encoding_rs)文件监视Watching快照、原生事件、轮询:NodeFileWatcher 的移植snapshots, native events, polling: a NodeFileWatcher port两边由 Durable 的 env 一致性套件和 test/differential.test.ts 一起检查:随机操作序列在 RemoteExecutionEnv 与本机 NodeExecutionEnv 上比对。Both sides are checked by Durable's env conformance suite and test/differential.test.ts: random operation sequences compared with NodeExecutionEnv.只有错误消息可以不同;错误码才是契约。Only error messages may differ; codes are the contract.客户端client守护进程daemon

docs/semantics.md 记录了标准库不遵守、但这里必须遵守的规则:递归 mkdir 遇到已有文件报 EEXIST;rm 目录不带 recursive 报 ERR_FS_EISDIR;listDir 按字节序;mkdtemp 的前缀用 POSIX path.join……docs/semantics.md records rules the standard libraries do not follow but this must: a recursive mkdir over an existing file fails with EEXIST; rm of a directory without recursive gives ERR_FS_EISDIR; listDir is in byte order; mkdtemp joins its prefix with POSIX path.join…

图解 Pi Agent · Pi v1.1.0Pi Agent, Illustrated · Pi v1.1.003
03 · 通过 SSH 连接03 · Connecting over SSHInfra

通过 SSH 连接Connecting over SSH

Reaching a machine over SSH: trust → detect → deploy → startdeployDaemon 的细节(POSIX)deployDaemon in detail (POSIX)src/ssh.tssshArguments():每次调用都锁死这些选项sshArguments(): these options on every callscanHostKey用临时 known_hosts 抓取主机密钥,给用户比对指纹capture the host key with a temp known_hosts; owner checks fingerprintsacceptHostKey写进应用自己的 known_hosts,固定别名store it in the app's own known_hosts, under a fixed aliasdetectPlatformPOSIX:sh -c uname …;Windows:PowerShellPOSIX: sh -c uname …; Windows: PowerShelldeployDaemon按 SHA-256 命名、校验后才放到位named by SHA-256, verified before it is put in placepi-env serve --token <hex>经 ssh 启动;等到 "PI-ENV <token>" 这一行started over ssh; wait for the "PI-ENV <token>" lineBatchMode=yesClearAllForwardings=yesForwardAgent=noForwardX11=noControlMaster=noControlPath=noneRemoteCommand=nonePermitLocalCommand=noSendEnv=-*ServerAliveInterval=15GlobalKnownHostsFile=noneHashKnownHosts=noIdentitiesOnly=yesStrictHostKeyChecking=yesUserKnownHostsFile=<app file>HostKeyAlias=<alias>connectSsh():立即探测与部署(3–4),守护进程在第一个请求时启动;sshConnection():全部懒执行,connectSsh(): detects and deploys now (3–4), the daemon starts on the first request; sshConnection(): all lazily;失败就是该操作的错误,下一次操作再试。forgetHostKey() 删除已存的密钥。a failure is that operation's error and the next one retries. forgetHostKey() removes a key.f=~/.pi/mobile/tools/pi-env-{sha256 前 32 位}f=~/.pi/mobile/tools/pi-env-{first 32 of sha256}# 检查:present → 直接用 · missing → 上传 · nohash → 报错# check: present → use it · missing → upload · nohash → errort=$(mktemp "$d/.pi-env.XXXXXX"); cat > "$t"[ "$(hash "$t")" = {sha256} ] || 删除并报 corrupt[ "$(hash "$t")" = {sha256} ] || rm, "upload is corrupt"chmod 700 "$t"; mv -f "$t" "$f"# 删除其他 pi-env-*(运行中的保持打开直到退出)# remove other pi-env-* (running ones stay open until exit)hash 依次尝试 sha256sum、shasum、openssl;Windows 用 Get-FileHash 与 base64 上传。hash tries sha256sum, shasum, openssl; Windows uses Get-FileHash and a base64 upload.不转发任何东西、不复用连接、不执行配置的命令、不发送本地 locale。No forwarding of any kind, no shared connections, no configured commands, no local locale.accept-new 只在 scanHostKey 的临时文件里使用。accept-new is used only against scanHostKey's temporary file.12345信任trust部署与启动deploy & start顺序order

守护进程放在 ~/.pi/mobile/tools/pi-env-<sha256 前 32 位>:按内容命名,版本之间不会冲突;每次启动前都先校验哈希。The daemon lives at ~/.pi/mobile/tools/pi-env-<first 32 of sha256>: named by content so versions never collide, and its hash is checked before every start.

图解 Pi Agent · Pi v1.1.0Pi Agent, Illustrated · Pi v1.1.004
04 · 线上协议04 · The wire protocolInfra

线上协议The wire protocol

The wire format: after the sync line, only frames同步Sync一帧(两个方向都一样,大端序,最多 16 MiB)One frame (both directions, big-endian, at most 16 MiB)6 种帧类型The 6 frame typesconnection.ts · frame.rsshell 启动文件可能打印的杂项…whatever shell startup files print…PI-ENV 3f9a…c1\nframeframeframe …length之后的字节数bytes after this fieldtype1–6id请求 id;0 = 不属于请求request id; 0 = nonejsonLengthjsonJSON 对象a JSON objectbytes文件内容 / 输出file contents / output123456客户端丢弃同步行之前的The client discards一切输出everything before itu32u8u32u32UTF-8rawbytes 长度 = length − 9 − jsonLength;JSON 解析失败 → EINVAL;帧格式错误 → 断开连接。bytes length = length − 9 − jsonLength; unparsable JSON → EINVAL; a framing error ends the connection.request客户端 → 守护进程client → daemonjson.op 指明操作json.op names the operationresult守护进程 → 客户端daemon → client请求成功并结束success; the request is finishederror守护进程 → 客户端daemon → client{ code, message, syscall?, path? }event守护进程 → 客户端daemon → client进度:exec 输出、watch 变化;不结束请求progress: exec output, watch changes; not finishingcancel客户端 → 守护进程client → daemon取消请求 idcancel request idping双向both心跳;接收方忽略liveness; ignored by the receiver同步行sync line帧字段frame fields帧类型frame types

文件内容和命令输出走原始字节负载,不做 base64;更大的传输由客户端拆分。File contents and output travel as raw payload bytes, not base64; larger transfers are split by the client.

图解 Pi Agent · Pi v1.1.0Pi Agent, Illustrated · Pi v1.1.005
05 · 连接的生命周期05 · The connection lifecycleInfra

连接的生命周期The connection lifecycle

The life of a connection: lazy start, heartbeats, restart after loss连接丢失时(客户端)When the connection is lost (client)connection.ts上层如何感知What the layers above see第一个请求first request懒启动lazy startssh … serve--token <hex>PI-ENV <token>同步行sync linehello { protocol: 1 }60 s 内必须完成within 60 s{ os, arch, home, … }请求 / 结果 / 事件requests / results / events工作线程 · exec · watchworkers · exec · watch双向 ping,每 5 sping both ways, every 5 s30 s 没收到任何字节30 s without any bytes杀掉所有进程组并退出kill every process group, exit客户端 Connectionclient Connection守护进程daemonsshstdin 结束时守护进程也退出:The daemon also exits when stdin断线 = 远端命令停止。ends: a dropped link stops commands.进行中的请求失败Requests in flight fail{ code: "unknown", lost: true }变更类请求的结果因此未知A mutation's outcome is then unknown写入可能已发生,也可能没有the write may or may not have happened下一个请求启动新的守护进程The next request starts a new daemon再次同步 + hellosync line + hello again旧会话的句柄不会落到新进程上Old handles never reach the new daemon它们的请求直接失败their requests fail instead原因:进程退出、帧损坏("Corrupt frame from pi-env")、30 s 静默、hello 协议不符。Causes: process exit, a corrupt frame, 30 s of silence, a hello protocol mismatch.isConnectionLost(err)区分"断线"和"守护进程拒绝了请求"tells "lost" apart from "the daemon refused"RemoteExecutionEnv把丢失映射成 FileError / ExecutionError 的 unknownmaps a loss to FileError / ExecutionError unknownRemoteWatcher新守护进程启动后重新打开监视并报告 overflowreopens the watch on the new daemon, reports overflowsshConnection()懒连接:网络或密钥错误只是那一次操作的错误lazy: a network or key failure fails that operation only请求request回应reply丢失loss

丢失时正在进行的变更(写入、改名…)结果未知:可能执行了,也可能没有。For mutations in flight when the link is lost (writes, renames…), the outcome is unknown: they may or may not have happened.

图解 Pi Agent · Pi v1.1.0Pi Agent, Illustrated · Pi v1.1.006
06 · 命令与输出06 · Commands and outputInfra

命令与输出Commands and output

exec: commands, output windows, spill files and cancellation输出:event { kind: "output", stream }Output: event { kind: "output", stream }调度:回复先于大块数据Scheduling: replies before bulk data命令怎样结束How a command settlesexec { command | argv, cwd, env, … }timeoutMs? · spill? · window?字符串 → shellstring → shellshellPath,否则 /bin/bash、which bash、sh;-cshellPath, else /bin/bash, which bash, sh; -cargv 数组 → 直接运行argv array → run directlyWindows 按 libuv 规则搜索,拒绝批处理Windows: libuv search, batch files refused新会话(进程组)new session (process group)默认信号处理 · 空信号掩码default signal dispositions · empty mask控制队列control queue结果 · 错误 · ping · watch 事件results · errors · pings · watch大块队列bulk queue命令输出 · > 64 KiB 的结果command output · results > 64 KiBstdout→ ssh → 客户端→ ssh → client{ exitCode }timeoutaborted137spawn_errorshell_unavailable每个流独立解码为 UTF-8,作为负载字节发送。Each stream is decoded to UTF-8 and sent as payload bytes.window { maxBytes, maxLines, minIntervalMs, bytesPerSecond }合并并按调用方的提交节奏发送;每条命令最多一帧未发出;coalesced and paced like the caller's commits; at most one unsent frame;超出窗口的部分在下一帧里变成 skipped { bytes, newlines, … }output beyond the window becomes skipped { bytes, newlines, … }spill { afterBytes, afterLines }越线后完整原始输出写入 tmp-…/pi-output-<uuid>.log;once crossed, the full raw output goes to tmp-…/pi-output-<uuid>.log;结果带 spillPath(超时 / 取消时也带)。the result carries spillPath (also on timeout / abort).进程退出后继续收集输出,直到两个管道都关闭或 100 ms 无输出。After exit, output is collected until both pipes end or 100 ms pass silently.没有 window 的命令:未发输出超过 4 MiB 就等待——像管道写满一样拖慢命令。Without a window: output waits while more than 4 MiB is unsent, slowing the command like a full pipe.正常退出;被信号杀死报告 128 + 信号normal exit; killed by a signal → 128 + signal超时:SIGKILL 整个进程组timeoutMs passed: SIGKILL the groupcancel 帧:杀进程组,以 aborted 结束a cancel frame: kill the group, settle abortedcleanup():cancel { mode: "kill" },不 abortcleanup(): cancel { mode: "kill" }, no abortcwd 不存在等missing cwd, etc.配置的 shellPath 不存在the configured shellPath is missing请求request输出output控制优先control first结束方式settlements

调度:结果、错误、ping 和 watch 事件排在命令输出之前,超过 64 KiB 的结果也排到输出后面——慢链路只延迟大块数据,不延迟回复。Scheduling: results, errors, pings and watch events go before queued command output, and results above 64 KiB queue behind output too — a slow link delays bulk data, not replies.

图解 Pi Agent · Pi v1.1.0Pi Agent, Illustrated · Pi v1.1.007
07 · 文件操作07 · File operationsInfra

文件操作File operations

File operations: ExecutionEnv methods → the daemon's 17 file opsRemoteExecutionEnv守护进程的文件 opThe daemon's file opsprotocol.md客户端的分块与流水线Client chunking and pipeliningremote-env.ts错误码映射(toFileError)Error mapping (toFileError)errors.ts标准库不遵守、但这里遵守的规则(节选)Rules the standard libraries do not follow (excerpt)semantics.mdlstatrealpathwritewriteChunktruncatefsyncrenamemkdirrmmkdtempopenpreadfstatscanLinesopendirreaddirclosereadTextFile / readBinaryFile→ open(mode:"read") → pread…openBinaryReader→ open(noFollow) · pread · fstatopenTextLineReader→ pread + StreamDecoderwriteFile / appendFile→ write(keep) · writeChunk…listDir / openDirReader→ opendir · readdir(max)fileInfo / exists→ lstatcanonicalPath→ realpathrenameFile / remove→ rename · rmcreateDir / createTempDir→ mkdir · mkdtemptruncateFile / flushFile→ truncate · fsyncwatch→ watch全部在 16 个工作线程的固定池里运行;All run on a fixed pool of 16 worker threads;同一句柄的分块写按到达顺序执行。chunk writes to one handle run in order.句柄是数字,close 或连接结束前有效;Handles are numbers, valid until close or打开超过 4096 个 → EMFILE。the connection ends; over 4096 → EMFILE.路径在客户端解析成绝对路径后再发送。Paths are made absolute by the client.READ_CHUNK256 KiB × 8二进制读,8 个并发binary reads, 8 in flightWRITE_CHUNK512 KiB × 8writeFile,8 个并发writeFile, 8 in flightREAD_FILE_CHUNK512 KiBreadFile 每次读each readFile readLINE_CHUNK64 KiB行读取器每次读each line-reader readENOENT→ not_foundEACCES · EPERM→ permission_deniedENOTDIR→ not_directoryEISDIR→ is_directoryEINVAL …→ invalid· mkdir -p:已有文件 → EEXISTrecursive mkdir: an existing file → EEXIST· rm 目录不带 recursive → ERR_FS_EISDIRrm of a dir without recursive → ERR_FS_EISDIR· listDir:按字节序排序名字listDir: names in byte order· openBinaryReader:noFollow → O_NOFOLLOWopenBinaryReader: noFollow → O_NOFOLLOW客户端方法client methods守护进程 opdaemon ops错误映射error mapping

write 默认像递归 mkdir 那样创建缺失的父目录;带 keep 时返回句柄供后续 writeChunk,一块失败后后续块都报 EBADF。write creates missing parents like a recursive mkdir by default; with keep it returns a handle for later writeChunks, and after a failed chunk later chunks fail with EBADF.

图解 Pi Agent · Pi v1.1.0Pi Agent, Illustrated · Pi v1.1.008
08 · 文件监视08 · WatchingInfra

文件监视Watching

Watching: NodeFileWatcher running next to the files变化怎样被发现How changes are foundevent 帧(请求一直持续到被取消)Event frames (the request lasts until cancelled)断线之后(客户端 watch.ts)After a lost link (client watch.ts)env.watch(targets, …)RemoteWatcher(客户端)RemoteWatcher (client)watch { targets, mode?, pollIntervalMs?, … }targets:{ path, recursive, exclude }targets: { path, recursive, exclude }守护进程 watch.rsdaemon watch.rsDurable NodeFileWatcher 的移植a port of Durable's NodeFileWatcher原生事件native eventsinotify / FSEvents去抖 50 msdebounce 50 msFSEvents 再等 500 msFSEvents settles 500 ms重新扫描rescan最多 10,000 个目录up to 10,000 directories比较快照diff snapshots≤ 256 KiB 的文件比哈希hash files ≤ 256 KiBchange { paths }快照差异 + 事件路径snapshot diff + event paths原生事件只触发重新扫描;读取不算事件。Windows、网络与 FUSE 文件系统改用轮询(默认 2000 ms)。Native events only trigger a rescan; reads are not events. Windows, network and FUSE file systems poll instead (2000 ms by default).{ kind: "ready", mode }覆盖范围建立;之后才有变化coverage established; changes follow{ kind: "change", paths }一批变化的路径a batch of changed paths{ kind: "change", overflow: true, mode: "polling" }原生监视用尽 → 转轮询native watches ran out → polling{ kind: "error", code, message }之后不再有任何事件nothing follows新守护进程启动后,RemoteWatcher 重新打开同样的监视,Once a new daemon starts, RemoteWatcher reopens the same watch并报告一次 overflow——期间可能漏掉了变化。and reports overflow: changes may have been missed meanwhile.重连退避:1 s 起,最多 30 s。Reconnect backoff: from 1 s up to 30 s.cancel 帧让 watch 以 {} 结束。A cancel frame finishes the watch with {}.收到 overflow 的使用方应当整体重新读取,A consumer that sees overflow should re-read而不是只看路径列表。everything rather than trust the path list.客户端client守护进程daemon事件events

去抖 50 ms(FSEvents 再等 500 ms 稳定);轮询默认每 2000 ms;最多 10,000 个目录;≤ 256 KiB 的文件比较哈希。Debounce 50 ms (FSEvents settles another 500 ms); polling every 2000 ms by default; at most 10,000 directories; files ≤ 256 KiB are compared by hash.

图解 Pi Agent · Pi v1.1.0Pi Agent, Illustrated · Pi v1.1.009
扩展点Extension pointspi-coding-agent

env 的扩展点Where to plug into env

1
RemoteExecutionEnv({ connection, id, cwd })RemoteExecutionEnv({ connection, id, cwd })

任何 Connection 都行:ssh、管道或测试里的本地进程。Any Connection works: ssh, a pipe or a local process in tests.

2
sshConnection / connectSsh(options)sshConnection / connectSsh(options)

懒或立即;binary 可替换,loginShell 可选。Lazy or eager; the binary can be replaced, loginShell is optional.

3
scanHostKey · acceptHostKey · forgetHostKeyscanHostKey · acceptHostKey · forgetHostKey

把主机密钥信任交给你的 UI。Put host-key trust in your own UI.

4
deployDaemon(target, remote, binary?)deployDaemon(target, remote, binary?)

单独部署,或部署自己构建的守护进程。Deploy on its own, or deploy a daemon you built.

5
PI_ENV_DAEMONPI_ENV_DAEMON

测试时指定守护进程二进制(CI 用发布构建)。Point tests at a daemon binary (CI uses release builds).

图解 Pi Agent · Pi v1.1.0Pi Agent, Illustrated · Pi v1.1.010
←
1 / 10