图解 Pi AgentPi Agent, Illustratedv1.1.0
包Packages· protocol
演示Slides
Run modes已完成Donepackages/protocol · v1.1.0

protocol@earendil-works/pi-protocol

远程 pi 会话的传输无关 CBOR 协议。Transport-neutral CBOR protocol for remote pi sessions.

1.1.0
版本version
8
源文件src files
869
源码行src LOC
3
测试文件test files
36
测试用例test cases
1
导出路径export paths
依赖Depends onchord被依赖Used byclientserver

这个包是什么What this package ispi-protocol 是远程 pi 会话的线上格式,且只有格式:协议版本 8 定义了 3 种客户端消息和 5 种服务端消息、把调用围在一台逻辑服务器 / 一个 Session / 一个实时附着里的路由、一个严格的 CBOR 子集,以及“4 字节大端长度 + 一个 CBOR 项”的分帧。它不懂任何服务契约:call、result、update 只被检查为严格 JSON,其语义归 chord,数据归应用。pi-client 与 pi-server 是它的两端。pi-protocol is the wire format for remote pi sessions, and only the format: protocol version 8 defines 3 client messages and 5 server messages, routes that fence a call to one logical server / one Session / one live attachment, a strict CBOR subset, and framing as “a 4-byte big-endian length + one CBOR item”. It knows no service contract: call, result and update are only checked to be strict JSON, their meaning belongs to chord and the data to the application. pi-client and pi-server are its two ends.

869 行 TypeScript,运行时无关(不依赖 Node API),一个导入路径。依赖 chord(JsonValue / isJsonValue)与 typebox(schema)。被 pi-client、pi-server 以及 coding-agent 的实验性 server / client 运行时使用。README 明确写着:实验性,不保证兼容。869 lines of TypeScript, runtime-neutral (no Node APIs), one import path. Depends on chord (JsonValue / isJsonValue) and typebox (schemas). Used by pi-client, pi-server and coding-agent's experimental server / client runtime. The README is explicit: experimental, no compatibility guarantees.

01Run modes

三层:信封、编解码、分帧Three layers: envelopes, codec, framing#

一句话结论Takeawayprotocol.ts 用 TypeBox 声明信封 schema;codec.ts 把“校验 → CBOR → 分帧”串成 encodeClientMessage() / encodeServerMessage(),反向是增量的 ClientMessageDecoder / ServerMessageDecoder;framing.ts 只管长度前缀;cbor/ 是一个手写的、只支持定长项的 CBOR 编解码器。index.ts 把这些全部从一个导入路径导出。protocol.ts declares the envelope schemas with TypeBox; codec.ts chains “validate → CBOR → frame” into encodeClientMessage() / encodeServerMessage(), and the incremental ClientMessageDecoder / ServerMessageDecoder run it backwards; framing.ts only handles the length prefix; cbor/ is a hand-written CBOR codec for definite-length items only. index.ts exports all of it from one import path.

pi-protocol 的三层:信封 schema、编解码与分帧pi-protocol in three layers: envelope schemas, codec and framing
← 左右滑动查看,点击放大 →← swipe to scroll, tap to enlarge →
pi-protocol: 869 lines in three layers — envelopes, codec, framingpackages/protocol/srccbor/依赖与使用者Dependencies and users22index.ts唯一导入路径 ".":cbor、codec、framing 全部导出,protocol.ts 导出类型与 PROTOCOL_VERSIONthe only import path ".": all of cbor, codec, framing; types and PROTOCOL_VERSION from protocol.ts141codec.tsparse* · encodeClientMessage / encodeServerMessage · ClientMessageDecoder / ServerMessageDecoder · ProtocolValidationErrorparse* · encodeClient/ServerMessage · Client/ServerMessageDecoder · ProtocolValidationError110protocol.tsTypeBox 信封 schemaTypeBox envelope schemasPROTOCOL_VERSION = 8151framing.tsu32 大端长度前缀u32 big-endian prefixFrameDecoder · 16 MiB216encoder.tsencodeCbor:定长子集encodeCbor: definite subset168decoder.tsdecodeCbor:严格解码decodeCbor: strict decode52options.ts上限默认值 · CborErrordefault limits · CborErrorhellorequestcancelhellohello_errorresponseservice_updateattachment@earendil-works/chord ^1.1.0JsonValue · isJsonValue:严格 JSON 边界JsonValue · isJsonValue: the strict-JSON boundarytypebox 1.3.27Type.Object(additionalProperties: false) + Check5clientpi-client:Connection 握手、请求信封pi-client: Connection handshake, request envelopes5serverpi-server:ClientMessageDecoder、回应与更新pi-server: ClientMessageDecoder, responses, updates8coding-agentexperimental/:ServerId、DEFAULT_MAX_FRAME_LENGTHexperimental/: ServerId, DEFAULT_MAX_FRAME_LENGTH校验validate分帧frame客户端消息(3)Client messages (3)服务端消息(5)Server messages (5)依赖Depends on被导入(src 中导入它的文件数)Imported by (files in src)coding-agent 只把三个包列为 devDependencies,coding-agent lists all three packages only as供实验性的 server / client 运行时使用。devDependencies, for its experimental server / client runtime.README:默认上限 16 MiB / 帧、1,000,000 个数组元素或映射项、64 层嵌套;协议是实验性的,不保证兼容。README: default limits 16 MiB per frame, 1,000,000 array elements or map entries, 64 nesting levels; experimental, no compatibility guarantees.标签 = 行数 / 文件数tag = lines / files信封与编解码envelopes & codecCBOR依赖dependencies入口entry使用uses

要点Key points

  • 没有任何 Node 专用 API:同一份代码可以用在浏览器、Bun 或 worker 里;传输(Unix socket、WebSocket…)由 client / server 提供。No Node-only APIs: the same code works in a browser, Bun or a worker; transports (Unix sockets, WebSockets…) come from client / server.
  • schema 是 additionalProperties: false 的 StrictObject,所以多一个字段也会被拒绝。Schemas are StrictObjects with additionalProperties: false, so even one extra field is rejected.
  • coding-agent 把 protocol、client、server 三个包都只列为 devDependencies,供 experimental/ 下的 server 与 client 运行时使用。coding-agent lists protocol, client and server only as devDependencies, for the server and client runtime under experimental/.

工作原理How it works

  1. 发送:transport.send(encodeClientMessage({ type: "hello", version: PROTOCOL_VERSION }))。Send: transport.send(encodeClientMessage({ type: "hello", version: PROTOCOL_VERSION })).
  2. 接收:const decoder = new ServerMessageDecoder({ maxFrameLength }),然后 for (const m of decoder.push(chunk)) …,流结束时 decoder.end()。Receive: const decoder = new ServerMessageDecoder({ maxFrameLength }), then for (const m of decoder.push(chunk)) …, and decoder.end() when the stream ends.
  3. 两端的 maxFrameLength 要一致(默认 16 MiB)。Both peers must use the same maxFrameLength (16 MiB by default).

关键文件Key files · v1.1.0

文件File符号Symbol作用Role
packages/protocol/src/index.tsL2export *唯一入口the only entry
packages/protocol/src/protocol.tsL5PROTOCOL_VERSION协议版本protocol version
packages/protocol/src/codec.tsL56encodeClientMessage()校验并编码一帧validate and encode one frame
packages/protocol/src/framing.tsL44FrameDecoder拆帧frame splitting
packages/protocol/src/cbor/encoder.tsL211encodeCbor()CBOR 编码CBOR encoding
02Run modes

公共 APIPublic API#

一句话结论Takeaway只有 1 个导入路径。index.ts 通过 export * 与一个具名列表公开了 6 个模块里的 20 个值和 17 个类型:编码 / 解码函数与类、分帧、CBOR,以及从 protocol.ts 选出的信封类型(schema 本身不导出)。Just 1 import path. Through export * and one named list, index.ts makes 20 values and 17 types public from 6 modules: the encode / decode functions and classes, framing, CBOR, and a selection of envelope types from protocol.ts (the schemas themselves are not exported).

导入路径Import path目标Target说明Notes
@earendil-works/pi-protocol./dist/index.js信封类型、校验与编码、增量解码、分帧与 CBORenvelope types, validation and encoding, incremental decoding, framing and CBOR

package.json · exportsL8

常用导出Key exports

模块Module常用导出Key exports用途Use
packages/protocol/src/codec.tsencodeClientMessage encodeServerMessage校验并编码一整帧validate and encode one whole frame
packages/protocol/src/codec.tsClientMessageDecoder ServerMessageDecoder增量解码并校验decode and validate incrementally
packages/protocol/src/codec.tsparseClientMessage parseServerMessage ProtocolValidationError只校验,以及唯一的错误类型validation only, and the one error type
packages/protocol/src/protocol.tsPROTOCOL_VERSION isServerId协议版本 8 与 serverId 检查protocol version 8 and the serverId check
packages/protocol/src/framing.tsencodeFrame FrameDecoder DEFAULT_MAX_FRAME_LENGTH只用分帧framing on its own
packages/protocol/src/cbor/encoder.tsencodeCbor decodeCbor CborError只用 CBORCBOR on its own

根入口的全部导出(按声明所在模块,逐行链接)Every root export, by declaring module (line-linked)

由构建脚本展开 index.ts 的 export * 与 export { … } from 并扫描声明生成;每个符号链接到 v1.1.0 中的定义行。Generated at build time by expanding the export * and export { … } from statements in index.ts and scanning the declarations; each symbol links to its definition at v1.1.0.

03Run modes

一条消息的往返One message, there and back#

一句话结论Takeaway编码先校验:parseClientMessage() 用 TypeBox Check 检查 schema,再用 chord 的 isJsonValue 递归检查严格 JSON;然后 encodeCbor()(上限 = maxFrameLength)和 encodeFrame()。接收方把任意切分的字节交给 push():FrameDecoder 产出完整负载,decodeCbor() 解出恰好一个项,同一套 schema 再检查一次。Encoding validates first: parseClientMessage() checks the schema with TypeBox Check, then strict JSON recursively with chord's isJsonValue; then encodeCbor() (limit = maxFrameLength) and encodeFrame(). The receiver hands arbitrarily split bytes to push(): FrameDecoder yields complete payloads, decodeCbor() decodes exactly one item, and the same schemas are checked again.

一条消息的往返:校验、CBOR、分帧与反向解码One message there and back: validation, CBOR, framing and decoding
← 左右滑动查看,点击放大 →← swipe to scroll, tap to enlarge →
One message: validate → CBOR → frame → bytes → split → decode → validate任何一步失败都是 ProtocolValidationErrorAny failure along the way is a ProtocolValidationErrorcodec.tsClientMessage普通 JS 对象a plain JS objectparseClientMessageTypeBox Check + isJsonValueencodeCbor上限 = maxFrameLengthlimit = maxFrameLengthencodeFrame前缀 u32 大端长度prefix a u32 big-endian lengthUint8Array一整帧,交给传输one whole frame, to the transportByteTransport / ByteConnection:可以任意切分、合并字节,但必须保持顺序ByteTransport / ByteConnection: may split and coalesce bytes arbitrarily, but must preserve their orderchunk, chunk, …任意大小的片段fragments of any sizeFrameDecoder.push凑齐 4 字节头 → 负载4-byte header → payloaddecodeCbor恰好一个定长 CBOR 项exactly one definite-length itemparseServerMessage同一套 schema 再校验一次the same schemas, checked againServerMessage[]push() 返回 0..n 条消息push() returns 0..n messages发送方(encodeClientMessage / encodeServerMessage)Sender (encodeClientMessage / encodeServerMessage)接收方(ClientMessageDecoder / ServerMessageDecoder)Receiver (ClientMessageDecoder / ServerMessageDecoder)schema 不符或含非 JSON 值Schema mismatch or a non-JSON value未知字段、Uint8Array、NaN、undefined、原型、环unknown fields, Uint8Array, NaN, undefined, prototypes, cyclesCBOR 或分帧出错Malformed CBOR or framingCborError / FrameError 被包装,消息截断到 500 字符CborError / FrameError are wrapped, messages cut to 500 chars解码器一旦失败Once a decoder fails之后的 push() / end() 都直接抛错every later push() / end() throwsend() 时仍有半帧end() with a partial frame"Truncated frame at end of stream"协议代码protocol code传输transport数据流data flow失败failure

要点Key points

  • 任何失败都变成 ProtocolValidationError:CborError 与 FrameError 被包装,错误消息截断到 500 个字符。Every failure becomes a ProtocolValidationError: CborError and FrameError are wrapped and the message is cut to 500 characters.
  • 解码器失败后进入 failed:之后的 push() 和 end() 都直接抛错,没有“跳过坏帧”这回事——调用方应该断开连接。A failed decoder stays failed: every later push() and end() throws; there is no “skip the bad frame” — the caller should drop the connection.
  • 被拒绝的非 JSON 值:Uint8Array、非有限数、undefined、带原型的对象和环。Rejected non-JSON values: Uint8Array, non-finite numbers, undefined, objects with prototypes, and cycles.

工作原理How it works

  1. encodeProtocolMessage(value, parse, kind):先 parse(value),再 encodeFrame(encodeCbor(validated, { maxByteLength }))。encodeProtocolMessage(value, parse, kind): parse(value) first, then encodeFrame(encodeCbor(validated, { maxByteLength })).
  2. ValidatedMessageDecoder.push(chunk):对每个完整帧 parse(decodeCbor(frame)),返回 0..n 条消息。ValidatedMessageDecoder.push(chunk): parse(decodeCbor(frame)) for every complete frame, returning 0..n messages.
  3. 流结束时调用 end():还有半帧就报 "Truncated frame at end of stream"。Call end() when the stream ends: a partial frame reports "Truncated frame at end of stream".

关键文件Key files · v1.1.0

文件File符号Symbol作用Role
packages/protocol/src/codec.tsL20parseClientMessage()schema + 严格 JSONschema + strict JSON
packages/protocol/src/codec.tsL39encodeProtocolMessage()编码流水线the encode pipeline
packages/protocol/src/codec.tsL65ValidatedMessageDecoder增量解码incremental decoding
packages/protocol/src/codec.tsL34boundedErrorMessage()500 字符500 characters
packages/protocol/src/codec.tsL13ProtocolValidationError唯一的错误类型the one error type
04Run modes

8 种信封The 8 envelopes#

一句话结论Takeaway客户端发 hello(必须是第一帧)、request、cancel;服务端发 hello(带 serverId)、hello_error(终止)、response(ok: true 带可选 result,或 ok: false 带 error)、service_update(某个订阅的更新)和 attachment(带外的路由变化)。错误是 { code, message },code 是非空字符串,对协议不透明。The client sends hello (must be the first frame), request and cancel; the server sends hello (with serverId), hello_error (terminal), response (ok: true with an optional result, or ok: false with error), service_update (an update for one subscription) and attachment (an out-of-band route change). Errors are { code, message }, the code a non-empty string that is opaque to the protocol.

协议版本 8 的 3 种客户端消息与 5 种服务端消息Protocol version 8: 3 client messages and 5 server messages
← 左右滑动查看,点击放大 →← swipe to scroll, tap to enlarge →
Protocol version 8: 8 envelopes — 3 client messages, 5 server messagesClientMessage客户端 → 服务端client → serverServerMessage服务端 → 客户端server → client«first»ClientHellotype: "hello"version: integer ≥ 0RequestEnvelopetype: "request"id: string (non-empty)target: RpcTargetcall: JSON (opaque)CancelEnvelopetype: "cancel"id: stringtarget: RpcTarget«first»ServerHellotype: "hello"version: 8 (literal)serverId: ServerId«terminal»ServerHelloErrortype: "hello_error"error: ProtocolErrorResponseEnvelopetype: "response"id · ok: trueresult?: JSON (opaque)type: "response"id · ok: falseerror: ProtocolErrorServiceEventEnvelopetype: "service_update"subscriptionId: stringupdate: JSON (opaque)«out of band»AttachmentEnvelopetype: "attachment"attachment: SessionTarget | nullProtocolErrorcode: string (non-empty)message: string错误码对协议是不透明的:Codes are opaque to the protocol:version、invalid_request、cancelled、wrong_server …version, invalid_request, cancelled, wrong_server …由 server 与 chord 定义。are defined by the server and chord.所有信封都是 additionalProperties: false 的 StrictObject;id 是非空字符串;call、result、update 只校验为严格 JSON(Type.Unknown + isJsonValue)。Every envelope is a StrictObject with additionalProperties: false; ids are non-empty strings; call, result and update are only checked to be strict JSON (Type.Unknown + isJsonValue).response 的 ok: true 可以没有 result 字段(void 回应)。A response with ok: true may omit result (a void response).客户端发送sent by the client服务端发送sent by the server错误error

要点Key points

  • 客户端 hello 的 version 是任意非负整数,服务端的则是字面量 8:版本不符由服务端用 hello_error { code: "version" } 回答。The client's hello.version is any non-negative integer, the server's the literal 8: a mismatch is answered by the server with hello_error { code: "version" }.
  • 请求 id 是客户端选的非空字符串(pi-client 用 request-1、request-2…),回应用同一个 id 关联。Request ids are non-empty strings chosen by the client (pi-client uses request-1, request-2…); responses correlate by the same id.
  • service_update 只按 subscriptionId 寻址,不带 target:订阅已经属于某个连接和附着。service_update is addressed by subscriptionId only, without a target: the subscription already belongs to a connection and attachment.

工作原理How it works

  1. 所有消息都是 Type.Union:ClientMessageSchema 有 3 个成员,ServerMessageSchema 有 5 个(ResponseEnvelope 自身又是 ok / not-ok 的联合)。All messages are a Type.Union: ClientMessageSchema has 3 members and ServerMessageSchema 5 (ResponseEnvelope is itself an ok / not-ok union).
  2. 类型从 schema 推导:type ClientMessage = Static<typeof ClientMessageSchema>。Types are derived from the schemas: type ClientMessage = Static<typeof ClientMessageSchema>.
  3. void 回应:{ type: "response", id, ok: true },没有 result 字段。A void response: { type: "response", id, ok: true }, without a result field.

关键文件Key files · v1.1.0

文件File符号Symbol作用Role
packages/protocol/src/protocol.tsL29ClientHelloSchema客户端 helloclient hello
packages/protocol/src/protocol.tsL49RequestEnvelopeSchema请求request
packages/protocol/src/protocol.tsL74ResponseEnvelopeSchema回应response
packages/protocol/src/protocol.tsL88ServiceEventEnvelopeSchema订阅更新subscription update
packages/protocol/src/protocol.tsL98ServerMessageSchema5 种服务端消息5 server messages
05Run modes

路由:serverId、sessionId、attachmentIdRoutes: serverId, sessionId, attachmentId#

一句话结论Takeaway每个 request 与 cancel 都带一个 RpcTarget:服务器级调用是 { serverId },Session 调用是 { serverId, sessionId, attachmentId }。serverId 是规范的小写 UUIDv4,是逻辑身份而不是 socket 地址;attachmentId 由服务器在每次成功 attach 时生成,只通过带外的 attachment 消息告诉客户端。三者合起来防止跨服务器、跨 Session 和过期的调用。Every request and cancel carries an RpcTarget: server-wide calls use { serverId }, Session calls { serverId, sessionId, attachmentId }. serverId is a canonical lowercase UUIDv4, a logical identity rather than a socket address; attachmentId is generated by the server on every successful attach and reaches the client only through the out-of-band attachment message. Together they prevent cross-server, cross-Session and stale calls.

RpcTarget:逻辑服务器、持久 Session 与实时附着RpcTarget: logical server, durable Session and live attachment
← 左右滑动查看,点击放大 →← swipe to scroll, tap to enlarge →
RpcTarget: fencing a call to one logical server, one Session, one live attachment逻辑服务器Logical serverserverId持久 SessionDurable SessionsessionId实时附着Live attachmentattachmentId路由怎样到达客户端How the route reaches the client服务器拒绝的路由Routes the server rejects{ serverId }服务器级调用:目录、管理server-wide calls: directory, management{ serverId, sessionId, attachmentId }Session 调用:每个请求都带完整路由Session calls: every request carries the full routewrong_serversession_not_attachedserver_draininginvalid_request规范的小写 UUIDv4;是逻辑身份,不是 socket 地址a canonical lowercase UUIDv4; a logical identity, not a socket addressSession 与它的 Harness 留在 Session worker 进程里the Session and its Harness stay in the Session worker process每次成功 attach 由服务器生成(randomUUID)generated by the server on every successful attach (randomUUID)一个 Session 可以有多个 presentation 附着;A Session may have several presentation attachments;同一连接重复 attach 同一 Session 是幂等的。repeating attach for the same Session on one connection is idempotent.^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$// 管理调用的结果里没有任何路由 id// management results carry no route idsawait sessions.attach(sessionId)// 服务器装好路由后,带外发送:// once installed, the server sends out of band:{ type: "attachment",attachment: { serverId, sessionId, attachmentId } }// detach / 断开 / 失效 → attachment: null// detach / disconnect / invalidation → attachment: nulltarget.serverId 不是这台服务器(在访问仓库之前)target.serverId is another server (before any repository access)本连接没有附着该 Session,或 attachmentId 已过期this connection holds no such attachment, or the id is stale服务器正在关闭the server is shutting down信封本身不合法the envelope itself is invalid切换或重新附着之后,带旧 attachmentId 的延迟帧会被拒绝:路由同时防止跨服务器、跨 Session 和过期的调用。After switching or reattaching, delayed frames with an old attachmentId are rejected: the route prevents cross-server, cross-Session and stale calls alike.服务器级server-wideSession附着attachment拒绝rejected

要点Key points

  • 管理服务的 attach() / detach() 不返回任何路由 id:服务器装好路由后才发 attachment,解除时发 attachment: null。Management attach() / detach() return no route ids: the server sends attachment once the route is installed, and attachment: null when it is cleared.
  • 切换或重新附着后,带旧 attachmentId 的延迟帧被拒绝(session_not_attached)。After switching or reattaching, delayed frames with an old attachmentId are rejected (session_not_attached).
  • 一个 Session 可以同时被多个 presentation 附着;同一连接重复 attach 同一个 Session 是幂等的。A Session can have several presentation attachments at once; repeating attach for the same Session on one connection is idempotent.
  • isServerId() 是导出的校验函数;client 与 server 的构造函数都用它拒绝非规范的 id。isServerId() is the exported check; the client and server constructors both use it to reject non-canonical ids.

工作原理How it works

  1. 服务器级:client.request({ serverId: client.hello.serverId }, call)。Server-wide: client.request({ serverId: client.hello.serverId }, call).
  2. Session:client.request(client.attachment, call)——attachment 是最近一次带外消息给出的完整路由。Session: client.request(client.attachment, call) — the attachment is the full route from the latest out-of-band message.
  3. 服务端先比较 target.serverId(wrong_server),再在 SessionRouter 里比较 sessionId 与 attachmentId。The server compares target.serverId first (wrong_server), then sessionId and attachmentId in the SessionRouter.

关键文件Key files · v1.1.0

文件File符号Symbol作用Role
packages/protocol/src/protocol.tsL12ServerIdSchemaUUIDv4 正则the UUIDv4 pattern
packages/protocol/src/protocol.tsL40SessionTargetSchemaSession 路由Session route
packages/protocol/src/protocol.tsL94AttachmentEnvelopeSchema带外路由变化out-of-band route change
packages/server/src/session-router.tsL168attachment.id服务器生成 attachmentIdthe server generates attachmentId
packages/server/src/session-router.tsL224requireAttachment()检查路由checking the route
06Run modes

一条连接上的消息顺序Message order on one connection#

一句话结论Takeaway客户端先发 hello { version: 8 };服务端检查版本、为这个连接准备服务器级服务,然后回 hello { version, serverId }——或者 hello_error 并关闭。之后客户端发 request / cancel,服务端发 response、service_update 和 attachment。订阅的快照在回应里,在快照之前产生的更新在服务端排队,回应之后才发出。The client sends hello { version: 8 } first; the server checks the version, prepares the server-scoped services for this connection, then answers hello { version, serverId } — or hello_error and closes. After that the client sends request / cancel and the server sends response, service_update and attachment. A subscription's snapshot is in its response; updates produced before it queue on the server and go out after the response.

一条连接上的消息顺序:握手、请求、附着、订阅与取消Message order on one connection: handshake, requests, attachment, subscriptions, cancellation
← 左右滑动查看,点击放大 →← swipe to scroll, tap to enlarge →
Message order on one connection: handshake, requests, out-of-band attachment, subscriptions, cancellationClientpi-clientServerpi-serverhello { version: 8 }hello { version: 8, serverId }request { id: "request-1", target, call: attach }attachment { attachment: { serverId, sessionId, attachmentId } }response { id: "request-1", ok: true }request { id: "request-2", call: subscribe }response { id: "request-2", ok: true, result: snapshot }service_update { subscriptionId, update }cancel { id: "request-3", target }response { id: "request-3", ok: false, error: { code: "cancelled" } }① 握手① Handshake第一帧必须是 hellothe first frame must be hello② 管理调用② A management callattach 的结果里没有路由 idattach returns no route ids③ 订阅③ A subscription先快照,再更新snapshot first, then updates④ 取消④ Cancellation同一 id、同一 targetsame id, same target服务端 5 s 内没完成握手No handshake within 5 s→ hello_error,关闭连接→ hello_error, close版本不符 → hello_error { version }Wrong version → hello_error { version }serverId 不符 → 客户端断开serverId mismatch → client drops带外:在 attach 的回应之前Out of band: before the attach送达,路由已经装好response, route already installed快照之前产生的更新Updates produced before the在服务端排队,回应之后才发snapshot queue until after it请求在服务端被 abortThe request is aborted on the客户端早已在本地 rejectserver; the client rejected locally握手之后再发 hello、或在客户端发出 hello 之前收到服务端数据,都是协议错误:连接被关闭。A second hello after the handshake, or server data before the client's hello was sent, is a protocol error: the connection closes.客户端 → 服务端client → server服务端 → 客户端server → client错误回应error response

要点Key points

  • 服务端握手超时默认 5 秒;第一条消息不是 hello、或之后再发 hello,都会以 hello_error { code: "invalid_request" } 结束连接。The server's handshake timeout is 5 seconds by default; a first message that is not hello, or a later hello, ends the connection with hello_error { code: "invalid_request" }.
  • 客户端检查服务端报告的 serverId 是否就是它期望的那台逻辑服务器,不符就断开。The client checks that the reported serverId is the logical server it expects, and disconnects otherwise.
  • 握手期间到达的请求不会丢:服务端在握手完成后再处理它们。Requests arriving during the handshake are not lost: the server handles them once the handshake completes.
  • cancel 只取消同一连接上、同一 id 且同一 target 的活动请求;被取消的请求以 cancelled 回应。cancel only cancels an active request with the same id and the same target on the same connection; the cancelled request answers cancelled.

工作原理How it works

  1. Server.dispatchMessage():awaitingHello → handshaking → ready;ready 之后按类型调用 handleRequest 或 handleCancel。Server.dispatchMessage(): awaitingHello → handshaking → ready; after that it calls handleRequest or handleCancel by type.
  2. Connection.#handleMessage()(客户端):连接中只接受 hello / hello_error;连上后 hello 类消息都是协议错误。Connection.#handleMessage() (client): while connecting only hello / hello_error are accepted; once connected, any hello-type message is a protocol error.
  3. attach 的顺序:路由安装 → 带外 attachment → attach 调用的 response。Attach ordering: route installed → out-of-band attachment → the attach call's response.

关键文件Key files · v1.1.0

文件File符号Symbol作用Role
packages/server/src/server.tsL42DEFAULT_HANDSHAKE_TIMEOUT_MS5 秒5 seconds
packages/server/src/server.tsL262finishHandshake()版本检查与服务器 helloversion check and server hello
packages/server/src/server.tsL298handleCancel()取消cancellation
packages/client/src/connection.tsL163#handleMessage()客户端握手client handshake
packages/protocol/src/codec.tsL139isSupportedProtocolVersion()版本判断version check
07Run modes

分帧与 FrameDecoderFraming and FrameDecoder#

一句话结论Takeaway一帧 = 4 字节无符号大端长度 + 恰好一个定长 CBOR 项。FrameDecoder.push(chunk) 接受任意切分与合并:它先凑齐 4 字节头,长度超过 maxFrameLength 时立刻失败(不等负载到达),长度为 0 时产出空帧,否则按 64 KiB 的块收集负载,收满后返回。状态只有 open、ended、failed 三个。A frame = a 4-byte unsigned big-endian length + exactly one definite-length CBOR item. FrameDecoder.push(chunk) accepts any splitting and coalescing: it fills the 4-byte header, fails at once if the length exceeds maxFrameLength (without waiting for the payload), yields an empty frame for length 0, and otherwise collects the payload in 64 KiB blocks and returns it when complete. There are only three states: open, ended and failed.

4 字节长度前缀与 FrameDecoder 的状态The 4-byte length prefix and FrameDecoder states
← 左右滑动查看,点击放大 →← swipe to scroll, tap to enlarge →
Framing: a u32 big-endian length + one CBOR item; 16 MiB by default一段字节流(chunk 边界可以落在任何地方)A byte stream (chunk boundaries may fall anywhere)FrameDecoder 的状态FrameDecoder statesframing.tspush(chunk) 做的事What push(chunk) doesopenendedfailed00 00 00 15CBOR · 21 字节CBOR · 21 bytes00 00 00 0000 02 00 00负载 · 131,072 字节 …payload · 131,072 bytes …chunk 1chunk 2chunk 3chunk 4 …长度 = 21length = 21{ type: "hello", version: 8 }空帧 → Uint8Array(0)empty frame → Uint8Array(0)长度 = 131,072length = 131,072= 2 个 64 KiB 块,收满后拼接= two 64 KiB blocks, joined when complete编码:encodeFrame(payload) 只加 4 字节头;负载超过 0xffffffff 字节 → RangeError。Encoding: encodeFrame(payload) only adds the 4-byte header; a payload above 0xffffffff bytes → RangeError.end():没有半帧end(): no partial frame超限 · 截断too long · truncatedpush(chunk) → Uint8Array[]留在 openstays open之后的 push / end → FrameErrorlater push / end → FrameError清空缓冲;之后一律 FrameErrorbuffers cleared; FrameError from then onClient/ServerMessageDecoder 把它包装成 ProtocolValidationError。Client/ServerMessageDecoder wrap it as ProtocolValidationError.凑齐 4 字节头,可以跨多个 chunkFill the 4-byte header, across chunks if needed长度 > maxFrameLength → 立即失败Length > maxFrameLength → fail at once不等负载到达without waiting for the payload长度 0 → 立刻产出一个空帧Length 0 → emit an empty frame now负载按 64 KiB 的块收集Collect the payload in 64 KiB blocksPAYLOAD_BLOCK_SIZE收满:单块直接返回,否则拼成一个数组Complete: one block as is, else joined总是复制字节,不引用输入 chunkBytes are always copied, never aliased123456帧frames解码器decoder正常结束clean end失败failure

要点Key points

  • 负载总是被复制:返回的帧不引用调用方传入的 chunk,调用方可以复用自己的缓冲区。Payloads are always copied: returned frames never alias the caller's chunks, so callers may reuse their buffers.
  • 按 64 KiB 分块分配,所以一个声明很大但迟迟不到的帧不会一次性占用全部内存。Allocation is in 64 KiB blocks, so a frame that declares a large size but arrives slowly does not reserve all of its memory up front.
  • maxFrameLength 可以是 0 到 0xffffffff;客户端与服务端还要求它 ≥ 1。maxFrameLength may be 0 to 0xffffffff; the client and server additionally require at least 1.

工作原理How it works

  1. encodeFrame(payload):分配 4 + n 字节,写长度,复制负载。encodeFrame(payload): allocate 4 + n bytes, write the length, copy the payload.
  2. push() 的外层循环:头还没齐就继续读头;齐了就检查长度并开始收集负载;负载齐了就产出一帧并重置。push()'s outer loop: keep reading the header until complete; then check the length and start collecting; when the payload is complete, emit a frame and reset.
  3. end():头或负载只读了一部分 → failed + FrameError。end(): a partially read header or payload → failed + FrameError.

关键文件Key files · v1.1.0

文件File符号Symbol作用Role
packages/protocol/src/framing.tsL3PAYLOAD_BLOCK_SIZE64 KiB 块64 KiB blocks
packages/protocol/src/framing.tsL6DEFAULT_MAX_FRAME_LENGTH16 MiB16 MiB
packages/protocol/src/framing.tsL28encodeFrame()加长度前缀add the length prefix
packages/protocol/src/framing.tsL59push()增量拆帧incremental splitting
packages/protocol/src/framing.tsL132end()截断检查truncation check
08Run modes

严格的 CBOR 子集A strict CBOR subset#

一句话结论Takeawaycbor/ 实现 RFC 8949 的一个严格子集:主类型 0–5 与 7 中的 false、true、null 和 float64;tag(主类型 6)、不定长项、半精度与单精度浮点一律拒绝。映射的键必须是字符串且不重复。默认上限:16 MiB、1,000,000 个元素、64 层嵌套(可配置到 512 层),长度与深度在遍历之前就检查。cbor/ implements a strict subset of RFC 8949: major types 0–5 plus false, true, null and float64 from type 7; tags (major type 6), indefinite-length items and half / single floats are always rejected. Map keys must be unique strings. Default limits: 16 MiB, 1,000,000 elements and 64 nesting levels (configurable up to 512), with lengths and depth checked before traversal.

严格的 CBOR 子集:主类型、上限与一个编码例子The strict CBOR subset: major types, limits and a worked encoding
← 左右滑动查看,点击放大 →← swipe to scroll, tap to enlarge →
A strict RFC 8949 subset: definite-length items only, no tags8 种主类型(major type)The 8 major typescbor/decoder.ts · encoder.tsCborOptions 与规则CborOptions and rulesoptions.ts例子:encodeClientMessage({ type: "hello", version: 8 })Example: encodeClientMessage({ type: "hello", version: 8 })0✓1✓2✓3✓4✓5✓6✕7✓31✕16 MiBmaxByteLength输入 / 输出与单个串的长度input / output and any one string1,000,000maxContainerLength数组元素或映射项array elements or map entries64 (≤ 512)maxDepth嵌套层数nesting levels无符号整数unsigned intnumber(安全整数)number (safe integer)负整数negative intnumber,-1 − n 必须安全number; -1 − n must be safe字节串byte stringUint8Array;信封层再拒绝(非 JSON)Uint8Array; refused later by the envelope check文本串text stringfatal UTF-8;保留开头的 BOMfatal UTF-8; a leading BOM is kept数组array不能有空洞或 undefinedno holes or undefined映射map键必须是字符串且不重复;__proto__ 是普通数据string keys, no duplicates; __proto__ is datatag一律拒绝always rejected简单值 / 浮点simple / float只有 false、true、null 与有限的 float64only false, true, null and finite float64不定长(附加信息 31)indefinite length (info 31)一律拒绝,break 也是always rejected, break too编码器Encoder整数用最短的参数;-0 与小数写成 float64shortest argument for integers; -0 and fractions as float64值为 undefined 的属性被省略,假值保留undefined properties are omitted, falsy values kept环、符号键、非普通对象 → CborErrorcycles, symbol keys, non-plain objects → CborError字符串必须是合法的 Unicode 标量序列strings must be valid Unicode scalar values解码器Decoder先检查声明的长度与深度,再遍历checks declared lengths and depth before traversing恰好一个项:尾随字节 → 错误exactly one item: trailing bytes → error整数超出安全范围 → 错误integers outside the safe range → error00 00 00 15 a2 64 74 79 70 65 65 68 65 6c 6c 6f 67 76 65 72 73 69 6f 6e 0825 字节:4 字节长度(21)· a2 = 2 项映射 · 64 "type" · 65 "hello" · 67 "version" · 08 = 825 bytes: 4-byte length (21) · a2 = map of 2 · 64 "type" · 65 "hello" · 67 "version" · 08 = 8支持supported拒绝rejected上限limits

要点Key points

  • 编码器为整数选最短的参数;非整数与 -0 写成 float64(0xfb);非有限数、非安全整数直接报错。The encoder picks the shortest argument for integers; non-integers and -0 are written as float64 (0xfb); non-finite numbers and unsafe integers are errors.
  • 文本按 fatal UTF-8 解码并保留开头的 BOM;编码时孤立代理项会被检测并拒绝。Text is decoded as fatal UTF-8 and keeps a leading BOM; lone surrogates are detected and rejected on encode.
  • 解码出的对象用 defineProperty 设置属性,所以 __proto__ 只是普通的数据键。Decoded objects set properties with defineProperty, so __proto__ is just a data key.
  • 字节串(主类型 2)在 CBOR 层是合法的,但信封层的严格 JSON 检查会拒绝它。Byte strings (major type 2) are legal at the CBOR layer but rejected by the envelope's strict-JSON check.

工作原理How it works

  1. encodeCbor(value, { maxByteLength, maxContainerLength, maxDepth }) → Uint8Array。encodeCbor(value, { maxByteLength, maxContainerLength, maxDepth }) → Uint8Array.
  2. decodeCbor(bytes, options) → 恰好一个值;尾随数据是错误。decodeCbor(bytes, options) → exactly one value; trailing data is an error.
  3. hello 的编码是 25 字节:4 字节长度 + 21 字节 CBOR(见图中的例子)。hello encodes to 25 bytes: a 4-byte length + 21 bytes of CBOR (see the example in the diagram).

关键文件Key files · v1.1.0

文件File符号Symbol作用Role
packages/protocol/src/cbor/options.tsL6DEFAULT_MAX_CBOR_*默认上限default limits
packages/protocol/src/cbor/decoder.tsL26readItem()按主类型解码decoding by major type
packages/protocol/src/cbor/decoder.tsL112readLength()拒绝不定长indefinite lengths refused
packages/protocol/src/cbor/encoder.tsL83writeArgument()最短参数shortest argument
packages/protocol/src/cbor/encoder.tsL119encodeValue()按值类型编码encoding by value type
09Run modes

protocol、chord 与应用的分界Where protocol, chord and the application meet#

一句话结论Takeawayprotocol 只拥有信封:类型、id、路由和“负载是严格 JSON”这条规则。call 的语法 { serviceId, instance?, member, args }、$chord.service 控制词汇(目录、订阅、退订)、订阅快照与更新、服务错误码以及复制状态的 Delta 编解码都属于 chord;Session 目录、管理结果、transcript、模型、插件这些值属于应用。The protocol owns only the envelope: types, ids, routes and the rule “payloads are strict JSON”. The call grammar { serviceId, instance?, member, args }, the $chord.service control vocabulary (catalogue, subscribe, unsubscribe), subscription snapshots and updates, service error codes and the Delta codecs for replicated state belong to chord; the Session directory, management results, transcripts, models and plugins belong to the application.

分界:信封归 protocol,调用归 chord,数据归应用The boundary: envelope for protocol, call for chord, data for the application
← 左右滑动查看,点击放大 →← swipe to scroll, tap to enlarge →
Who owns which layer: the envelope is protocol's, the call is chord's, the data is the application'spi-protocol 校验的信封The envelope pi-protocol validatesRequestEnvelopechord 解释的 callThe call chord interpretsServiceCall应用自己的数据Application dataargs · result · update谁在哪里解析 chord 语法Who parses the chord grammar, where不在公共协议里Not in the public protocoltype: "request"idtarget: RpcTargetcall: …serviceIdinstance?memberargscataloguesubscribeunsubscribeSession 目录状态Session directory state管理结果management resultstranscripttranscripts模型models插件plugins其他服务值other service valuespi-client · client.tsparseServiceCall · parseServiceCataloguecreateServiceStateDecoder · parseWire…Snapshotpi-server · server.tsdecodeServiceControlCall · parseServiceCallcreateServiceStateEncoder · RemoteServiceErrorcoding-agent · services/connection.tscreateClientServiceTransport(client, route)→ 应用自己的类型化 chord 服务绑定→ the app's own typed chord service bindings或者一个 $chord.service 控制调用:or a $chord.service control call:回应里的服务目录、订阅快照与更新、服务错误码、复制状态的 Delta 路径编解码都属于 chord。Catalogues, subscription snapshots and updates, service error codes and Delta path codecs belong to chord.对 protocol 与 server 都是不透明的 JSON;Opaque JSON to both the protocol and the server;由 chord 绑定与应用在服务边界上校验。validated by chord bindings and the app at the service boundary.服务器与 worker 的生命周期:协调器只转发不透明消息Server and worker lifecycle: the coordinator only relays opaque messages对端认证与已认证的服务上下文:实验性传输没有实现Peer authentication and authenticated contexts: not implemented兼容性保证:协议是实验性的Compatibility guarantees: the protocol is experimentalprotocol 只检查 call、result、update 是严格 JSON;它不导出、也不校验 chord 的语法——这让信封层可以在不了解任何服务契约的情况下路由。The protocol only checks that call, result and update are strict JSON; it neither exports nor validates chord's grammar — so the envelope layer routes without knowing any service contract.protocolchord应用application范围之外out of scope

要点Key points

  • 因此服务端可以把 Session 调用原样转发给 Session worker,而不需要加载那个服务的契约。So the server can forward a Session call to the Session worker as is, without loading that service's contract.
  • client 在服务适配边界用 chord 的解析器(parseServiceCall、parseServiceCatalogue、createServiceStateDecoder),server 用 decodeServiceControlCall 与 createServiceStateEncoder。At the service-adapter boundary the client uses chord's parsers (parseServiceCall, parseServiceCatalogue, createServiceStateDecoder), the server decodeServiceControlCall and createServiceStateEncoder.
  • 不在公共协议里:服务器与 worker 的生命周期(实验性的本地协调器只是不透明的消息路由),以及对端认证。Not in the public protocol: server and worker lifecycle (the experimental local coordinator is just an opaque message router), and peer authentication.

工作原理How it works

  1. coding-agent:createClientServiceTransport(client, () => client.attachment) 把 Session 路由变成一个 chord 传输,再交给应用的类型化服务绑定。coding-agent: createClientServiceTransport(client, () => client.attachment) turns the Session route into a chord transport for the application's typed service bindings.
  2. 服务端收到订阅时为它建一个 ServiceStateEncoder,之后的更新都经它编码。On a subscription the server creates a ServiceStateEncoder for it, and later updates are encoded through it.
  3. 应用的 payload 语义校验在 chord 服务里完成,而不是在协议层。Semantic payload validation happens in the chord services, not in the protocol.

关键文件Key files · v1.1.0

文件File符号Symbol作用Role
packages/protocol/README.mdL15README.md分界的原文the boundary, as written
packages/protocol/src/protocol.tsL8OpaqueJsonValueSchema不透明 JSONopaque JSON
packages/client/src/client.tsL448createClientServiceTransport()路由 → chord 传输route → chord transport
packages/server/src/server.tsL332decodeServiceControlCall服务端识别控制调用the server recognises control calls
10Run modes

扩展点Extension points#

maxFrameLength每一端的帧上限(默认 16 MiB);两端必须一致。Each peer's frame limit (16 MiB by default); both peers must match.
encodeCbor / decodeCbor(…, CborOptions)单独使用 CBOR 编解码,配置字节、容器与深度上限。Use the CBOR codec on its own, with byte, container and depth limits.
FrameDecoder / encodeFrame只用分帧,承载你自己的负载。Use only the framing, carrying your own payloads.
parseClientMessage / parseServerMessage校验从别的通道得到的消息对象。Validate message objects obtained through another channel.
ByteTransport (client) · ServerListener (server)任何保持字节顺序的流都能承载协议:Unix socket、WebSocket 中继、管道。Any order-preserving byte stream can carry the protocol: Unix sockets, a WebSocket relay, pipes.
11Run modes

测试Tests#

一句话结论Takeaway三个测试文件:分帧(每个切分点、合并、空帧、上限)、信封校验(版本 8、不透明负载、取消、附着、未知字段)与 CBOR(undefined、BOM、__proto__、环、深度与长度上限)。Three test files: framing (every split point, coalescing, empty frames, limits), envelope validation (version 8, opaque payloads, cancellation, attachments, unknown fields) and CBOR (undefined, BOM, __proto__, cycles, depth and length limits).

用例数 = 源码中 it( / test( 的出现次数(静态统计,不运行测试)。Cases = occurrences of it( / test( in the source (static count; tests were not run).

12Run modes

交叉链接Cross-links#