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

chordchord

应用组合运行时:服务、复制状态、RPC 与插件Application composition runtime for services, replicated state, RPC, and plugins

Infra

← → 翻页 · 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 · 模块结构01 · Module structureInfra

模块结构Module structure

chord modules (8,817 lines of TypeScript)API 与类型API & typesFacet 宿主Facet host服务ServicesDeltaContext 与 NodeContext & Node导入路径、依赖与使用者Import paths, dependencies and users85index.ts根入口:重新导出root entry: re-exports110api.tscreateFacetHost() 等createFacetHost() …339types.tsFacet · Service · Context …120json.tsisJsonValue · copyJson906host.tsFacetKernel:激活 · 重载FacetKernel: activate · reload6loader.tsLoadedFacets 的释放disposing LoadedFacets713consumer.ts远程绑定:稳定外观remote binding: stable facade633provider.tsRemoteServiceProvider457state.ts复制状态 · 订阅replicated state · subscriptions248wire.ts$chord.service 控制调用$chord.service control calls176state-codec.ts每个订阅一套路径字典one path dictionary per subscription151instances.tskeyed 实例 · generationkeyed instances · generations113handle.tsServiceSlot · 代理视图ServiceSlot · proxy views2,205tracker.tstrack() · Draft · prepare/adopt694index.tsOp · WireOp · apply · 编解码Op · WireOp · apply · codec523diff.tsdiffRevisions()128apply-immutable-trusted.ts结构共享地重放structurally shared replay75revision-validator.ts校验不可信的 opvalidates untrusted ops121index.ts取消 · 调用范围的值cancellation · scoped values416bundle-loader.ts校验 SHA-256 · node:vmSHA-256 check · node:vm222bundle.tsesbuild → .cjs + 清单esbuild → .cjs + manifest232package.tspackage.json 中的 chord.facetschord.facets in package.json@earendil-works/chord@earendil-works/chord/context@earendil-works/chord/delta@earendil-works/chord/bundler@earendil-works/chord/nodeesbuild 0.28.2durable · 41coding-agent · 26server · 7client · 2env · 2protocol · 2setup 同步声明依赖;setup declares deps synchronously;宿主校验整张图后the host validates the whole graph,按依赖顺序激活。then activates in dependency order.较小的文件:Smaller files:services/errors.tsservices/loopback.tsservices/state-internals.tsdelta/draft.tsnode/manifest.tsbundler.ts · node.ts依赖:dep:被这些包导入(src 中的文件数):Imported by (files in src):标签 = 行数;不依赖任何其他 pi 包tag = lines; depends on no other pi packagefacets服务与状态services & statedeltacontext · node

最大的单个文件是 delta/tracker.ts:它让提供方像改普通对象一样改状态(Draft 代理),再算出 op。The largest single file is delta/tracker.ts: it lets a provider change state like a plain object (a Draft proxy) and then computes the ops.

图解 Pi Agent · Pi v1.1.0Pi Agent, Illustrated · Pi v1.1.002
02 · Facet 的一代:装配与激活02 · A facet generation: assembly and activationInfra

Facet 的一代:装配与激活A facet generation: assembly and activation

One facet generation: setup → validate → connect → activate → disposesetup(env) 能做的事What setup(env) can doFacetEnvironmentassembling:validateFacets()assembling: validateFacets()例:两个 facetExample: two facetssetup同步调用 facet.setup(env)facet.setup(env), synchronouslyassembling外部目录 · 校验 · 排序catalogues · validate · orderconnecting等所有绑定 ready()wait for every binding ready()activating按拓扑顺序 activate()activate() in topological orderactive服务可用services usabledisposing逆序释放reverse-order disposalmodels-facetenv.provide(Models, impl)ui-facetenv.use(Models)任何一步失败:#terminate() 释放已建好的一切,原错误与清理错误一起以 AggregateError 抛出("Facet generation startup and cleanup failed")。A failure at any step: #terminate() disposes whatever was built and throws the original error with any cleanup errors as an AggregateError ("Facet generation startup and cleanup failed").env.use()声明对单例服务的硬依赖,返回稳定句柄hard dependency on a singleton; returns a stable handleenv.observe()依赖 keyed 服务,逐个观察活着的实例depend on a keyed service; observe each live instanceenv.provide()提供单例实现(必须是对象)provide a singleton implementation (an object)env.provideMany()拥有 keyed 服务,返回 spawn(key, impl)own a keyed service; returns spawn(key, impl)env.replicatedState()创建本 facet 的可变复制状态create mutable replicated stateenv.own()登记一个释放函数register a disposal functionenv.onActivate()依赖就绪后的异步初始化async init once dependencies are readyenv.onDeactivate()最终拆除(= own)final teardown (same as own)每个服务只能有一个提供者(facet 或宿主的外部来源)each service has exactly one provider (a facet or a host source)"is provided by both … and …"singleton / keyed 模式必须一致singleton / keyed modes must match"provided as both singleton and keyed"每个 use / observe 都必须有提供者every use / observe needs a provider"… but no facet provides it"依赖图无环:Kahn 拓扑排序给出激活顺序no cycles: a Kahn topological sort gives the activation order"Facet dependency cycle: …"激活:models-facet → ui-facetactivate: models-facet → ui-facet释放:ui-facet → models-facetdispose: ui-facet → models-facet每个 facet 内部,own() 登记的释放Inside each facet, own() disposals函数也按登记的逆序执行;also run in reverse registration order;错误收集后统一抛出。errors are collected and rethrown.setup 返回 Promise 会被拒绝("setup must be synchronous");use / provide 等只能在 setup 期间调用,句柄只能在 active 期间使用。A setup that returns a Promise is rejected ("setup must be synchronous"); use / provide and friends work only during setup, and handles only while active.facet 宿主facet host远程绑定remote bindings阶段顺序phase order依赖depends on

setup 必须同步:返回 Promise 会被拒绝。这让依赖图在任何异步工作开始前就是完整、可校验的。setup must be synchronous: returning a Promise is rejected. That makes the dependency graph complete and checkable before any async work starts.

图解 Pi Agent · Pi v1.1.0Pi Agent, Illustrated · Pi v1.1.003
03 · 热重载03 · Hot reloadInfra

热重载Hot reload

FacetHost.reload(facets): replace implementations without disconnecting handles失败时On failure为什么句柄不断开Why handles stay connectedservices/handle.ts检查Checkphase 必须是 active;ID 唯一且都已激活phase must be active; IDs unique and all active候选 setupCandidate setupsameFacetShape():requires / provides 不变sameFacetShape(): same requires / provides激活候选Activate candidates按原激活顺序;旧实现仍在服务请求in the existing order; the old ones still serve切换Cut over单例:local → bindSingleton · 远程 → replace()singletons: local → bindSingleton · remote → replace()退役旧 facetRetire old facets逆序 disposedispose in reverse order连接 keyedConnect keyed新实例拿到新的 generationnew instances get fresh generationsconsumer拿着代理视图holds a proxy viewServiceSlot宿主拥有owned by the hostold implnew impl切换之前(第 1–3 步)Before the cutover (steps 1–3)释放候选;宿主回到 active,旧实现照常工作。candidates are disposed; the host returns to active and the old ones keep working.只有清理也失败时才中止整个代。Only if cleanup fails too is the whole generation aborted.切换之后(第 4–6 步)After the cutover (steps 4–6)#abort(previous):撤销所有 facet 的服务访问并终止整个代,#abort(previous): revoke every facet's service access and terminate抛出 "Facet reload failed after cutover"。the generation; throws "Facet reload failed after cutover".每次访问成员时,代理都通过 slot.resolve() 读当前实现,Every member access resolves the current implementation through所以 bind(new) 之后,已有句柄立即调用新代码。slot.resolve(), so after bind(new) existing handles call the new code.unbind() 之后访问 → "Service … is disconnected"。Access after unbind() → "Service … is disconnected".README 推荐的重载流程:先 load() 候选的 LoadedFacets → 把 facets 交给 reload() → 失败则释放候选;The README's reload recipe: load() the candidate LoadedFacets → pass its facets to reload() → dispose the candidate on failure;成功切换之后才释放退役的那一代 LoadedFacets(代码随之可被回收)。dispose the retired LoadedFacets only after a successful cutover (its code can then be collected).keyed 实例按代区分(ServiceInstanceAddress = key + generation):重载后旧实例关闭,新实例以新 generation 出现。Keyed instances are generation-specific (ServiceInstanceAddress = key + generation): old instances close, new ones appear with new generations.123456步骤steps切换cutover失败处理failure handling

形状不变是硬性要求:重载不能增删依赖或提供的服务("must preserve its service requirements and provisions")。改形状需要新的一代。Unchanged shape is a hard rule: a reload cannot add or remove dependencies or provisions ("must preserve its service requirements and provisions"). Changing the shape needs a new generation.

图解 Pi Agent · Pi v1.1.0Pi Agent, Illustrated · Pi v1.1.004
04 · 服务、句柄与 Context04 · Services, handles and ContextInfra

服务、句柄与 ContextServices, handles and Context

Services: tokens · modes · stable handles · Context服务令牌:defineService<T>(id, options?)Service tokens: defineService<T>(id, options?)两种提供方式Two provision modes一个调用的路径The path of one callContext:显式传递的调用上下文Context: an explicitly passed call contextchord/context远程(默认)remote (default)本地:{ local: true }local: { local: true }pi.transcriptpi.modelspi.agent-controllerpi.local.presentation-uisingletonprovide(S, impl) ↔ use(S)keyedprovideMany(S) ↔ observe(S, …)models.select(m, ctx)facet 拿到的句柄handle the facet holdsServiceSlot.resolve()取当前实现(重载可换)current implementation (reload-safe)impl.select(m, ctx)本地对象,或远程绑定的成员代理local object, or a remote member proxy成员只能是:members must be:(…json, Context) => Promise或 ReplicatedState<json>or ReplicatedState<json>任意 TypeScript 形状;any TypeScript shape;只在本进程内提供,provided in-process only,不能跨传输。never crosses a transport.coding-agent/src/experimental/services 中的例子:Examples from coding-agent/src/experimental/services:空 ID 会抛错;"$chord." 前缀保留给 chord 自己的控制调用。An empty ID throws; the "$chord." prefix is reserved for chord's own control calls.一个服务 ID 只有一个实现;One implementation per service ID;use() 返回稳定句柄。use() returns a stable handle.spawn(key, impl) 返回 close();spawn(key, impl) returns close();observer 对每个活实例被调用,the observer runs for each live实例关闭时执行其清理函数。instance and cleans up on close.来源:facet 提供,或宿主的 serviceSources(外部服务目录)提供——Source: a facet, or one of the host's serviceSources (external catalogues)两者不能同时提供同一个服务。— never both for the same service.远程成员最后一个A remote member's last参数必须是 Context,argument must be a Context,否则 service_invalid_value。else service_invalid_value.绑定关闭后调用 →Calling after the bindingservice_stale_instance。closes → service_stale_instance.BACKGROUND_CONTEXT · TODO_CONTEXT根上下文(TODO 标记"尚待接线")roots (TODO marks "not wired yet")createContextKey · withContextValue调用范围内的键值call-scoped valueswithAbortSignal · withCancel合并取消信号(AbortSignal.any)combine cancellation (AbortSignal.any)withoutAbortSignal保留值,去掉取消keep values, drop cancellationawaitWithContext(p, ctx)取消时只拒绝等待者,不中止 pcancellation rejects the waiter only, not p远程服务remote services本地服务local servicesfacet 侧facet sideContext

远程约束在类型层面检查(RemoteServiceContract<T>),运行时在边界上再校验一次(validateRemoteServiceImplementation)。The remote constraint is checked in the type system (RemoteServiceContract<T>) and again at runtime at the boundary (validateRemoteServiceImplementation).

图解 Pi Agent · Pi v1.1.0Pi Agent, Illustrated · Pi v1.1.005
05 · 跨进程的服务05 · Services across processesInfra

跨进程的服务Services across processes

Services across processes: provider ⇄ transport ⇄ binding提供方进程Provider process传输(应用自选)Transport (application's choice)RemoteServiceTransport使用方进程Consumer process订阅更新的 6 种类型The 6 subscription update typesServiceProviderUpdate背压与错误Backpressure and errors服务实现service implementationfacet 的 provide / provideManya facet's provide / provideManyRemoteServiceProviderinvoke(call) · subscribe(id, mode)createRemoteServiceEndpoint()处理 "$chord.service" 控制调用serves "$chord.service" control callscreateRemoteServiceBinding()稳定外观:成员代理 + 复制状态stable facade: member proxies + replicasfacet 的 use() / observe()a facet's use() / observe()像本地对象一样调用calls it like a local objectstate.value · state.subscribe()按序重放 delta 的副本replica that replays deltas in ordernot_allowednot_foundmode_mismatchmember_not_foundmember_mismatchinstance_not_foundstale_instanceinvalid_value调用 [...args, ctx]calls [...args, ctx]invoke(call, context)subscribe(serviceId, mode, listener, ctx)chord 不规定外层协议:这些值可以放进chord does not prescribe the outer protocol:任何路由 / 请求 / 事件信封。wrap these values in any envelope.$chord.service 的三种控制调用:Three $chord.service control calls:· catalogue· subscribe(subscriptionId, serviceId, mode)· unsubscribe(subscriptionId)同进程:createLoopbackServiceTransport(provider)In-process: createLoopbackServiceTransport(provider)调用 / 订阅invoke / subscribe更新updatesstate某成员的一批 op,sequence 连续a batch of ops for one member, contiguous sequencereset整份快照:每个 state 都是 ["r", value]full snapshot: every state is ["r", value]unavailable当前没有提供者no provider right nowreplaced提供者被替换(如重载)the provider was replaced (e.g. reload)spawnedkeyed 新实例出现a new keyed instance appearedclosedkeyed 实例关闭a keyed instance closed每个订阅最多缓存 100 个待发更新;第 101 个到来时,At most 100 pending updates per subscription; when update 101整个队列被替换成一个 { type: "reset", snapshot }。arrives the whole queue becomes one { type: "reset", snapshot }.普通 state 更新绝不允许 sequence 跳号。An ordinary state update never permits a sequence gap.可以跨边界的 8 个错误码:The 8 error codes that may cross the boundary:错误码都带 service_ 前缀all codes carry a service_ prefix调用calls订阅更新subscription updates提供方provider使用方consumer

订阅更新有 6 种:state(某成员的一批 op)、reset、unavailable、replaced、spawned、closed。Subscription updates come in 6 kinds: state (a batch of ops for one member), reset, unavailable, replaced, spawned, closed.

图解 Pi Agent · Pi v1.1.0Pi Agent, Illustrated · Pi v1.1.006
06 · 复制状态06 · Replicated stateInfra

复制状态Replicated state

Replicated state: how one change() becomes a new value elsewhere发布之后:两类订阅者After publishing: two kinds of subscriber远端副本:严格按序The remote replica: strictly in orderchange(ctx, draft => …)不可重入;回调必须同步no reentrancy; callback must be synctracker.beginChange()拿到 Draft 代理get a Draft proxymutate(draft)像普通对象一样改mutate like a plain objectchange.prepare()算出 ops(失败则 abort)compute ops (abort on throw)tracker.adopt(prepared)换根指针:新不可变版本swap the root: new immutable revisionpublish(value, ops)sequence + 1;没有 op 就不发布sequence + 1; no ops, no publish内部精确订阅internal exact subscriptions(ops, sequence, ctx):同步,拿到每个版本(ops, sequence, ctx): synchronous, every revisionstate.subscribe(fn)(value, ctx, delivery):每个订阅串行(value, ctx, delivery): serialized per subscriptionhydrate(7, [["r", v]])基线baselineupdate(8, ops)applyupdate(9, ops)applyupdate(11, …)跳号!gap!回调抛错 → 原值和 sequence 都不变。Callback throws → value and sequence unchanged.replace(ctx, value) 走同一条路径(prepareReplace)。replace(ctx, value) takes the same path (prepareReplace).→ 远程编码器 / provider 订阅→ remote encoders / provider subscriptions每个 client × state 独立编码encoded separately per client × state先 hydrate(当前值),再 update;hydrate (current value) first, then updates;最多 100 个待处理:溢出时只留最新的,at most 100 pending: on overflow keep the newest,所以公开的 sequence 可能跳号。so public sequences may skip.异步回调里读捕获的 value,不要读 state.value。In async callbacks read the captured value, not state.value.update 要求 sequence == 上一个 + 1;否则清空副本并抛出update requires sequence == previous + 1; otherwise the replica clears and throws"Replicated state update sequence has a gap"provider 侧的 reset 是唯一合法的"重新起跑":它带着新的基线 sequence,A provider reset is the only legal restart: it carries new baseline sequences,并清空该订阅的路径字典。and clears that subscription's path dictionaries.值是不可变、结构共享的:没变的子树在版本之间共用。Values are immutable and structurally shared: unchanged subtrees are reused.123456顺序orderdelta tracker订阅subscriptions违约violation

一次 change() 恰好发布一个原子版本;回调抛错时值和 sequence 都不变;没有产生 op 就不发布。A successful change() publishes exactly one atomic revision; if the callback throws, value and sequence stay unchanged; no ops, no publication.

图解 Pi Agent · Pi v1.1.0Pi Agent, Illustrated · Pi v1.1.007
07 · JSON delta07 · JSON deltasInfra

JSON deltaJSON deltas

Delta: 7 ops and on-the-wire compressionOp(delta/index.ts)Op (delta/index.ts)字符串怎样变成 opHow a string change becomes opsWireOp:只多两种压缩WireOp: just two compressionsrsdatpm["r", value]替换根replace the root["s", path, value]设置字段 / 下标set a field / index["d", path]删除字段delete a field["a", path, str]字符串末尾追加append to a string["t", path, n]字符串去掉前 n 个字符drop the first n chars of a string["p", path, start, del, items]数组 splicearray splice["m", path, perm]数组重排:new[i] = old[perm[i]]reorder: new[i] = old[perm[i]]新值以旧值开头new starts with old→ ["a", path, tail]旧值的后缀是新值的前缀old suffix = new prefix→ ["t", …] + ["a", …](重叠扫描最多 65,536 字符:滚动日志)(overlap scan up to 65,536 chars: rolling logs)其他情况anything else→ ["s", path, new]超过 4,096 个 op → 折叠成一个 ["r", 整个根]more than 4,096 ops → fold into one ["r", whole root]["#", id, path]:路径第二次出现时定义一个编号["#", id, path]: defines an id on a path's second use之后用数字代替该路径later ops reference it by number缩短的元组:同一批内复用上一个 op 的路径shortened tuples reuse the previous op's path (within a batch)编码器 / 解码器每个订阅一对,每个实例 × 成员一本字典;one encoder / decoder per subscription, one dictionary per替换、不可用、关闭、重新 hydrate 时清空。instance × member; cleared on replace, unavailable, close, hydrate.例:README 里的 { output, count }Example: the README's { output, count }draft.output += "done\n"; draft.count += 1; // 两次修改draft.output += "done\n"; draft.count += 1; // two edits→ ops [["a", ["output"], "done\n"], ["s", ["count"], 3]]→ wire [["a", ["output"], "done\n"], ["s", ["count"], 3]] // 路径各用一次,不定义编号→ wire [["a", ["output"], "done\n"], ["s", ["count"], 3]] // each path used once: no ids下一批再追加 output(第二次使用)→ ["#", 0, ["output"]], ["a", 0, "…"]next batch appends to output (second use) → ["#", 0, ["output"]], ["a", 0, "…"]

超过 4,096 个 op 时折叠成 ["r", 整个根];去头 + 追加的重叠扫描最多 65,536 个字符。More than 4,096 ops fold into ["r", whole root]; the truncate-plus-append overlap scan covers up to 65,536 characters.

图解 Pi Agent · Pi v1.1.0Pi Agent, Illustrated · Pi v1.1.008
08 · 打包与加载插件08 · Bundling and loading pluginsInfra

打包与加载插件Bundling and loading plugins

Bundling and loading facets: esbuild → manifest → verified, then run in node:vm打包时At bundle timenode/bundle.ts清单 chord-facets.jsonManifest chord-facets.jsonnode/manifest.ts加载时:createFacetBundleLoader(...).load()At load time: createFacetBundleLoader(...).load()node/bundle-loader.ts插件包plugin packagepackage.json 的 "chord": { facets }package.json "chord": { facets }bundleFacetPackage()esbuild:cjs · bundle · 单文件esbuild: cjs · bundled entries输出目录output directoryfacet-<hash>.cjs + chord-facets.jsoncreateFacetBundleLoaderload() → LoadedFacets读清单read manifest格式 / 版本必须匹配format / version must match校验 SHA-256verify SHA-256与 integrity 不符就拒绝reject on integrity mismatchnode:vm 编译compile in node:vmcompileFunction(code, …)受限 requirerestricted require未声明的 external → 报错undeclared external → error校验 facetsvalidate facets然后交给 reload()then hand to reload()· 依赖包(peer)和 @earendil-works/chord 都标为 externalpeers and @earendil-works/chord stay external· 入口名 facet-<12 位短哈希>-[hash],扩展名 .cjsentry names facet-<12-char hash>-[hash], .cjs· target node22.19 或 es2022;不支持动态 import;"use strict" 横幅target node22.19 or es2022; no dynamic import; "use strict" banner· 先写到 .<out>.tmp-<uuid>,再改名替换write to .<out>.tmp-<uuid>, then rename into place· 旧目录先移到 .old-<uuid>;替换失败 → 移回old dir moved to .old-<uuid>; swap fails → moved back{ "format": "chord.facet-bundle", "version": 2,"plugin": { "id": …, "version": … },"entries": [{"file": "facet-….cjs","integrity": "sha256-…","externalImports": ["…"],"sourceMap": … }] }也可以把整个包读成一个 artifact(readFacetBundleArtifact,格式 chord.facet-bundle-artifact)再用 createFacetBundleArtifactLoader 加载。A bundle can also be read as one artifact (readFacetBundleArtifact, format chord.facet-bundle-artifact) and loaded with createFacetBundleArtifactLoader.12345打包bundling产物artifacts加载loading流程flow

输出先写到临时目录,再整体改名替换;旧目录先挪到 .old-<uuid>,替换失败就挪回来——加载方永远看不到写了一半的包。Output goes to a temporary directory and is renamed into place; the old directory is moved to .old-<uuid> first and moved back if the swap fails — a loader never sees a half-written bundle.

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

chord 的扩展点Where to plug into chord

1
defineFacet({ id, setup })defineFacet({ id, setup })

写一个功能单元:在 setup 里声明 use / provide。Write a unit of functionality: declare use / provide in setup.

2
defineService<T>(id, { local? })defineService<T>(id, { local? })

定义一个服务契约,远程或本地。Define a service contract, remote or local.

3
RemoteServiceTransportRemoteServiceTransport

实现 invoke / subscribe,把服务接到任何传输上。Implement invoke / subscribe to carry services over any transport.

4
FacetLoaderFacetLoader

load() 返回 LoadedFacets:静态、组合或从打包文件加载。load() returns LoadedFacets: static, combined or from bundles.

5
RemoteServiceSourceRemoteServiceSource

catalogue() + open():把另一个进程的服务目录接进宿主。catalogue() + open(): plug another process's service catalogue into a host.

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