Merge branch 'master' into test/dynamic-tool-cache-e2e-flake-2d600396
This commit is contained in:
commit
22e627169e
558 files changed
+15729
-680
No files matched your search
+4
-4
@@ -21,8 +21,8 @@
|
||||
en: 57dbdd385f48bb3e
|
||||
zh: 5e15463fae4be582
|
||||
/agent-note-bounded-recovery-for-transient-llm-request-failures/decision/bound-stalled-streams-where-they-can-be-stopped:
|
||||
en: f7369086e87192b9
|
||||
zh: e2839ed8cc5c23b8
|
||||
en: 6a55107caa329eea
|
||||
zh: 13a76e482739141e
|
||||
/agent-note-bounded-recovery-for-transient-llm-request-failures/decision/keep-attempts-separate-in-the-existing-log:
|
||||
en: 8f57db4bfd6e1c22
|
||||
zh: 67ac62217bb68570
|
||||
@@ -33,8 +33,8 @@
|
||||
en: a6d0306740d470ee
|
||||
zh: 700bd4b0aa58f3e8
|
||||
/agent-note-bounded-recovery-for-transient-llm-request-failures/verification:
|
||||
en: db343f335a9d7cad
|
||||
zh: 49b8300de55dcfeb
|
||||
en: 7412edfd162c4a28
|
||||
zh: 64108a11e6194e35
|
||||
/agent-note-bounded-recovery-for-transient-llm-request-failures/consequences:
|
||||
en: 08659e8606455ada
|
||||
zh: 862080f8df5f28ec
|
||||
|
||||
@@ -78,7 +78,7 @@ Each adapter exposes a validated `streamIdleTimeoutMs` configuration field with
|
||||
|
||||
`@deepseek-ai/dsh-timeout` exposes a rearmable idle-watchdog primitive. One stable local `AbortController` is fused with the caller signal and passed to the transport for the whole adapter call; each outstanding `next()` arms the watchdog, resolution disarms it, and the next demand rearms it. Out-of-band transport activity calls `pulse()` to rearm an outstanding demand without yielding a value. Timeout aborts that stable controller with a capability-owned `TimeoutReason`, and `finally` clears the timer. The adapter classifies its watchdog as `TIMEOUT` and an earlier upstream abort as `ABORTED`. The existing one-shot `deadline()` is not presented as a sliding timer.
|
||||
|
||||
Boundary tests prove termination at both actual transports. The hand-written adapter aborts its fetch/reader, and the pi-ai adapter maps the stable signal through the SDK and proves the SDK closes the response. A timer that merely rejects a consumer promise while leaving the request running does not satisfy the contract.
|
||||
Boundary tests prove termination at both actual transports. The hand-written adapter aborts its fetch/reader, and the pi-ai adapter maps the stable signal through the SDK and proves the SDK closes the response. A timer that merely rejects a consumer promise while leaving the request running does not satisfy the contract, so the watchdog does both: the signal aborts the request, and the deadline settles the outstanding demand even when the transport never observes that abort.
|
||||
|
||||
### Keep attempts separate in the existing log
|
||||
|
||||
@@ -117,7 +117,7 @@ If recovery is exhausted, the final failure is stored once on `turn/end.reason`
|
||||
- Real agent-loop tests cover failure before chunks, partial chunks then failure, thrown and in-band failures, retry to success inside the same turn, exhaustion to structured `turn/end.reason`, and composition with `dsh-compaction-basic` context-overflow recovery.
|
||||
- The partial-chunk integration test proves failed chunks remain attributed to the failed step, no assistant message or tool side effect is committed for that step, and the successful retry records its own chunk seqs and provider/model route.
|
||||
- The plugin-owned `llm/retry` event is non-surface, survives a JSONL round trip, is ignored by message derivation, and drives TUI and Web retraction plus scheduled-retry rendering. Client tests cover complete wire validation, clock-independent countdown, cancellation versus completed retry labels, and trajectory attribution; keyless UI snapshots cover Web scheduling and success, real Web composition tests cover partial transport failure through recovery and exhausted recovery's terminal error row beside the settled retry chain, and ACP automation snapshots confirm that a discarded attempt stays off the wire while the recovered reply is emitted.
|
||||
- Idle-watchdog tests prove the stable signal is rearmed only while `next()` is outstanding, disarmed during consumer think time and in `finally`, and classified separately from a total-call deadline and an earlier caller abort; adapter tests prove the signal stops the underlying request rather than merely detaching it.
|
||||
- Idle-watchdog tests prove the stable signal is rearmed only while `next()` is outstanding, disarmed during consumer think time and in `finally`, and classified separately from a total-call deadline and an earlier caller abort; adapter tests prove the signal stops the underlying request rather than merely detaching it, and that the idle deadline settles a demand the transport abandoned.
|
||||
- Direct `ctx.llm.stream()` callers remain single-attempt and receive the same structured failure facts.
|
||||
|
||||
## Consequences
|
||||
|
||||
+2
-2
@@ -78,7 +78,7 @@ agent loop(智能体循环)会将终止 finish 的 `LlmFailure` 传给 `agen
|
||||
|
||||
`@deepseek-ai/dsh-timeout` 公开一个可重新布防的空闲看门狗原语。一个稳定的局部 `AbortController` 会与调用方信号融合,并在整个适配器调用期间传给传输层;每个尚未完成的 `next()` 都会布防看门狗,该调用完成时解除布防,下一次请求数据时再重新布防。带外传输活动会调用 `pulse()`,在不产生值的情况下为尚未完成的需求重新布防。超时会使用能力自身拥有的 `TimeoutReason` 中止这个稳定控制器,`finally` 则会清除定时器。适配器将自身看门狗归类为 `TIMEOUT`,将更早发生的上游中止归类为 `ABORTED`。现有的一次性 `deadline()` 不会被描述为滑动计时器。
|
||||
|
||||
边界测试证明两个实际传输层都能终止。手写适配器会中止其 fetch/reader,pi-ai 适配器会把稳定信号映射到 SDK,并证明 SDK 会关闭响应。如果定时器只拒绝消费方 promise,却让请求继续运行,就不满足此约定。
|
||||
边界测试证明两个实际传输层都能终止。手写适配器会中止其 fetch/reader,pi-ai 适配器会把稳定信号映射到 SDK,并证明 SDK 会关闭响应。如果定时器只拒绝消费方 promise,却让请求继续运行,就不满足此约定;因此 watchdog 两者都做:信号中止请求,截止时间则在传输层始终不观察该中止时结算尚未完成的 demand。
|
||||
|
||||
### 在现有日志中分隔尝试
|
||||
|
||||
@@ -117,7 +117,7 @@ agent loop(智能体循环)会将终止 finish 的 `LlmFailure` 传给 `agen
|
||||
- 真实 agent-loop 测试覆盖分片前失败、部分分片后失败、抛出及带内失败、在同一轮次内重试至成功、耗尽后写入结构化 `turn/end.reason`,以及与 `dsh-compaction-basic` 上下文溢出恢复的组合。
|
||||
- 部分分片集成测试证明:失败分片仍归属于失败步骤,该步骤不会提交 assistant 消息或工具副作用,成功的重试会记录自己的分片 seq 和提供方/模型路由。
|
||||
- 插件拥有的不进入表层的 `llm/retry` 事件可在 JSONL 往返后保留,被消息派生忽略,并驱动 TUI 和 Web 撤回及计划重试渲染。客户端测试覆盖完整的 wire 验证、独立于时钟的倒计时、已取消与已完成重试标签的区别以及轨迹归属;无密钥 UI 快照覆盖 Web 的调度与成功,真实 Web 组合测试覆盖部分传输失败直至恢复,以及耗尽后终态错误行与定格重试链并列的画面,ACP 自动化快照确认,被丢弃的尝试不会通过协议发出,而恢复后的回复会正常发出。
|
||||
- 空闲看门狗测试证明:只有 `next()` 尚未完成时才会重新布防稳定信号;在消费方思考期间及 `finally` 中会解除布防;它与总调用 deadline 以及更早发生的调用方中止分开分类。适配器测试证明该信号会终止底层请求,而不只是与其脱离。
|
||||
- 空闲看门狗测试证明:只有 `next()` 尚未完成时才会重新布防稳定信号;在消费方思考期间及 `finally` 中会解除布防;它与总调用 deadline 以及更早发生的调用方中止分开分类。适配器测试证明该信号会终止底层请求,而不只是与其脱离,并证明空闲截止时间会结算传输层放弃的 demand。
|
||||
- `ctx.llm.stream()` 的直接调用方仍只尝试一次,并收到相同的结构化失败事实。
|
||||
|
||||
## 后果
|
||||
|
||||
+4
-4
@@ -21,8 +21,8 @@
|
||||
en: 333eab4967d753ec
|
||||
zh: dd7537bb82cc717f
|
||||
/agent-note-a-shared-timeout-deadline-primitive-with-hard-kill-left-to-each-capability/consequences:
|
||||
en: 832f4dbd93a47f94
|
||||
zh: 65ada43f9f23a39b
|
||||
en: 0f723f16456c343d
|
||||
zh: 52b7aab9a6c4f7be
|
||||
/agent-note-a-shared-timeout-deadline-primitive-with-hard-kill-left-to-each-capability/alternatives-considered:
|
||||
en: 7b3445622becbd36
|
||||
zh: bb358acf2ff28e26
|
||||
en: 0f3e3ecfa399c81a
|
||||
zh: 951c2ee71047b11a
|
||||
@@ -101,7 +101,7 @@ The signal only *notifies*; termination is always the listener's job, and the li
|
||||
- `SpawnSpec.timeoutMs` and `SpawnOutcome.timedOut`/`aborted` were removed rather than kept as always-zero/always-false vestiges: with `runBash` owning no timer and the executor owning classification, they were read nowhere. An always-0 field read by nothing is dead weight under the per-file coverage gate.
|
||||
- web_fetch shed its bespoke controller/timer/listener/reason-recovery; the classifier now keys off the deadline signal (`timeoutOf` + `aborted`) rather than the thrown error's shape, which is robust across both the request-phase reject-with-reason and the read-phase bare-`AbortError`.
|
||||
- `AbortSignal.any` and `using`/`Symbol.dispose` enter the repo for the first time here (Node ≥ 24 baseline, already met).
|
||||
- Model streams now share one rearmable timer contract without turning a sliding idle interval into a total-call deadline or charging consumer think time. Adapters that can observe out-of-band transport activity may pulse an outstanding demand; suppressed activity remains invisible to the watchdog. The primitive still only notifies; adapter tests prove their transports observe its stable signal and terminate.
|
||||
- Model streams now share one rearmable timer contract without turning a sliding idle interval into a total-call deadline or charging consumer think time. Adapters that can observe out-of-band transport activity may pulse an outstanding demand; suppressed activity remains invisible to the watchdog. The one-shot `deadline()` still only notifies; adapter tests prove their transports observe the rearmable watchdog's stable signal and terminate. The watchdog additionally settles the one demand it drives when its idle interval elapses, because a transport that drops the error of an aborted in-flight read would otherwise hold the caller past that interval.
|
||||
|
||||
Out of scope, named to mark the boundary: `web_search` can gain an optional model-facing `timeout_ms` once its tool-schema/snapshot coverage is planned; the ripgrep-backed fs discovery tools ([packaged ripgrep search](../../archived/architecture/2026-08-01-packaged-ripgrep-search.md)) consume the same provider-owned deadline shape through `dsh-tool-call-timeout-policy` and `exec.signal`; a `tools/execute` waterfall middleware could arm a default deadline for every tool call by driving `exec.signal` — that would be a plugin that *consumes* this library and still only notifies, the hard kill remaining each capability's job.
|
||||
|
||||
@@ -111,6 +111,6 @@ Out of scope, named to mark the boundary: `web_search` can gain an optional mode
|
||||
|
||||
**Per-tool ad-hoc timeout, no shared code (the prior status quo, and Claude Code's choice).** Rejected because it was already producing divergence and duplicated correctness burden: web_fetch hand-rolled the exact controller/reason logic that future network/process-backed tools would each have to re-derive, and the fusion + `signal.reason` recovery are the error-prone parts. Claude Code tolerates full duplication; this repo has a single shared abort channel (`exec.signal` on every `execute`) that makes a small shared primitive strictly cleaner, so the cost/benefit differs.
|
||||
|
||||
**A `withTimeout(promise, ms)` wrapper instead of a signal factory.** Rejected because racing a promise against a timer resolves the *tool-call* promise on deadline without stopping the underlying work — the child process or fetch socket leaks on. Handing out a signal and requiring the capability to listen is what forces a real termination path to exist. This mirrors the "dispose must reach quiescence, not just request it" defensive rule.
|
||||
**A `withTimeout(promise, ms)` wrapper instead of a signal factory.** Rejected because racing a promise against a timer resolves the *tool-call* promise on deadline without stopping the underlying work — the child process or fetch socket leaks on. Handing out a signal and requiring the capability to listen is what forces a real termination path to exist. This mirrors the "dispose must reach quiescence, not just request it" defensive rule. `idleWatchdog` is the deliberate exception, and only for the single iterator demand it drives: the signal it hands the transport still owns releasing that read (an abort destroys the socket), while the deadline ends the demand when the transport never settles it.
|
||||
|
||||
**Keep separate bash timeout and cancellation triggers.** Rejected because one deadline signal removes the bespoke timer and standardizes classification. Racing causes report whichever abort arrived first, while the provider-owned termination path is independent of which cause won.
|
||||
@@ -101,7 +101,7 @@ export function timeoutOf(x: AbortSignal | { reason?: unknown }, code?: string):
|
||||
- `SpawnSpec.timeoutMs` 和 `SpawnOutcome.timedOut`/`aborted` 被移除,而非作为始终为零/始终为 false 的残余保留:由于 `runBash` 不再拥有定时器且执行器负责分类,这些字段无处被读取。一个始终为 0 且无处读取的字段在逐文件覆盖率门禁下属于死代码。
|
||||
- web_fetch 去除了其定制的 controller/timer/listener/reason-recovery;分类器现在基于 deadline 信号(`timeoutOf` + `aborted`)而非抛出错误的形状来判断,这在请求阶段的 reject-with-reason 和读取阶段的裸 `AbortError` 两种情况下都是健壮的。
|
||||
- `AbortSignal.any` 和 `using`/`Symbol.dispose` 在此首次进入本仓库(Node ≥ 24 基线,已满足)。
|
||||
- 模型流现在共享一个可重启的定时器约定,不会把滑动的空闲间隔变成总调用截止时间,也不会计入消费方思考时间。能够观察到带外传输活动的适配器可以对尚未结算的 demand 调用 `pulse()`;被屏蔽的活动对 watchdog 仍不可见。该原语仍然只做通知;适配器测试证明其传输观察到稳定信号并终止。
|
||||
- 模型流现在共享一个可重启的定时器约定,不会把滑动的空闲间隔变成总调用截止时间,也不会计入消费方思考时间。能够观察到带外传输活动的适配器可以对尚未结算的 demand 调用 `pulse()`;被屏蔽的活动对 watchdog 仍不可见。一次性的 `deadline()` 仍然只做通知;适配器测试证明其传输观察到可重启 watchdog 的稳定信号并终止。此外,watchdog 会在空闲间隔走完时结算它所驱动的那个 demand:否则丢弃已中止读取错误的传输层会让调用方一直等到间隔之外。
|
||||
|
||||
以下内容不在本次范围内,列出以标明边界:`web_search` 可以在其工具 schema 和快照覆盖规划完成后获得可选的面向模型的 `timeout_ms`;基于 ripgrep 的文件系统发现工具([打包的 ripgrep 搜索](../../archived/architecture/2026-08-01-packaged-ripgrep-search.md))通过 `dsh-tool-call-timeout-policy` 和 `exec.signal` 消费同样的提供方自有 deadline 形状;`tools/execute` waterfall(瀑布式事件)中间件可以通过驱动 `exec.signal` 为每次工具调用设置默认 deadline——那将是一个*消费*本库的插件,仍然只做通知,硬终止仍是各能力自己的事。
|
||||
|
||||
@@ -111,6 +111,6 @@ export function timeoutOf(x: AbortSignal | { reason?: unknown }, code?: string):
|
||||
|
||||
**每个工具各自实现超时,不共享代码(先前的现状,也是 Claude Code 的选择)。** 否决,因为它已经在产生分化和重复的正确性负担:web_fetch 手写了与未来网络/进程类工具各自需要重新推导的完全相同的 controller/reason 逻辑,而融合 + `signal.reason` 恢复正是容易出错的部分。Claude Code 容忍完全重复;本仓库有一个统一的共享 abort 通道(每次 `execute` 上的 `exec.signal`),使得采用一个小型共享原语明显更简洁,因此成本/收益不同。
|
||||
|
||||
**用 `withTimeout(promise, ms)` 包装器代替信号工厂。** 否决,因为让 promise 与定时器竞争只是在截止时间到达时 resolve *工具调用*的 promise,而不会停止底层工作——子进程或 fetch socket 会泄漏。分发信号并要求能力监听,才能强制一条真实的终止路径存在。这与「dispose 必须达到完全停稳,而非仅仅请求它」的防御性规则一致。
|
||||
**用 `withTimeout(promise, ms)` 包装器代替信号工厂。** 否决,因为让 promise 与定时器竞争只是在截止时间到达时 resolve *工具调用*的 promise,而不会停止底层工作——子进程或 fetch socket 会泄漏。分发信号并要求能力监听,才能强制一条真实的终止路径存在。这与「dispose 必须达到完全停稳,而非仅仅请求它」的防御性规则一致。`idleWatchdog` 是有意保留的例外,且仅针对它所驱动的单个迭代器 demand:交给传输层的信号仍负责释放该读取(中止会销毁 socket),而当传输层永不结算时由截止时间结束该 demand。
|
||||
|
||||
**保留 bash 独立的超时和取消触发器。** 否决,因为一个 deadline 信号移除了定制定时器并标准化了分类。发生竞争时,报告先到达的那个 abort 作为原因,由提供方管理的终止路径不受哪个原因先胜出的影响。
|
||||
+10
-10
@@ -6,8 +6,8 @@
|
||||
en: 6a4338564c01ad91
|
||||
zh: 241248cff9bd4cfc
|
||||
/agent-note-add-immutable-profile-resolution-generations/problem:
|
||||
en: 8e192c176fe48c4c
|
||||
zh: adb965d650088eb0
|
||||
en: 907a40c722a6faff
|
||||
zh: a471d7034d8c231f
|
||||
/agent-note-add-immutable-profile-resolution-generations/decision:
|
||||
en: a3022a87337058c5
|
||||
zh: 1d53a08a25ed6eb3
|
||||
@@ -21,8 +21,8 @@
|
||||
en: 53a9e92698ce9a5d
|
||||
zh: 6124d446e8d4a532
|
||||
/agent-note-add-immutable-profile-resolution-generations/decision/immutable-generations:
|
||||
en: cd4e64bd80f448a2
|
||||
zh: 585e1fbd8721ae99
|
||||
en: e10b224e0e68b701
|
||||
zh: d43fbdf37e6bfc54
|
||||
/agent-note-add-immutable-profile-resolution-generations/decision/shared-esm-and-commonjs-rule:
|
||||
en: 0e0575a04a081b52
|
||||
zh: 94c74334939e6d13
|
||||
@@ -33,8 +33,8 @@
|
||||
en: 99308bd758859e02
|
||||
zh: 35f1e7719fe80d2e
|
||||
/agent-note-add-immutable-profile-resolution-generations/decision/additive-package-changes:
|
||||
en: 718dfa7826783f2a
|
||||
zh: 21c3d5fdfae9cb99
|
||||
en: 0e5e2006caec3c91
|
||||
zh: fb3b61dd5692d2e3
|
||||
/agent-note-add-immutable-profile-resolution-generations/decision/filesystem-and-runtime-carriers:
|
||||
en: b0607713102a093d
|
||||
zh: 2de9c33bfaa0e3e2
|
||||
@@ -45,8 +45,8 @@
|
||||
en: 2426173c58a17a48
|
||||
zh: 0423b3700273accd
|
||||
/agent-note-add-immutable-profile-resolution-generations/verification:
|
||||
en: fbb17aa8fe62b1e8
|
||||
zh: 2ed091fdfd995fc8
|
||||
en: 74cb43ea2ccdb334
|
||||
zh: b966bbed43a83c82
|
||||
/agent-note-add-immutable-profile-resolution-generations/consequences:
|
||||
en: 54cae20b2734a07e
|
||||
zh: caa334f8f7a40452
|
||||
en: 457a7d1e82b45fe0
|
||||
zh: 800036e083a7674c
|
||||
@@ -8,7 +8,7 @@ English | [中文](2026-09-09-profile-resolution-generations.zh.md)
|
||||
|
||||
A profile loads plugin rows from its own package project, while Harness packages and packages carried by selected bundles can live outside that project's ordinary dependency tree. Bridging the trees through shared symlinks, profile-owned links, or packaged-executable proxy packages persists package selections across processes and installations. Those files require reconciliation and locking, expose generated proxy manifests to metadata readers, and cannot represent a process-local change atomically.
|
||||
|
||||
The runtime design keeps installation-first, ordered-bundle, and local-before-fallback precedence. It covers imports performed by plugin modules as well as Loader row imports and works in the main thread and Harness-owned Workers. Generation replacement preserves existing package mappings and local package names, permits linked-root membership changes, and never mutates a live table entry by entry.
|
||||
The runtime design keeps installation-first, ordered-bundle, and local-before-fallback precedence. It covers imports performed by plugin modules as well as Loader row imports and works in the main thread and Harness-owned Workers. Generation replacement preserves retained package identities, permits removing profile packages and changing linked-root membership, and never mutates a live table entry by entry.
|
||||
|
||||
## Decision
|
||||
|
||||
@@ -75,7 +75,7 @@ Linked-root membership is immutable within a generation. A successor may add or
|
||||
|
||||
The router retains the real target of each successfully published link name for its lifetime, solely to validate successors. Removing a root does not erase that record or keep its directory intercepted. Re-adding the same name and target is allowed; a different target is rejected because Node caches real paths. Failed publication changes neither the current generation nor the recorded targets.
|
||||
|
||||
The launcher constructs one startup generation. The service accepts a complete successor, but no package-manager transaction invokes replacement in this implementation.
|
||||
The launcher constructs one startup generation. `ProfileRuntimeResolution` retains only the installation anchor, Harness home, and optional profile directory as recomputation inputs; successors reread disk selection and layers rather than inheriting a supplied profile's synthetic layers. A generation without a profile directory can recompute installation packages. The [package refresh decision](2026-09-30-profile-package-refresh-and-manifest-invalidation.md) assigns successor publication to Plugin Manager operations.
|
||||
|
||||
### Shared ESM and CommonJS rule
|
||||
|
||||
@@ -105,9 +105,9 @@ New Workers inherit the latest published generation. Existing Workers keep the g
|
||||
|
||||
### Additive package changes
|
||||
|
||||
A caller adding a package completes its pnpm transaction before constructing a successor generation. Replacement rejects any generation that changes the directory or version of an existing package. The caller publishes an additive successor before mounting the new Loader row; this implementation does not provide that package transaction. A mount failure may leave the package installed but inactive.
|
||||
A caller adding a package completes its pnpm transaction before constructing a successor generation. Replacement rejects any generation that changes the directory or version of a retained package. Plugin Manager publishes the successor before mounting the new Loader row. A mount failure may leave the package installed but inactive.
|
||||
|
||||
Changing or removing an existing runtime package mapping, or removing a recorded profile-local package name, requires process restart because Node's ESM Module Map, CommonJS cache, existing object references, and running Workers can retain the old module identity. These restrictions do not prohibit removing a linked root from the interception scope. Generation replacement does not claim to unload modules.
|
||||
A retained package keeps its normalized directory, version, and scope; installation entries also keep their declaring anchor and cannot be removed. Violating these restrictions requires process restart because Node's ESM Module Map, CommonJS cache, existing object references, and running Workers can retain the old module identity. A profile entry may change its declaring anchor: deselecting the first bundle that reaches a shared dependency can leave another bundle selecting the same physical package. Profile-scoped mappings and profile-local package names may be removed; the caller stops their plugins before deleting package files. These restrictions do not prohibit removing a linked root from the interception scope. Generation replacement does not claim to unload modules.
|
||||
|
||||
### Filesystem and runtime carriers
|
||||
|
||||
@@ -142,7 +142,7 @@ Behavior tests exercise root order, transitive and peer dependencies, local and
|
||||
## Verification
|
||||
|
||||
- One eager computation supplies the runtime resolution; startup neither writes nor retires module-resolution data.
|
||||
- [Generation tests](../../../../packages/boot/app-boot/tests/profile-resolution.spec.ts) cover installation and selected-bundle graphs with ordinary directories and recursive symlinks, including different dependency versions beside logical and real anchors. They also cover linked-root removal, same-target restoration, overlapping roots, native misses, new requests from loaded modules, and relink rejection after removal.
|
||||
- [Generation tests](../../../../packages/boot/app-boot/tests/profile-resolution.spec.ts) cover installation and selected-bundle graphs with ordinary directories and recursive symlinks, including different dependency versions beside logical and real anchors. They also cover same-package profile declarer changes, retained-identity rejection, captured-directory recomputation, linked-root removal, same-target restoration, overlapping roots, native misses, new requests from loaded modules, and relink rejection after removal.
|
||||
- [Source-launch tests](../../../../apps/cli/tests/source-launch.compat.spec.ts) and [built-bin tests](../../../../apps/cli/tests/built-bin.e2e.ts) run both profile layouts through the real CLI. They assert ESM/CJS versions, loaded paths, per-format dependency identity, and consistent Tools/AgentLoop module instances with an accessible scheduler key.
|
||||
- Pkg and Electron carriers select runtime resolution; Electron executes its Host in Node mode from the ASAR-backed dsh tree while native executable entries remain unpacked.
|
||||
- ESM and CommonJS adapters share one router and delegate final resolution to Node without `module.registerHooks` or `_findPath` replacement.
|
||||
@@ -152,4 +152,4 @@ Behavior tests exercise root order, transitive and peer dependencies, local and
|
||||
|
||||
## Consequences
|
||||
|
||||
Runtime startup avoids disk mutation and proxy manifests while retaining package-precedence rules. Real-directory anchors align dependency discovery with default Node loading and tsx workspace mapping, including cases where a logical symlink path would select another version. The implementation accepts the maintenance cost of Node Internal compatibility tests and an early, self-contained bootstrap in each owned Worker; it provides no disk-only backend or dual comparison mode. Package mappings and local package names remain additive; linked-root membership may change without unloading modules.
|
||||
Runtime startup avoids disk mutation and proxy manifests while retaining package-precedence rules. Real-directory anchors align dependency discovery with default Node loading and tsx workspace mapping, including cases where a logical symlink path would select another version. The implementation accepts the maintenance cost of Node Internal compatibility tests and an early, self-contained bootstrap in each owned Worker; it provides no disk-only backend or dual comparison mode. Profile package records and linked-root membership may be removed without unloading modules; retained package identities stay unchanged.
|
||||
+6
-6
@@ -8,7 +8,7 @@ Status: implemented
|
||||
|
||||
profile 从自己的包项目加载插件配置项,而 Harness 包和所选 bundle 携带的包可能位于该项目普通依赖树之外。通过共享 symlink、profile 自有链接或打包可执行文件的代理包连接两棵依赖树,会让选包结果跨进程和安装版本持续存在。这些文件需要协调和锁来维护,并向元数据读取方暴露生成的代理 manifest(元数据清单),也无法原子表示进程内变更。
|
||||
|
||||
运行时设计保留安装优先、有序 bundle 和本地包优先于 fallback 的顺序。它覆盖插件模块内部的 import 以及 Loader 配置项的 import,并在主线程和 Harness 自有 Worker 中工作。generation 替换保留既有包映射和本地包名,允许调整 linked root 集合,不会逐项修改正在使用的表。
|
||||
运行时设计保留安装优先、有序 bundle 和本地包优先于 fallback 的顺序。它覆盖插件模块内部的 import 以及 Loader 配置项的 import,并在主线程和 Harness 自有 Worker 中工作。generation 替换保持保留包的身份不变,允许移除 profile 包和调整 linked root 集合,不会逐项修改正在使用的表。
|
||||
|
||||
## Decision
|
||||
|
||||
@@ -75,7 +75,7 @@ linked root 集合在一个 generation 内不可变。后继 generation 可以
|
||||
|
||||
解析器在自身生命周期内保留每个已成功发布的 link 名称对应的真实目标,仅用于校验后继 generation。移除 root 不会抹掉该记录,也不会让其目录继续被拦截。同名、同目标可以重新加入;不同目标会被拒绝,因为 Node 缓存真实路径。发布失败时,当前 generation 和已记录目标均不变。
|
||||
|
||||
launcher 只构造启动 generation。服务接受完整的后继 generation,但本实现没有包管理器事务调用替换操作。
|
||||
launcher 只构造启动 generation。`ProfileRuntimeResolution` 仅保留安装锚点、Harness home 和可选的 profile 目录作为重算输入;后继代重新读取磁盘上的选择与配置层,不继承传入 profile 的合成层。没有 profile 目录的 generation 可以重算安装包。[包操作刷新决策](2026-09-30-profile-package-refresh-and-manifest-invalidation.zh.md)规定由 Plugin Manager 的操作发布后继代。
|
||||
|
||||
### ESM 与 CommonJS 共用规则
|
||||
|
||||
@@ -105,9 +105,9 @@ runtime resolution 列出它提供的包;Loader entries 组成活动插件列
|
||||
|
||||
### 只增加包的变更
|
||||
|
||||
添加包的调用方先完成 pnpm 事务,再构造下一代。替换操作会拒绝改变任何既有 package name 的目录或版本。调用方先发布只增加映射的后继 generation,再挂载新的 Loader 配置项;本实现不提供该包事务。挂载失败可以留下已安装但未启用的包。
|
||||
添加包的调用方先完成 pnpm 事务,再构造下一代。替换操作会拒绝改变任何保留包的目录或版本。Plugin Manager 先发布后继 generation,再挂载新的 Loader 配置项。挂载失败可以留下已安装但未启用的包。
|
||||
|
||||
修改或删除既有运行时包映射,或删除已记录的 profile 本地包名,需要重启,因为 Node 的 ESM Module Map、CommonJS cache、现存对象引用和运行中的 Worker 都可能保留旧模块 identity。这些限制不禁止从拦截范围移除 linked root。generation 换代不声称卸载模块。
|
||||
保留包的规范化目录、版本和作用域必须不变;安装条目还必须保留声明锚点,且不能移除。违反这些限制需要重启,因为 Node 的 ESM Module Map、CommonJS cache、现存对象引用和运行中的 Worker 都可能保留旧模块 identity。profile 条目可以改变声明锚点:取消选入最先找到共享依赖的组合包后,另一个组合包仍可能选中同一物理包。profile 范围的映射和 profile 本地包名可以移除;调用方在删除包文件之前先停止相关插件。这些限制不禁止从拦截范围移除 linked root。generation 换代不声称卸载模块。
|
||||
|
||||
### 文件系统与运行时载体
|
||||
|
||||
@@ -142,7 +142,7 @@ generation 构造发生在启动或显式更新阶段,不属于单次 resolve
|
||||
## Verification
|
||||
|
||||
- 一次 eager 计算供应 runtime resolution;启动既不写入也不退休模块解析数据。
|
||||
- [Generation 测试](../../../../packages/boot/app-boot/tests/profile-resolution.spec.ts)覆盖普通目录和递归软链接下的安装图与所选 bundle 图,包括逻辑锚点和真实锚点旁存在不同依赖版本的情况。测试还覆盖 linked root 移除、同目标恢复、重叠 root、原生缺包、已加载模块的新请求,以及移除后重新链接不同目标时的拒绝。
|
||||
- [Generation 测试](../../../../packages/boot/app-boot/tests/profile-resolution.spec.ts)覆盖普通目录和递归软链接下的安装图与所选 bundle 图,包括逻辑锚点和真实锚点旁存在不同依赖版本的情况。测试还覆盖同包 profile 声明者变更、保留包身份变更拒绝、捕获目录后的重算、linked root 移除、同目标恢复、重叠 root、原生缺包、已加载模块的新请求,以及移除后重新链接不同目标时的拒绝。
|
||||
- [源码启动测试](../../../../apps/cli/tests/source-launch.compat.spec.ts)与[构建入口测试](../../../../apps/cli/tests/built-bin.e2e.ts)通过真实 CLI 运行两种 profile 布局,断言 ESM/CJS 版本、加载路径、各模块格式内的依赖身份,以及一致的 Tools/AgentLoop 模块实例和可访问的 scheduler 键。
|
||||
- pkg 与 Electron 载体选择 runtime 解析;Electron 以 Node 模式从 ASAR 承载的 dsh 依赖树执行 Host,原生可执行条目保持 unpacked。
|
||||
- ESM 与 CommonJS 适配器共享同一个路由器,并把最终解析委托给 Node,不使用 `module.registerHooks` 或替换 `_findPath`。
|
||||
@@ -152,4 +152,4 @@ generation 构造发生在启动或显式更新阶段,不属于单次 resolve
|
||||
|
||||
## Consequences
|
||||
|
||||
runtime 启动避免磁盘修改和代理 manifest,同时保留包优先级规则。真实目录锚点让依赖发现对齐 Node 默认加载与 tsx workspace 映射,也覆盖软链接逻辑路径会选中另一版本的情况。实现需要持续维护 Node Internal 兼容测试,并在每个自有 Worker 中尽早执行自包含 bootstrap;它不提供纯磁盘后端或 dual 对比模式。包映射和本地包名仍只允许新增;linked root 集合可以变化,而不卸载模块。
|
||||
runtime 启动避免磁盘修改和代理 manifest,同时保留包优先级规则。真实目录锚点让依赖发现对齐 Node 默认加载与 tsx workspace 映射,也覆盖软链接逻辑路径会选中另一版本的情况。实现需要持续维护 Node Internal 兼容测试,并在每个自有 Worker 中尽早执行自包含 bootstrap;它不提供纯磁盘后端或 dual 对比模式。profile 包记录和 linked root 集合可以移除,而不卸载模块;保留包的身份保持不变。
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
# Bilingual-pair consistency record for 2026-09-30-profile-package-refresh-and-manifest-invalidation.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-30-profile-package-refresh-and-manifest-invalidation.md
|
||||
/agent-note-refresh-runtime-resolution-after-package-operations-and-expire-hmr-package-configuration:
|
||||
en: ea66719b3dcdd21c
|
||||
zh: 893364551594ddaf
|
||||
/agent-note-refresh-runtime-resolution-after-package-operations-and-expire-hmr-package-configuration/problem:
|
||||
en: e59e3f5c61158701
|
||||
zh: 2ced78e3c84f604e
|
||||
/agent-note-refresh-runtime-resolution-after-package-operations-and-expire-hmr-package-configuration/decision:
|
||||
en: a15c7c13f167e5ac
|
||||
zh: a15c7c13f167e5ac
|
||||
/agent-note-refresh-runtime-resolution-after-package-operations-and-expire-hmr-package-configuration/decision/responsibilities:
|
||||
en: 3ffd0c78fa204b04
|
||||
zh: f468bab00a7ff357
|
||||
/agent-note-refresh-runtime-resolution-after-package-operations-and-expire-hmr-package-configuration/decision/runtime-resolution:
|
||||
en: ca45b973e1968533
|
||||
zh: 002f23bb70e4fed7
|
||||
/agent-note-refresh-runtime-resolution-after-package-operations-and-expire-hmr-package-configuration/decision/plugin-manager:
|
||||
en: 9b04926c175f22e8
|
||||
zh: fbf05dd4065b0738
|
||||
/agent-note-refresh-runtime-resolution-after-package-operations-and-expire-hmr-package-configuration/decision/hmr-package-configuration-expiry:
|
||||
en: c7de8dafe47a5587
|
||||
zh: 6010a70d8451edd1
|
||||
/agent-note-refresh-runtime-resolution-after-package-operations-and-expire-hmr-package-configuration/decision/future-work:
|
||||
en: 31818f8f7f80470b
|
||||
zh: ecb7ac85bb49cf6d
|
||||
/agent-note-refresh-runtime-resolution-after-package-operations-and-expire-hmr-package-configuration/alternatives-considered:
|
||||
en: 7c1c6d21570cda0d
|
||||
zh: e6f8334c85f1b404
|
||||
/agent-note-refresh-runtime-resolution-after-package-operations-and-expire-hmr-package-configuration/verification:
|
||||
en: 939cedceed200cb2
|
||||
zh: c7bb02d0da400c8d
|
||||
/agent-note-refresh-runtime-resolution-after-package-operations-and-expire-hmr-package-configuration/consequences:
|
||||
en: 1b9e31b4e773fac5
|
||||
zh: c140ce07fc64bd55
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
# Agent Note: Refresh runtime resolution after package operations and expire HMR package configuration
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-09-30-profile-package-refresh-and-manifest-invalidation.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Two pieces of Host state did not follow changes while the process ran:
|
||||
|
||||
- **Runtime resolution was built only at startup.** After a GUI install or enablement, nothing published the latest generation, so a bundle's private dependencies never reached the profile fallback and its bare plugins could not be found. After disablement or removal, a new generation could not remove profile-scoped entries or profile-local names, so stale records and package metadata remained. A newly installed package directory is new, and Node holds no stale cache for it; only the table was missing.
|
||||
- **Package configuration and module evaluation have separate caches.** Clearing a plugin's module cache does not refresh the package.json fields that Node uses for exports, main, imports, and format detection. A package.json can also be imported as a JSON module, in which case its consumers need ordinary module reloads.
|
||||
|
||||
## Decision
|
||||
|
||||
### Responsibilities
|
||||
|
||||
| Owner | Does | Does not |
|
||||
|---|---|---|
|
||||
| resolver (`app-boot/src/profile-resolution/resolver.ts`) | `replace()` allows removing `scope: profile` entries and profile-local names | Touch any Node cache |
|
||||
| plugin-manager | Calls `pluginPackages.refresh()` at fixed points of package operations | Construct resolutions or manage module and package-configuration caches |
|
||||
| HMR (`packages/boot/hmr`) | Expires package configuration and preserves ordinary reloads for manifests loaded as JSON modules | Reload plugins for configuration-only manifests; handle `node_modules` package replacement |
|
||||
|
||||
### Runtime resolution
|
||||
|
||||
The [generation Note](2026-09-09-profile-resolution-generations.md) owns package identity and Worker inheritance rules.
|
||||
|
||||
Validation of a new generation in `replace()`:
|
||||
|
||||
- `scope: profile` entries may be removed.
|
||||
- Profile-local package names may be removed.
|
||||
- Retained profile entries keep their normalized directory, version, and scope, but may change declaring anchor when another selected bundle supplies the same package. Installation mappings and declaring anchors remain unchanged. The profile scope cannot change; a new local name cannot override an existing entry; a published link name cannot select another real directory.
|
||||
|
||||
### Plugin Manager
|
||||
|
||||
| Operation | Publication point |
|
||||
|---|---|
|
||||
| Install a new package | After pnpm succeeds and the bundle selection is saved, before reconciliation, whether or not the bundle is enabled |
|
||||
| Overwrite an installed package | No publication; the result is `restart-required` |
|
||||
| Enable a bundle | After the selection is saved, before reconciliation |
|
||||
| Disable a bundle | With HMR, after reconciliation stops its plugins; without HMR, no publication, and running plugins keep the current table |
|
||||
| Remove | After pnpm remove succeeds, inside the HMR transaction |
|
||||
| Failed or cancelled install, failed removal | No publication |
|
||||
|
||||
`createRuntimeResolution()` returns a `ProfileRuntimeResolution`, which privately keeps the installation anchor, Harness home, and profile directory it was computed from. Its `computeLatestResolution()` rereads that profile with the same inputs and returns a new object for the latest generation, leaving the original unchanged; Workers receive only the table fields. `PluginPackages.refresh()` calls the current resolution's `computeLatestResolution()` and then `replace()`; a resolution constructed as plain data cannot be refreshed and makes the call throw.
|
||||
|
||||
Without HMR, deselecting a startup bundle does not stop its plugins. While such a bundle remains deselected, later package operations keep the existing runtime table instead of publishing its removal. Other no-HMR installations and removals of non-running bundles still publish normally.
|
||||
|
||||
A successful package operation is not rolled back when publication is rejected afterwards. The result reports the failed runtime application and retains the successful disk changes. Recomputing uses the captured profile directory on disk, not synthetic in-memory layers; a computed resolution without a profile recomputes installation packages only.
|
||||
|
||||
### HMR package-configuration expiry
|
||||
|
||||
In HMR's change dispatch, a changed file named `package.json` outside `node_modules` goes to `PackageManifests.invalidate()`. A manifest in the host dependency graph still requests a host reload; one loaded as a JSON module still reloads its consumers. A configuration-only manifest schedules no module reload. Configuration-owned paths stay with their dedicated watcher. Source changes in the same batch reload afterwards.
|
||||
|
||||
`invalidate(manifest)` records the directory as expired. Afterwards:
|
||||
|
||||
- package.json reads, scope lookups, type lookups, and nearest-manifest lookups below that directory read the current file. Ownership follows the real directory when a consumer reaches the package through a link.
|
||||
- ESM `ResolveCache` is cleared. Its entries do not record every consulted manifest, and a package entry may resolve outside the package directory.
|
||||
- CommonJS `_pathCache` is cleared for the same reason. Unrelated requests recompute their resolution without unloading their modules.
|
||||
- CommonJS loads using the default resolver pass the freshly resolved filename to the native loader. Its private request alias cannot select an older entry; cached module instances remain intact.
|
||||
|
||||
Configuration invalidation alone leaves loaded modules unchanged. Loader entries retain their raw import results before export normalization. HMR matches imported Node module objects to cached ModuleJob namespaces and reloads the loaded URLs. All matching entry records, including disabled entries, update only after a successful reload and remain unchanged on failure. Stopping and restarting a Loader entry resolves its package name again and selects the new entry.
|
||||
|
||||
Entry names are scoped by configuration-tree base URL and retain all distinct imported namespaces. An uninitialized entry contributes the name without erasing loaded namespaces; name-based resolution is used only when none is recorded, or for `cordis:` builtins. Dependency analysis considers every recorded module. Entries sharing a plugin runtime use one replacement operation, while each Loader entry selects its replacement by its original namespace. Module imports and export normalization finish before the old runtime is removed. Failed imports or activation restore the previous modules and plugin implementations and clean up partially activated replacements.
|
||||
|
||||
An instance without a Loader entry has no recorded module identity. If a shared runtime's replacement modules have different plugin callbacks, HMR reports an ambiguity error and rolls back instead of assigning that instance to an arbitrary module.
|
||||
|
||||
Package invalidation lives in `packages/boot/hmr/src/package-manifest.ts`, which encapsulates its Node internal interfaces. `index.ts` dispatches manifest changes and locates loaded entries by module identity. HMR's `node_modules` exclusion is unchanged.
|
||||
|
||||
| Interface | Action |
|
||||
|---|---|
|
||||
| modules binding `readPackageJSON`, `getPackageScopeConfig`, `getPackageType` | Replaced: paths in an expired directory read the current file; other calls reach the native method |
|
||||
| `package_json_reader.getNearestParentPackageJSON` | Replaced: expired directories bypass its JS cache; a lookup without a manifest returns the native absent result |
|
||||
| The ESM Loader's `ResolveCache` instance | The prototype `get` is replaced for one lookup to obtain the instance and restored at once; the resolution cache is then cleared |
|
||||
| CommonJS `Module._pathCache` | The request-to-filename cache is cleared |
|
||||
| CommonJS `Module._load` | Default-resolver requests load by resolved filename; builtins and registered resolve hooks keep the original path |
|
||||
|
||||
The binding's `getNearestParentPackageJSON` is called only by `package_json_reader` and is not replaced. Each HMR instance owns a separate configuration cache. Its replacements are installed at the first expiry and restored when the service is disposed, alongside watcher and reload-queue cleanup.
|
||||
|
||||
### Future Work
|
||||
|
||||
- Online replacement inside `node_modules`, same-path reinstall, changed link targets, and cross-package reload propagation remain unsupported. Package updates need process restart; this does not guarantee that every management result already reports that requirement correctly.
|
||||
- TSX versions using an asynchronous loader thread keep that thread's package configuration outside these hooks. Synchronizing it is deferred; this change does not provide general Worker cache synchronization.
|
||||
- CommonJS private-request-cache refresh with registered synchronous resolve hooks is deferred. Those requests retain their original loader behavior.
|
||||
- Disposing HMR restores the native readers, whose previous cached configuration can become visible again. Preserving invalidation state across HMR replacement is deferred.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Read current disk on every lookup (#4703).** Resolution follows the disk, but module caches stay and running plugins do not reload. Same-URL contents do not take effect, and different URLs load a second version beside the first in one process. It also maintains its own entry resolver, bypasses custom bare-name hooks, and does not refresh runtime resolution, so GUI-installed private dependencies still cannot be found.
|
||||
|
||||
**Reread every observed manifest when runtime resolution publishes (#5496).** This places package-configuration expiry in table publication, decoupled from module caches: overwriting an installed package reports restart, yet the next unrelated publication makes new imports load the new version while the old plugin still runs. Package configuration is content and belongs with module caches, in HMR.
|
||||
|
||||
**Let HMR reload packages inside `node_modules`.** It requires finding affected plugins over a module graph that includes `node_modules`, preventing duplicate evaluation of shared libraries and Cordis, and handling missing dynamic-import edges, module side effects, and Workers. It is not done here; those scenarios keep requiring restart.
|
||||
|
||||
**Reload every plugin for a package.json change.** Configuration-only manifests do not require module evaluation. Manifests actually imported as JSON modules retain ordinary dependency-driven reloads.
|
||||
|
||||
**Re-import reloaded plugins by package name.** It would let an entry rename follow a source reload, but changes `partialReload`'s rule that the loaded URL is the reload unit. Entry renames already take effect when the Loader entry restarts, so this is not changed.
|
||||
|
||||
**Keep one namespace per entry name.** Entries with the same name and base URL can have loaded different modules before and after a manifest change. Keeping only the first or last namespace loses a live module; an uninitialized entry must not erase another entry's imported namespace.
|
||||
|
||||
## Verification
|
||||
|
||||
| Coverage | Location |
|
||||
|---|---|
|
||||
| A new generation removes profile entries and local names and still rejects retained-entry changes | `packages/boot/app-boot/tests/profile-resolution.spec.ts` |
|
||||
| GUI install, deferred enablement, disablement, removal, overwrite, failure, and cancellation, with and without HMR | `packages/boot/plugin-manager/tests/package-reload.spec.ts` |
|
||||
| Captured-directory recomputation, computed installation-only refresh, and plain-data rejection | `packages/boot/app-boot/tests/profile-resolution.spec.ts`, `packages/boot/app-boot/tests/profile-resolution-service.spec.ts` |
|
||||
| Sequential no-HMR operations, shared dependency declarers, and disk-success/publication-failure outcomes | `packages/boot/plugin-manager/tests/package-reload.spec.ts` |
|
||||
| Expired exports, main, imports, type, scope, and nearest manifests; native-reader parity; actual CommonJS loads and retained module instances; the `node_modules` boundary; restoration | `packages/boot/hmr/tests/package-manifest.spec.ts` |
|
||||
| Configuration-only manifests, JSON-module and host reloads, source reloads, same-name and shared-runtime entries, import and activation rollback, entry restarts, and the `node_modules` exclusion | `packages/boot/hmr/tests/package-manifest-dispatch.spec.ts` |
|
||||
|
||||
Tests need no API key and make no model calls.
|
||||
|
||||
## Consequences
|
||||
|
||||
- With HMR, supported package operations publish the updated private dependencies and remove unused mappings after plugins stop. Without HMR, a pending startup-bundle disablement defers later publications until restart, preserving running plugins' mappings.
|
||||
- In the supported thread, package.json configuration reads use the changed fields; JSON modules retain their own reload behavior. The separate module, loader-thread, and Worker limitations above still apply.
|
||||
- Package configuration below expired directories is parsed in JavaScript and must keep the native reader's field and error semantics; tests compare it with the native reader on Node 22, 24, and 26.
|
||||
- The implementation depends on several Node internal interfaces, and these tests must run again for Node upgrades.
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
# Agent Note: 包操作刷新 runtime resolution 与 HMR 包配置失效
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-09-30-profile-package-refresh-and-manifest-invalidation.md) | 中文
|
||||
|
||||
## Problem
|
||||
|
||||
Host 运行期间有两处状态没有随变化更新:
|
||||
|
||||
- **runtime resolution 只在启动时构造。** GUI 安装或启用 bundle 后,没有人发布最新代,bundle 的私有依赖进不了 profile fallback,它的 bare 插件找不到。停用、卸载后,新一代又不允许移除 profile 范围的条目和本地包名,旧记录和包元数据一直留着。新安装的包目录本身是新的,Node 对它没有旧缓存,缺的只是这张表。
|
||||
- **包配置与模块求值使用独立缓存。** 清理插件的模块缓存不会刷新 Node 用于 exports、main、imports 和格式判断的 package.json 字段。package.json 也可以被作为 JSON 模块导入,此时其消费方需要普通的模块重载。
|
||||
|
||||
## Decision
|
||||
|
||||
### 职责
|
||||
|
||||
| 负责方 | 做什么 | 不做什么 |
|
||||
|---|---|---|
|
||||
| resolver(`app-boot/src/profile-resolution/resolver.ts`) | `replace()` 允许移除 `scope: profile` 条目和 profile 本地包名 | 不碰 Node 的任何缓存 |
|
||||
| plugin-manager | 在包操作的确定时点调用 `pluginPackages.refresh()` | 不构造 resolution;不管模块缓存与包配置缓存 |
|
||||
| HMR(`packages/boot/hmr`) | 使包配置失效,并保留作为 JSON 模块加载的 manifest 的普通重载 | 不因仅作为配置的 manifest 重载插件;不处理 `node_modules` 包替换 |
|
||||
|
||||
### runtime resolution
|
||||
|
||||
包身份与 Worker 继承规则由[代际说明](2026-09-09-profile-resolution-generations.zh.md)定义。
|
||||
|
||||
`replace()` 对新一代的校验:
|
||||
|
||||
- `scope: profile` 的条目可以移除。
|
||||
- profile 本地包名可以移除。
|
||||
- 保留的 profile 条目保持规范化目录、版本与作用域不变;另一个已选 bundle 提供同一包时,可以更换声明锚点。安装范围的映射与声明锚点保持不变。profile 范围不能变;新本地包名不能覆盖已有条目;已发布的 link 名称不能换真实目录。
|
||||
|
||||
### plugin-manager
|
||||
|
||||
| 操作 | 发布时点 |
|
||||
|---|---|
|
||||
| 安装新包 | pnpm 成功、保存 bundle 选择之后,reconciliation 之前;无论是否立即启用都发布 |
|
||||
| 覆盖已安装的包 | 不发布,结果为 `restart-required` |
|
||||
| 启用 bundle | 保存选择后发布,再做 reconciliation |
|
||||
| 停用 bundle | 有 HMR 时,reconciliation 让插件退出后再发布;没有 HMR 时不发布,运行中的插件继续使用原表 |
|
||||
| 卸载 | pnpm remove 成功后,在 HMR 事务内发布 |
|
||||
| 安装失败、取消、卸载失败 | 不发布 |
|
||||
|
||||
`createRuntimeResolution()` 返回 `ProfileRuntimeResolution`,它私有保存构造时的 installAnchor、home 和 profile 目录;`computeLatestResolution()` 用同一组输入重新读取 profile,返回表示最新代的新对象,原实例不变;Worker 只收到表数据。`PluginPackages.refresh()` 调用当前 resolution 的 `computeLatestResolution()` 再 `replace()`;以纯数据构造的 resolution 无法刷新,调用时抛错。
|
||||
|
||||
没有 HMR 时,取消启动 bundle 的选择不会停止它的插件。只要该 bundle 仍未选中,后续包操作就保留当前 runtime 表,不发布其移除。其他无 HMR 的安装和未运行 bundle 卸载仍正常发布。
|
||||
|
||||
包操作成功后若发布被拒绝,不回滚已完成的包操作。结果报告运行时应用失败,并保留成功的磁盘变更。重新计算使用捕获的磁盘 profile 目录,不继承内存合成 layers;没有 profile 的已计算 resolution 只重算安装范围的包。
|
||||
|
||||
### HMR 包配置失效
|
||||
|
||||
HMR 的文件变化分派中,文件名为 `package.json` 且不在 `node_modules` 内的变化,交给 `PackageManifests.invalidate()`。位于宿主依赖图中的 manifest 仍请求宿主重载;作为 JSON 模块加载的 manifest 仍重载消费方。仅作为配置的 manifest 不安排模块重载。配置专属路径仍由其独立 watcher 处理。同一批的源码变化随后重载。
|
||||
|
||||
`invalidate(manifest)` 把该目录登记为已失效,之后:
|
||||
|
||||
- 位于该目录下的 package.json 读取、scope 查询、type 查询、最近 package.json 查询,都按当前磁盘解析。消费方经由链接路径访问时,按真实目录判断归属。
|
||||
- 清空 ESM `ResolveCache`。其条目不记录查询过的全部 manifest,包入口也可能解析到包目录之外。
|
||||
- 基于相同原因清空 CommonJS `_pathCache`。无关请求重新计算解析结果,不卸载其模块。
|
||||
- 使用默认 resolver 的 CommonJS 加载把重新解析的文件名交给原生 loader。其私有请求别名不再选中旧入口,已缓存的模块实例保持不变。
|
||||
|
||||
仅使配置失效不会改变已加载模块。Loader entry 保留导出规范化之前的原始导入结果。HMR 将已导入的 Node 模块对象与缓存中 ModuleJob 的模块命名空间按对象身份匹配,重载已加载 URL。所有匹配的 entry 记录,包括已停用的 entry,仅在重载成功后更新,失败时保持不变。停止并重新启动 Loader entry 时,会重新按包名解析并选择新入口。
|
||||
|
||||
entry 名称以配置树 base URL 为作用域,并保留所有不同的已导入命名空间。尚未初始化的 entry 只登记名称,不抹去已加载的命名空间;仅在没有记录命名空间或入口为 `cordis:` 内置模块时按名称解析。依赖分析考虑每个已记录模块。共享插件运行时的 entry 使用同一次替换操作,但每个 Loader entry 按自身原命名空间选择替换实现。模块导入与导出规范化在移除旧运行时之前完成。导入或激活失败时,恢复原来的模块和插件实现,并清理已部分激活的替换实例。
|
||||
|
||||
没有 Loader entry 的实例不具备已记录的模块身份。如果共享运行时的替换模块具有不同的插件回调,HMR 会报告歧义错误并回滚,而不是将该实例随意分配给某个模块。
|
||||
|
||||
包配置失效实现在 `packages/boot/hmr/src/package-manifest.ts`,封装其使用的 Node internal 接口。`index.ts` 分派 manifest 变更,并按模块身份定位已加载 entry。HMR 的 `node_modules` 排除规则不变。
|
||||
|
||||
| 接口 | 动作 |
|
||||
|---|---|
|
||||
| modules binding 的 `readPackageJSON`、`getPackageScopeConfig`、`getPackageType` | 替换:路径位于已失效目录时按当前磁盘解析,其余调用原生方法 |
|
||||
| `package_json_reader.getNearestParentPackageJSON` | 替换:已失效目录绕过它的 JS 缓存;没有 manifest 时返回原生的缺失结果 |
|
||||
| ESM Loader 的 `ResolveCache` 实例 | 临时替换原型的 `get`,取得实例后立即恢复,再清空解析缓存 |
|
||||
| CJS `Module._pathCache` | 清空请求到文件名的缓存 |
|
||||
| CJS `Module._load` | 默认 resolver 的请求按解析后的文件名加载;内置模块及注册的 resolve hooks 保留原路径 |
|
||||
|
||||
binding 的 `getNearestParentPackageJSON` 只被 `package_json_reader` 自己调用,不替换。每个 HMR 实例拥有独立的配置缓存,在第一次失效时安装 hook,并在服务销毁时恢复,同时清理 watcher 和重载队列。
|
||||
|
||||
### 后续工作
|
||||
|
||||
- 不支持 `node_modules` 内在线替换、同路径重装、link 换目标及跨包重载传播。包更新需要重启进程;这不保证每个管理操作的结果都已正确报告这一要求。
|
||||
- 使用异步 loader 线程的 TSX 版本,其线程内包配置不受这些 hook 管理。线程同步留待后续;本次不提供通用 Worker 缓存同步。
|
||||
- 注册了同步 resolve hooks 时,CommonJS 私有请求缓存刷新留待后续;这些请求保留原有 loader 行为。
|
||||
- 销毁 HMR 会恢复原生 reader,其此前缓存的配置可能重新可见。跨 HMR 替换保留失效状态留待后续。
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**每次查找都读当前磁盘(#4703)。** 解析能跟上磁盘,但不清模块缓存,也不重载运行中的插件。同一 URL 时新内容不生效,不同 URL 时新旧版本在同一进程并存。它还要维护一份入口解析实现,并且绕过自定义 bare-name hook。它也没有刷新 runtime resolution,GUI 安装的私有依赖依然找不到。
|
||||
|
||||
**在 runtime resolution 发布时全量重读已见过的 manifest(#5496)。** 它把包配置失效放进了绑定表发布,和模块缓存脱了钩:覆盖已安装的包时报需要重启,但下一次无关的发布会让新导入拿到新版本,旧插件仍在运行。包配置缓存属于内容,应该和模块缓存由同一方负责,也就是 HMR。
|
||||
|
||||
**让 HMR 支持 `node_modules` 内的包重载。** 这需要沿包含 `node_modules` 的模块图确定受影响插件,防止共享库和 Cordis 被重复求值,还要处理动态 import 漏边、模块副作用和 Worker。本次不做,这些场景继续要求重启。
|
||||
|
||||
**package.json 变化时重载所有插件。** 仅作为配置的 manifest 不要求模块求值。实际作为 JSON 模块导入的 manifest 保留普通的依赖驱动重载。
|
||||
|
||||
**按插件名重新 import 被重载的插件。** 这能让入口改名随源码重载生效,但会改变 `partialReload` 以已加载 URL 为重载单位的规则。入口改名在 Loader entry 重启时已经生效,所以本次不改。
|
||||
|
||||
**每个 entry 名称只保留一个命名空间。** 名称和 base URL 相同的 entry,可能在 manifest 变化前后加载了不同模块。只保留最早或最后一个命名空间会遗漏仍在使用的模块;尚未初始化的 entry 也不能抹去其他 entry 的已导入命名空间。
|
||||
|
||||
## Verification
|
||||
|
||||
| 覆盖面 | 位置 |
|
||||
|---|---|
|
||||
| 新一代可移除 profile 条目与本地包名、仍拒绝修改保留条目 | `packages/boot/app-boot/tests/profile-resolution.spec.ts` |
|
||||
| GUI 安装、延迟启用、停用、卸载、覆盖、失败、取消,有无 HMR | `packages/boot/plugin-manager/tests/package-reload.spec.ts` |
|
||||
| 捕获目录后的重新计算、无 profile 的已计算 resolution 刷新、纯数据拒绝 | `packages/boot/app-boot/tests/profile-resolution.spec.ts`、`packages/boot/app-boot/tests/profile-resolution-service.spec.ts` |
|
||||
| 无 HMR 连续操作、共享依赖声明来源、磁盘成功但发布失败 | `packages/boot/plugin-manager/tests/package-reload.spec.ts` |
|
||||
| 失效后的 exports、main、imports、type、scope、最近 package.json,原生 reader 对照,实际 CommonJS 加载及旧模块实例保留,node_modules 边界,恢复 | `packages/boot/hmr/tests/package-manifest.spec.ts` |
|
||||
| 配置专用 manifest、JSON 模块与宿主重载、源码重载、同名及共享运行时的 entry、导入与激活失败回滚、entry 重启和 node_modules 排除规则 | `packages/boot/hmr/tests/package-manifest-dispatch.spec.ts` |
|
||||
|
||||
测试不需要 API key,不调用模型。
|
||||
|
||||
## Consequences
|
||||
|
||||
- 有 HMR 时,支持的包操作发布更新后的私有依赖,并在插件停止后移除不再需要的映射。没有 HMR 时,待生效的启动 bundle 停用会将后续发布延迟到重启,保留运行中插件的映射。
|
||||
- 在支持的线程内,package.json 配置读取使用更新后的字段;JSON 模块保留自己的重载行为。上述模块、loader 线程及 Worker 的独立限制仍然适用。
|
||||
- 包配置读取改由 JS 解析 package.json,需要与原生读取保持字段和错误语义一致;测试在 Node 22、24、26 上与原生读取逐项比较。
|
||||
- 依赖多个 Node internal 接口,Node 升级时需要重跑这些测试。
|
||||
@@ -17,12 +17,12 @@
|
||||
/agent-note-private-npm-publication-as-three-independent-sequences/decision/versions-land-in-the-repository-from-a-local-command-ci-only-checks-and-uploads:
|
||||
en: 71d4cda384013bfb
|
||||
zh: 909d853c18496925
|
||||
/agent-note-private-npm-publication-as-three-independent-sequences/decision/vendor-publish-what-changed-and-let-tags-be-the-ledger:
|
||||
en: 7f578bb18f7f8693
|
||||
zh: 49f25210f7c1a61f
|
||||
/agent-note-private-npm-publication-as-three-independent-sequences/decision/vendor-advance-the-complete-family-with-per-package-tags:
|
||||
en: 321f48c7e4afac24
|
||||
zh: 974a7fa1a75c810d
|
||||
/agent-note-private-npm-publication-as-three-independent-sequences/decision/publication-runs-only-on-github-and-the-registry-decides-what-goes-out:
|
||||
en: 4f399390f58d7a34
|
||||
zh: 9df67248acfc61c8
|
||||
en: 8c8cd90e6eabe07d
|
||||
zh: 7035b71a6eeeb379
|
||||
/agent-note-private-npm-publication-as-three-independent-sequences/decision/workspace-internal-references-use-the-workspace-protocol:
|
||||
en: 82a7925d449fc283
|
||||
zh: 653358a1ffbad80a
|
||||
@@ -42,8 +42,8 @@
|
||||
en: c505237cad2c4128
|
||||
zh: 55364ba462536680
|
||||
/agent-note-private-npm-publication-as-three-independent-sequences/alternatives-considered:
|
||||
en: 11877d09e5a84f6e
|
||||
zh: ea372c779254be93
|
||||
en: ed22fa8a47b5c4b6
|
||||
zh: 7b367971a51a67ff
|
||||
/agent-note-private-npm-publication-as-three-independent-sequences/consequences:
|
||||
en: f961ebb927e30b5c
|
||||
zh: 019f1628fb1f2c56
|
||||
en: 46f82110175ea3f7
|
||||
zh: 1cdd1c99bfb88440
|
||||
@@ -36,9 +36,9 @@ Each sequence has one bump-and-commit command: it derives the target version, wr
|
||||
|
||||
For equal release numbers, SemVer compares alphanumeric prerelease identifiers lexically: `alpha` is lower than `canary`, `canary` is lower than `rc`, and every prerelease is lower than the stable version. npm dist-tags are mutable aliases and do not participate in version precedence.
|
||||
|
||||
### vendor: publish what changed, and let tags be the ledger
|
||||
### vendor: advance the complete family with per-package tags
|
||||
|
||||
The vendored packages are decoupled from upstream by their scope but keep their own version lines. The published version is the higher of the manifest version and the last published version, with the patch incremented — which also drops an upstream prerelease segment. The first published versions:
|
||||
The vendored packages are decoupled from upstream by their scope but keep their own version lines. The next version uses the higher of the manifest version and the latest tagged version, incrementing the patch after a stable version. The first published versions:
|
||||
|
||||
| Package | Upstream version | First published version |
|
||||
|---|---|---|
|
||||
@@ -52,13 +52,13 @@ The vendored packages are decoupled from upstream by their scope but keep their
|
||||
| `@deepseek-ai/cordis-plugin-group` | 1.0.0 | 1.0.1 |
|
||||
| `@deepseek-ai/cordis-plugin-logger-console` | 1.0.0 | 1.0.1 |
|
||||
|
||||
Taking the last published version as the baseline is what survives a re-sync: upstream restoring `4.0.0-rc.8` after this repository published `4.0.1` would otherwise compute `4.0.1` again and collide. `--prerelease rc.1` publishes a rehearsal instead, which takes `--tag next` and leaves the release numbers free: a prerelease has lower precedence than the release it precedes, so `4.0.1` still follows `4.0.1-rc.1`. That ordering is computed here rather than read from `git tag --sort=v:refname`, which places a prerelease above its release.
|
||||
Taking the latest tagged version as the baseline prevents reuse after a re-sync: upstream restoring `4.0.0-rc.8` after this repository tagged `4.0.1` would otherwise compute `4.0.1` again. `release:vendor --prerelease rc.1` or `--prerelease alpha.1` selects a prerelease, whose default npm dist-tag is `next`. Later prereleases and the stable release reuse its release numbers: `4.0.1` follows `4.0.1-rc.1`. The script compares SemVer precedence rather than using `git tag --sort=v:refname`, which places a prerelease above its release.
|
||||
|
||||
Only changed packages publish, and the change judgement adds no state file: **each package has its own tag, and that tag records the commit it last published from**. For each package, bump reads the newest `vendor-<package>-v*` tag and diffs the package directory against it. A path counts when the manifest's `files` selects it, when npm publishes it regardless (`package.json`, `README*`, `LICENSE*`), or — for a package whose `files` selects `lib/` — when it is a build input (`src/**`, `tsconfig*.json`, a build config). That last rule exists because a built payload is not tracked by git: without it, a real source change reads as "nothing changed" and the next publication fails on a version whose bytes moved.
|
||||
Every vendor release advances all nine packages, each with its own version and `vendor-<package>-v<version>` tag. Repacking from another repository state can change a member's dependency ranges or built payload even when its directory has no source changes. Advancing the complete family avoids reusing an existing version for those different bytes.
|
||||
|
||||
A tag is a commit pointer, not proof of publication. Bump asks the registry whether the version its newest tag names exists and fails for a human to resolve when it does not, because a tag pushed for a publication that then failed would otherwise read as "already published" and skip the package indefinitely. Querying a private package needs credentials, so an unauthenticated machine reports the gap instead of failing.
|
||||
A tag reserves a package version and identifies its commit; it does not establish successful publication. Bump uses the latest tag even if its publication failed. The publisher checks registry versions and integrity, and the release manager verifies completion for the complete family.
|
||||
|
||||
`vendor/cordis` publishes `src` as well. Its export map declares `"./src/*"`, so a tarball without those files points consumers at absent paths, and `files` selecting only build output left the change judgement with no tracked path to match.
|
||||
`vendor/cordis` publishes `src` as well. Its export map declares `"./src/*"`, so a tarball without those files points consumers at absent paths.
|
||||
|
||||
### Publication runs only on GitHub, and the registry decides what goes out
|
||||
|
||||
@@ -76,6 +76,10 @@ All three sequences decide this way, including the native one: it publishes thro
|
||||
|
||||
Two registry behaviours shape how a publish is attempted. Writes are spaced by at least two seconds and retried with a backoff, because publishing several packages back to back outruns the registry's own processing and earns `E409 Failed to save packument`. And every retry re-reads the registry first: a reported failure can answer a write that landed anyway, so a version that now exists with this tarball's integrity counts as published rather than as a version to place again.
|
||||
|
||||
For dsh and vendor, `release:publish --dist-tag <tag>` overrides the family's default publication channel. The vendor publish workflow accepts the optional `dist-tag` input; omitting it preserves the family default. An override must be a valid npm dist-tag. Before publishing any tarball, the publisher checks every packed member: the selected tag must already name the intended version, or both that tag and the version must be absent. A tag bound elsewhere, or an already published version without the selected tag, rejects the entire attempt before any upload. Existing integrity checks and retries still apply. Skipped versions are never retagged.
|
||||
|
||||
The preflight does not reserve npm tags atomically. The release manager coordinates external publishers that could change a binding after the check. A dedicated dist-tag also does not exclude stable versions from existing dependency ranges: isolation requires prerelease versions and an audit of the actual packed dependency ranges.
|
||||
|
||||
### Workspace-internal references use the `workspace:` protocol
|
||||
|
||||
Every reference to a workspace member uses the `workspace:` protocol. Every dependency section and consumer uses exact `workspace:*` DSH references and `workspace:~` vendor/native references, including the native entry's optional platform packages. Vendor and native patch releases must preserve consumer-facing APIs and binary interfaces. Local workspace linking is unchanged.
|
||||
@@ -146,9 +150,9 @@ The installed-consumer probe captures npm's HTTP diagnostics and includes them w
|
||||
|
||||
**Event-level tags (`vendor-r1`, `vendor-r2`).** Prepared for one release event carrying several package versions. Once the registry decides what publishes, the workflow no longer infers the set from the tag, so per-package tags suffice — and each one names its own package's real version.
|
||||
|
||||
**Putting the nine vendored packages on one `4.0.x` line.** It removes change detection, but cosmokit would jump from `1.8.1` to `4.0.1` and lose its upstream lineage; the upstream ranges inside the nine (`^1.8.1` and friends) would stop matching immediately, forcing a rewrite of the vendored manifests.
|
||||
**Putting the nine vendored packages on one `4.0.x` line.** Cosmokit would jump from `1.8.1` to `4.0.1` and lose its upstream lineage; the upstream ranges inside the nine (`^1.8.1` and friends) would stop matching immediately, forcing a rewrite of the vendored manifests.
|
||||
|
||||
**Incrementing every vendored package on every vendor release, with no change detection.** The least machinery, at the cost of new version numbers for packages whose content is byte-identical to the previous release. Tags reduce change detection to reading one tag and running one diff, which is not worth trading for inflated version numbers.
|
||||
**Publishing only changed vendor directories.** A directory diff does not establish that repacking from another repository state produces identical bytes. Advancing all nine packages avoids integrity collisions for unchanged directories at the cost of additional versions.
|
||||
|
||||
**Deciding "already published" from the version alone, without comparing content.** The reference flow queries no registry: publish uploads each tarball and npm rejects a duplicate version. Skipping on the version alone misses code that changed without a bump, which is the only failure that quietly leaves stale bytes on the registry. The cost is a registry query and a dependency on reproducible builds.
|
||||
|
||||
@@ -166,14 +170,14 @@ The installed-consumer probe captures npm's HTTP diagnostics and includes them w
|
||||
|
||||
## Consequences
|
||||
|
||||
The release scripts are importable modules behind a guarded entry point, and their judgements carry unit tests: tag naming, publish order and cycle reporting, version-baseline arithmetic, the payload change judgement, and each family's payload policy. Two defects the first draft carried — a publish command that ran the pack command on import, and a change judgement blind to `vendor/cordis` source edits — are exactly what a test at that seam catches.
|
||||
The release scripts are importable modules behind a guarded entry point, and their judgements carry unit tests: tag naming, publish order and cycle reporting, version-baseline arithmetic, and each family's payload policy. The entry guard prevents an import from executing a release command.
|
||||
|
||||
A pull request runs the full pack for both sequences without credentials and installs the packed dsh tarballs into a throwaway consumer, where plain Node drives `dsh --version`. That probe is deliberately one command: it proves `files` selected a complete payload and that the published ranges resolve, and says nothing about interactive behavior.
|
||||
|
||||
What this costs:
|
||||
|
||||
- **Tags can drift from the registry.** A tag pushed for a publication that then failed is caught by bump's registry check, but only where credentials exist; an unauthenticated machine reports the gap and continues.
|
||||
- **The change judgement depends on visible tags.** A shallow clone, or a checkout without tags, degrades the vendored judgement to "publish everything for the first time". `fetch-depth: 0` is a precondition, not an optimization.
|
||||
- **Tags can drift from the registry.** Tags reserve versions even when publication fails; the release manager verifies registry completion separately.
|
||||
- **The version baseline depends on visible tags.** A shallow clone or a checkout without tags can reuse a reserved vendor version. `fetch-depth: 0` is a precondition, not an optimization.
|
||||
- **The protocol rewrite touched 1504 dependency declarations.** It does not change local resolution — pnpm already resolves from the workspace — but it changes the ranges that go out.
|
||||
- **Private packages need credentials to install.** Every consumer — CI, sandbox e2e, outside users — needs scope credentials, including for the Landlock packages, which have never been published and so cut off no existing anonymous path.
|
||||
- **`repository` names a different organization than the one running the workflows.** Token-based publication is unaffected; npm's OIDC attestation requires the two to agree, so adopting it means either repointing `repository` or publishing from the organization it names.
|
||||
|
||||
@@ -36,9 +36,9 @@ Status: implemented
|
||||
|
||||
基础版本号相同时,SemVer 按字典序比较字母数字型预发布标识:`alpha` 小于 `canary`,`canary` 小于 `rc`,所有预发布版本都小于稳定版本。npm dist-tag 是可变别名,不参与版本优先级比较。
|
||||
|
||||
### vendor:谁改了谁发版,tag 就是账本
|
||||
### vendor:整族递增,每包保留自己的 tag
|
||||
|
||||
vendor 九包加了 scope 之后与上游脱钩,但保留各自的版本线。发布版本取「manifest 版本」与「上次发布版本」中较高的那个,再递增 patch——这一步同时去掉上游的预发布段。首发版本:
|
||||
vendor 九包加了 scope 之后与上游脱钩,但保留各自的版本线。下一版本取 manifest 版本与最新 tag 版本中较高的那个,稳定版本之后递增 patch。首发版本:
|
||||
|
||||
| 包 | 上游版本 | 首发版本 |
|
||||
|---|---|---|
|
||||
@@ -52,13 +52,13 @@ vendor 九包加了 scope 之后与上游脱钩,但保留各自的版本线。
|
||||
| `@deepseek-ai/cordis-plugin-group` | 1.0.0 | 1.0.1 |
|
||||
| `@deepseek-ai/cordis-plugin-logger-console` | 1.0.0 | 1.0.1 |
|
||||
|
||||
以「上次发布版本」为基线才扛得住重同步:本仓发过 `4.0.1` 之后上游把版本恢复成 `4.0.0-rc.8`,只看 manifest 会再算出 `4.0.1` 并撞上已发版本。加 `--prerelease rc.1` 则发一次排练版:它进 `--tag next`,而且不占用那组数字——预发布的优先级低于它所先行的正式版,所以 `4.0.1` 仍然接在 `4.0.1-rc.1` 之后。这个次序由脚本自己算,不读 `git tag --sort=v:refname`——git 会把预发布排在正式版之前。
|
||||
以最新 tag 的版本为基线可避免重同步后复用版本:本仓标记 `4.0.1` 之后,上游若将版本恢复成 `4.0.0-rc.8`,只看 manifest 会再次算出 `4.0.1`。`release:vendor --prerelease rc.1` 或 `--prerelease alpha.1` 选择预发布版本,默认 npm dist-tag 为 `next`。后续预发布和稳定版本复用其基础版本号:`4.0.1` 接在 `4.0.1-rc.1` 之后。脚本按 SemVer 优先级比较,不使用会将预发布排在稳定版本之前的 `git tag --sort=v:refname`。
|
||||
|
||||
只发改动过的包,而变更判据不引入新的状态文件:**每包一个 tag,tag 就是「上次发布到哪个 commit」的记录**。bump 对每个包取最新的 `vendor-<包名>-v*` tag,拿包目录与它做 diff。一条路径算命中的条件是:manifest 的 `files` 选中它,或 npm 无论如何都会发布它(`package.json`、`README*`、`LICENSE*`),或者——当该包的 `files` 选中 `lib/` 时——它是构建输入(`src/**`、`tsconfig*.json`、构建配置)。最后那条规则的存在理由是构建产物不在 git 里:没有它,真实的源码改动会读成「没变化」,而下一次发布会在一个字节已变的版本上失败。
|
||||
每次 vendor 发布都递增全部九个包,每包保留自己的版本和 `vendor-<package>-v<version>` tag。即使包目录没有源码变更,从不同仓库状态重新打包也可能改变其依赖范围或构建产物。整族递增可避免为这些不同的字节复用已有版本。
|
||||
|
||||
tag 只是 commit 指针,不是发布成功的证明。bump 会向 registry 核对「最新 tag 指向的版本是否真的存在」,不存在就明确失败交人处理——否则一个为失败发布而推的 tag 会被读成「已发布」,从此永远跳过该包。查询私有包需要凭据,因此未鉴权的机器只报告这道核对被跳过,不失败。
|
||||
tag 预留包版本并标识其 commit,不代表发布成功。即使对应发布失败,bump 仍使用最新 tag。publisher 核对 registry 中的版本和 integrity,发布负责人核实整族发布完成。
|
||||
|
||||
`vendor/cordis` 现在也发布 `src`。它的 exports 声明了 `"./src/*"`,tarball 里没有这些文件就等于把消费方指向不存在的路径;而 `files` 只选构建产物,也让变更判据没有任何受 git 跟踪的路径可匹配。
|
||||
`vendor/cordis` 也发布 `src`。它的 exports 声明了 `"./src/*"`,tarball 中缺少这些文件就会将消费方指向不存在的路径。
|
||||
|
||||
### 发布只在 GitHub 执行,由 registry 状态决定发什么
|
||||
|
||||
@@ -76,6 +76,10 @@ tag 只是 commit 指针,不是发布成功的证明。bump 会向 registry
|
||||
|
||||
registry 的两个行为决定了「怎么尝试一次发布」。写入之间至少间隔两秒并带退避重试,因为连续背靠背发多个包会超出 registry 自身的处理速度,换来 `E409 Failed to save packument`。而每次重试都先重查 registry:报出来的失败可能对应一次其实已经落地的写入,所以「该版本现在存在且 integrity 与本 tarball 相同」算作已发布,而不是又一个待放置的版本。
|
||||
|
||||
对 dsh 和 vendor,`release:publish --dist-tag <tag>` 覆盖该族默认发布通道。vendor 发布 workflow 接受可选的 `dist-tag` 输入;省略时保留该族默认值。覆盖值必须是合法的 npm dist-tag。发布任何 tarball 前,publisher 检查每个打包成员:所选 tag 必须已指向预期版本,或者该 tag 和版本都不存在。tag 指向其他版本,或版本已经发布但缺少所选 tag,都会在上传前拒绝整次发布。原有 integrity 检查与重试继续生效。跳过的版本不会重新绑定 tag。
|
||||
|
||||
预检不会原子性地预留 npm tag。发布负责人协调可能在检查后更改绑定的外部发布者。专用 dist-tag 也不会让稳定版本被已有依赖范围排除:隔离需要预发布版本,并核查实际打包产物中的依赖范围。
|
||||
|
||||
### workspace 内部引用走 `workspace:` 协议
|
||||
|
||||
所有指向 workspace 成员的引用都用 `workspace:` 协议。每个消费者的所有依赖区段对 DSH 使用精确的 `workspace:*` 引用,对 vendor/native 使用 `workspace:~` 引用,包括原生入口的可选平台包。vendor 与 native 的补丁版本必须保持面向消费者的 API 和二进制接口兼容。本地 workspace 链接方式不变。
|
||||
@@ -146,9 +150,9 @@ dsh 的验证会一并安装 vendored 族的 pack 产物。harness 的包把 ven
|
||||
|
||||
**事件级 tag(`vendor-r1`、`vendor-r2`)。** 为「一次发布事件携带多个包版本」准备。既然由 registry 决定发什么,workflow 就不再从 tag 推断集合,per-package tag 够用,而且每个 tag 携带的是它自己那个包的真实版本。
|
||||
|
||||
**把九个 vendored 包统一到一条 `4.0.x` 版本线。** 省掉变更检测,但 cosmokit 会从 `1.8.1` 跳到 `4.0.1`、丢失上游血缘;九包内部的上游范围(`^1.8.1` 之类)会立刻失配,必须改写 vendored manifest。
|
||||
**把九个 vendored 包统一到一条 `4.0.x` 版本线。** Cosmokit 会从 `1.8.1` 跳到 `4.0.1`、丢失上游血缘;九包内部的上游范围(`^1.8.1` 之类)会立刻失配,必须改写 vendored manifest。
|
||||
|
||||
**每次 vendor 发布把九包全部 patch+1,不做变更检测。** 机制最少,代价是内容与上一版逐字节相同的包也拿到新版本号。tag 把变更检测的成本压到「读一个 tag、跑一次 diff」,不值得为省这点让版本号虚涨。
|
||||
**只发布目录有变更的 vendor 包。** 目录 diff 不能证明从不同仓库状态重新打包会产出相同字节。递增全部九个包可避免未改目录的 integrity 冲突,代价是增加版本号。
|
||||
|
||||
**只按版本号判断「是否已发布」,不比对内容。** 参照流程根本不查 registry:publish 逐个上传,重复版本由 npm 拒绝。只按版本号跳过会漏掉「改了代码没 bump」,而这是唯一会安静地把旧字节留在 registry 上的错误。代价是引入一次 registry 查询和对构建可复现性的依赖。
|
||||
|
||||
@@ -166,14 +170,14 @@ dsh 的验证会一并安装 vendored 族的 pack 产物。harness 的包把 ven
|
||||
|
||||
## 后果
|
||||
|
||||
发布脚本是带入口守卫的可 import 模块,其判断都有单测覆盖:tag 命名、发布顺序与环报告、版本基线运算、payload 变更判据,以及各族的 payload 策略。第一版带过的两个缺陷——publish 命令在 import 时执行了 pack 命令、变更判据对 `vendor/cordis` 的源码改动失明——正是这类测试在对应接缝上能抓住的。
|
||||
发布脚本是带入口守卫的可 import 模块,其判断都有单测覆盖:tag 命名、发布顺序与环报告、版本基线运算,以及各族的 payload 策略。入口守卫阻止 import 执行发布命令。
|
||||
|
||||
一个 pull request 会为两条序列跑完整的 pack(无凭据),并把打包好的 dsh tarball 装进一次性 consumer,用普通 Node 驱动 `dsh --version`。这个探针刻意只有一条命令:它证明 `files` 选出了完整 payload、发布出去的范围可解析,不涉及任何交互行为。
|
||||
|
||||
代价:
|
||||
|
||||
- **tag 可能与 registry 漂移。** 为失败发布而推的 tag 由 bump 的 registry 核对拦下,但只在有凭据的地方;未鉴权的机器只报告这道核对被跳过。
|
||||
- **变更判据依赖 tag 可见。** shallow clone 或未拉 tag 会把 vendored 族的判据退化成「全部首发」。`fetch-depth: 0` 是前提,不是优化。
|
||||
- **tag 可能与 registry 漂移。** 即使发布失败,tag 仍预留版本;发布负责人另行核实 registry 中的完成状态。
|
||||
- **版本基线依赖 tag 可见。** shallow clone 或未拉取 tag 的 checkout 可能复用已预留的 vendor 版本。`fetch-depth: 0` 是前提,不是优化。
|
||||
- **协议改写触及 1504 处依赖声明。** 它不改变本机解析(pnpm 本来就从 workspace 解析),但改变了发布出去的范围写法。
|
||||
- **私有包需要凭据才能安装。** 任何消费方——CI、沙箱 e2e、外部使用者——都要持有 scope 凭据,Landlock 三包也在其中;它们从未发布过,所以没有切断既有的匿名安装路径。
|
||||
- **`repository` 指向的组织与运行 workflow 的组织不同。** 用 token 发布不受影响;npm 的 OIDC attestation 要求二者一致,届时要么把 `repository` 改指过去,要么从它指向的组织发布。
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
# Bilingual-pair consistency record for 2026-10-03-pi-extensions-bridge.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/proposed/feature/2026-10-03-pi-extensions-bridge.md
|
||||
/agent-note-pi-extensions-bridge:
|
||||
en: cd9f68956ba34672
|
||||
zh: 1fc649c5eb0d3cf4
|
||||
/agent-note-pi-extensions-bridge/problem:
|
||||
en: 9bfc5f0c9ff223a6
|
||||
zh: 7c5f026c94677dd8
|
||||
/agent-note-pi-extensions-bridge/proposal:
|
||||
en: 56b36193ca6f829d
|
||||
zh: a31e226794c1ca90
|
||||
/agent-note-pi-extensions-bridge/proposal/packages-and-composition:
|
||||
en: 2ab10852b93ad41b
|
||||
zh: 3c14d410235fcfac
|
||||
/agent-note-pi-extensions-bridge/proposal/loading-and-execution:
|
||||
en: d95ece799bc6bab7
|
||||
zh: 1e19d3b49473a57e
|
||||
/agent-note-pi-extensions-bridge/proposal/serving-the-api:
|
||||
en: b1365f16d76fe27a
|
||||
zh: 105b88f80eb8964d
|
||||
/agent-note-pi-extensions-bridge/proposal/built-in-tool-calls-as-pi-sees-them:
|
||||
en: c74e4662ad0a8ee7
|
||||
zh: 653729b41e983b13
|
||||
/agent-note-pi-extensions-bridge/proposal/unserved-features:
|
||||
en: 70c74cf93526cfad
|
||||
zh: cab3efc2c109ad5a
|
||||
/agent-note-pi-extensions-bridge/proposal/extension-state:
|
||||
en: be58f498b8d28548
|
||||
zh: 4cc057f794c288bf
|
||||
/agent-note-pi-extensions-bridge/proposal/core-mechanisms-narrower-than-pi-s:
|
||||
en: 9fa8297436332064
|
||||
zh: 9a000e157940b0f8
|
||||
/agent-note-pi-extensions-bridge/alternatives-considered:
|
||||
en: a3769b960278c17f
|
||||
zh: e7d86ff1aff243b8
|
||||
/agent-note-pi-extensions-bridge/acceptance-criteria:
|
||||
en: 60894db423a54ec3
|
||||
zh: 13b435271f6a1371
|
||||
/agent-note-pi-extensions-bridge/risks:
|
||||
en: 2b9a626b13c604f6
|
||||
zh: f67cb80d2b699989
|
||||
@@ -0,0 +1,142 @@
|
||||
# Agent Note: Pi extensions bridge
|
||||
|
||||
Status: proposed
|
||||
|
||||
English | [中文](2026-10-03-pi-extensions-bridge.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Pi 1.0.0 extensions are in-process TypeScript factories `(pi: ExtensionAPI) => void`. A factory subscribes to about forty lifecycle, context, tool, and provider events, registers model tools, slash commands, model providers, and MCP servers, and talks to the user through `ctx.ui` ([extensions guide](https://github.com/earendil-works/pi/blob/v1.0.0/packages/coding-agent/docs/extensions.md), [API types](https://github.com/earendil-works/pi/blob/v1.0.0/packages/coding-agent/src/core/extensions/types.ts)). The API is published and widely used: about 11,000 npm packages carried the `pi-package` keyword in October 2026.
|
||||
|
||||
DSH has bridges for two other ecosystems: shell hooks written for Claude Code and Codex ([hooks group](../../../../packages/hooks/README.md)) and Claude Code mods ([compatibility page](../../../../docs/subsystems/claude-code-mods.md)). Pi's extension API is broader than either. It lets one in-process module rewrite the messages of a single request, edit tool arguments, append its own durable records, and continue a finished run. Serving that API from DSH extension points answers two questions: how much of an extension written for another harness runs unchanged, and which DSH core mechanisms are narrower than Pi's.
|
||||
|
||||
## Proposal
|
||||
|
||||
Add an experimental bridge that loads unmodified Pi extension sources and serves Pi's `ExtensionAPI` from existing DSH extension points. The bridge is a capability demonstration: it runs extensions as written where an existing DSH mechanism carries the behavior, leaves the rest unserved, and lists every difference on a compatibility page. Apart from the restricted plugin records that extension state needs (row 9 of the gap table), each core gap is closed by a separate change.
|
||||
|
||||
### Packages and composition
|
||||
|
||||
- `@deepseek-ai/dsh-experimental-pi-extensions` is the Host package and provides `ctx.piExtensions`. `@deepseek-ai/dsh-experimental-client-ui-pi-extensions` is the Web package. Neither is an optional bundle; an opt-in overlay composes them, as for the Claude Code mods bridge.
|
||||
- One cordis.yml row names one extension source: a file, a directory with an `index.ts` or `index.js`, or a Pi package directory whose `package.json` lists `pi.extensions`. The row also carries the extension's flag values. Row order is Pi's load order, which is also its handler dispatch order.
|
||||
- The bridge does not scan `~/.pi` or `.pi`, install packages, or decide project trust. A Pi package's skills, prompt templates, and themes are not loaded.
|
||||
|
||||
### Loading and execution
|
||||
|
||||
- The bridge has its own loader and event dispatcher. `@earendil-works/pi-coding-agent` is not a dependency.
|
||||
- `jiti` imports the source, so TypeScript loads in a built installation. The loader resolves the imports Pi supplies to extensions: `typebox` and `@earendil-works/pi-ai` to the bridge's own dependencies, `@earendil-works/pi-coding-agent` to a module of the bridge that implements the helpers that need no Pi runtime (`defineTool`, the tool-event type guards, the truncation helpers, `withFileMutationQueue`, `getAgentDir`), and `@earendil-works/pi-tui` to the optional peer dependency when the deployment installed it. An extension that imports a module the loader cannot resolve fails to load with an error naming the module.
|
||||
- Extension code runs in the Host process with the process's full authority, as mods do.
|
||||
- Every Session with an in-process Agent gets its own runtime: the module is evaluated once per row, and its factory runs once per Session, as Pi re-runs factories for each Session. State an extension keeps at module scope is shared by all Sessions of the Host process; Pi also keeps one module instance across the Sessions of a process, but runs one Session at a time. A subagent's Session gets a separate runtime in Pi's `print` mode with `hasUI: false`, so a tool call a subagent makes passes the same extension handlers as the root's.
|
||||
|
||||
### Serving the API
|
||||
|
||||
| Pi API | DSH mechanism |
|
||||
|---|---|
|
||||
| `session_start`, `session_shutdown` | `agent/created` (awaited before the first turn), `agent/disposed` |
|
||||
| `input` (after inbox admission; see row 7), `before_agent_start` | `system-prompt/assemble` and `agent/pre-step`: claimed user messages are transformed or dropped, a returned `systemPrompt` replaces the assembled prompt for that turn, and returned messages join the step |
|
||||
| `tool_call` | `tools/pre-execute`: `block` becomes a denial; a handler that throws denies the call |
|
||||
| `tool_result` | `tools/post-execute`: replaced content is installed as the result's content |
|
||||
| `agent_before_settle` | `agent/turn-stopping`: returned custom entries are recorded, and a returned message with `continue` is steered into the turn |
|
||||
| `agent_start`, `agent_end`, `agent_settled`, `turn_start`, `turn_end`, `message_start`, `message_end`, `tool_execution_start`, `tool_execution_end`, `model_select`, `session_info_changed`, `session_compact` | Session events and `agent/status`, delivered in order as notifications |
|
||||
| `registerTool`, `ctx.executeTool` | Agent-scoped `ctx.tools` registrations; nested calls run the full tool pipeline; built-in names follow the translation below |
|
||||
| `registerCommand`, `getCommands` | Agent-scoped `ctx.commands` |
|
||||
| `sendUserMessage`, `sendMessage` | `agent.followup()`, `agent.steer()`, `agent.inject()` |
|
||||
| `appendEntry`, `setLabel`, tool `details`, `ctx.sessionManager` | Ignorable Session records and `tool/result` metadata, read back as Pi entries |
|
||||
| `getActiveTools`, `setActiveTools`, `getAllTools` | `ctx.tools.schemas()`; `setActiveTools` applies a scoped `ctx.tools.restrict()` to global tools and adds or removes the bridge's own Agent-scoped registrations, because a restriction does not hide scoped tools |
|
||||
| `setModel`, `getThinkingLevel`, `setThinkingLevel` | An `agent/request` override |
|
||||
| `exec` | `ctx.subprocess`, argv without a shell |
|
||||
| `ctx.ui.select`, `confirm`, `input`, `editor` | `ctx.userQuestions` |
|
||||
| `ctx.ui.notify`, `setStatus`, `setWidget`, `setTitle`, `setEditorText` | A Remote stream the Web package renders |
|
||||
| `registerFlag`, `getFlag`, `events` | Row configuration; an in-process event bus per Session runtime |
|
||||
|
||||
All Pi handlers for one Session run through one ordered queue. A notification never overtakes an earlier one, and an awaited hook such as `tool_call` runs after every notification queued before it, which keeps the order Pi's awaited dispatch gives handlers.
|
||||
|
||||
The Web and Desktop GUI appear to extensions as Pi's `rpc` mode with `hasUI: true`; headless, SDK, and ACP Sessions appear as `print` mode. Extensions already handle both: terminal-only calls such as `ctx.ui.custom()` return what Pi's `rpc` mode returns.
|
||||
|
||||
### Built-in tool calls as Pi sees them
|
||||
|
||||
DSH and Pi share the names `bash`, `read`, `edit`, `write`, and `grep`, but not their arguments, and Pi's `find` is DSH's `glob`. A Pi safety extension reads `event.input.path` or `event.input.command`. The bridge therefore presents each call of these six tools to handlers with Pi's name and Pi's argument names, translated from the DSH call. Other tools pass through unchanged. A test loads the registered DSH tool schemas and fails when a mapped parameter is renamed or removed, or when a tool gains a parameter the table has not classified.
|
||||
|
||||
The opposite direction uses the same table. `getActiveTools()` and `getAllTools()` report Pi names for the six tools and DSH names for every other tool, and `setActiveTools()` accepts the same names. `ctx.executeTool()` given a Pi name translates the arguments into the DSH call; an `edit` call with more than one entry in `edits` is refused, because one DSH `edit` call makes one replacement. An extension tool registered under a Pi built-in name replaces the corresponding DSH tool for that Agent, as it replaces Pi's own tool (`find` replaces `glob`).
|
||||
|
||||
### Unserved features
|
||||
|
||||
- An event DSH cannot raise is accepted at registration, never raised, and named in a warning when the extension loads.
|
||||
- A `pi` or `ctx` member DSH cannot serve throws an error naming the member.
|
||||
- Where Pi fails closed, the bridge does too. A `tool_call` handler that edits `event.input` gets the call denied with a reason, because the logged call would otherwise differ from the executed one.
|
||||
|
||||
### Extension state
|
||||
|
||||
Pi extensions rebuild their state on `session_start` from what they wrote earlier. The bridge keeps that state in the Session log so that it follows resume and fork:
|
||||
|
||||
- `appendEntry` and `setLabel` write ignorable records whose type starts with `plugin:`. A restricted core operation, callable only from experimental packages, appends them. They are not `SessionEventMap` members, so the persistence catalog and its type history are unchanged. A build without the bridge retains and skips them when it reads the current format, as the [ignorable events decision](../../implemented/architecture/2026-08-30-retain-ignorable-external-session-events.md) requires. A later format edge carries them on a best-effort basis: it keeps them under their names where it can and otherwise states which it drops, while the alpha historical edges refuse every unknown event ([decision](../../implemented/architecture/2026-08-31-alpha-historical-unknown-event-refusal.md)).
|
||||
- A tool's `details` is stored as the `tool/result` metadata the tool's output declaration projects.
|
||||
- `sendMessage` content is logged as an ordinary user-sourced message with the extension's content unchanged. A companion ignorable record carries `customType`, `display`, and `details`.
|
||||
|
||||
### Core mechanisms narrower than Pi's
|
||||
|
||||
Ranked by how much of Pi's API each blocks, with the GitHub issue that tracks each. Rows 1 and 3 follow from DSH rules: a request is derived from the Session log, and the log is linear. The other rows are missing extension points.
|
||||
|
||||
| # | Mechanism | Pi API blocked | DSH today | Issue |
|
||||
|---|---|---|---|---|
|
||||
| 1 | Per-request rewrite of model input | `context`, `context_with_system`, `before_provider_request`, `prepareLoadout` hidden declarations | The loop derives each request from the log and freezes it; `llm/stream` listeners read it. A rewrite needs an awaited hook and a core event that every log reader applies to that request. | #5661 |
|
||||
| 2 | Tool-argument rewrite | `tool_call` editing `event.input` | `PreToolDecision` excludes input rewriting ([proposal](2026-06-30-pre-tool-input-rewrite.md)). | #5662 |
|
||||
| 3 | Session tree and assistant-message replacement | `navigateTree`, `session_before_tree`, `session_tree`, `message_end` replacement | The log is linear, a fork creates a new Session, and an `assistant/message` cannot replace another. | #5663 |
|
||||
| 4 | Awaited step-end and pre-commit hooks | `turn_end` entries and `continue`, `message_end` | `step/end` and `assistant/message` are committed events; a Session-event listener cannot append. | #5664 |
|
||||
| 5 | Tool progress and result replacement | `onUpdate`, `tool_execution_update`, `tool_result` turning an error into a success, tool `usage` | `ToolRunContext` has no progress channel, and `tools/post-execute` cannot replace the value of a failed result. | #5665 |
|
||||
| 6 | Per-tool exposure | `codemode`, `deferred`, and `hidden` exposure | Presentation is chosen per Agent, and a tool the model cannot see refuses execution. | #5666 |
|
||||
| 7 | Input admission | `input` before the prompt is stored | A prompt is durable in the inbox before `agent/pre-step`; a swallowed prompt leaves a turn without a step. | #5667 |
|
||||
| 8 | Logged model call by a plugin | `ctx.modelRegistry.complete`, `stream`, `streamSimple` | No Session event records a plugin's own model request, which also blocks `$.model.complete` for mods. | #5668 |
|
||||
| 9 | Plugin-owned durable records | `appendEntry` | `Session.append()` accepts declared event types only. The restricted `plugin:` records close this for experimental packages. | #5669 |
|
||||
| 10 | Compaction customization | `session_before_compact` | One `CompactionEngine` owns summarization, and `/compact` takes no instructions. | #5670 |
|
||||
| 11 | Lifecycle reasons | `session_start` reasons `new`, `fork`, and `reload`; `session_shutdown` reasons | `agent/created` reports `startup` or `resume`; `SessionStartSource` also declares `clear` and `compact`, which nothing emits yet; `agent/disposed` carries no reason. | #5671 |
|
||||
| 12 | Host-plugin display in the GUI | `ctx.ui.notify`, `setStatus`, `setWidget` | No shared mechanism; each bridge ships its own Client package. | #5672 |
|
||||
| 13 | Command capabilities | Argument completion; `newSession`, `fork`, and `switchSession` moving the user to another Session | `ctx.commands` takes unstructured text and has no Client-side effects. | #5673 |
|
||||
| 14 | Provider request hooks | `before_provider_headers`, `after_provider_response`, `provider_stream_event` | Headers, responses, and raw stream events stay inside each adapter. | #5674 |
|
||||
| 15 | Fault isolation | An extension bug stops one Pi process | An uncaught exception in the Host disposes the application for every Session. | #5675 |
|
||||
|
||||
Terminal rendering (`ctx.ui.custom`, custom editors, renderers, shortcuts) is outside this list: DSH's GUI is not a terminal, and Pi's own `rpc` mode does not serve these either.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Depend on `@earendil-works/pi-coding-agent` and reuse its loader and runner.** It would reproduce Pi's handler ordering and error rules exactly and supply every helper extensions import. It was rejected because the exercise is to carry Pi's API on DSH mechanisms, not to embed Pi's harness: the runner is constructed over Pi's `SessionManager` and `ModelRegistry`, the package brings Pi's whole dependency closure, and its built-in tool factories would run Pi's own file and shell implementations outside DSH's filesystem and sandbox policy.
|
||||
|
||||
**A wrapper module per extension, like `defineMod`.** A Pi extension is already a default-exported factory with no manifest to replace, so a wrapper adds a step for every extension and prevents loading a published Pi package directory.
|
||||
|
||||
**Discover Pi's own locations automatically.** Loading `~/.pi/agent/extensions` and project `.pi/extensions` executes code a DSH user did not compose, which needs a project-trust decision DSH does not have. The mods bridge removed its directory loader for the same composition reason. Discovery can be a later opt-in layer.
|
||||
|
||||
**Run extensions in a separate process per Session.** An uncaught extension error would then reach the Host as an event. Every awaited hook would become an inter-process round trip, and Pi's edit-in-place event objects would have to be reproduced by sending changes back. Deferred until real extensions show the isolation is needed.
|
||||
|
||||
**Evaluate each extension module once per Session.** Concurrent Sessions would then not share module-scope state. Rejected because Pi keeps one module instance across the Sessions of a process, so an extension may rely on module state surviving a Session switch, and per-Session evaluation repeats the transpilation for every Session and subagent.
|
||||
|
||||
**Run extensions for root Sessions only.** It needs an explicit filter, since `agent/created` fires for every Agent, and it lets a subagent's tool calls bypass a Pi safety extension.
|
||||
|
||||
**Pass DSH tool names and arguments to handlers unchanged.** Pi's own `protected-paths` example reads `event.input.path`; with DSH's `file_path` it throws, and a handler written slightly differently would allow the write.
|
||||
|
||||
**Declare `pi/*` Session events and a `pi-extension` message source.** Correct attribution, but each is a required-on-read persistence type: the catalog and type history change, and a build without the bridge refuses the Session.
|
||||
|
||||
**Keep extension state in a storage domain outside the log.** State would not follow a fork, and `ctx.sessionManager.getBranch()` would not show what the extension wrote.
|
||||
|
||||
**Raise `context` and `before_provider_request` without logging their effect.** The model would see input that the Session log cannot reconstruct.
|
||||
|
||||
**Refuse to load an extension that subscribes to an unserved event.** Most extensions use several features; the mods bridge already warns and keeps the rest working.
|
||||
|
||||
**Build a shared GUI notification mechanism first.** It would also serve the mods bridge, but it is a core addition; the Web package of this bridge renders its own stream until that exists.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- Pi's MIT-licensed example extensions are committed unchanged as fixtures, and only those a test exercises. Through a real agent loop with a mock model: a permission extension denies a tool call and the model sees the reason; an extension tool is called by the model and its `details` survive a resumed Session; an extension command runs; an extension replaces the system prompt for a turn; `appendEntry` state is rebuilt on `session_start` after resume.
|
||||
- A subagent's tool call reaches the handlers of its own runtime.
|
||||
- The tool translation test fails when a mapped DSH tool parameter changes.
|
||||
- A Loader composition test boots the bridge from a cordis.yml, and a disposal test shows every registration removed when the bridge unloads.
|
||||
- A recorded-Session snapshot covers an extension-denied tool call and an extension tool call.
|
||||
- `docs/subsystems/pi-extensions.md` lists every event and member with its status and every behavioral difference.
|
||||
- The new packages' `src/` have 100% coverage, and `doc-sync` passes.
|
||||
|
||||
## Risks
|
||||
|
||||
- Extension code has the Host's full authority, and one uncaught error stops every Session. Mount only extensions you would run as a plugin.
|
||||
- Fidelity rests on the bridge's reading of Pi 1.0.0 dispatch rules, checked against Pi's examples but not executed on Pi's code. A later Pi release can change an event or helper the bridge serves.
|
||||
- An extension that imports a helper the bridge does not implement fails to load, even if it would have used that helper on one code path only.
|
||||
- A future Session format edge that cannot carry plugin records loses extension state for Sessions written before it.
|
||||
- Extensions that keep per-Session state at module scope mix the state of concurrent Sessions, including subagents.
|
||||
- A message an extension sends is attributed to the user everywhere outside the Web package that reads the companion record.
|
||||
@@ -0,0 +1,142 @@
|
||||
# Agent Note: Pi 扩展桥接
|
||||
|
||||
Status: proposed
|
||||
|
||||
[English](2026-10-03-pi-extensions-bridge.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
Pi 1.0.0 的扩展是进程内的 TypeScript 工厂函数 `(pi: ExtensionAPI) => void`。工厂函数订阅约四十个生命周期、上下文、工具与提供方事件,注册模型工具、斜杠命令、模型提供商与 MCP server,并通过 `ctx.ui` 与用户交互([扩展指南](https://github.com/earendil-works/pi/blob/v1.0.0/packages/coding-agent/docs/extensions.md)、[API 类型](https://github.com/earendil-works/pi/blob/v1.0.0/packages/coding-agent/src/core/extensions/types.ts))。该 API 已发布并被广泛使用:截至 2026 年 10 月,npm 上约有 11,000 个包带有 `pi-package` 关键字。
|
||||
|
||||
DSH 已为另外两个生态提供桥接:为 Claude Code 与 Codex 编写的 shell 钩子([hooks 组](../../../../packages/hooks/README.zh.md)),以及 Claude Code mods([兼容性页面](../../../../docs/subsystems/claude-code-mods.zh.md))。Pi 的扩展 API 比两者都宽。它允许一个进程内模块改写单次请求的消息、编辑工具参数、追加自己的持久记录,并让已经结束的运行继续。用 DSH 扩展点提供这套 API 可以回答两个问题:为另一个 harness 编写的扩展有多少能原样运行,以及 DSH 的哪些核心机制比 Pi 窄。
|
||||
|
||||
## 提案
|
||||
|
||||
新增一个实验性桥接,加载未修改的 Pi 扩展源码,并用现有的 DSH 扩展点提供 Pi 的 `ExtensionAPI`。该桥接是一次能力演示:凡现有 DSH 机制能承载的行为,就让扩展原样运行;其余部分不提供,并在兼容性页面列出每一处差异。除扩展状态所需的受限插件记录(缺口表第 9 行)外,每项核心缺口都由单独的变更弥补。
|
||||
|
||||
### 包与组合
|
||||
|
||||
- `@deepseek-ai/dsh-experimental-pi-extensions` 是 Host 包,提供 `ctx.piExtensions`。`@deepseek-ai/dsh-experimental-client-ui-pi-extensions` 是 Web 包。两者都不是可选 bundle;与 Claude Code mods 桥接一样,由一个按需启用的 overlay 组合它们。
|
||||
- cordis.yml 的一行指定一个扩展来源:一个文件、一个含 `index.ts` 或 `index.js` 的目录,或一个在 `package.json` 中列出 `pi.extensions` 的 Pi 包目录。该行同时携带扩展的 flag 值。行的顺序就是 Pi 的加载顺序,也是其 handler 的分发顺序。
|
||||
- 桥接不扫描 `~/.pi` 或 `.pi`,不安装包,也不判定项目信任。Pi 包中的 skill、提示词模板与主题不会被加载。
|
||||
|
||||
### 加载与执行
|
||||
|
||||
- 桥接有自己的 loader 与事件分发器。`@earendil-works/pi-coding-agent` 不是依赖。
|
||||
- 源码由 `jiti` 导入,因此 TypeScript 在已构建的安装中也能加载。loader 解析 Pi 提供给扩展的那些导入:`typebox` 与 `@earendil-works/pi-ai` 解析到桥接自己的依赖;`@earendil-works/pi-coding-agent` 解析到桥接的一个模块,它实现了不需要 Pi 运行时的辅助函数(`defineTool`、工具事件类型守卫、截断辅助函数、`withFileMutationQueue`、`getAgentDir`);`@earendil-works/pi-tui` 在部署安装了该可选对等依赖(peer dependency)时解析到它。扩展导入了 loader 无法解析的模块时加载失败,错误信息会指出该模块。
|
||||
- 与 mods 一样,扩展代码在 Host 进程中以进程的全部权限运行。
|
||||
- 每个拥有进程内 Agent 的 Session 都有自己的运行时:每行的模块只求值一次,其工厂函数对每个 Session 运行一次,正如 Pi 为每个 Session 重新运行工厂函数。扩展保存在模块作用域的状态由 Host 进程中的所有 Session 共享;Pi 同样在一个进程的多个 Session 之间保留同一个模块实例,但同一时间只运行一个 Session。subagent 的 Session 有独立的运行时,处于 Pi 的 `print` 模式且 `hasUI: false`,因此 subagent 发起的工具调用与根 Agent 的调用经过同样的扩展 handler。
|
||||
|
||||
### 提供 API
|
||||
|
||||
| Pi API | DSH 机制 |
|
||||
|---|---|
|
||||
| `session_start`、`session_shutdown` | `agent/created`(在首个轮次之前等待完成)、`agent/disposed` |
|
||||
| `input`(在 inbox 准入之后,见第 7 行)、`before_agent_start` | `system-prompt/assemble` 与 `agent/pre-step`:被认领的用户消息可被改写或丢弃,返回的 `systemPrompt` 替换该轮次已组装的提示词,返回的消息加入该步骤 |
|
||||
| `tool_call` | `tools/pre-execute`:`block` 成为拒绝;handler 抛出异常时拒绝该调用 |
|
||||
| `tool_result` | `tools/post-execute`:被替换的内容安装为结果内容 |
|
||||
| `agent_before_settle` | `agent/turn-stopping`:返回的自定义条目被记录,带 `continue` 返回的消息被 steer 进该轮次 |
|
||||
| `agent_start`、`agent_end`、`agent_settled`、`turn_start`、`turn_end`、`message_start`、`message_end`、`tool_execution_start`、`tool_execution_end`、`model_select`、`session_info_changed`、`session_compact` | 会话事件与 `agent/status`,作为通知按顺序送达 |
|
||||
| `registerTool`、`ctx.executeTool` | Agent 作用域的 `ctx.tools` 注册;嵌套调用走完整的工具管线;内置名称遵循下文的转换 |
|
||||
| `registerCommand`、`getCommands` | Agent 作用域的 `ctx.commands` |
|
||||
| `sendUserMessage`、`sendMessage` | `agent.followup()`、`agent.steer()`、`agent.inject()` |
|
||||
| `appendEntry`、`setLabel`、工具 `details`、`ctx.sessionManager` | ignorable Session 记录与 `tool/result` 元数据,读回时还原为 Pi 条目 |
|
||||
| `getActiveTools`、`setActiveTools`、`getAllTools` | `ctx.tools.schemas()`;`setActiveTools` 对全局工具使用作用域内的 `ctx.tools.restrict()`,并增删桥接自己在 Agent 作用域的注册,因为限制不会隐藏作用域内的工具 |
|
||||
| `setModel`、`getThinkingLevel`、`setThinkingLevel` | `agent/request` 覆盖 |
|
||||
| `exec` | `ctx.subprocess`,不经 shell 的 argv |
|
||||
| `ctx.ui.select`、`confirm`、`input`、`editor` | `ctx.userQuestions` |
|
||||
| `ctx.ui.notify`、`setStatus`、`setWidget`、`setTitle`、`setEditorText` | 由 Web 包渲染的 Remote 流 |
|
||||
| `registerFlag`、`getFlag`、`events` | 行配置;每个 Session 运行时一个进程内事件总线 |
|
||||
|
||||
同一个 Session 的所有 Pi handler 都经过一个有序队列。通知不会越过更早的通知;`tool_call` 这类需要等待的钩子,在排在它之前的所有通知之后运行。这样就保持了 Pi 的等待式分发给 handler 的顺序。
|
||||
|
||||
Web 与 Desktop GUI 对扩展呈现为 Pi 的 `rpc` 模式且 `hasUI: true`;headless、SDK 与 ACP Session 呈现为 `print` 模式。扩展本来就处理这两种模式:`ctx.ui.custom()` 这类仅限终端的调用返回 Pi 的 `rpc` 模式所返回的值。
|
||||
|
||||
### Pi 视角下的内置工具调用
|
||||
|
||||
DSH 与 Pi 共有 `bash`、`read`、`edit`、`write` 与 `grep` 这些名称,但参数不同,而 Pi 的 `find` 是 DSH 的 `glob`。Pi 的安全类扩展会读取 `event.input.path` 或 `event.input.command`。因此,桥接把这六个工具的每次调用以 Pi 的名称和 Pi 的参数名呈现给 handler,由 DSH 调用转换而来。其他工具原样传递。有一项测试加载已注册的 DSH 工具 schema,在被映射的参数改名或被移除时失败,在工具新增了映射表尚未归类的参数时也失败。
|
||||
|
||||
反方向使用同一张映射表。`getActiveTools()` 与 `getAllTools()` 对这六个工具报告 Pi 名称,对其他工具报告 DSH 名称;`setActiveTools()` 接受同样的名称。`ctx.executeTool()` 收到 Pi 名称时,把参数转换为 DSH 调用;`edits` 中多于一项的 `edit` 调用会被拒绝,因为一次 DSH `edit` 调用只做一处替换。以 Pi 内置名称注册的扩展工具会替换该 Agent 中对应的 DSH 工具,正如它在 Pi 中替换 Pi 自己的工具(`find` 替换 `glob`)。
|
||||
|
||||
### 不提供的功能
|
||||
|
||||
- DSH 无法触发的事件:注册时接受,永不触发,并在扩展加载时的警告中列出名称。
|
||||
- DSH 无法提供的 `pi` 或 `ctx` 成员:抛出指明该成员的错误。
|
||||
- Pi 在哪里失败关闭,桥接也在哪里失败关闭。编辑 `event.input` 的 `tool_call` handler 会使该调用被拒绝并附带原因,否则已记录的调用会与实际执行的调用不一致。
|
||||
|
||||
### 扩展状态
|
||||
|
||||
Pi 扩展在 `session_start` 时根据自己此前写入的内容重建状态。桥接把这些状态保存在 Session 日志中,使其跟随 resume 与 fork:
|
||||
|
||||
- `appendEntry` 与 `setLabel` 写入类型以 `plugin:` 开头的 ignorable 记录。追加它们的是一项受限的核心操作,只能由实验性包调用。它们不是 `SessionEventMap` 成员,因此持久化目录及其类型历史不变。不带该桥接的构建在读取当前格式时保留并跳过它们,这是 [ignorable 事件决策](../../implemented/architecture/2026-08-30-retain-ignorable-external-session-events.zh.md)的要求。之后的格式迁移边以尽力而为的方式携带它们:能保留时以原名称保留,否则说明丢弃哪些;Alpha 历史格式迁移边则拒绝所有未知事件([决策](../../implemented/architecture/2026-08-31-alpha-historical-unknown-event-refusal.zh.md))。
|
||||
- 工具的 `details` 保存为 `tool/result` 元数据,由工具的输出声明投影得出。
|
||||
- `sendMessage` 的内容记录为普通的用户来源消息,扩展的内容保持不变。一条伴随的 ignorable 记录携带 `customType`、`display` 与 `details`。
|
||||
|
||||
### 比 Pi 窄的核心机制
|
||||
|
||||
按各自阻塞的 Pi API 数量排序,并列出跟踪每一项的 GitHub issue。第 1、3 行源自 DSH 的规则:请求由 Session 日志派生,而日志是线性的。其余各行是缺失的扩展点。
|
||||
|
||||
| # | 机制 | 被阻塞的 Pi API | DSH 现状 | Issue |
|
||||
|---|---|---|---|---|
|
||||
| 1 | 按请求改写模型输入 | `context`、`context_with_system`、`before_provider_request`、`prepareLoadout` 隐藏声明 | 循环从日志派生每个请求并将其冻结;`llm/stream` 监听器只读取它。改写需要一个可等待的钩子,以及一个每个日志读取方都会应用到该请求上的核心事件。 | #5661 |
|
||||
| 2 | 改写工具参数 | 编辑 `event.input` 的 `tool_call` | `PreToolDecision` 不包含输入改写([提案](2026-06-30-pre-tool-input-rewrite.zh.md))。 | #5662 |
|
||||
| 3 | Session 树与 assistant 消息替换 | `navigateTree`、`session_before_tree`、`session_tree`、`message_end` 替换 | 日志是线性的,fork 会创建新的 Session,`assistant/message` 不能替换另一条。 | #5663 |
|
||||
| 4 | 可等待的步骤结束钩子与提交前钩子 | `turn_end` 的条目与 `continue`、`message_end` | `step/end` 与 `assistant/message` 是已提交的事件;会话事件监听器不能追加事件。 | #5664 |
|
||||
| 5 | 工具进度与结果替换 | `onUpdate`、`tool_execution_update`、把错误变为成功的 `tool_result`、工具 `usage` | `ToolRunContext` 没有进度通道,`tools/post-execute` 不能替换失败结果的值。 | #5665 |
|
||||
| 6 | 按工具设置可见性 | `codemode`、`deferred` 与 `hidden` exposure | 呈现方式按 Agent 选择,模型看不到的工具拒绝执行。 | #5666 |
|
||||
| 7 | 输入准入 | 在提示词被保存之前的 `input` | 提示词在 `agent/pre-step` 之前就已持久保存在 inbox 中;被吞掉的提示词会留下一个没有步骤的轮次。 | #5667 |
|
||||
| 8 | 插件发起且被记录的模型调用 | `ctx.modelRegistry.complete`、`stream`、`streamSimple` | 没有会话事件记录插件自己的模型请求,这同样阻塞了 mods 的 `$.model.complete`。 | #5668 |
|
||||
| 9 | 插件拥有的持久记录 | `appendEntry` | `Session.append()` 只接受已声明的事件类型。受限的 `plugin:` 记录为实验性包补上了这一点。 | #5669 |
|
||||
| 10 | 定制压缩 | `session_before_compact` | 摘要由唯一的 `CompactionEngine` 负责,`/compact` 不接受指令。 | #5670 |
|
||||
| 11 | 生命周期原因 | `session_start` 的 `new`、`fork` 与 `reload` 原因;`session_shutdown` 的原因 | `agent/created` 报告 `startup` 或 `resume`;`SessionStartSource` 还声明了尚无生产者的 `clear` 与 `compact`;`agent/disposed` 不带原因。 | #5671 |
|
||||
| 12 | Host 插件在 GUI 中的显示 | `ctx.ui.notify`、`setStatus`、`setWidget` | 没有共享机制;每个桥接各自附带 Client 包。 | #5672 |
|
||||
| 13 | 命令能力 | 参数补全;把用户带到另一个 Session 的 `newSession`、`fork` 与 `switchSession` | `ctx.commands` 接受非结构化文本,没有 Client 侧效果。 | #5673 |
|
||||
| 14 | 提供方请求钩子 | `before_provider_headers`、`after_provider_response`、`provider_stream_event` | 请求头、响应与原始流事件都留在各适配器内部。 | #5674 |
|
||||
| 15 | 故障隔离 | 扩展的缺陷只中止一个 Pi 进程 | Host 中未捕获的异常会为所有 Session 释放整个应用。 | #5675 |
|
||||
|
||||
终端渲染(`ctx.ui.custom`、自定义编辑器、渲染器、快捷键)不在此列:DSH 的 GUI 不是终端,Pi 自己的 `rpc` 模式也不提供这些。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**依赖 `@earendil-works/pi-coding-agent` 并复用其 loader 与 runner。** 这样可以精确复现 Pi 的 handler 顺序与错误规则,并提供扩展导入的全部辅助函数。否决的原因是:这项工作要用 DSH 的机制承载 Pi 的 API,而不是嵌入 Pi 的 harness。该 runner 构造于 Pi 的 `SessionManager` 与 `ModelRegistry` 之上,这个包会带入 Pi 的全部依赖闭包,而且它的内置工具工厂会在 DSH 的文件系统与沙箱策略之外运行 Pi 自己的文件与 shell 实现。
|
||||
|
||||
**像 `defineMod` 那样为每个扩展写一个包装模块。** Pi 扩展本身就是默认导出的工厂函数,没有需要替代的 manifest,因此包装只会给每个扩展多加一步,并且无法直接加载已发布的 Pi 包目录。
|
||||
|
||||
**自动发现 Pi 自己的位置。** 加载 `~/.pi/agent/extensions` 与项目的 `.pi/extensions` 会执行 DSH 用户并未组合进来的代码,这需要 DSH 尚不具备的项目信任判定。mods 桥接出于同样的组合原因移除了它的目录 loader。发现机制可以作为之后按需启用的一层。
|
||||
|
||||
**为每个 Session 在独立进程中运行扩展。** 这样未捕获的扩展错误会以事件的形式到达 Host。但每个需要等待的钩子都会变成一次跨进程往返,Pi 的就地编辑事件对象也必须通过回传变更来复现。待真实扩展表明确实需要这种隔离时再做。
|
||||
|
||||
**为每个 Session 各求值一次扩展模块。** 这样并发的 Session 不会共享模块作用域的状态。否决的原因是:Pi 在一个进程的多个 Session 之间保留同一个模块实例,扩展可能依赖模块状态在切换 Session 后仍然存在;而且按 Session 求值会为每个 Session 与 subagent 重复转译。
|
||||
|
||||
**只为根 Session 运行扩展。** 这需要显式过滤,因为 `agent/created` 对每个 Agent 都会触发;它还会让 subagent 的工具调用绕过 Pi 的安全类扩展。
|
||||
|
||||
**把 DSH 的工具名称与参数原样传给 handler。** Pi 自己的 `protected-paths` 示例读取 `event.input.path`;遇到 DSH 的 `file_path` 时它会抛出异常,而写法稍有不同的 handler 会放行这次写入。
|
||||
|
||||
**声明 `pi/*` 会话事件与 `pi-extension` 消息来源。** 归属是正确的,但两者都是读取时必需的持久化类型:目录与类型历史会改变,没有该桥接的构建会拒绝这个 Session。
|
||||
|
||||
**把扩展状态保存在日志之外的 storage domain 中。** 状态不会跟随 fork,`ctx.sessionManager.getBranch()` 也看不到扩展写入的内容。
|
||||
|
||||
**触发 `context` 与 `before_provider_request` 但不记录其效果。** 模型会看到 Session 日志无法重建的输入。
|
||||
|
||||
**拒绝加载订阅了不提供的事件的扩展。** 多数扩展同时使用多项功能;mods 桥接已经采用“发出警告并让其余部分继续工作”的做法。
|
||||
|
||||
**先构建共享的 GUI 通知机制。** 它也能服务 mods 桥接,但属于核心新增;在它出现之前,本桥接的 Web 包渲染自己的流。
|
||||
|
||||
## 验收标准
|
||||
|
||||
- Pi 以 MIT 许可发布的示例扩展原样提交为 fixture,且只提交有测试覆盖的那些。在使用 mock 模型的真实 agent loop 中:权限扩展拒绝一次工具调用,模型看到原因;模型调用扩展工具,其 `details` 在恢复的 Session 中仍然存在;扩展命令可以运行;扩展替换某个轮次的系统提示词;resume 之后,`appendEntry` 的状态在 `session_start` 时被重建。
|
||||
- subagent 的工具调用到达它自己运行时的 handler。
|
||||
- 被映射的 DSH 工具参数变化时,工具转换测试失败。
|
||||
- Loader 组合测试从 cordis.yml 启动桥接,释放测试表明桥接卸载时每项注册都被移除。
|
||||
- 一个录制的 Session 快照覆盖一次被扩展拒绝的工具调用和一次扩展工具调用。
|
||||
- `docs/subsystems/pi-extensions.md` 列出每个事件与成员及其状态,以及每一处行为差异。
|
||||
- 新包的 `src/` 覆盖率为 100%,`doc-sync` 通过。
|
||||
|
||||
## 风险
|
||||
|
||||
- 扩展代码拥有 Host 的全部权限,一个未捕获的错误会中止所有 Session。只挂载你愿意作为插件运行的扩展。
|
||||
- 保真度取决于桥接对 Pi 1.0.0 分发规则的理解;它经过 Pi 示例的检验,但没有在 Pi 的代码上执行。之后的 Pi 版本可能改变桥接所提供的事件或辅助函数。
|
||||
- 扩展导入了桥接未实现的辅助函数时会加载失败,即使它只在某一条代码路径上用到该函数。
|
||||
- 无法携带插件记录的未来 Session 格式迁移边,会使迁移之前写入的 Session 丢失扩展状态。
|
||||
- 把按 Session 区分的状态放在模块作用域的扩展,会混用并发 Session(包括 subagent)的状态。
|
||||
- 扩展发送的消息,在读取伴随记录的 Web 包之外的所有地方,都归属于用户。
|
||||
@@ -0,0 +1,46 @@
|
||||
# Bilingual-pair consistency record for 2026-10-03-pi-plugins-compatibility.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/proposed/feature/2026-10-03-pi-plugins-compatibility.md
|
||||
/agent-note-pi-plugins-compatibility-levels-of-support:
|
||||
en: 68e63fd54584ecbc
|
||||
zh: e1cedbf64975bc53
|
||||
/agent-note-pi-plugins-compatibility-levels-of-support/problem:
|
||||
en: f98ddabdc0ea831b
|
||||
zh: e695439507286576
|
||||
/agent-note-pi-plugins-compatibility-levels-of-support/proposal:
|
||||
en: 37a27883d679bb6a
|
||||
zh: 62c60e0cd06bf8fb
|
||||
/agent-note-pi-plugins-compatibility-levels-of-support/what-a-pi-plugin-is:
|
||||
en: fd37c3aa5d6c092b
|
||||
zh: e39a84ef9aabaa48
|
||||
/agent-note-pi-plugins-compatibility-levels-of-support/level-1-session-facets:
|
||||
en: 5e08e808af0a741a
|
||||
zh: eae79e9c8f1fc301
|
||||
/agent-note-pi-plugins-compatibility-levels-of-support/level-2-presentation-facets-on-the-host:
|
||||
en: 6a4b96426808d653
|
||||
zh: c341dd2ba594ed45
|
||||
/agent-note-pi-plugins-compatibility-levels-of-support/level-3-transcript-and-stored-selection:
|
||||
en: defc273e0759bb9f
|
||||
zh: 59a4cf0dd0b46faa
|
||||
/agent-note-pi-plugins-compatibility-levels-of-support/level-4-presentation-facets-in-the-browser:
|
||||
en: 5b2fcfdb38eb6121
|
||||
zh: 1d583d9a6486d2dd
|
||||
/agent-note-pi-plugins-compatibility-levels-of-support/level-5-parity:
|
||||
en: 657aa240a06f663f
|
||||
zh: 74ca5260a5a3d215
|
||||
/agent-note-pi-plugins-compatibility-levels-of-support/runtime-differences-the-plugin-model-exposes:
|
||||
en: 114b54fae1ff94cd
|
||||
zh: 0d0dde9f0563b27b
|
||||
/agent-note-pi-plugins-compatibility-levels-of-support/chord-mechanisms-to-evaluate-for-dsh:
|
||||
en: e44ed4dcd7fc9eb9
|
||||
zh: 825a41daa69931f9
|
||||
/agent-note-pi-plugins-compatibility-levels-of-support/alternatives-considered:
|
||||
en: 390883362b220cfb
|
||||
zh: 1c072dd19744a92e
|
||||
/agent-note-pi-plugins-compatibility-levels-of-support/acceptance-criteria:
|
||||
en: 386ae81c30ec7cfc
|
||||
zh: 6f8bc4257fa8898c
|
||||
/agent-note-pi-plugins-compatibility-levels-of-support/risks:
|
||||
en: b6410c36a55acc47
|
||||
zh: e7de4e26be7a3c58
|
||||
@@ -0,0 +1,140 @@
|
||||
# Agent Note: Pi plugins compatibility — levels of support
|
||||
|
||||
Status: proposed
|
||||
|
||||
English | [中文](2026-10-03-pi-plugins-compatibility.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Pi 1.0.0 has two separate mechanisms for running customization code. Extensions are in-process TypeScript factories with a published API; the [Pi extensions bridge note](2026-10-03-pi-extensions-bridge.md) covers them. Plugins are a newer mechanism built on Pi's Chord composition runtime: a plugin package contributes facets, and each facet runs in the process its name selects. This note covers plugins.
|
||||
|
||||
A compatibility layer for Pi plugins is not worth building today, for two reasons the upstream sources state themselves.
|
||||
|
||||
- **The API is unpublished and unstable.** The plugin stack runs only with `PI_EXPERIMENTAL=1`. The `./experimental/plugin` export of `@earendil-works/pi-coding-agent` has only a `source` condition, and the npm tarball excludes its build output. Chord's plan says it "is not a stable public API contract yet" ([PLANNING.md](https://github.com/earendil-works/pi/blob/v1.0.0/packages/chord/PLANNING.md)), and pi-durable, which the Session worker runs on, says its "API changes without notice between releases" ([README](https://github.com/earendil-works/pi/blob/v1.0.0/packages/durable/README.md)). The [services README](https://github.com/earendil-works/pi/blob/v1.0.0/packages/coding-agent/src/experimental/services/README.md) lists `AgentController.navigate()`, `nextRun()`, and `resume()` as dropped.
|
||||
- **A plugin can do less than an extension.** The Session worker builds its tool, hook, and prompt registry itself and does not expose it as a service, so a facet cannot register a model tool, intercept a tool call, or change the prompt. Pi ships one example plugin.
|
||||
|
||||
Deferring the work without a record loses two results of the investigation: the mapping from Pi's plugin services onto DSH services, which decides what each increment of support costs, and the DSH runtime properties that differ from Chord's, which matter to DSH independently of Pi.
|
||||
|
||||
## Proposal
|
||||
|
||||
Treat Pi-plugin compatibility as six levels. Each level names what a plugin author gets, the upstream change that makes it worth building, and the DSH work it needs. DSH stays at level 0 until the trigger of a higher level occurs. A level includes the levels below it, except that level 4 replaces the Host-side presentation of level 2.
|
||||
|
||||
| Level | Support | Upstream trigger | DSH work |
|
||||
|---|---|---|---|
|
||||
| 0 | None. This note is the only artifact. | — | Re-read the upstream status when Pi releases. |
|
||||
| 1 | `session` facets run in one Chord host per DSH Session. `AgentController`, `Models`, and `SessionPlugins` are served over DSH services. | Pi ships the plugin API in its npm package, or removes the `PI_EXPERIMENTAL` requirement. | One experimental Host package. No core change. |
|
||||
| 2 | `tui` facets run on the Host. Their slash commands become DSH commands, `select` becomes a user question, and `showStatus` a status line. Pi's example plugin runs unmodified. | Same as level 1. | Adapters, plus the Web status display of the Pi extensions bridge. |
|
||||
| 3 | `Transcript` is served, and a Session's plugin selection is stored with the Session. | A plugin that consumes `Transcript` exists, or pi-durable declares `ConversationView` stable. | A projection from the Session log to `ConversationView`, and a per-Session selection record. |
|
||||
| 4 | `tui` facets run in the browser and reach Session services through a generic Chord bridge. | Chord commits to browser support, and Pi defines a presentation facet that is not terminal-specific. | One generic JSON Remote endpoint, a browser facet loader, and argument text for Client commands. |
|
||||
| 5 | Parity: one worker per Session, and plugins register model tools and hooks. | Pi exposes its tool and hook registry to facets. | Per-Session process isolation, and the tool and hook mappings of the Pi extensions bridge. |
|
||||
|
||||
## What a Pi plugin is
|
||||
|
||||
A plugin is a package directory. Its `package.json` may carry `chord.facets`, which maps a facet name to an entry file or disables it; without the field, Pi uses `src/session.ts` and `src/tui.ts` when they exist. An entry default-exports `{ id, setup(env) }`. `setup` is synchronous and only declares: `env.use` and `env.observe` acquire services, `env.provide` and `env.provideMany` publish them, `env.replicatedState` creates shared state, and `env.own`, `env.onActivate`, and `env.onDeactivate` attach lifecycle work. The Chord host validates the complete dependency graph, activates providers before consumers, and can replace a facet whose declared services are unchanged while consumers keep their service handles.
|
||||
|
||||
The server builds each entry with Chord's esbuild bundler into a content-addressed CommonJS file listed in `chord-facets.json`. The Session worker loads the `session` entry with `node:vm` after checking its SHA-256 digest, and `require` resolves only the externals the entry declared. The `tui` entry reaches each client as a JSON artifact in a service response and loads the same way. The loader is not a sandbox: plugin code runs with the process's full authority.
|
||||
|
||||
`pi server -e <package>` sets the server's default plugins, and `pi client -e <package>` selects plugins for one Session. Pi stores a Session's selection and refuses a different selection while that Session's worker is running.
|
||||
|
||||
Pi serves these to facets:
|
||||
|
||||
| Scope | Service | Members |
|
||||
|---|---|---|
|
||||
| server | `SessionDirectory`, `SessionManagement`, `PresentationPlugins` | Session list state; create, remove, attach, detach; build and reload of presentation artifacts |
|
||||
| session | `AgentController` | `prompt`, `steer`, `followUp`, `cancelQueued`, `abort`, `compact`, `waitForPrompt` |
|
||||
| session | `Models` | replicated catalog and selection state; `select`, `selectThinking`, `cycleThinking`, `getThinkingLevels`, `refresh` |
|
||||
| session | `Transcript` | the root conversation's pi-durable `ConversationView` as replicated state |
|
||||
| session | `SessionPlugins` | `reload` |
|
||||
| presentation, process-local | `SlashCommands` | `register`, `replace`, `list`, `subscribe`; a command has a name, description, argument hint, optional argument completions, and `run(args, context)` |
|
||||
| presentation, process-local | `PresentationUI` | `select(title, items, selected, context)`, `showStatus(message, context)` |
|
||||
|
||||
A service that is not process-local may contain only replicated JSON state and methods whose arguments and result are JSON and whose last parameter is a Chord `Context`.
|
||||
|
||||
## Level 1: session facets
|
||||
|
||||
The package depends on `@earendil-works/chord` at one exact version. It builds each selected package with `bundleFacetPackage` into a cache under the Harness home and loads the `session` entry with `createFacetBundleLoader`. Chord neither installs dependencies nor runs lifecycle scripts, so a package arrives with its dependencies installed.
|
||||
|
||||
The loader resolves two externals itself. `@earendil-works/chord` resolves to the package's own Chord instance, because Chord identifies replicated state through a registry held by the module instance. `@earendil-works/pi-coding-agent/experimental/plugin` resolves to a module that defines service tokens with Pi's IDs (`pi.agent-controller`, `pi.local.presentation-ui`, `pi.local.slash-commands`); Chord matches services by ID string, so a token defined outside Pi binds to a facet compiled against Pi's.
|
||||
|
||||
The package creates one Chord host per Agent in `agent/created` and disposes it with the Agent's context. Built-in facets serve Pi's Session services:
|
||||
|
||||
| Pi member | DSH mechanism | Difference |
|
||||
|---|---|---|
|
||||
| `AgentController.prompt` | `agent.followup()` | Refusal with `busy` is derived from `agent.status`. |
|
||||
| `steer`, `followUp` | `agent.steer()`, `agent.followup()` | None. |
|
||||
| `cancelQueued` | `agent.inbox.remove()` | Telling `already_consumed` from `not_found` reads the Session log. |
|
||||
| `abort` | `agent.cancel()` | None. |
|
||||
| `compact` | `ctx.compaction.compactNow()` | Non-null `customInstructions` is refused; DSH compaction takes none. |
|
||||
| `waitForPrompt` | A fold over Session events from the queued message to its `turn/end` | DSH does not link a prompt to its answer; the fold does. |
|
||||
| `Models` | The model catalog and model selection of `session-controller` | Reasoning-effort identifiers stand in for Pi's thinking levels. |
|
||||
| `SessionPlugins.reload` | Rebuild, `FacetHost.reload()`, then dispose the retired generation | None. |
|
||||
|
||||
`Transcript` is not provided at this level. Chord rejects a host whose facet requires a service nobody provides, so a plugin that uses `Transcript` fails to load with an error naming it.
|
||||
|
||||
## Level 2: presentation facets on the Host
|
||||
|
||||
Pi's `tui` facets use no terminal API. They consume `SlashCommands`, `PresentationUI`, and Session services, so they can run in a second Chord host per Agent on the DSH Host, bound in-process to the Session host.
|
||||
|
||||
A registered slash command becomes an Agent-scoped `ctx.commands` registration whose handler passes the raw input as `args`. A command run on the Host is logged as `command/run` and `command/done`; Pi does not log presentation commands. `getArgumentCompletions` has no DSH counterpart and is not called. `PresentationUI.select` maps to `ctx.userQuestions.ask()` and needs a connected Web Client; `showStatus` maps to the status display the Pi extensions bridge adds to the Web Client.
|
||||
|
||||
## Level 3: transcript and stored selection
|
||||
|
||||
`Transcript` requires a projection from DSH Session events to pi-durable's `ConversationView`: entries in pi-durable's entry kinds plus its live, inbox, agent, and usage documents. The projection is lossy where DSH records events Pi has no entry for, and it binds DSH to a type pi-durable declares unstable.
|
||||
|
||||
A Session's plugin selection is stored as a `plugin:`-prefixed ignorable Session record, the mechanism the Pi extensions bridge uses for extension state, and a live Agent's selection is fixed, as in Pi.
|
||||
|
||||
## Level 4: presentation facets in the browser
|
||||
|
||||
Running `tui` facets in the page matches Pi's placement of presentation code. It needs three additions. A browser loader evaluates the CommonJS artifact, because Chord's artifact loader uses the filesystem and `node:vm`. One generic Remote endpoint carries Chord's service calls, snapshots, and updates as JSON between the page and the Host, because DSH generates Remote methods at build time and the Client does not discover Host services at runtime. Client commands accept argument text, which `ui-commands` actions do not receive today.
|
||||
|
||||
## Level 5: parity
|
||||
|
||||
Pi isolates each Session in a worker process and plans to give plugins tools and hooks through its registry. Matching that needs a DSH Session to run in its own process or worker thread with Chord's service protocol across the process boundary, and, once Pi exposes the registry, the tool, hook, and prompt mappings that the Pi extensions bridge already defines.
|
||||
|
||||
## Runtime differences the plugin model exposes
|
||||
|
||||
| Property | Chord and Pi | DSH | Levels affected |
|
||||
|---|---|---|---|
|
||||
| Provider replacement | A consumer holds a handle that resolves the current provider on each access; a reload swaps the implementation behind it. | A fiber whose injected service changes is unloaded and re-run. | 1: the Chord host keeps Chord's behavior for services between facets; a DSH service mapped into Chord still restarts its adapter. |
|
||||
| Fault isolation | One worker process per Session. | All Sessions share the Host process, and an uncaught exception or unhandled rejection disposes the application ([app-boot](../../../../packages/boot/app-boot/README.md)). | 1–4 run plugin code in the Host; 5 removes the difference. |
|
||||
| Remote declaration | A host publishes a service's remote members from the object it was given at runtime. | Remote methods are generated at build time, and the Client does not discover Host services at runtime ([remotes](../../../../packages/api/remotes/README.md)). | 4 |
|
||||
| Shared state | Replicated JSON state with operation batches, hydration, and gap recovery, read through one API locally and remotely. | Each domain defines its own snapshot and delta types, or publishes complete projection values. | 3, 4 |
|
||||
| Runtime loading | One bundle per facet, loaded outside the module cache after an integrity check. | Packages are built ahead of time; a built installation does not transpile TypeScript. | 1 adds Chord's bundler as a dependency of the plugin package. |
|
||||
|
||||
## Chord mechanisms to evaluate for DSH
|
||||
|
||||
Four Chord mechanisms address limits DSH has independently of Pi. Each needs its own proposal; this note only records them.
|
||||
|
||||
- **Stable service handles.** A consumer keeps its handle while a provider is replaced, so a plugin reload does not restart its dependents and lose their state.
|
||||
- **Declaration before activation.** Setup declares every dependency synchronously, and the host rejects a missing provider, a duplicate provider, or a cycle before any facet activates. Cordis resolves dependencies as services appear, so a fiber with a missing provider stays pending until an audit after startup reports it.
|
||||
- **Replicated JSON state.** One primitive gives local and remote consumers complete immutable values, with operation batches on the wire and a full reset when a subscriber falls behind.
|
||||
- **Verified plugin artifacts.** A content-addressed bundle with a manifest digest lets a host load a plugin generation outside the module cache and release it when retired.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Build levels 1 and 2 now as a prototype.** The API is unpublished, has one example plugin, and its services README already lists three `AgentController` methods as dropped. A prototype would need maintenance against each Pi release without a user. The service mapping above keeps the design result without that cost.
|
||||
|
||||
**Skip the record and decide when Pi stabilizes.** The service mapping and the runtime comparison took a full reading of Chord and Pi's experimental server, worker, and client. The runtime differences also apply to DSH work that has nothing to do with Pi.
|
||||
|
||||
**Implement Pi's plugin services on Cordis without Chord.** A facet calls Chord's API directly: `env.use`, `env.provide`, and `replicatedState`. Serving that API without Chord means reimplementing graph validation, stable handles, keyed services, and replicated state. Chord is MIT-licensed, has one runtime dependency, and is the runtime plugins are compiled against.
|
||||
|
||||
**Add plugins to the Pi extensions bridge package.** The two mechanisms share no API and have different stability. Separate packages let each be enabled, versioned, and removed without the other.
|
||||
|
||||
**Run presentation facets only in the browser.** Level 2 reuses Pi's Node artifacts unchanged and needs no browser loader. Browser support is an open decision in Chord's plan, and Pi has no presentation facet that is not terminal-specific.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- **Level 0:** this note is merged, and the Pi extensions compatibility page links to it for the plugin mechanism.
|
||||
- **Level 1:** the `session` facet of Pi's example plugin loads from its unmodified package directory. A test calls its greeting service and every served `AgentController` member against a real agent loop with a mock model. `SessionPlugins.reload` replaces the facet generation without recreating the Chord host, and disposing the Agent disposes the host.
|
||||
- **Level 2:** `/hello <name>` from Pi's example plugin runs through `ctx.commands`, shows its status text in the Web Client, and submits its prompt.
|
||||
- **Level 3:** a facet that subscribes to `Transcript` receives the root conversation after hydration and after each committed step, and a resumed Session loads the plugins it was created with.
|
||||
- **Level 4:** the `tui` facet of Pi's example plugin runs in the browser, and its service calls reach the Host through the generic endpoint.
|
||||
- **Every level:** a compatibility page lists each difference from Pi, and the package's README states that plugin code runs in the Host with its full authority.
|
||||
|
||||
## Risks
|
||||
|
||||
- The upstream API can change or be withdrawn before a level is built. The service mapping describes Pi 1.0.0 and needs a re-read against the release that triggers the work.
|
||||
- Levels 1 to 4 run plugin code in the Host process, where one uncaught error stops every Session.
|
||||
- Level 3 depends on a pi-durable type that upstream declares unstable.
|
||||
- Level 4 adds a Remote endpoint whose payloads are validated by Chord's wire parsers instead of generated per-method codecs.
|
||||
- The plugin package brings esbuild as a runtime dependency through Chord's bundler.
|
||||
@@ -0,0 +1,140 @@
|
||||
# Agent Note: Pi 插件兼容——分级支持
|
||||
|
||||
Status: proposed
|
||||
|
||||
[English](2026-10-03-pi-plugins-compatibility.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
Pi 1.0.0 有两套相互独立、用于运行定制代码的机制。扩展是进程内的 TypeScript 工厂函数,API 已发布,由 [Pi 扩展桥接 Agent Note](2026-10-03-pi-extensions-bridge.zh.md) 负责。插件是较新的机制,构建在 Pi 的 Chord 组合运行时之上:一个插件包贡献若干 facet,每个 facet 运行在其名称选定的进程里。本 Agent Note 只讨论插件。
|
||||
|
||||
现在还不值得为 Pi 插件构建兼容层,上游资料自己给出了两条理由。
|
||||
|
||||
- **API 未发布且不稳定。** 插件栈只在 `PI_EXPERIMENTAL=1` 下运行。`@earendil-works/pi-coding-agent` 的 `./experimental/plugin` 导出只有 `source` 条件,npm tarball 不包含它的构建产物。Chord 的规划文档称它“is not a stable public API contract yet”([PLANNING.md](https://github.com/earendil-works/pi/blob/v1.0.0/packages/chord/PLANNING.md));Session worker 所基于的 pi-durable 称其“API changes without notice between releases”([README](https://github.com/earendil-works/pi/blob/v1.0.0/packages/durable/README.md))。[services README](https://github.com/earendil-works/pi/blob/v1.0.0/packages/coding-agent/src/experimental/services/README.md) 把 `AgentController.navigate()`、`nextRun()` 与 `resume()` 列为已移除。
|
||||
- **插件能做的事比扩展少。** Session worker 自行构建工具、钩子与提示词注册表,不把它暴露为服务,因此 facet 无法注册模型工具、拦截工具调用或修改提示词。Pi 只附带一个示例插件。
|
||||
|
||||
如果不留记录就搁置这项工作,会丢失调研得到的两项结果:Pi 插件服务到 DSH 服务的映射,它决定每一级支持的成本;以及 DSH 运行时与 Chord 的差异,这些差异与 Pi 无关,对 DSH 自身同样重要。
|
||||
|
||||
## 提案
|
||||
|
||||
把 Pi 插件兼容划分为六个级别。每一级说明插件作者能得到什么、哪项上游变化使它值得构建,以及需要哪些 DSH 工作。在更高级别的触发条件出现之前,DSH 停留在 0 级。每一级包含其下各级,唯一例外是 4 级取代 2 级在 Host 侧的 presentation 实现。
|
||||
|
||||
| 级别 | 支持内容 | 上游触发条件 | DSH 工作 |
|
||||
|---|---|---|---|
|
||||
| 0 | 无。本 Agent Note 是唯一产物。 | — | Pi 发版时重新核对上游状态。 |
|
||||
| 1 | `session` facet 运行在每个 DSH Session 各自的 Chord host 中。`AgentController`、`Models` 与 `SessionPlugins` 由 DSH 服务提供。 | Pi 在 npm 包中发布插件 API,或取消 `PI_EXPERIMENTAL` 要求。 | 一个实验性 Host 包。不改核心。 |
|
||||
| 2 | `tui` facet 运行在 Host 上。它们的斜杠命令成为 DSH 命令,`select` 成为用户提问,`showStatus` 成为状态行。Pi 的示例插件原样运行。 | 同 1 级。 | 适配器,以及 Pi 扩展桥接的 Web 状态显示。 |
|
||||
| 3 | 提供 `Transcript`,并把 Session 的插件选择随 Session 保存。 | 出现消费 `Transcript` 的插件,或 pi-durable 宣布 `ConversationView` 稳定。 | 从 Session 日志到 `ConversationView` 的投影,以及按 Session 保存的选择记录。 |
|
||||
| 4 | `tui` facet 运行在浏览器中,经通用 Chord 桥访问 Session 服务。 | Chord 承诺支持浏览器,且 Pi 定义了不依赖终端的 presentation facet。 | 一个通用 JSON Remote 端点、一个浏览器 facet loader,以及 Client 命令的参数文本。 |
|
||||
| 5 | 对等:每个 Session 一个 worker,插件可注册模型工具与钩子。 | Pi 把工具与钩子注册表暴露给 facet。 | 按 Session 的进程隔离,以及 Pi 扩展桥接已有的工具与钩子映射。 |
|
||||
|
||||
## Pi 插件是什么
|
||||
|
||||
插件是一个包目录。它的 `package.json` 可以带 `chord.facets`,把 facet 名称映射到入口文件或将其禁用;没有该字段时,Pi 在 `src/session.ts` 与 `src/tui.ts` 存在的情况下使用它们。入口默认导出 `{ id, setup(env) }`。`setup` 是同步的,只做声明:`env.use` 与 `env.observe` 获取服务,`env.provide` 与 `env.provideMany` 发布服务,`env.replicatedState` 创建共享状态,`env.own`、`env.onActivate` 与 `env.onDeactivate` 挂接生命周期工作。Chord host 校验完整的依赖图,先激活提供方再激活消费方,并且能在消费方保留服务句柄的同时替换所声明服务不变的 facet。
|
||||
|
||||
服务器用 Chord 的 esbuild 打包器把每个入口构建为内容寻址的 CommonJS 文件,并登记在 `chord-facets.json` 中。Session worker 校验 SHA-256 摘要后用 `node:vm` 加载 `session` 入口,`require` 只解析该入口声明过的外部依赖。`tui` 入口作为 JSON 制品随服务响应到达各客户端,并以同样方式加载。该 loader 不是沙箱:插件代码以进程的全部权限运行。
|
||||
|
||||
`pi server -e <package>` 设置服务器的默认插件,`pi client -e <package>` 为单个 Session 选择插件。Pi 保存 Session 的选择,并在该 Session 的 worker 运行期间拒绝不同的选择。
|
||||
|
||||
Pi 向 facet 提供以下服务:
|
||||
|
||||
| 作用域 | 服务 | 成员 |
|
||||
|---|---|---|
|
||||
| server | `SessionDirectory`、`SessionManagement`、`PresentationPlugins` | Session 列表状态;创建、删除、attach、detach;presentation 制品的构建与重载 |
|
||||
| session | `AgentController` | `prompt`、`steer`、`followUp`、`cancelQueued`、`abort`、`compact`、`waitForPrompt` |
|
||||
| session | `Models` | 可复制的目录与选择状态;`select`、`selectThinking`、`cycleThinking`、`getThinkingLevels`、`refresh` |
|
||||
| session | `Transcript` | 根对话的 pi-durable `ConversationView`,以可复制状态提供 |
|
||||
| session | `SessionPlugins` | `reload` |
|
||||
| presentation,进程内 | `SlashCommands` | `register`、`replace`、`list`、`subscribe`;命令包含名称、描述、参数提示、可选的参数补全,以及 `run(args, context)` |
|
||||
| presentation,进程内 | `PresentationUI` | `select(title, items, selected, context)`、`showStatus(message, context)` |
|
||||
|
||||
非进程内的服务只能包含可复制的 JSON 状态,以及参数与结果均为 JSON、最后一个参数为 Chord `Context` 的方法。
|
||||
|
||||
## 1 级:session facet
|
||||
|
||||
该包依赖某一精确版本的 `@earendil-works/chord`。它用 `bundleFacetPackage` 把每个选中的包构建到 Harness 主目录下的缓存,并用 `createFacetBundleLoader` 加载 `session` 入口。Chord 既不安装依赖也不运行生命周期脚本,因此包到达时必须已装好依赖。
|
||||
|
||||
loader 自行解析两个外部依赖。`@earendil-works/chord` 解析为该包自己的 Chord 实例,因为 Chord 通过模块实例持有的注册表识别可复制状态。`@earendil-works/pi-coding-agent/experimental/plugin` 解析为一个模块,它用 Pi 的 ID(`pi.agent-controller`、`pi.local.presentation-ui`、`pi.local.slash-commands`)定义服务 token;Chord 按 ID 字符串匹配服务,因此在 Pi 之外定义的 token 可以绑定到按 Pi 的 token 编译的 facet。
|
||||
|
||||
该包在 `agent/created` 中为每个 Agent 创建一个 Chord host,并随 Agent 的上下文一同释放。内置 facet 提供 Pi 的 Session 服务:
|
||||
|
||||
| Pi 成员 | DSH 机制 | 差异 |
|
||||
|---|---|---|
|
||||
| `AgentController.prompt` | `agent.followup()` | 以 `busy` 拒绝的行为由 `agent.status` 推导。 |
|
||||
| `steer`、`followUp` | `agent.steer()`、`agent.followup()` | 无。 |
|
||||
| `cancelQueued` | `agent.inbox.remove()` | 区分 `already_consumed` 与 `not_found` 需要读取 Session 日志。 |
|
||||
| `abort` | `agent.cancel()` | 无。 |
|
||||
| `compact` | `ctx.compaction.compactNow()` | 拒绝非空的 `customInstructions`;DSH 压缩不接受指令。 |
|
||||
| `waitForPrompt` | 对 Session 事件做 fold,从入队消息到它的 `turn/end` | DSH 不关联提示词与其回答;由该 fold 完成关联。 |
|
||||
| `Models` | `session-controller` 的模型目录与模型选择 | 以推理强度标识符代替 Pi 的 thinking level。 |
|
||||
| `SessionPlugins.reload` | 重新构建、调用 `FacetHost.reload()`,再释放退役的 generation | 无。 |
|
||||
|
||||
本级不提供 `Transcript`。Chord 会拒绝含有“所需服务无人提供”的 facet 的 host,因此使用 `Transcript` 的插件加载失败,错误信息会指出该服务。
|
||||
|
||||
## 2 级:Host 上的 presentation facet
|
||||
|
||||
Pi 的 `tui` facet 不使用任何终端 API。它们消费 `SlashCommands`、`PresentationUI` 与 Session 服务,因此可以运行在 DSH Host 上每个 Agent 的第二个 Chord host 中,并在进程内绑定到 Session host。
|
||||
|
||||
注册的斜杠命令成为 Agent 作用域的 `ctx.commands` 注册,其 handler 把原始输入作为 `args` 传入。在 Host 上运行的命令会记录为 `command/run` 与 `command/done`;Pi 不记录 presentation 命令。`getArgumentCompletions` 在 DSH 中没有对应物,不会被调用。`PresentationUI.select` 映射到 `ctx.userQuestions.ask()`,需要已连接的 Web Client;`showStatus` 映射到 Pi 扩展桥接为 Web Client 增加的状态显示。
|
||||
|
||||
## 3 级:transcript 与保存的选择
|
||||
|
||||
`Transcript` 需要从 DSH Session 事件到 pi-durable `ConversationView` 的投影:按 pi-durable 条目类型组织的条目,加上它的 live、inbox、agent 与 usage 文档。DSH 记录了 Pi 没有对应条目的事件,在这些地方投影是有损的;它还把 DSH 绑定到一个被 pi-durable 声明为不稳定的类型上。
|
||||
|
||||
Session 的插件选择保存为带 `plugin:` 前缀的 ignorable Session 记录,即 Pi 扩展桥接保存扩展状态所用的机制;与 Pi 一样,存活 Agent 的选择固定不变。
|
||||
|
||||
## 4 级:浏览器中的 presentation facet
|
||||
|
||||
在页面中运行 `tui` facet 与 Pi 对 presentation 代码的放置一致。它需要三项新增。浏览器 loader 负责求值 CommonJS 制品,因为 Chord 的制品 loader 使用文件系统与 `node:vm`。一个通用 Remote 端点以 JSON 在页面与 Host 之间承载 Chord 的服务调用、快照与更新,因为 DSH 在构建时生成 Remote 方法,Client 不在运行时发现 Host 服务。Client 命令需要接受参数文本,目前 `ui-commands` 的 action 收不到它。
|
||||
|
||||
## 5 级:对等
|
||||
|
||||
Pi 把每个 Session 隔离在一个 worker 进程中,并计划通过其注册表向插件提供工具与钩子。要达到这一点,DSH Session 需要运行在自己的进程或 worker 线程中,并让 Chord 的服务协议跨越进程边界;在 Pi 暴露注册表之后,还需要 Pi 扩展桥接已经定义的工具、钩子与提示词映射。
|
||||
|
||||
## 插件模型暴露出的运行时差异
|
||||
|
||||
| 属性 | Chord 与 Pi | DSH | 受影响的级别 |
|
||||
|---|---|---|---|
|
||||
| 提供方替换 | 消费方持有句柄,每次访问时解析当前提供方;重载在句柄背后替换实现。 | 所注入服务发生变化的 fiber 会被卸载并重新运行。 | 1:Chord host 对 facet 之间的服务保持 Chord 的行为;映射进 Chord 的 DSH 服务仍会重启其适配器。 |
|
||||
| 故障隔离 | 每个 Session 一个 worker 进程。 | 所有 Session 共享 Host 进程,未捕获的异常或未处理的 rejection 会释放整个应用([app-boot](../../../../packages/boot/app-boot/README.zh.md))。 | 1–4 在 Host 中运行插件代码;5 消除这一差异。 |
|
||||
| Remote 声明 | host 根据运行时拿到的对象发布服务的远程成员。 | Remote 方法在构建时生成,Client 不在运行时发现 Host 服务([remotes](../../../../packages/api/remotes/README.zh.md))。 | 4 |
|
||||
| 共享状态 | 可复制的 JSON 状态,带操作批次、hydration 与缺口恢复,本地与远程通过同一 API 读取。 | 每个领域定义自己的快照与增量类型,或发布完整的投影值。 | 3、4 |
|
||||
| 运行时加载 | 每个 facet 一个 bundle,经完整性校验后在模块缓存之外加载。 | 包提前构建;已构建的安装不转译 TypeScript。 | 1 把 Chord 的打包器加为插件包的依赖。 |
|
||||
|
||||
## 值得为 DSH 评估的 Chord 机制
|
||||
|
||||
有四项 Chord 机制针对的是 DSH 自身存在、与 Pi 无关的限制。每一项都需要单独的提案;本 Agent Note 只做记录。
|
||||
|
||||
- **稳定的服务句柄。** 提供方被替换时消费方保留句柄,因此插件重载不会重启其依赖方并丢失它们的状态。
|
||||
- **先声明后激活。** setup 同步声明全部依赖,host 在任何 facet 激活之前拒绝缺失的提供方、重复的提供方或循环依赖。Cordis 随服务出现逐步解析依赖,因此缺少提供方的 fiber 会一直挂起,直到启动后的审计报告它。
|
||||
- **可复制的 JSON 状态。** 一个原语为本地与远程消费方提供完整的不可变值,线上传输操作批次,订阅方落后时做一次完整重置。
|
||||
- **经过校验的插件制品。** 带 manifest 摘要的内容寻址 bundle 让 host 在模块缓存之外加载某一代插件,并在其退役后释放。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
**现在就把 1、2 级做成原型。** 该 API 未发布,只有一个示例插件,其 services README 已把三个 `AgentController` 方法列为已移除。原型需要跟随 Pi 的每次发版维护,却没有用户。上文的服务映射保留了设计结论,而不必付出这项成本。
|
||||
|
||||
**不留记录,等 Pi 稳定后再决定。** 服务映射与运行时对比来自对 Chord 以及 Pi 实验性 server、worker 与 client 的完整阅读。这些运行时差异同样适用于与 Pi 无关的 DSH 工作。
|
||||
|
||||
**不用 Chord,直接在 Cordis 上实现 Pi 的插件服务。** facet 直接调用 Chord 的 API:`env.use`、`env.provide` 与 `replicatedState`。不用 Chord 而提供这套 API,意味着重新实现依赖图校验、稳定句柄、keyed 服务与可复制状态。Chord 采用 MIT 许可,只有一个运行时依赖,而且插件正是针对它编译的。
|
||||
|
||||
**把插件并入 Pi 扩展桥接包。** 两套机制没有共同的 API,稳定性也不同。分成独立的包,才能各自启用、定版与移除。
|
||||
|
||||
**只在浏览器中运行 presentation facet。** 2 级原样复用 Pi 的 Node 制品,不需要浏览器 loader。浏览器支持在 Chord 的规划中仍是未决事项,Pi 也没有不依赖终端的 presentation facet。
|
||||
|
||||
## 验收标准
|
||||
|
||||
- **0 级:** 本 Agent Note 已合并,Pi 扩展兼容性页面在插件机制处链接到它。
|
||||
- **1 级:** Pi 示例插件的 `session` facet 从未修改的包目录加载。测试在使用 mock 模型的真实 agent loop 上调用它的问候服务以及每个已提供的 `AgentController` 成员。`SessionPlugins.reload` 替换 facet generation 而不重建 Chord host,释放 Agent 时 host 随之释放。
|
||||
- **2 级:** Pi 示例插件的 `/hello <name>` 经 `ctx.commands` 运行,在 Web Client 中显示其状态文本,并提交其提示词。
|
||||
- **3 级:** 订阅 `Transcript` 的 facet 在 hydration 之后以及每个已提交的步骤之后收到根对话,恢复的 Session 加载其创建时所用的插件。
|
||||
- **4 级:** Pi 示例插件的 `tui` facet 在浏览器中运行,其服务调用经通用端点到达 Host。
|
||||
- **每一级:** 兼容性页面列出与 Pi 的每一处差异,包的 README 说明插件代码以 Host 的全部权限运行。
|
||||
|
||||
## 风险
|
||||
|
||||
- 在任何一级构建之前,上游 API 都可能变化或被撤回。服务映射描述的是 Pi 1.0.0,需要对照触发这项工作的发行版重新核对。
|
||||
- 1 至 4 级在 Host 进程中运行插件代码,一个未捕获的错误会中止所有 Session。
|
||||
- 3 级依赖一个被上游声明为不稳定的 pi-durable 类型。
|
||||
- 4 级增加的 Remote 端点由 Chord 的 wire 解析器校验载荷,而不是由按方法生成的编解码器校验。
|
||||
- 插件包经由 Chord 的打包器引入 esbuild 作为运行时依赖。
|
||||
@@ -7,6 +7,12 @@ name: Release publish (vendor)
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dist-tag:
|
||||
description: Optional unused npm channel. Leave empty for latest (stable) or next (prerelease).
|
||||
required: false
|
||||
type: string
|
||||
default: ''
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -111,4 +117,10 @@ jobs:
|
||||
- name: Publish tarballs
|
||||
env:
|
||||
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
|
||||
run: pnpm run release:publish --family vendor --from dist/npm-vendor
|
||||
RELEASE_DIST_TAG: ${{ inputs.dist-tag }}
|
||||
run: |
|
||||
args=()
|
||||
if [[ -n "$RELEASE_DIST_TAG" ]]; then
|
||||
args+=(--dist-tag "$RELEASE_DIST_TAG")
|
||||
fi
|
||||
pnpm run release:publish --family vendor --from dist/npm-vendor "${args[@]}"
|
||||
@@ -23,6 +23,7 @@
|
||||
"packages/typert/generator/tests/fixtures/type-model/**", // tsgolint rejects this fixture's preserved project shapes before rules run.
|
||||
"website/.generated/**",
|
||||
"vendor/**", // Vendored source keeps upstream style and idioms.
|
||||
"packages/experimental/claude-code-mods/examples/*/{hooks,tests,types}/**", // Claude Code's published example mods and their tests keep the upstream style; see the headers.
|
||||
"native/**", // The imported landlock-run subtree has its own gates; see native/README.md.
|
||||
"**/*.js",
|
||||
"**/*.mjs",
|
||||
|
||||
@@ -141,7 +141,7 @@ External packages installed for runtime use or distributed inside the prebuilt b
|
||||
|
||||
pnpm applies local patches to the following packages at install time, so shipped artifacts carry modified copies; each patch file is the complete record of the modification:
|
||||
|
||||
- `@earendil-works/pi-ai@0.87.1` — [`patches/@earendil-works__pi-ai@0.87.1.patch`](patches/@earendil-works__pi-ai@0.87.1.patch)
|
||||
- `@earendil-works/pi-ai@1.0.2` — [`patches/@earendil-works__pi-ai@1.0.2.patch`](patches/@earendil-works__pi-ai@1.0.2.patch)
|
||||
- `@electron/[email protected]` — [`patches/@[email protected]`](patches/@[email protected])
|
||||
- `@fortune-sheet/[email protected]` — [`patches/@[email protected]`](patches/@[email protected])
|
||||
- `@fortune-sheet/[email protected]` — [`patches/@[email protected]`](patches/@[email protected])
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh",
|
||||
"description": "dsh CLI: profile launch, plugin management, and configuration inspection",
|
||||
"version": "0.2.0-rc.2",
|
||||
"version": "0.2.1-alpha.1",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-desktop-host",
|
||||
"description": "Private Node-mode host process for the Electron desktop application",
|
||||
"version": "0.2.0-rc.2",
|
||||
"version": "0.2.1-alpha.1",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
|
||||
@@ -27,7 +27,7 @@ async function main(): Promise<void> {
|
||||
profile: 'desktop',
|
||||
resolvedProfile: { profile, installAnchor },
|
||||
patchFiles: [],
|
||||
args: ['--no-open', '--port', '19387'],
|
||||
args: ['--no-open', '--port', '0'],
|
||||
...(process.argv[5] === undefined ? {} : {
|
||||
packageManager: {
|
||||
command: process.execPath,
|
||||
|
||||
@@ -29,7 +29,11 @@ export function installOfficeEngineResolution(runtimeDir: string): ModuleHooks |
|
||||
return registerHooks({
|
||||
resolve(specifier, context, nextResolve) {
|
||||
const resolved = nextResolve(specifier, context)
|
||||
if (!/^@deepseek-ai\/libreoffice-kit-(?:darwin|win32|linux)-/u.test(specifier)) return resolved
|
||||
if (!resolved.url.startsWith('file:')) return resolved
|
||||
const engineRequest = /^@deepseek-ai\/libreoffice-kit-(?:darwin|win32|linux)-/u.test(specifier)
|
||||
const engineTarget = /\/node_modules\/@deepseek-ai\/libreoffice-kit-(?:darwin|win32|linux)-[^/]+\//u
|
||||
.test(new URL(resolved.url).pathname)
|
||||
if (!engineRequest && !engineTarget) return resolved
|
||||
const canonical = pathToFileURL(realpathSync(fileURLToPath(resolved.url))).href
|
||||
if (!canonical.startsWith(source)) {
|
||||
if (canonical.startsWith(pathToFileURL(archive + '/').href)) {
|
||||
|
||||
@@ -1,19 +1,26 @@
|
||||
import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
|
||||
import { createRequire, type ModuleHooks } from 'node:module'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { dirname, join, sep } from 'node:path'
|
||||
import { afterEach, expect, it } from 'vitest'
|
||||
import { installOfficeEngineResolution } from '../src/office-engine.ts'
|
||||
import { PackageManifests } from '../../../packages/boot/hmr/src/package-manifest.ts'
|
||||
|
||||
const roots: string[] = []
|
||||
const hooks: ModuleHooks[] = []
|
||||
const disposers: Array<() => void> = []
|
||||
afterEach(() => {
|
||||
for (const dispose of disposers.splice(0).reverse()) dispose()
|
||||
for (const hook of hooks.splice(0)) hook.deregister()
|
||||
const cache = createRequire(import.meta.url).cache
|
||||
for (const path of Object.keys(cache)) {
|
||||
if (roots.some(root => path.startsWith(root + sep))) Reflect.deleteProperty(cache, path)
|
||||
}
|
||||
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function fixture(runtimeName = 'dsh') {
|
||||
const root = mkdtempSync(join(tmpdir(), 'desktop-office-resolution-'))
|
||||
const root = realpathSync(mkdtempSync(join(tmpdir(), 'desktop-office-resolution-')))
|
||||
roots.push(root)
|
||||
const runtime = join(root, 'app.asar', runtimeName)
|
||||
const manifest = 'node_modules/@deepseek-ai/libreoffice-kit-darwin-arm64/package.json'
|
||||
@@ -40,6 +47,45 @@ it('resolves engine manifests to physical directories and leaves unrelated modul
|
||||
expect(f.require('@deepseek-ai/libreoffice-kit/package.json')).toEqual({ name: '@deepseek-ai/libreoffice-kit' })
|
||||
})
|
||||
|
||||
it('resolves an absolute engine manifest to its unpacked copy', () => {
|
||||
const f = fixture()
|
||||
expect(f.require(join(f.runtime, f.manifest)))
|
||||
.toMatchObject({ path: realpathSync(dirname(join(f.root, 'app.asar.unpacked', 'dsh', f.manifest))) })
|
||||
})
|
||||
|
||||
it.each(['exports', 'main'] as const)('refreshes a cached plugin %s while Office resolution stays active', (field) => {
|
||||
const f = fixture()
|
||||
const packageRoot = join(f.root, 'plugin')
|
||||
mkdirSync(packageRoot)
|
||||
const manifest = join(packageRoot, 'package.json')
|
||||
const a = join(packageRoot, 'a.cjs')
|
||||
const b = join(packageRoot, 'b.cjs')
|
||||
writeFileSync(manifest, JSON.stringify({ name: 'plugin', [field]: './a.cjs' }))
|
||||
writeFileSync(a, 'module.exports = { marker: "a" }')
|
||||
writeFileSync(b, 'module.exports = { marker: "b" }')
|
||||
symlinkSync(packageRoot, join(f.runtime, 'node_modules', 'plugin'), process.platform === 'win32' ? 'junction' : 'dir')
|
||||
const require = createRequire(join(f.runtime, 'package.json'))
|
||||
const original: unknown = require('plugin')
|
||||
const next: unknown = require(b)
|
||||
const originalModule = require.cache[a]
|
||||
const nextModule = require.cache[b]
|
||||
const engine = f.require('@deepseek-ai/libreoffice-kit-darwin-arm64/package.json')
|
||||
expect(original).toEqual({ marker: 'a' })
|
||||
expect(next).toEqual({ marker: 'b' })
|
||||
expect(require('plugin')).toBe(original)
|
||||
const manifests = new PackageManifests()
|
||||
disposers.push(() => { manifests.dispose() })
|
||||
writeFileSync(manifest, JSON.stringify({ name: 'plugin', [field]: './b.cjs' }))
|
||||
manifests.invalidate(manifest)
|
||||
expect(require('plugin')).toBe(next)
|
||||
expect(require.resolve('plugin')).toBe(b)
|
||||
expect(require.cache[a]).toBe(originalModule)
|
||||
expect(require.cache[b]).toBe(nextModule)
|
||||
expect(require(a)).toBe(original)
|
||||
expect(f.require('@deepseek-ai/libreoffice-kit-darwin-arm64/package.json')).toBe(engine)
|
||||
expect(f.require(join(f.runtime, f.manifest))).toBe(engine)
|
||||
})
|
||||
|
||||
it('rejects an engine missing from the unpacked tree instead of using its archived copy', () => {
|
||||
const f = fixture()
|
||||
rmSync(join(f.root, 'app.asar.unpacked'), { recursive: true })
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write apps/desktop/README.md
|
||||
/deepseek-harness-desktop:
|
||||
en: 882c46770e3796c8
|
||||
zh: 7d22f2f4f9190c64
|
||||
en: 0f0f6ea914eff51e
|
||||
zh: 4934245b04e9f1c0
|
||||
/deepseek-harness-desktop/terminal-command:
|
||||
en: bef92953c237363f
|
||||
zh: d21d6effd21b8c00
|
||||
|
||||
@@ -4,7 +4,7 @@ English | [中文](README.zh.md)
|
||||
|
||||
Desktop analytics follows the [product collection policy](../../packages/client/product-analytics/README.md), including its live application setting. Web usage is excluded. Update installation waits for its local analytics intake before locking API admission and stopping the Host. Intake has a one-second deadline; failure does not prevent installation, and collector delivery is not awaited.
|
||||
|
||||
The desktop application is an Electron shell around the complete dsh Web application. An Electron RunAsNode child starts the shared profile runner, and Electron immediately loads the packaged Web entry at `dsh-app://app/`. Its shared loading page waits for Host boot injections, then starts the client without navigating to another document. Electron forwards application HTTP requests to the authenticated Web Host, dropping connection-level response headers (`transfer-encoding`, `connection`, `keep-alive`) that describe the Node fetch rather than the resource, and marking plugin bundle responses `no-store` because their per-launch revisions would only accumulate in Chromium's disk cache; WebSocket streams connect to that Host with credentials attached only for the owned application window. Node IPC carries boot injections, readiness, and shutdown. Desktop defaults to port `19387`, separate from Web’s `3080`; a `webserver.config.port` patch can override it.
|
||||
The desktop application is an Electron shell around the complete dsh Web application. An Electron RunAsNode child starts the shared profile runner, and Electron immediately loads the packaged Web entry at `dsh-app://app/`. Its shared loading page waits for Host boot injections, then starts the client without navigating to another document. Electron forwards application HTTP requests to the authenticated Web Host, dropping connection-level response headers (`transfer-encoding`, `connection`, `keep-alive`) that describe the Node fetch rather than the resource, and marking plugin bundle responses `no-store` because their per-launch revisions would only accumulate in Chromium's disk cache; WebSocket streams connect to that Host with credentials attached only for the owned application window. Node IPC carries boot injections, readiness, and shutdown. Desktop listens on an OS-assigned port by default, so it cannot collide with Web’s `3080` or a reserved port; a `webserver.config.port` patch can override it.
|
||||
|
||||
The first application-menu command, **About DeepSeek Harness**, opens Electron's native About panel with the application icon, product name, and installed release version. The menu follows the Desktop shell locale. On macOS, Hide, Hide Others, Show All, and Quit use localized labels; Hide and Quit include the DeepSeek Harness product name. These commands retain their native actions and shortcuts. macOS reads the icon from its application bundle, so an unpackaged development launch displays Electron's icon; Windows receives the packaged PNG.
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
桌面埋点遵循[产品采集策略](../../packages/client/product-analytics/README.zh.md)及其动态应用配置,不包含 Web 使用情况。安装更新会等待该操作的本地埋点接收请求结束,再锁定 API 准入并停止 Host。接收请求的时限为一秒,失败不会阻止安装,也不等待收集端完成发送。
|
||||
|
||||
桌面应用是完整 dsh Web 应用外的一层 Electron 壳。Electron RunAsNode 子进程启动共享 profile runner,Electron 立即从 `dsh-app://app/` 加载打包内的 Web 入口。共享加载页等待 Host 启动注入,然后在同一文档中启动客户端。Electron 将应用 HTTP 请求转发给已认证的 Web Host,转发时丢弃描述 Node fetch 连接而非资源本身的响应头(`transfer-encoding`、`connection`、`keep-alive`),并把插件 bundle 响应标记为 `no-store`,因为其每次启动都变化的 revision 只会在 Chromium 磁盘缓存中累积;WebSocket 流连接到该 Host,仅为归属的应用窗口附加凭据。Node IPC 承载启动注入、就绪与关闭。Desktop 默认使用端口 `19387`,与 Web 的 `3080` 分开;可通过 `webserver.config.port` patch 覆盖。
|
||||
桌面应用是完整 dsh Web 应用外的一层 Electron 壳。Electron RunAsNode 子进程启动共享 profile runner,Electron 立即从 `dsh-app://app/` 加载打包内的 Web 入口。共享加载页等待 Host 启动注入,然后在同一文档中启动客户端。Electron 将应用 HTTP 请求转发给已认证的 Web Host,转发时丢弃描述 Node fetch 连接而非资源本身的响应头(`transfer-encoding`、`connection`、`keep-alive`),并把插件 bundle 响应标记为 `no-store`,因为其每次启动都变化的 revision 只会在 Chromium 磁盘缓存中累积;WebSocket 流连接到该 Host,仅为归属的应用窗口附加凭据。Node IPC 承载启动注入、就绪与关闭。Desktop 默认监听系统分配的端口,因此不会与 Web 的 `3080` 或系统保留端口冲突;可通过 `webserver.config.port` patch 覆盖。
|
||||
|
||||
应用菜单第一项“**关于 DeepSeek Harness**”打开 Electron 原生关于面板,展示应用图标、产品名称和当前安装的发布版本。菜单文案跟随桌面壳的语言。macOS 的隐藏、隐藏其他、显示全部和退出条目使用本地化文案,隐藏和退出条目包含 DeepSeek Harness 产品名称。这些条目保留原生动作和快捷键。macOS 从应用包读取图标,因此未打包的开发启动会显示 Electron 图标;Windows 使用随包分发的 PNG。
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-desktop",
|
||||
"description": "Electron desktop shell for a bundled dsh runtime and external plugins",
|
||||
"version": "0.2.0-rc.2",
|
||||
"version": "0.2.1-alpha.1",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
@@ -9,7 +9,7 @@
|
||||
"scripts": {
|
||||
"test:installer": "node scripts/test-windows-installer.mjs",
|
||||
"build": "tsc -b && pnpm run bundle",
|
||||
"bundle": "tsdown",
|
||||
"bundle": "tsdown --config-loader native",
|
||||
"dev": "tsx scripts/dev.ts",
|
||||
"start": "tsx scripts/dev.ts --skip-build",
|
||||
"test:updates:local": "pnpm run build && node scripts/test-local-updater.mjs",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-web-frontend",
|
||||
"description": "Web application entry: vite build over the @deepseek-ai/dsh-client-web shell library; dist/ served by apps/cli's dsh web",
|
||||
"version": "0.2.0-rc.2",
|
||||
"version": "0.2.1-alpha.1",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -742,7 +742,6 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
|
||||
patches: [],
|
||||
}
|
||||
const resolutionOptions = { installAnchor: INSTALL_ANCHOR, home: harnessHome, profile }
|
||||
const resolution = await createRuntimeResolution(resolutionOptions)
|
||||
await mkdir(profileDir, { recursive: true })
|
||||
const rootConfig = join(profileDir, 'cordis.yml')
|
||||
await writeFile(rootConfig, '[]\n')
|
||||
@@ -796,6 +795,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
|
||||
throw new Error(`web e2e scaffold: the web app requested exit ${String(code)} with no arguments to reject`)
|
||||
},
|
||||
})
|
||||
const resolution = await createRuntimeResolution(resolutionOptions)
|
||||
await ctx.plugin(PluginPackages, {
|
||||
resolution,
|
||||
})
|
||||
|
||||
@@ -34,8 +34,7 @@ it('guides a newly enabled voice plugin to installation and lets the user postpo
|
||||
const card = page.locator('[data-plugin-package="@deepseek-ai/dsh-experimental-voice-input-bundle"]')
|
||||
expect(await card.getByRole('status').count()).toBe(0)
|
||||
await toggle.click()
|
||||
await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('false')
|
||||
await toggle.click()
|
||||
await page.getByRole('switch', { name: 'Enable Voice input', exact: true, checked: false }).click()
|
||||
await dialog.getByRole('button', { name: 'Go to setup', exact: true }).click()
|
||||
await page.getByRole('button', { name: 'Download and prepare', exact: true }).waitFor()
|
||||
expect(await dialog.count()).toBe(0)
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
en: 334b4f390b6e376b
|
||||
zh: ce094f24c6162be3
|
||||
/deepseek-harness-architecture/desktop-application:
|
||||
en: 9f08587366c03567
|
||||
zh: bf6abf664869dd88
|
||||
en: b9eeb18645121a8b
|
||||
zh: 70d87a12c43e042a
|
||||
/deepseek-harness-architecture/core-packages:
|
||||
en: b4a221ac2910600a
|
||||
zh: 341046d3c9a9c9cd
|
||||
|
||||
@@ -52,7 +52,7 @@ The Python SDK follows the same application architecture. Its runtime wheel pack
|
||||
|
||||
The [Electron desktop application](../apps/desktop/README.md) ships its exact dsh production runtime in signed resources and owns the reserved `$DSH_HOME/profiles/desktop`. Shared helpers initialize profile files, reconcile bundles, and resolve installation and bundle dependencies without replacing pnpm-owned packages. Desktop and npm CLI share product data but keep packages, activation, and lockfiles separate. Desktop’s bundled CLI manages its initialized plugins.
|
||||
|
||||
Electron starts the private Desktop Host in Electron Node mode. The Host invokes the shared CLI profile runner and complete Web application. The window immediately loads packaged Web assets and waits for boot injections before activating client plugins in the same document. Web owns RPC and streams; the desktop carrier connects the local page to the authenticated Host. Node IPC carries boot injections, readiness, fatal errors, and shutdown. Desktop defaults to port `19387`; profile configuration can override it. Shell-owned UI runs plugin transactions through bundled pnpm with normal user and profile configuration.
|
||||
Electron starts the private Desktop Host in Electron Node mode. The Host invokes the shared CLI profile runner and complete Web application. The window immediately loads packaged Web assets and waits for boot injections before activating client plugins in the same document. Web owns RPC and streams; the desktop carrier connects the local page to the authenticated Host. Node IPC carries boot injections, readiness, fatal errors, and shutdown. Desktop listens on an OS-assigned port by default; profile configuration can override it. Shell-owned UI runs plugin transactions through bundled pnpm with normal user and profile configuration.
|
||||
|
||||
## Core packages
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ Python SDK 遵循相同的应用架构。其运行时 wheel 把普通 `dsh` CLI
|
||||
|
||||
[Electron 桌面应用](../apps/desktop/README.zh.md)在签名资源中携带精确匹配的 dsh 生产运行时,并拥有保留的 `$DSH_HOME/profiles/desktop`。共享 helper 初始化 profile 文件、协调 bundle,并解析安装与 bundle 的依赖而不替换 pnpm 拥有的包。Desktop 与 npm CLI 共享产品数据,但包、启用选择与锁文件保持独立。Desktop 内置 CLI 管理其已初始化的插件。
|
||||
|
||||
Electron 使用 Electron Node 模式启动私有 Desktop Host。Host 调用共享 CLI profile runner 与完整 Web 应用。窗口立即加载打包 Web 资源,等待启动注入后在同一文档中激活客户端插件。Web 负责 RPC 与流;桌面载体将本地页面连接到已认证的 Host。Node IPC 承载启动注入、就绪、致命错误与关闭。Desktop 默认端口为 `19387`,profile 配置可覆盖。壳拥有的 UI 通过内置 pnpm 执行插件事务,并遵循正常用户与 profile 配置。
|
||||
Electron 使用 Electron Node 模式启动私有 Desktop Host。Host 调用共享 CLI profile runner 与完整 Web 应用。窗口立即加载打包 Web 资源,等待启动注入后在同一文档中激活客户端插件。Web 负责 RPC 与流;桌面载体将本地页面连接到已认证的 Host。Node IPC 承载启动注入、就绪、致命错误与关闭。Desktop 默认监听系统分配的端口,profile 配置可覆盖。壳拥有的 UI 通过内置 pnpm 执行插件事务,并遵循正常用户与 profile 配置。
|
||||
|
||||
## 核心包
|
||||
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
en: 41d772192075e133
|
||||
zh: 5ef3da69b4de1d8a
|
||||
/capability-seams-and-core-services:
|
||||
en: 65f0b4104daac871
|
||||
zh: 48bbc8eb309f27d4
|
||||
en: 06fac1d7fa402fba
|
||||
zh: cc9b56495dc42e69
|
||||
@@ -236,6 +236,8 @@ flowchart LR
|
||||
pkg_experimental_agent_team["experimental-agent-team"]
|
||||
svc_agentTeams["ctx.agentTeams<br/>Agent Teams coordination domain"]
|
||||
pkg_experimental_tool_agent_team["experimental-tool-agent-team"]
|
||||
pkg_experimental_claude_code_mods["experimental-claude-code-mods"]
|
||||
svc_claudeCodeMods["ctx.claudeCodeMods<br/>Claude Code mods bridge"]
|
||||
pkg_inspector["inspector"]
|
||||
svc_inspector["ctx.inspector<br/>Cross-realm runtime inspection"]
|
||||
pkg_jobs["jobs"]
|
||||
@@ -319,6 +321,7 @@ flowchart LR
|
||||
pkg_experimental_browser_use_chrome_devtools_mcp --> svc_browserUse
|
||||
pkg_experimental_browser_use_playwright_mcp --> svc_browserUse
|
||||
pkg_experimental_browser_use_stagehand_native --> svc_browserUse
|
||||
pkg_experimental_claude_code_mods --> svc_claudeCodeMods
|
||||
pkg_experimental_computer_use_cua_driver_mcp --> svc_computerUse
|
||||
pkg_experimental_computer_use_cua_driver_native --> svc_computerUse
|
||||
pkg_experimental_ptc_runtime_python --> svc_ptcRuntime
|
||||
@@ -647,6 +650,7 @@ flowchart LR
|
||||
| `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process), [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-claude-code`](../packages/subagent/subagent-claude-code), [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control), [`tool-ralph`](../packages/workflow/tool-ralph) | - | Providers implement transports; the service also owns optional Activation-based continuation orchestration, tool-subagent selects one-shot or continuable delegation, tool-subagent-control delivers follow-ups, and tool-ralph requires one fresh structured-output route. |
|
||||
| `ctx.speechToText` | `seam` | [`experimental-speech-to-text`](../packages/experimental/speech-to-text) | [`experimental-speech-to-text-sensevoice`](../packages/experimental/speech-to-text-sensevoice) | [`experimental-api-speech-to-text`](../packages/experimental/api-speech-to-text) | - | Routes explicit recognizers; the browser uses the authenticated Remote and keeps transcripts in the draft until submission. |
|
||||
| `ctx.agentTeams` | `core` | [`experimental-agent-team`](../packages/experimental/agent-team) | - | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team) | - | Owns the implicit-root roster, durable peer mailbox, shared task DAG, and continuable-child lifecycle; tool-agent-team contributes model controls. |
|
||||
| `ctx.claudeCodeMods` | `core` | [`experimental-claude-code-mods`](../packages/experimental/claude-code-mods) | - | - | - | Loads mods that defineMod plugins add, raises their hook chains from harness extension points, and draws the band above the prompt over its Remote. |
|
||||
| `ctx.inspector` | `core` | `inspector` | - | - | - | Owns the Worker-hosted CDP target and the transport-independent Host and Client observation and Cordis-tree query API. |
|
||||
| `ctx.jobs` | `seam` | [`jobs`](../packages/jobs/jobs) | [`jobs-local`](../packages/jobs/jobs-local) | [`tool-bash`](../packages/shell/tool-bash), [`tool-pwsh`](../packages/shell/tool-pwsh), [`tool-terminal`](../packages/terminal/tool-terminal), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-jobs`](../packages/jobs/tool-jobs), [`api-job-controller`](../packages/api/job-controller) | - | Producers (background bash/pwsh, PTY sends, and subagent delegations) register running work; record-declaring jobs additionally stream raw output for non-consuming observers; tool-jobs is the model-facing controller that reads, lists, and kills it; jobs-local is the process-local registry. |
|
||||
| `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-fetch-http`](../packages/web/web-fetch-http) | [`tool-web`](../packages/web/tool-web) | - | Search and fetch providers register into one ctx.web seam; tool-web owns the stable model-facing names. |
|
||||
|
||||
@@ -238,6 +238,8 @@ flowchart LR
|
||||
pkg_experimental_agent_team["experimental-agent-team"]
|
||||
svc_agentTeams["ctx.agentTeams<br/>Agent Teams coordination domain"]
|
||||
pkg_experimental_tool_agent_team["experimental-tool-agent-team"]
|
||||
pkg_experimental_claude_code_mods["experimental-claude-code-mods"]
|
||||
svc_claudeCodeMods["ctx.claudeCodeMods<br/>Claude Code mods bridge"]
|
||||
pkg_inspector["inspector"]
|
||||
svc_inspector["ctx.inspector<br/>Cross-realm runtime inspection"]
|
||||
pkg_jobs["jobs"]
|
||||
@@ -321,6 +323,7 @@ flowchart LR
|
||||
pkg_experimental_browser_use_chrome_devtools_mcp --> svc_browserUse
|
||||
pkg_experimental_browser_use_playwright_mcp --> svc_browserUse
|
||||
pkg_experimental_browser_use_stagehand_native --> svc_browserUse
|
||||
pkg_experimental_claude_code_mods --> svc_claudeCodeMods
|
||||
pkg_experimental_computer_use_cua_driver_mcp --> svc_computerUse
|
||||
pkg_experimental_computer_use_cua_driver_native --> svc_computerUse
|
||||
pkg_experimental_ptc_runtime_python --> svc_ptcRuntime
|
||||
@@ -649,6 +652,7 @@ flowchart LR
|
||||
| `ctx.subagents` | `seam` | [`subagent`](../packages/subagent/subagent) | [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process), [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process), [`subagent-acp`](../packages/subagent/subagent-acp), [`subagent-codex`](../packages/subagent/subagent-codex), [`subagent-claude-code`](../packages/subagent/subagent-claude-code), [`subagent-dsh-sdk`](../packages/subagent/subagent-dsh-sdk) | [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-subagent-control`](../packages/subagent/tool-subagent-control), [`tool-ralph`](../packages/workflow/tool-ralph) | - | 提供方实现传输;该服务还负责可选的、基于 Activation 的延续编排,tool-subagent 选择一次性或可延续委派,tool-subagent-control 传递后续消息,而 tool-ralph 要求一条全新的结构化输出路由。 |
|
||||
| `ctx.speechToText` | `seam` | [`experimental-speech-to-text`](../packages/experimental/speech-to-text) | [`experimental-speech-to-text-sensevoice`](../packages/experimental/speech-to-text-sensevoice) | [`experimental-api-speech-to-text`](../packages/experimental/api-speech-to-text) | - | 路由显式选择的识别器;浏览器使用带认证的 Remote,并在提交前将转写保留在草稿中。 |
|
||||
| `ctx.agentTeams` | `core` | [`experimental-agent-team`](../packages/experimental/agent-team) | - | [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team) | - | 负责隐式 Root roster、持久 peer mailbox、共享任务 DAG 与 continuable child 生命周期;tool-agent-team 提供模型控制工具。 |
|
||||
| `ctx.claudeCodeMods` | `core` | [`experimental-claude-code-mods`](../packages/experimental/claude-code-mods) | - | - | - | 加载 defineMod 插件添加的模组,从 harness 扩展点触发其钩子链,并通过其 Remote 绘制提示框上方的横幅。 |
|
||||
| `ctx.inspector` | `core` | `inspector` | - | - | - | 负责 Worker 托管的 CDP target,以及独立于传输的 Host 和 Client observation 与 Cordis tree query API。 |
|
||||
| `ctx.jobs` | `seam` | [`jobs`](../packages/jobs/jobs) | [`jobs-local`](../packages/jobs/jobs-local) | [`tool-bash`](../packages/shell/tool-bash), [`tool-pwsh`](../packages/shell/tool-pwsh), [`tool-terminal`](../packages/terminal/tool-terminal), [`tool-subagent`](../packages/subagent/tool-subagent), [`tool-jobs`](../packages/jobs/tool-jobs), [`api-job-controller`](../packages/api/job-controller) | - | 生产方(后台 bash/pwsh、PTY 发送和 subagent 委派)登记正在运行的工作;声明 record 的 job 还为非消费观察者流式提供原始输出;tool-jobs 是面向模型的控制器,用于读取、列出和终止这些工作;jobs-local 是进程本地注册表。 |
|
||||
| `ctx.web` | `seam` | [`web`](../packages/web/web) | [`web-search-exa`](../packages/web/web-search-exa), [`web-search-perplexity`](../packages/web/web-search-perplexity), [`web-search-deepseek`](../packages/web/web-search-deepseek), [`web-fetch-http`](../packages/web/web-fetch-http) | [`tool-web`](../packages/web/tool-web) | - | 搜索和抓取提供方注册到同一个 ctx.web seam;tool-web 负责稳定的面向模型名称。 |
|
||||
|
||||
+28
-1
@@ -941,6 +941,32 @@ export interface StagehandModelConfig {
|
||||
```
|
||||
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-experimental-browser-use-stagehand-native -->
|
||||
|
||||
<!-- BEGIN GENERATED config-catalog:@deepseek-ai/dsh-experimental-claude-code-mods -->
|
||||
<a id="deepseek-aidsh-experimental-claude-code-mods"></a>
|
||||
|
||||
## `@deepseek-ai/dsh-experimental-claude-code-mods`
|
||||
|
||||
- `source`: [`packages/experimental/claude-code-mods/src/index.ts:52`](../packages/experimental/claude-code-mods/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Plugin config: the limits mod hooks run under. */
|
||||
export interface Config {
|
||||
/** A hook's own running-time limit in milliseconds (Claude Code: 10 seconds). */
|
||||
hookTimeoutMs?: number
|
||||
/** A `.catch` handler's running-time limit in milliseconds (Claude Code: 1 second). */
|
||||
catchTimeoutMs?: number
|
||||
/** Default `$.process.run` and `$.http.fetch` timeout in milliseconds (Claude Code: 30 seconds). */
|
||||
processTimeoutMs?: number
|
||||
/** Claude Code tool name → harness tool name entries added to the built-in alias table. */
|
||||
toolAliases?: Record<string, string>
|
||||
/** Columns the band above the prompt reports to `ui.render` as `bodyColumns` and `viewport.columns`. */
|
||||
bandColumns?: number
|
||||
/** Rows the band reports as `maxRows`. */
|
||||
bandRows?: number
|
||||
}
|
||||
```
|
||||
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-experimental-claude-code-mods -->
|
||||
|
||||
<!-- BEGIN GENERATED config-catalog:@deepseek-ai/dsh-experimental-computer-use-cua-driver-mcp -->
|
||||
<a id="deepseek-aidsh-experimental-computer-use-cua-driver-mcp"></a>
|
||||
|
||||
@@ -1305,7 +1331,7 @@ export interface Config {
|
||||
## `@deepseek-ai/dsh-hmr`
|
||||
|
||||
- `refs`: `ChokidarOptions` (`chokidar`)
|
||||
- `source`: [`packages/boot/hmr/src/index.ts:51`](../packages/boot/hmr/src/index.ts)
|
||||
- `source`: [`packages/boot/hmr/src/index.ts:53`](../packages/boot/hmr/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Module roots and watcher timing, with Chokidar deployment options. */
|
||||
@@ -4386,6 +4412,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
|
||||
| `@deepseek-ai/dsh-deepseek-llm-api-extensions` | — | [`packages/llm/deepseek-llm-api-extensions/src/index.ts`](../packages/llm/deepseek-llm-api-extensions/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-experimental-auto-review` | `approval` · `llm` · `permissionPresets` · `sessions` · `tools` | [`packages/experimental/auto-review/src/index.ts`](../packages/experimental/auto-review/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-experimental-client-ui-agent-team` | — | [`packages/experimental/client-ui-agent-team/src/index.ts`](../packages/experimental/client-ui-agent-team/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-experimental-client-ui-claude-code-mods` | — | [`packages/experimental/client-ui-claude-code-mods/src/index.ts`](../packages/experimental/client-ui-claude-code-mods/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-experimental-client-ui-voice-input` | — | [`packages/experimental/client-ui-voice-input/src/index.ts`](../packages/experimental/client-ui-voice-input/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-experimental-computer-use-cua-driver-native` | `computerUse` · `tools` · `systemPrompt` | [`packages/experimental/computer-use-cua-driver-native/src/index.ts`](../packages/experimental/computer-use-cua-driver-native/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-experimental-session-inspector` | — | [`packages/experimental/session-inspector/src/index.ts`](../packages/experimental/session-inspector/src/index.ts) |
|
||||
|
||||
@@ -943,6 +943,32 @@ export interface StagehandModelConfig {
|
||||
```
|
||||
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-experimental-browser-use-stagehand-native -->
|
||||
|
||||
<!-- BEGIN GENERATED config-catalog:@deepseek-ai/dsh-experimental-claude-code-mods -->
|
||||
<a id="deepseek-aidsh-experimental-claude-code-mods"></a>
|
||||
|
||||
## `@deepseek-ai/dsh-experimental-claude-code-mods`
|
||||
|
||||
- `source`: [`packages/experimental/claude-code-mods/src/index.ts:52`](../packages/experimental/claude-code-mods/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Plugin config: the limits mod hooks run under. */
|
||||
export interface Config {
|
||||
/** A hook's own running-time limit in milliseconds (Claude Code: 10 seconds). */
|
||||
hookTimeoutMs?: number
|
||||
/** A `.catch` handler's running-time limit in milliseconds (Claude Code: 1 second). */
|
||||
catchTimeoutMs?: number
|
||||
/** Default `$.process.run` and `$.http.fetch` timeout in milliseconds (Claude Code: 30 seconds). */
|
||||
processTimeoutMs?: number
|
||||
/** Claude Code tool name → harness tool name entries added to the built-in alias table. */
|
||||
toolAliases?: Record<string, string>
|
||||
/** Columns the band above the prompt reports to `ui.render` as `bodyColumns` and `viewport.columns`. */
|
||||
bandColumns?: number
|
||||
/** Rows the band reports as `maxRows`. */
|
||||
bandRows?: number
|
||||
}
|
||||
```
|
||||
<!-- END GENERATED config-catalog:@deepseek-ai/dsh-experimental-claude-code-mods -->
|
||||
|
||||
<!-- BEGIN GENERATED config-catalog:@deepseek-ai/dsh-experimental-computer-use-cua-driver-mcp -->
|
||||
<a id="deepseek-aidsh-experimental-computer-use-cua-driver-mcp"></a>
|
||||
|
||||
@@ -1307,7 +1333,7 @@ export interface Config {
|
||||
## `@deepseek-ai/dsh-hmr`
|
||||
|
||||
- `refs`: `ChokidarOptions` (`chokidar`)
|
||||
- `source`: [`packages/boot/hmr/src/index.ts:51`](../packages/boot/hmr/src/index.ts)
|
||||
- `source`: [`packages/boot/hmr/src/index.ts:53`](../packages/boot/hmr/src/index.ts)
|
||||
|
||||
```ts config-catalog
|
||||
/** Module roots and watcher timing, with Chokidar deployment options. */
|
||||
@@ -4388,6 +4414,7 @@ export interface Config {
|
||||
| `@deepseek-ai/dsh-deepseek-llm-api-extensions` | — | [`packages/llm/deepseek-llm-api-extensions/src/index.ts`](../packages/llm/deepseek-llm-api-extensions/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-experimental-auto-review` | `approval` · `llm` · `permissionPresets` · `sessions` · `tools` | [`packages/experimental/auto-review/src/index.ts`](../packages/experimental/auto-review/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-experimental-client-ui-agent-team` | — | [`packages/experimental/client-ui-agent-team/src/index.ts`](../packages/experimental/client-ui-agent-team/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-experimental-client-ui-claude-code-mods` | — | [`packages/experimental/client-ui-claude-code-mods/src/index.ts`](../packages/experimental/client-ui-claude-code-mods/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-experimental-client-ui-voice-input` | — | [`packages/experimental/client-ui-voice-input/src/index.ts`](../packages/experimental/client-ui-voice-input/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-experimental-computer-use-cua-driver-native` | `computerUse` · `tools` · `systemPrompt` | [`packages/experimental/computer-use-cua-driver-native/src/index.ts`](../packages/experimental/computer-use-cua-driver-native/src/index.ts) |
|
||||
| `@deepseek-ai/dsh-experimental-session-inspector` | — | [`packages/experimental/session-inspector/src/index.ts`](../packages/experimental/session-inspector/src/index.ts) |
|
||||
|
||||
@@ -9,8 +9,8 @@
|
||||
en: 5207d17725c07a27
|
||||
zh: 258884c4300ac63a
|
||||
/development-guide/setup-tutorial/prerequisites:
|
||||
en: 6078798f0fda1347
|
||||
zh: ea73704c4ec4f2a9
|
||||
en: a8543c278338a02d
|
||||
zh: b5790c5fea7c0bc6
|
||||
/development-guide/setup-tutorial/windows-and-wsl-2:
|
||||
en: e86be579be6f1813
|
||||
zh: 14f6a220337d0861
|
||||
|
||||
@@ -9,6 +9,7 @@ The setup tutorial takes a new contributor from prerequisites to a checked check
|
||||
### Prerequisites
|
||||
|
||||
- Node.js supports 22.19+ and 24+. CI covers 22.19, 24, and 26; see the [Node engine floor Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md).
|
||||
- Node.js TypeScript type stripping enabled. The repository build scripts load `tsdown.config.ts` with tsdown's native config loader, so they fail when `--no-experimental-strip-types` is in `NODE_OPTIONS` or the Node.js build lacks TypeScript support; `pnpm run build` checks this first and names the cause.
|
||||
- Corepack-enabled pnpm. The repo pins `[email protected]` in `package.json`; run `corepack enable` if `pnpm --version` does not resolve through Corepack.
|
||||
- Git 2.26 or newer; hook setup enables Git's worktree-specific configuration extension.
|
||||
- Optional: a DeepSeek API key for the Web, headless, and ACP automation demos and real-API e2e tests.
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
### 前置条件
|
||||
|
||||
- Node.js 支持 22.19+ 与 24+。CI 覆盖 22.19、24 和 26;见 [Node 引擎下限 Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.zh.md)。
|
||||
- 启用 Node.js TypeScript 类型剥离。仓库构建脚本用 tsdown 的 native 配置加载器加载 `tsdown.config.ts`,因此当 `NODE_OPTIONS` 含 `--no-experimental-strip-types` 或 Node.js 构建缺少 TypeScript 支持时会失败;`pnpm run build` 会先检查这一条件并指出原因。
|
||||
- 启用了 Corepack 的 pnpm。仓库在 `package.json` 中固定使用 `[email protected]`;如果 `pnpm --version` 无法通过 Corepack 解析,请先运行 `corepack enable`。
|
||||
- Git 2.26 或更高版本;钩子设置会启用 Git 的 worktree 专属配置扩展。
|
||||
- 可选:一个 DeepSeek API key,用于 Web、headless 和 ACP(Agent Client Protocol)自动化 agent(智能体)演示以及真实 API 的 e2e 测试。
|
||||
|
||||
@@ -11,13 +11,13 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
||||
| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:238`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - |
|
||||
| `agent-preset/selected` | `emit` | [`packages/preset/agent-preset-registry/src/types.ts:69`](../packages/preset/agent-preset-registry/src/types.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`) | `remotes` |
|
||||
| `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:363`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` |
|
||||
| `agent/created` | `serial` | [`packages/core/agent/src/runtime-types.ts:261`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`serial`) | `agent-team`, `browser-use-runtime`, [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`loader-smoke`](../packages/test-support/loader-smoke), `session-controller`, `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
|
||||
| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:270`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
|
||||
| `agent/created` | `serial` | [`packages/core/agent/src/runtime-types.ts:261`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`serial`) | `agent-team`, `browser-use-runtime`, `claude-code-mods`, [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`loader-smoke`](../packages/test-support/loader-smoke), `session-controller`, `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
|
||||
| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:270`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `claude-code-mods`, [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
|
||||
| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:393`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`deepseek-account`](../packages/credentials/deepseek-account), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`session-telemetry`](../packages/session/session-telemetry) |
|
||||
| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:299`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs), [`user-questions`](../packages/interaction/user-questions) |
|
||||
| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:307`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`user-questions`](../packages/interaction/user-questions) |
|
||||
| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:288`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) |
|
||||
| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:320`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `session-controller`, [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill) |
|
||||
| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:320`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `session-controller`, [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill) |
|
||||
| `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:337`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) |
|
||||
| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:353`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) |
|
||||
| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:280`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller` |
|
||||
@@ -51,8 +51,8 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
||||
| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:59`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) |
|
||||
| `goal/activation-changed` | `emit` | [`packages/goal/goal/src/types.ts:150`](../packages/goal/goal/src/types.ts) | [`goal`](../packages/goal/goal) (`emit`) | `remotes` |
|
||||
| `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) |
|
||||
| `hmr/change` | `emit` | [`packages/boot/hmr/src/index.ts:30`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`emit`) | - |
|
||||
| `hmr/reload` | `emit` | [`packages/boot/hmr/src/index.ts:35`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`emit`) | - |
|
||||
| `hmr/change` | `emit` | [`packages/boot/hmr/src/index.ts:32`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`emit`) | - |
|
||||
| `hmr/reload` | `emit` | [`packages/boot/hmr/src/index.ts:37`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`emit`) | - |
|
||||
| `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), `remotes` |
|
||||
| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:75`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) |
|
||||
| `permission-presets/catalog-changed` | `emit` | [`packages/interaction/permission-presets/src/types.ts:48`](../packages/interaction/permission-presets/src/types.ts) | [`permission-presets`](../packages/interaction/permission-presets) (`events.dispatch`) | `remotes` |
|
||||
@@ -63,7 +63,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
||||
| `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:44`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
|
||||
| `session/created` | `emit` | [`packages/core/session/src/index.ts:54`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`permission-presets`](../packages/interaction/permission-presets), [`schedule`](../packages/schedule/schedule), `server`, `session-controller`, [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry) |
|
||||
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:63`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`workspace-changes`](../packages/deliverables/workspace-changes) |
|
||||
| `session/event` | `emit` | [`packages/core/session/src/index.ts:74`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`loader-smoke`](../packages/test-support/loader-smoke), `product-analytics`, `server`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`user-questions`](../packages/interaction/user-questions), [`workspace-changes`](../packages/deliverables/workspace-changes) |
|
||||
| `session/event` | `emit` | [`packages/core/session/src/index.ts:74`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `agent-team`, `claude-code-mods`, [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`loader-smoke`](../packages/test-support/loader-smoke), `product-analytics`, `server`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`user-questions`](../packages/interaction/user-questions), [`workspace-changes`](../packages/deliverables/workspace-changes) |
|
||||
| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:82`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) |
|
||||
| `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:75`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`emit`) | `remotes` |
|
||||
| `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:296`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - |
|
||||
@@ -74,11 +74,11 @@ This matrix shows which packages dispatch each harness-owned event and which pac
|
||||
| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:31`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), `browser-use-runtime`, [`session-reference`](../packages/context/session-reference) |
|
||||
| `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:37`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - |
|
||||
| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:208`](../packages/core/tools/src/index.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`), [`tools`](../packages/core/tools) (`emit`) | `browser-use-runtime`, [`tool-subagent`](../packages/subagent/tool-subagent) |
|
||||
| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:164`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `browser-use-runtime`, `browser-use-stagehand-native`, `computer-use-cua-driver-native`, [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `timeout-policy` |
|
||||
| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:176`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) |
|
||||
| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:164`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `browser-use-runtime`, `browser-use-stagehand-native`, `claude-code-mods`, `computer-use-cua-driver-native`, [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `timeout-policy` |
|
||||
| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:176`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `claude-code-mods`, [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) |
|
||||
| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:153`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `auto-review`, [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-jobs`](../packages/jobs/tool-jobs), [`workspace-changes`](../packages/deliverables/workspace-changes) |
|
||||
| `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:190`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) |
|
||||
| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:198`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`tool-present`](../packages/deliverables/tool-present) |
|
||||
| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:198`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`tool-present`](../packages/deliverables/tool-present) |
|
||||
| `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:157`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes` |
|
||||
| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:34`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` |
|
||||
| `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) |
|
||||
|
||||
@@ -13,13 +13,13 @@
|
||||
| `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:238`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - |
|
||||
| `agent-preset/selected` | `emit` | [`packages/preset/agent-preset-registry/src/types.ts:69`](../packages/preset/agent-preset-registry/src/types.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`) | `remotes` |
|
||||
| `agent/assistant-stream` | `emit` | [`packages/core/agent/src/runtime-types.ts:363`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`headless`](../packages/bundle/headless), `session-controller` |
|
||||
| `agent/created` | `serial` | [`packages/core/agent/src/runtime-types.ts:261`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`serial`) | `agent-team`, `browser-use-runtime`, [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`loader-smoke`](../packages/test-support/loader-smoke), `session-controller`, `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
|
||||
| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:270`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
|
||||
| `agent/created` | `serial` | [`packages/core/agent/src/runtime-types.ts:261`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`serial`) | `agent-team`, `browser-use-runtime`, `claude-code-mods`, [`file-reference-local`](../packages/context/file-reference-local), [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`loader-smoke`](../packages/test-support/loader-smoke), `session-controller`, `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
|
||||
| `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:270`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `claude-code-mods`, [`file-reference-local`](../packages/context/file-reference-local), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`subagent`](../packages/subagent/subagent), `tool-agent-team`, [`tool-subagent`](../packages/subagent/tool-subagent) |
|
||||
| `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:393`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`deepseek-account`](../packages/credentials/deepseek-account), [`goal-round-driver`](../packages/goal/goal-round-driver), `session-controller`, [`session-telemetry`](../packages/session/session-telemetry) |
|
||||
| `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:299`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`tool-jobs`](../packages/jobs/tool-jobs), [`user-questions`](../packages/interaction/user-questions) |
|
||||
| `agent/inbox/discarded` | `emit` | [`packages/core/agent/src/runtime-types.ts:307`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver), [`subagent`](../packages/subagent/subagent), [`user-questions`](../packages/interaction/user-questions) |
|
||||
| `agent/inbox/inserted` | `emit` | [`packages/core/agent/src/runtime-types.ts:288`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) |
|
||||
| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:320`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `session-controller`, [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill) |
|
||||
| `agent/pre-step` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:320`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`plan-mode`](../packages/plan/plan-mode), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `session-controller`, [`session-reference`](../packages/context/session-reference), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`time-context`](../packages/context/time-context), [`tmux-context`](../packages/context/tmux-context), [`tool-skill`](../packages/skill/tool-skill) |
|
||||
| `agent/request` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:337`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`agent`](../packages/core/agent), [`webhook`](../packages/webhook/webhook) |
|
||||
| `agent/request-error` | `waterfall` | [`packages/core/agent/src/runtime-types.ts:353`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`waterfall`) | [`compaction-basic`](../packages/compaction/compaction-basic), [`compaction-image-offload`](../packages/compaction/compaction-image-offload), [`llm-retry`](../packages/llm/llm-retry) |
|
||||
| `agent/status` | `emit` | [`packages/core/agent/src/runtime-types.ts:280`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`goal-round-driver`](../packages/goal/goal-round-driver), `server`, `session-controller` |
|
||||
@@ -53,8 +53,8 @@
|
||||
| `fs/write-intent` | `waterfall` | [`packages/fs/fs/src/index.ts:59`](../packages/fs/fs/src/index.ts) | [`tool-fs`](../packages/fs/tool-fs) (`waterfall`), [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) (`waterfall`) | [`fs-observation-policy`](../packages/fs/fs-observation-policy) |
|
||||
| `goal/activation-changed` | `emit` | [`packages/goal/goal/src/types.ts:150`](../packages/goal/goal/src/types.ts) | [`goal`](../packages/goal/goal) (`emit`) | `remotes` |
|
||||
| `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-round-driver`](../packages/goal/goal-round-driver) |
|
||||
| `hmr/change` | `emit` | [`packages/boot/hmr/src/index.ts:30`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`emit`) | - |
|
||||
| `hmr/reload` | `emit` | [`packages/boot/hmr/src/index.ts:35`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`emit`) | - |
|
||||
| `hmr/change` | `emit` | [`packages/boot/hmr/src/index.ts:32`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`emit`) | - |
|
||||
| `hmr/reload` | `emit` | [`packages/boot/hmr/src/index.ts:37`](../packages/boot/hmr/src/index.ts) | [`hmr`](../packages/boot/hmr) (`emit`) | - |
|
||||
| `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/types.ts:23`](../packages/llm/llm/src/types.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | [`acp`](../packages/acp/acp), `remotes` |
|
||||
| `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:75`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`llm-replay`](../packages/test-support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) |
|
||||
| `permission-presets/catalog-changed` | `emit` | [`packages/interaction/permission-presets/src/types.ts:48`](../packages/interaction/permission-presets/src/types.ts) | [`permission-presets`](../packages/interaction/permission-presets) (`events.dispatch`) | `remotes` |
|
||||
@@ -65,7 +65,7 @@
|
||||
| `session-telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:44`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - |
|
||||
| `session/created` | `emit` | [`packages/core/session/src/index.ts:54`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`permission-presets`](../packages/interaction/permission-presets), [`schedule`](../packages/schedule/schedule), `server`, `session-controller`, [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry) |
|
||||
| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:63`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `agent-team`, `file-upload`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title), [`workspace-changes`](../packages/deliverables/workspace-changes) |
|
||||
| `session/event` | `emit` | [`packages/core/session/src/index.ts:74`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `agent-team`, [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`loader-smoke`](../packages/test-support/loader-smoke), `product-analytics`, `server`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`user-questions`](../packages/interaction/user-questions), [`workspace-changes`](../packages/deliverables/workspace-changes) |
|
||||
| `session/event` | `emit` | [`packages/core/session/src/index.ts:74`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-instructions`](../packages/context/agent-instructions), [`agent-loop`](../packages/core/agent-loop), [`agent-preset-registry`](../packages/preset/agent-preset-registry), `agent-team`, `claude-code-mods`, [`compaction-basic`](../packages/compaction/compaction-basic), [`file-reference-local`](../packages/context/file-reference-local), `file-upload`, [`goal`](../packages/goal/goal), [`goal-round-driver`](../packages/goal/goal-round-driver), [`headless`](../packages/bundle/headless), [`loader-smoke`](../packages/test-support/loader-smoke), `product-analytics`, `server`, `session-controller`, [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`user-questions`](../packages/interaction/user-questions), [`workspace-changes`](../packages/deliverables/workspace-changes) |
|
||||
| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:82`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-telemetry`](../packages/session/session-telemetry) |
|
||||
| `settings/document-updated` | `emit` | [`packages/settings/settings/src/types.ts:75`](../packages/settings/settings/src/types.ts) | [`settings`](../packages/settings/settings) (`emit`) | `remotes` |
|
||||
| `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:296`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - |
|
||||
@@ -76,11 +76,11 @@
|
||||
| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:31`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), `browser-use-runtime`, [`session-reference`](../packages/context/session-reference) |
|
||||
| `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:37`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - |
|
||||
| `tools/change` | `emit` | [`packages/core/tools/src/index.ts:208`](../packages/core/tools/src/index.ts) | [`agent-preset-registry`](../packages/preset/agent-preset-registry) (`emit`), [`tools`](../packages/core/tools) (`emit`) | `browser-use-runtime`, [`tool-subagent`](../packages/subagent/tool-subagent) |
|
||||
| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:164`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `browser-use-runtime`, `browser-use-stagehand-native`, `computer-use-cua-driver-native`, [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `timeout-policy` |
|
||||
| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:176`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) |
|
||||
| `tools/execute` | `waterfall` | [`packages/core/tools/src/index.ts:164`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `browser-use-runtime`, `browser-use-stagehand-native`, `claude-code-mods`, `computer-use-cua-driver-native`, [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), `timeout-policy` |
|
||||
| `tools/post-execute` | `waterfall` | [`packages/core/tools/src/index.ts:176`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `claude-code-mods`, [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`repeat-tool-reminder`](../packages/guard/repeat-tool-reminder), [`spill-policy`](../packages/spill/spill-policy), [`tool-fs-search`](../packages/fs/tool-fs-search) |
|
||||
| `tools/pre-execute` | `waterfall` | [`packages/core/tools/src/index.ts:153`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | `auto-review`, [`hooks-claude-code`](../packages/hooks/hooks-claude-code), [`hooks-codex`](../packages/hooks/hooks-codex), [`tool-jobs`](../packages/jobs/tool-jobs), [`workspace-changes`](../packages/deliverables/workspace-changes) |
|
||||
| `tools/ptc-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:190`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) |
|
||||
| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:198`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`tool-present`](../packages/deliverables/tool-present) |
|
||||
| `tools/result` | `emit` | [`packages/core/tools/src/index.ts:198`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`events.dispatch`) | [`agent-instructions`](../packages/context/agent-instructions), `claude-code-mods`, [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver), [`tool-present`](../packages/deliverables/tool-present) |
|
||||
| `user-questions/request` | `waterfall` | [`packages/interaction/user-questions/src/types.ts:157`](../packages/interaction/user-questions/src/types.ts) | [`user-questions`](../packages/interaction/user-questions) (`waterfall`) | `remotes` |
|
||||
| `webserver/index-inject` | `emit` | [`packages/host/webserver/src/index.ts:34`](../packages/host/webserver/src/index.ts) | `webserver` (`emit`) | `connection`, `inspector`, `modules`, `shortcuts`, `ui-sidebar-documentpreview`, `ui-theme` |
|
||||
| `workflow/agent-end` | `emit` | [`packages/workflow/workflow/src/index.ts:79`](../packages/workflow/workflow/src/index.ts) | [`workflow`](../packages/workflow/workflow) (`events.dispatch`) | [`tool-workflow`](../packages/workflow/tool-workflow) |
|
||||
|
||||
@@ -254,7 +254,9 @@ flowchart TD
|
||||
pkg_experimental_browser_use_playwright_mcp["experimental-browser-use-playwright-mcp"]
|
||||
pkg_experimental_browser_use_runtime["experimental-browser-use-runtime"]
|
||||
pkg_experimental_browser_use_stagehand_native["experimental-browser-use-stagehand-native"]
|
||||
pkg_experimental_claude_code_mods["experimental-claude-code-mods"]
|
||||
pkg_experimental_client_ui_agent_team["experimental-client-ui-agent-team"]
|
||||
pkg_experimental_client_ui_claude_code_mods["experimental-client-ui-claude-code-mods"]
|
||||
pkg_experimental_client_ui_voice_input["experimental-client-ui-voice-input"]
|
||||
pkg_experimental_computer_use_cua_driver_mcp["experimental-computer-use-cua-driver-mcp"]
|
||||
pkg_experimental_computer_use_cua_driver_native["experimental-computer-use-cua-driver-native"]
|
||||
@@ -1137,6 +1139,19 @@ flowchart TD
|
||||
pkg_experimental_browser_use_runtime --> pkg_scope
|
||||
pkg_experimental_browser_use_runtime --> pkg_system_prompt
|
||||
pkg_experimental_browser_use_runtime --> pkg_tools
|
||||
pkg_experimental_claude_code_mods --> pkg_agent
|
||||
pkg_experimental_claude_code_mods --> pkg_commands
|
||||
pkg_experimental_claude_code_mods --> pkg_fs
|
||||
pkg_experimental_claude_code_mods --> pkg_llm
|
||||
pkg_experimental_claude_code_mods --> pkg_scope
|
||||
pkg_experimental_claude_code_mods --> pkg_session
|
||||
pkg_experimental_claude_code_mods --> pkg_session_projection
|
||||
pkg_experimental_claude_code_mods --> pkg_storage_domain
|
||||
pkg_experimental_claude_code_mods --> pkg_subprocess
|
||||
pkg_experimental_claude_code_mods --> pkg_token_meter
|
||||
pkg_experimental_claude_code_mods --> pkg_tools
|
||||
pkg_experimental_claude_code_mods --> pkg_typert_protocol
|
||||
pkg_experimental_claude_code_mods --> pkg_user_questions
|
||||
pkg_webhook_github --> pkg_credentials
|
||||
pkg_webhook_github --> pkg_host_webserver
|
||||
pkg_webhook_github --> pkg_session
|
||||
@@ -1272,6 +1287,17 @@ flowchart TD
|
||||
pkg_experimental_client_ui_agent_team --> pkg_client_ui_workspace
|
||||
pkg_experimental_client_ui_agent_team --> pkg_experimental_agent_team
|
||||
pkg_experimental_client_ui_agent_team --> pkg_session
|
||||
pkg_experimental_client_ui_claude_code_mods --> pkg_api_remotes
|
||||
pkg_experimental_client_ui_claude_code_mods --> pkg_api_session_controller
|
||||
pkg_experimental_client_ui_claude_code_mods --> pkg_client_locale
|
||||
pkg_experimental_client_ui_claude_code_mods --> pkg_client_ui_conversation
|
||||
pkg_experimental_client_ui_claude_code_mods --> pkg_client_ui_primitives
|
||||
pkg_experimental_client_ui_claude_code_mods --> pkg_client_ui_renderer
|
||||
pkg_experimental_client_ui_claude_code_mods --> pkg_client_ui_session
|
||||
pkg_experimental_client_ui_claude_code_mods --> pkg_client_ui_slots
|
||||
pkg_experimental_client_ui_claude_code_mods --> pkg_experimental_claude_code_mods
|
||||
pkg_experimental_client_ui_claude_code_mods --> pkg_session
|
||||
pkg_experimental_client_ui_claude_code_mods --> pkg_typert_protocol
|
||||
pkg_experimental_client_ui_voice_input --> pkg_api_gateway
|
||||
pkg_experimental_client_ui_voice_input --> pkg_api_remotes
|
||||
pkg_experimental_client_ui_voice_input --> pkg_api_session_controller
|
||||
@@ -1640,6 +1666,7 @@ flowchart TD
|
||||
| [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) |
|
||||
| [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
|
||||
| [`experimental-browser-use-runtime`](../packages/experimental/browser-use-runtime) | `experimental` | [`agent`](../packages/core/agent), [`browser-use`](../packages/browser-use/browser-use), [`mcp-client`](../packages/mcp/mcp-client), [`scope`](../packages/core/scope), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
|
||||
| [`experimental-claude-code-mods`](../packages/experimental/claude-code-mods) | `experimental` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`storage-domain`](../packages/storage/storage-domain), [`subprocess`](../packages/subprocess/subprocess), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-questions`](../packages/interaction/user-questions) |
|
||||
| [`webhook-github`](../packages/webhook/webhook-github) | `webhook` | [`credentials`](../packages/credentials/credentials), [`host-webserver`](../packages/host/webserver), [`session`](../packages/core/session), [`webhook`](../packages/webhook/webhook) |
|
||||
| [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
|
||||
| [`subagent-claude-code`](../packages/subagent/subagent-claude-code) | `subagent` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
|
||||
@@ -1658,6 +1685,7 @@ flowchart TD
|
||||
| [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process) | `subagent` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) |
|
||||
| [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process) | `subagent` | [`subagent`](../packages/subagent/subagent), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) |
|
||||
| [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | `experimental` | [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`experimental-agent-team`](../packages/experimental/agent-team), [`session`](../packages/core/session) |
|
||||
| [`experimental-client-ui-claude-code-mods`](../packages/experimental/client-ui-claude-code-mods) | `experimental` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`experimental-claude-code-mods`](../packages/experimental/claude-code-mods), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) |
|
||||
| [`experimental-client-ui-voice-input`](../packages/experimental/client-ui-voice-input) | `experimental` | [`api-gateway`](../packages/api/gateway), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-store`](../packages/client/store), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-plugin-manager`](../packages/client/ui-plugin-manager), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`experimental-api-speech-to-text`](../packages/experimental/api-speech-to-text), [`experimental-speech-to-text`](../packages/experimental/speech-to-text), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) |
|
||||
| [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team) | `experimental` | [`agent`](../packages/core/agent), [`experimental-agent-team`](../packages/experimental/agent-team), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
|
||||
| [`schedule`](../packages/schedule/schedule) | `schedule` | [`agent`](../packages/core/agent), [`api-session-controller`](../packages/api/session-controller), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`storage-domain`](../packages/storage/storage-domain), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`workspace`](../packages/workspace/workspace) |
|
||||
|
||||
@@ -256,7 +256,9 @@ flowchart TD
|
||||
pkg_experimental_browser_use_playwright_mcp["experimental-browser-use-playwright-mcp"]
|
||||
pkg_experimental_browser_use_runtime["experimental-browser-use-runtime"]
|
||||
pkg_experimental_browser_use_stagehand_native["experimental-browser-use-stagehand-native"]
|
||||
pkg_experimental_claude_code_mods["experimental-claude-code-mods"]
|
||||
pkg_experimental_client_ui_agent_team["experimental-client-ui-agent-team"]
|
||||
pkg_experimental_client_ui_claude_code_mods["experimental-client-ui-claude-code-mods"]
|
||||
pkg_experimental_client_ui_voice_input["experimental-client-ui-voice-input"]
|
||||
pkg_experimental_computer_use_cua_driver_mcp["experimental-computer-use-cua-driver-mcp"]
|
||||
pkg_experimental_computer_use_cua_driver_native["experimental-computer-use-cua-driver-native"]
|
||||
@@ -1139,6 +1141,19 @@ flowchart TD
|
||||
pkg_experimental_browser_use_runtime --> pkg_scope
|
||||
pkg_experimental_browser_use_runtime --> pkg_system_prompt
|
||||
pkg_experimental_browser_use_runtime --> pkg_tools
|
||||
pkg_experimental_claude_code_mods --> pkg_agent
|
||||
pkg_experimental_claude_code_mods --> pkg_commands
|
||||
pkg_experimental_claude_code_mods --> pkg_fs
|
||||
pkg_experimental_claude_code_mods --> pkg_llm
|
||||
pkg_experimental_claude_code_mods --> pkg_scope
|
||||
pkg_experimental_claude_code_mods --> pkg_session
|
||||
pkg_experimental_claude_code_mods --> pkg_session_projection
|
||||
pkg_experimental_claude_code_mods --> pkg_storage_domain
|
||||
pkg_experimental_claude_code_mods --> pkg_subprocess
|
||||
pkg_experimental_claude_code_mods --> pkg_token_meter
|
||||
pkg_experimental_claude_code_mods --> pkg_tools
|
||||
pkg_experimental_claude_code_mods --> pkg_typert_protocol
|
||||
pkg_experimental_claude_code_mods --> pkg_user_questions
|
||||
pkg_webhook_github --> pkg_credentials
|
||||
pkg_webhook_github --> pkg_host_webserver
|
||||
pkg_webhook_github --> pkg_session
|
||||
@@ -1274,6 +1289,17 @@ flowchart TD
|
||||
pkg_experimental_client_ui_agent_team --> pkg_client_ui_workspace
|
||||
pkg_experimental_client_ui_agent_team --> pkg_experimental_agent_team
|
||||
pkg_experimental_client_ui_agent_team --> pkg_session
|
||||
pkg_experimental_client_ui_claude_code_mods --> pkg_api_remotes
|
||||
pkg_experimental_client_ui_claude_code_mods --> pkg_api_session_controller
|
||||
pkg_experimental_client_ui_claude_code_mods --> pkg_client_locale
|
||||
pkg_experimental_client_ui_claude_code_mods --> pkg_client_ui_conversation
|
||||
pkg_experimental_client_ui_claude_code_mods --> pkg_client_ui_primitives
|
||||
pkg_experimental_client_ui_claude_code_mods --> pkg_client_ui_renderer
|
||||
pkg_experimental_client_ui_claude_code_mods --> pkg_client_ui_session
|
||||
pkg_experimental_client_ui_claude_code_mods --> pkg_client_ui_slots
|
||||
pkg_experimental_client_ui_claude_code_mods --> pkg_experimental_claude_code_mods
|
||||
pkg_experimental_client_ui_claude_code_mods --> pkg_session
|
||||
pkg_experimental_client_ui_claude_code_mods --> pkg_typert_protocol
|
||||
pkg_experimental_client_ui_voice_input --> pkg_api_gateway
|
||||
pkg_experimental_client_ui_voice_input --> pkg_api_remotes
|
||||
pkg_experimental_client_ui_voice_input --> pkg_api_session_controller
|
||||
@@ -1642,6 +1668,7 @@ flowchart TD
|
||||
| [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) |
|
||||
| [`compaction-tool-result-pruner`](../packages/compaction/compaction-tool-result-pruner) | `compaction` | [`compaction`](../packages/compaction/compaction), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) |
|
||||
| [`experimental-browser-use-runtime`](../packages/experimental/browser-use-runtime) | `experimental` | [`agent`](../packages/core/agent), [`browser-use`](../packages/browser-use/browser-use), [`mcp-client`](../packages/mcp/mcp-client), [`scope`](../packages/core/scope), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
|
||||
| [`experimental-claude-code-mods`](../packages/experimental/claude-code-mods) | `experimental` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`fs`](../packages/fs/fs), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`storage-domain`](../packages/storage/storage-domain), [`subprocess`](../packages/subprocess/subprocess), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`user-questions`](../packages/interaction/user-questions) |
|
||||
| [`webhook-github`](../packages/webhook/webhook-github) | `webhook` | [`credentials`](../packages/credentials/credentials), [`host-webserver`](../packages/host/webserver), [`session`](../packages/core/session), [`webhook`](../packages/webhook/webhook) |
|
||||
| [`subagent-acp`](../packages/subagent/subagent-acp) | `subagent` | [`agent`](../packages/core/agent), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
|
||||
| [`subagent-claude-code`](../packages/subagent/subagent-claude-code) | `subagent` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) |
|
||||
@@ -1660,6 +1687,7 @@ flowchart TD
|
||||
| [`subagent-fork-in-process`](../packages/subagent/subagent-fork-in-process) | `subagent` | [`agent`](../packages/core/agent), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) |
|
||||
| [`subagent-spawn-in-process`](../packages/subagent/subagent-spawn-in-process) | `subagent` | [`subagent`](../packages/subagent/subagent), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) |
|
||||
| [`experimental-client-ui-agent-team`](../packages/experimental/client-ui-agent-team) | `experimental` | [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`experimental-agent-team`](../packages/experimental/agent-team), [`session`](../packages/core/session) |
|
||||
| [`experimental-client-ui-claude-code-mods`](../packages/experimental/client-ui-claude-code-mods) | `experimental` | [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`experimental-claude-code-mods`](../packages/experimental/claude-code-mods), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) |
|
||||
| [`experimental-client-ui-voice-input`](../packages/experimental/client-ui-voice-input) | `experimental` | [`api-gateway`](../packages/api/gateway), [`api-remotes`](../packages/api/remotes), [`api-session-controller`](../packages/api/session-controller), [`client-locale`](../packages/client/locale), [`client-store`](../packages/client/store), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-plugin-manager`](../packages/client/ui-plugin-manager), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-renderer`](../packages/client/ui-renderer), [`client-ui-session`](../packages/client/ui-session), [`client-ui-slots`](../packages/client/ui-slots), [`experimental-api-speech-to-text`](../packages/experimental/api-speech-to-text), [`experimental-speech-to-text`](../packages/experimental/speech-to-text), [`session`](../packages/core/session), [`typert-protocol`](../packages/typert/protocol) |
|
||||
| [`experimental-tool-agent-team`](../packages/experimental/tool-agent-team) | `experimental` | [`agent`](../packages/core/agent), [`experimental-agent-team`](../packages/experimental/agent-team), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) |
|
||||
| [`schedule`](../packages/schedule/schedule) | `schedule` | [`agent`](../packages/core/agent), [`api-session-controller`](../packages/api/session-controller), [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`storage-domain`](../packages/storage/storage-domain), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`typert-protocol`](../packages/typert/protocol), [`workspace`](../packages/workspace/workspace) |
|
||||
|
||||
@@ -15,11 +15,11 @@
|
||||
en: 02d18ba8a66cfb10
|
||||
zh: 54b753bbcb8f8a9e
|
||||
/session-format-version-and-release-status/finalization-record:
|
||||
en: 9a1b2f4c61551ab7
|
||||
zh: 666a2339c47343e1
|
||||
en: 15b6134dcb3a09d5
|
||||
zh: 4260c82e79baa021
|
||||
/session-format-version-and-release-status/release-record:
|
||||
en: ee6e54ebc2b6dd37
|
||||
zh: 928343edfd3fb943
|
||||
en: 83ce79a241b630d7
|
||||
zh: 53f3c461ef731f67
|
||||
/session-format-version-and-release-status/updating-the-record:
|
||||
en: 7eaa730a2d163bd6
|
||||
zh: 53ed7618537db2f5
|
||||
|
||||
@@ -36,17 +36,17 @@ V4 has an accepted compatibility baseline in the [checkpoint](persistence-change
|
||||
|
||||
Finalization does not freeze every future V4 addition and does not assert publication. The release record below retains the independently verified published version. Ordinary comments, aliases, source locations, and implementation fixes preserving the accepted meaning do not change this baseline.
|
||||
|
||||
Before the first V4 publication, every integration of a newer V3-writing master must pass the [explicit V3 vocabulary check](cookbook/adding-a-session-format-version.md#final-v3-vocabulary) against the recorded local source commit. Verify the source pin’s freshness and review new event payload conversions before updating the migration-owned set. After publication, the final V3 vocabulary remains historical and independent of current V4 additions.
|
||||
The [final V3 vocabulary](cookbook/adding-a-session-format-version.md#final-v3-vocabulary) remains historical and independent of current V4 additions.
|
||||
|
||||
<a id="release-record"></a>
|
||||
## Release record
|
||||
|
||||
```yaml session-format-release
|
||||
latestReleasedVersion: 3
|
||||
evidenceTag: dsh-v0.1.5-alpha.1
|
||||
latestReleasedVersion: 4
|
||||
evidenceTag: dsh-v0.2.0-rc.2
|
||||
```
|
||||
|
||||
Evidence: published product tag `dsh-v0.1.5-alpha.1`; tagged writer: `packages/core/session/src/types.ts`.
|
||||
Evidence: published product tag `dsh-v0.2.0-rc.2`; tagged writer: `packages/core/session/src/types.ts`.
|
||||
|
||||
<a id="updating-the-record"></a>
|
||||
## Updating the record
|
||||
|
||||
@@ -36,17 +36,17 @@ V4 的已接受兼容性基线保存在[检查点](persistence-changes/finalized
|
||||
|
||||
定稿不冻结 V4 之后的每项新增,也不表示已发布。下方发布记录保留独立验证的已发布版本。普通注释、别名、源码位置,以及保留已接受含义的实现修复,不改变该基线。
|
||||
|
||||
首次发布 V4 前,每次集成仍写入 V3 的较新 master 都必须对已记录的本地源提交执行[显式 V3 词汇校验](cookbook/adding-a-session-format-version.zh.md#final-v3-vocabulary)。更新迁移所有的集合前,要核验源 pin 的新鲜度并审查新事件 payload 的转换。发布后,最终 V3 词汇保持为历史定义,与当前 V4 新增事件无关。
|
||||
最终 [V3 词汇](cookbook/adding-a-session-format-version.zh.md#final-v3-vocabulary)保持为历史定义,与当前 V4 新增事件无关。
|
||||
|
||||
<a id="release-record"></a>
|
||||
## 发布记录
|
||||
|
||||
```yaml session-format-release
|
||||
latestReleasedVersion: 3
|
||||
evidenceTag: dsh-v0.1.5-alpha.1
|
||||
latestReleasedVersion: 4
|
||||
evidenceTag: dsh-v0.2.0-rc.2
|
||||
```
|
||||
|
||||
证据:已发布产品标签 `dsh-v0.1.5-alpha.1`;该标签的写入器路径:`packages/core/session/src/types.ts`。
|
||||
证据:已发布产品标签 `dsh-v0.2.0-rc.2`;该标签的写入器路径:`packages/core/session/src/types.ts`。
|
||||
|
||||
<a id="updating-the-record"></a>
|
||||
## 更新记录
|
||||
|
||||
@@ -3,5 +3,5 @@
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/subsystems/README.md
|
||||
/subsystems:
|
||||
en: 3716b19db3b2e1b2
|
||||
zh: a9611e4745f0ac89
|
||||
en: d1e3439cb56ffc96
|
||||
zh: 18e3970ad3f6237b
|
||||
@@ -48,6 +48,7 @@ One page per subsystem of the DeepSeek Harness: what it is, the data structures
|
||||
| [subagent.md](subagent.md) | the subagent seam: the named-provider registry, `SubagentStartRequest`/`Result`/`Run`, the start-time-vs-runtime capability split |
|
||||
| [voice-input.md](voice-input.md) | experimental named recognizers, transient audio and revision-guarded draft insertion |
|
||||
| [agent-team.md](agent-team.md) | Agent Teams: implicit Lead identity, named continuable teammates, durable peer mailbox, and shared task DAG |
|
||||
| [claude-code-mods.md](claude-code-mods.md) | Claude Code mods through the experimental bridge: every event, `$` member, chain rule, drawing feature, and example mod that differs from Claude Code |
|
||||
| [web.md](web.md) | the web access seam: `WebSearchRequest`/`Result`, `WebFetchRequest`/`Result`, `WebFetchBody`, provider availability, `WebError` |
|
||||
| [spill.md](spill.md) | the spill storage seam: `SaveTextSpill`, `SpillOwner`/`SpillSource`, `SpillRef`, the branded `SpillLocator` |
|
||||
| [workflow.md](workflow.md) | the workflow seam: `WorkflowStartRequest`, `WorkflowMeta`, `WorkflowRun`/`Result`, the `workflow/*` event payloads, `WorkflowError` fatality |
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
| [subagent.md](subagent.zh.md) | subagent seam:命名提供方注册表、`SubagentStartRequest`/`Result`/`Run`、启动时与运行时能力拆分 |
|
||||
| [voice-input.md](voice-input.zh.md) | 实验性具名语音识别、临时音频和受版本保护的草稿插入 |
|
||||
| [agent-team.md](agent-team.zh.md) | Agent Teams:隐式 Lead 身份、具名 continuable teammate、持久 peer mailbox 与共享任务 DAG |
|
||||
| [claude-code-mods.md](claude-code-mods.zh.md) | 经实验性桥接运行的 Claude Code 模组:与 Claude Code 不同的每个事件、`$` 成员、链规则、绘制特性与示例模组 |
|
||||
| [web.md](web.zh.md) | Web 访问 seam:`WebSearchRequest`/`Result`、`WebFetchRequest`/`Result`、`WebFetchBody`、提供方可用性、`WebError` |
|
||||
| [spill.md](spill.zh.md) | spill 存储 seam:`SaveTextSpill`、`SpillOwner`/`SpillSource`、`SpillRef`、品牌类型 `SpillLocator` |
|
||||
| [workflow.md](workflow.zh.md) | 工作流 seam:`WorkflowStartRequest`、`WorkflowMeta`、`WorkflowRun`/`Result`、`workflow/*` 事件载荷、`WorkflowError` 致命性 |
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
# Bilingual-pair consistency record for claude-code-mods.md (docs/i18n/README.md): per heading
|
||||
# section, a hash of its English and Chinese blocks outside code blocks and generated regions.
|
||||
# After editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/subsystems/claude-code-mods.md
|
||||
/claude-code-mods-compatibility:
|
||||
en: e520df3bf44e1160
|
||||
zh: 557956eaf6faf4a7
|
||||
/claude-code-mods-compatibility/packaging-and-loading:
|
||||
en: 75cf021ce8babaab
|
||||
zh: caa20540f8bce11a
|
||||
/claude-code-mods-compatibility/events:
|
||||
en: cea776ec0063c311
|
||||
zh: 6b8c8b32f2f7ede9
|
||||
/claude-code-mods-compatibility/the-chain:
|
||||
en: 111c186536345342
|
||||
zh: 8ddc81c78c05a840
|
||||
/claude-code-mods-compatibility/members:
|
||||
en: 1f1d39f01937b39c
|
||||
zh: 6365f4d47ea0ba8f
|
||||
/claude-code-mods-compatibility/drawing:
|
||||
en: 1bd1a60970fcf426
|
||||
zh: 8f6a8e069f742fb7
|
||||
/claude-code-mods-compatibility/testing:
|
||||
en: 8b3530b79e09a067
|
||||
zh: c041e63f7fd8de5a
|
||||
/claude-code-mods-compatibility/the-example-mods:
|
||||
en: b3ef5ea831c5c48f
|
||||
zh: fa64186430e7c772
|
||||
@@ -0,0 +1,146 @@
|
||||
# Claude Code mods: compatibility
|
||||
|
||||
English | [中文](claude-code-mods.zh.md)
|
||||
|
||||
Every way a mod behaves differently through this bridge than under Claude Code, as of Claude Code 2.1.287 ([mods reference](https://code.claude.com/docs/en/plugins/mods/reference), its `claude-code.d.ts`) and the [Getting started with Claude Code mods](https://claude.dev/blog/getting-started-with-claude-code-mods/) post of 2026-10-01. A row names the Claude Code behavior, what this bridge does, and the reason or the follow-up. Anything not listed here behaves as the reference describes; a hook on an event this host never raises is named in a warning when the mod loads, and a `$` call outside the served table rejects with `no implementation for <namespace>.<method>`.
|
||||
|
||||
## Packaging and loading
|
||||
|
||||
| Claude Code | This bridge | Reason or follow-up |
|
||||
|---|---|---|
|
||||
| A plugin directory with `.claude-plugin/plugin.json` and `hooks/hooks.json` naming one hooks module; `claude --plugin-dir`, marketplaces, `/plugin install` | A mod is a plugin: `defineMod({ name, version, root, userConfig, register })` wraps the hooks module and mounts through cordis.yml after the bridge; `plugin.json` and `hooks.json` are not read | Mods join the one composition mechanism every DSH plugin uses; the example directories keep their manifests so they also run under `claude --plugin-dir` |
|
||||
| `userConfig` schema in `plugin.json` with typed defaults; options validated at load | `userConfig` is the defaults object of the `defineMod` spec; the plugin's cordis.yml `config` overlays it and is validated as a record of strings, numbers, booleans, and string lists | No manifest is read |
|
||||
| Tiers (`user`, `project`, `managed`) and `next.to(e, tier)` | Every mod loads as `user`; `tier()` in a test is accepted and ignored; `next.to` rejects | Managed settings are not a DSH concept |
|
||||
| Hot reload of the hooks module on edit; `claude plugin validate`, `claude plugin test`, types generation from `types/index.d.ts` | None of these commands; a remount re-runs `register` on the same evaluated module, so module-level variables keep their values | Follow-up only on demand; the test kit's `createModTestKit` replaces `claude plugin test` inside Vitest |
|
||||
| A hooks module may be TypeScript | A `.ts` hooks module loads only where the launcher transpiles (the source launch does; a built install does not) | DSH ships plain Node |
|
||||
| Mods run in-process with `$` as their only access to the host | The hooks module runs in-process with Node's globals, no access rule, and the process's full authority: `$.env` reads and writes the harness environment, `$.http.fetch` reaches any URL, `$.fs` and `$.tool.call` act as the session | No sandbox is applied to mods; mount only mods you would run as a plugin |
|
||||
| `import type { … } from 'claude-code'`, `declare module 'claude-code' { interface PluginState }` | The `claude-code` module name resolves only inside this repository's test setup, to the bridge's types; a mod outside it imports `@deepseek-ai/dsh-experimental-claude-code-mods` | The type names are the bridge's own |
|
||||
| `settings hooks` in `hooks.json` run beside mod hooks | Not run; mount `@deepseek-ai/dsh-hooks-claude-code` for them | Different bridge |
|
||||
|
||||
## Events
|
||||
|
||||
| Event | Claude Code | This bridge |
|
||||
|---|---|---|
|
||||
| `session.start` | Raised once per session, before the first prompt | Raised for a root agent from `agent/created`, awaited before its first turn; not raised for subagents |
|
||||
| `session.end` | Raised once per session | Raised for a root agent from `agent/disposed`; `$.state` is readable until the hooks settle |
|
||||
| `prompt.submit` | `e.text` is the typed prompt; `context` entries become one block each after the prompt as typed; a prompt a plugin submitted carries `origin: { kind: 'plugin', name }` | Same; `e.text` joins the text blocks of the human's own (`user`-sourced) messages in the claimed batch, a batch of injected context alone raises nothing, and a prompt `$.prompt.submit` queued carries the submitting mod as its origin |
|
||||
| `turn.start`, `turn.complete` | Per turn; subagent turns carry `agentId` | Same; `turn.complete`'s `{ text }` reaches the host log, not a line under the answer; `durationMs` counts from `turn/start` |
|
||||
| `tool.call` | Runs before Claude Code's permission check; `next({ ...e, command })` rewrites the arguments the tool runs with, and `next({ ...e, tool })` reroutes the call | Runs around `tools/execute`, after the harness permission decision; a hook (or `.catch` handler) that rewrites arguments or names another tool is skipped with a report — the argument case names the [pre-tool input rewrite proposal](../../.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.md) — because the logged call runs as logged. `{ deny }`, `{ result }`, observing, and rewriting the result or `isError` after `next` work as described |
|
||||
| `command.run` | Runs for a command the mod registered | Same |
|
||||
| `ui.render` | Raised for every render site (`AbovePrompt`, `Pane`, `ToolUse`, `AssistantMessage`, `Spinner`, …) | Raised for `AbovePrompt` only, once per redraw of a session's band; `Pane` hooks run only through the test kit's `$.ui.mount({ component: 'Pane' })` |
|
||||
| `tool.check`, `tool.describe`, `tool.list`, `tool.register` | Shape the tool list and permissions | Not raised; `$.tool.register` and `$.tool.list` are served as `$` calls |
|
||||
| `turn.step` | Per model step | Not raised |
|
||||
| Every other reference event: the other `prompt.*` events, `skill.prompt`, `attribution.text`, `command.describe`, `config.*`, the other `session.*` events, `agent.*`, `plugin.register`, `engine.create`, `telemetry.*`, `ui.resolve`, `ui.press`, `ui.input`, `ui.select`, `ui.focus`, `ui.scroll`, `ui.close`, `ui.message` | Raised by the engine | Registered, never raised, named in a warning at load |
|
||||
| `classic.*` | Classic settings hooks as events | Refused at `register`, like any unknown event name |
|
||||
| `<namespace>.<method>` op events (`fs.read`, `store.set`, …) | A later-loaded mod's `$` call reaches the mods loaded before it | Same; `tool.call` arrives through the tool pipeline instead |
|
||||
|
||||
## The chain
|
||||
|
||||
| Claude Code | This bridge | Reason |
|
||||
|---|---|---|
|
||||
| A hook may call `next` more than once; each call runs the chain beneath again | `next` runs once: a second call returns the first run's result | One tool execution per logged call |
|
||||
| A hook that answers while its `next` is still running | The run beneath is awaited before the hook's answer settles; a failure beneath after the answer is reported | The event settles only when nothing it started is running |
|
||||
| 10 s own-time budget, 1 s for `.catch`, time inside `next` and `$` calls excluded except `$.clock.sleep` | Same, configurable (`hookTimeoutMs`, `catchTimeoutMs`) | — |
|
||||
| Not returning a result object is a failure | Same; `null` is accepted as an answer (a surface drawn empty) | `ui.render` with nothing to draw |
|
||||
| A result's unknown fields are dropped and wrong types fail the hook | A `prompt.submit` result with wrong types keeps the original text and only the string context lines | Lenient on the one event where partial results are common |
|
||||
|
||||
## `$` members
|
||||
|
||||
| Namespace | Claude Code | This bridge |
|
||||
|---|---|---|
|
||||
| `$.plugin` | `name`, `root`, `tier` | `name`, `root` from the `defineMod` spec |
|
||||
| `$.ui` | `resolve`, `invalidate`, `open`, `close`, `panes`, `focus`, `log`, `toast`, `status`, `notice`, `ask`, `blit`, `copy`, `press`, `input`, `select` | `resolve`, `invalidate`, `open`, `close`, `panes`, `log`, `toast`, `status`, `ask`; `open` answers `{ isPlaced: false }` with a reason and redraws the band; `log`, `toast`, `status` reach the host log; the rest reject |
|
||||
| `$.command` | `register`, `run`, `list` | Same; `run` of a command nobody answers rejects with the registering mod named |
|
||||
| `$.tool` | `register`, `call`, `list` | Same; a registered tool is `mcp__<plugin>__<tool>`; `call` runs the harness pipeline and injects a tool's deferred contexts into the session |
|
||||
| `$.prompt` | `submit`, `compose` | `submit`; the message is `user`-sourced, framed `Message from the "<plugin>" mod:` unless `asUser`, and the `prompt.submit` it raises names the mod as origin |
|
||||
| `$.session` | `id`, `cwd`, `root`, `repo`, `model`, `turns`, `messages`, `usage`, `version`, `send`, `append`, `authorize`, `compact`, `surfaces` | `id`, `cwd`, `root`, `model`, `turns`, `messages`, `usage`, `version`; `cwd` and `root` both report the session workspace; `usage.context.window` is `0` and `percent` absent until the token meter knows the route's window; `rateLimits` is empty; `version` names this bridge |
|
||||
| `$.state` | Per session, survives hot reload | Per session, in memory; a read during `ui.render` subscribes the band to the slot |
|
||||
| `$.store` | Per plugin, durable | Same, in the `claude_code_mods` storage domain, 4 MiB per plugin |
|
||||
| `$.clock` | `now`, `sleep`, `after`, `every`; timers die with the plugin | Same; a timer also dies with the session whose event scheduled it, and `sleep` rejects when the event is cancelled |
|
||||
| `$.fs` | `read`, `write`, `list`, `exists`, `stat`, `ancestors` | All but `ancestors`; 4 MiB per file; `stat.mtimeMs` is `0` |
|
||||
| `$.process` | `run`, `spawn` | `run` only, argv without a shell, `processTimeoutMs` default |
|
||||
| `$.http` | `fetch` | Same, bodies up to 4 MiB |
|
||||
| `$.env` | `get`, `set` | Same; the process environment is shared by every session and plugin |
|
||||
| `$.model`, `$.agent`, `$.config`, `$.settings`, `$.mcp`, `$.audio`, `$.telemetry`, `$.turn` | Served | Reject with `no implementation`; `$.model.complete` waits on a logged side-request event |
|
||||
|
||||
## Drawing
|
||||
|
||||
| Claude Code | This bridge |
|
||||
|---|---|
|
||||
| `Box`, `Text`, `Button`, `Input`, `Spacer`, and the host's own element kinds | `Box`, `Text`, `Button` from `$.ui.resolve(e)`; the tree is validated, and a `Button`'s `onPress` stays in the host behind a per-drawing action id |
|
||||
| `Box` props: `flexDirection`, `padding*`, `gap`, `border*`, `width`, `height`, `overflow`, `scroll` | `flexDirection`, `padding`, `paddingX`, `paddingY`, `gap`, `border`, `borderColor`; the others serialize and the Web band ignores them |
|
||||
| `Text` props: `color`, `bold`, `dimColor`, `italic`, `underline`, `wrap`, `truncate` | `color` (the terminal palette names), `bold`, `dimColor`, `italic`, `underline`; the Web band wraps |
|
||||
| `Button` props: `label`, `hotkey`, `disabled`, `onPress`; a bare digit arms the hotkey | `label`, `hotkey` (shown as a hint), `disabled`, `onPress` (a click) |
|
||||
| The band is one instance; `hasSurvey`, `isWorking`, `maxRows`, `bodyColumns` reflect the terminal | One instance per session, consulted in mod load order; `bodyColumns` and `viewport.columns` are the bridge's `bandColumns` (default `120`), `maxRows` its `bandRows` (default `10`), `hasSurvey` and `isWorking` are `false`; the Web band wraps instead of scrolling |
|
||||
| The band redraws on the host's frame and when a `$.state` value a render read changes | Redraws after `session.start`, when a `$.state` value the last drawing read is written, after `$.ui.open`, `$.ui.close`, `$.ui.invalidate`, after each `tool.call` chain and `turn.complete` settle, and after a press; a mod that changes module-level state at other times draws it at the next trigger. A press runs `onPress` without a time limit |
|
||||
| `Pane` with `focus`, `scroll`, docked beside the transcript | No pane: `$.ui.open` answers `{ isPlaced: false }` so a mod falls back to the band; the test kit mounts `Pane` hooks directly |
|
||||
| Hotkeys, `TextInput`, focus, scrolling, `ui.press`, `ui.input`, `ui.select` | Not served; follow-up |
|
||||
|
||||
## Testing
|
||||
|
||||
| `claude-code/testing` | This bridge |
|
||||
|---|---|
|
||||
| `describe`, `test(name, ($, on) => …)`, `expect`, `mock`, `tier` | Inside this repository the same module name provides them over Vitest; `test` infers the mod from the test file's location (`<mod>/tests/*.test.ts` → `<mod>/index.ts`) and `defineModTests` overrides it. Outside, `createModTestKit` and `mock` come from `@deepseek-ai/dsh-experimental-claude-code-mods/testing` |
|
||||
| `on` stubs the engine's answers; the kit answers `ui.*` itself | Same; `ui.open` answers `{ isPlaced: false }`, `ui.invalidate` and `ui.close` succeed; `session.cwd`, `process.run`, `fs.*`, `store.*`, `env.*` need a stub (`mock.store`, `mock.env`, `mock.clock` answer whole namespaces) |
|
||||
| `$.ui.mount({ plugin, surface, component, props })` → `find`, `findAll`, `press`, `unmount` | Same, plus `tree()` and `text()`; every read renders afresh through every loaded mod's hooks; `plugin` and `surface` are accepted for source compatibility |
|
||||
| 5 s hook budget in tests | Same (`budgetMs`) |
|
||||
|
||||
## The example mods
|
||||
|
||||
| Mod | Source | Through this bridge |
|
||||
|---|---|---|
|
||||
| Token Weather | The post's module, type contract, and test, unchanged | Runs as published; the test's three `as any` casts are dropped because the kit is typed. Draws in the band from `$.session.usage` through the token meter |
|
||||
| Blast Radius | The post's `tool.call` hook unchanged; `classify`, `measure`, and the trees completed to the post's description | Holds the command with real `sleep` polls, draws the report in the band (`isPlaced: false`), and Proceed or Cancel decides; load it before Token Weather |
|
||||
| Replay Theater | The post's five hooks unchanged; `stepsFor`, the diff, `openReplay`, the pane and the hint completed | Records Edit and Write calls, hints in the band after the turn, and steps through the diffs in the band because no pane is placed |
|
||||
| `diff` ([anthropics/claude-code](https://github.com/anthropics/claude-code/tree/main/mods/diff), read at `52c7644`) | TypeScript, ~570 files, hooks `command.run`, `prompt.submit`, `session.start`, `tool.call`, `ui.close`, `ui.focus`, `ui.render`, `ui.scroll` | Not runnable: needs `$.settings.read`, `$.telemetry.*`, `$.session.messages` over a pane, `ui.focus` and `ui.scroll` events, and a placed `Pane`. Assessed by reading; the repository's terms do not allow a fixture copy |
|
||||
| `agents-md` (same repository) | Hooks `agent.spawn`, `prompt.context`, `session.start`, `tool.call` | Not runnable: `prompt.context` and `agent.spawn` are not raised and `$.fs.ancestors`, `$.telemetry.*` are not served |
|
||||
| `sec-default` (same repository) | Hooks `tool.check`, `prompt.compose`, `settings.read`, `classic.*`, `plugin.register`, … | Not runnable: managed-tier policy over events this host does not raise |
|
||||
| `telemetry` (same repository) | Hooks `engine.create`, `telemetry.*`, `session.start`, `session.end`; calls `$.session.repo`, `$.settings.read` | Not runnable: `telemetry.*` and `engine.create` are not raised and the two `$` members are not served |
|
||||
|
||||
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
|
||||
|
||||
<a id="cordis-surface"></a>
|
||||
|
||||
## Cordis API
|
||||
|
||||
Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md).
|
||||
|
||||
<a id="ctxclaudecodemods--claudecodemods"></a>
|
||||
|
||||
### `ctx.claudeCodeMods` — `ClaudeCodeMods`
|
||||
|
||||
The bridge service: loaded mods, their hooks, and the harness listeners that raise their events. Mods join through ClaudeCodeMods.add, which defineMod calls when a mod plugin mounts. The Remote methods let a Client draw each session's band above the prompt and press its buttons.
|
||||
|
||||
```ts cordis-catalog
|
||||
/**
|
||||
* Watch the band above the prompt of one session: the current drawing, then
|
||||
* every redraw, until the Client stops watching.
|
||||
* @param agent - the session's agent, resolved by the Gateway.
|
||||
* @param signal - carrier cancellation.
|
||||
* @returns the band's snapshots.
|
||||
*/
|
||||
@Remote({ mode: 'stream' }) watchBand(agent: Agent, signal: AbortSignal): AsyncIterable<SurfaceSnapshot>
|
||||
|
||||
/**
|
||||
* Press a button of the band's current drawing: runs the mod's `onPress` and redraws.
|
||||
* @param agent - the session's agent, resolved by the Gateway.
|
||||
* @param generation - the drawing the Client saw.
|
||||
* @param actionId - the button's action id in that drawing.
|
||||
* @returns the snapshot after the press.
|
||||
*/
|
||||
@Remote pressBand(agent: Agent, generation: number, actionId: string): Promise<SurfaceSnapshot>
|
||||
|
||||
/**
|
||||
* Load one mod beneath every mod loaded before it: run its `register`,
|
||||
* keep its hooks, and report which of its events this host never raises.
|
||||
* @param definition - the mod as its plugin defined it.
|
||||
* @returns the disposer that removes the mod's hooks, closes its timers, and releases its registrations.
|
||||
* @throws Error when the name is invalid or taken, or when `register` throws (Claude Code's `hooks module did not load` wording).
|
||||
*/
|
||||
async add(definition: ModDefinition): Promise<() => Promise<void>>
|
||||
```
|
||||
|
||||
Types: [Agent](core.md)
|
||||
|
||||
Source: [`packages/experimental/claude-code-mods/src/index.ts`](../../packages/experimental/claude-code-mods/src/index.ts)
|
||||
<!-- END GENERATED cordis-surface -->
|
||||
@@ -0,0 +1,146 @@
|
||||
# Claude Code 模组:兼容性
|
||||
|
||||
[English](claude-code-mods.md) | 中文
|
||||
|
||||
模组经本桥接运行时与在 Claude Code 下行为不同的每一处,基准为 Claude Code 2.1.287([模组参考](https://code.claude.com/docs/en/plugins/mods/reference)及其 `claude-code.d.ts`)与 2026-10-01 的 [Getting started with Claude Code mods](https://claude.dev/blog/getting-started-with-claude-code-mods/) 一文。每行给出 Claude Code 的行为、本桥接的做法,以及原因或后续工作。未列出的一切均按参考文档描述的方式运行;对本宿主永不触发的事件注册钩子会在模组加载时以警告点名,服务表之外的 `$` 调用以 `no implementation for <namespace>.<method>` 拒绝。
|
||||
|
||||
## 打包与加载
|
||||
|
||||
| Claude Code | 本桥接 | 原因或后续 |
|
||||
|---|---|---|
|
||||
| 一个插件目录,含 `.claude-plugin/plugin.json` 与指定一个钩子模块的 `hooks/hooks.json`;`claude --plugin-dir`、市场、`/plugin install` | 模组就是插件:`defineMod({ name, version, root, userConfig, register })` 包装钩子模块,并在桥接之后经 cordis.yml 挂载;不读取 `plugin.json` 与 `hooks.json` | 模组加入每个 DSH 插件都使用的同一组合机制;示例目录保留其清单,因此也能在 `claude --plugin-dir` 下运行 |
|
||||
| `plugin.json` 中带类型默认值的 `userConfig` schema;选项在加载时校验 | `userConfig` 是 `defineMod` 规格的默认值对象;插件的 cordis.yml `config` 覆盖其上,并按字符串、数字、布尔与字符串列表的记录校验 | 不读取清单 |
|
||||
| 层级(`user`、`project`、`managed`)与 `next.to(e, tier)` | 每个模组都以 `user` 加载;测试中的 `tier()` 被接受并忽略;`next.to` 拒绝 | 托管设置不是 DSH 的概念 |
|
||||
| 编辑时热重载钩子模块;`claude plugin validate`、`claude plugin test`、由 `types/index.d.ts` 生成类型 | 这些命令都没有;重新挂载会在同一已求值模块上重新运行 `register`,模块级变量保留其值 | 仅按需跟进;测试工具包的 `createModTestKit` 在 Vitest 中替代 `claude plugin test` |
|
||||
| 钩子模块可以是 TypeScript | `.ts` 钩子模块只在启动器转译时可加载(源码启动可以;构建后的安装不行) | DSH 交付的是纯 Node |
|
||||
| 模组在进程内运行,`$` 是其访问宿主的唯一途径 | 钩子模块以 Node 全局对象在进程内运行,没有访问规则,并拥有进程的全部权限:`$.env` 读写 harness 环境变量,`$.http.fetch` 可访问任意 URL,`$.fs` 与 `$.tool.call` 以会话的身份行动 | 不对模组施加沙箱;只挂载你愿意作为插件运行的模组 |
|
||||
| `import type { … } from 'claude-code'`、`declare module 'claude-code' { interface PluginState }` | `claude-code` 模块名只在本仓库的测试设置中解析到桥接的类型;仓库外的模组导入 `@deepseek-ai/dsh-experimental-claude-code-mods` | 类型名是桥接自己的 |
|
||||
| `hooks.json` 中的设置钩子与模组钩子并行运行 | 不运行;为它们挂载 `@deepseek-ai/dsh-hooks-claude-code` | 不同的桥接 |
|
||||
|
||||
## 事件
|
||||
|
||||
| 事件 | Claude Code | 本桥接 |
|
||||
|---|---|---|
|
||||
| `session.start` | 每会话触发一次,在首个提示词之前 | 由根智能体的 `agent/created` 触发,在其首轮之前等待完成;不为子智能体触发 |
|
||||
| `session.end` | 每会话触发一次 | 由根智能体的 `agent/disposed` 触发;钩子结束前 `$.state` 可读 |
|
||||
| `prompt.submit` | `e.text` 是输入的提示词;`context` 条目各成一块跟在输入的提示词之后;插件提交的提示词带 `origin: { kind: 'plugin', name }` | 相同;`e.text` 拼接已认领批次中人类自己(`user` 来源)消息的文本块,仅含注入上下文的批次不触发,`$.prompt.submit` 排入的提示词以提交的模组为来源 |
|
||||
| `turn.start`、`turn.complete` | 每轮触发;子智能体的轮带 `agentId` | 相同;`turn.complete` 的 `{ text }` 到达宿主日志,而非答案下方的一行;`durationMs` 从 `turn/start` 起算 |
|
||||
| `tool.call` | 在 Claude Code 的权限检查之前运行;`next({ ...e, command })` 改写工具实际运行的参数,`next({ ...e, tool })` 把调用改派给另一工具 | 包裹 `tools/execute` 运行,在 harness 权限决定之后;改写参数或改名工具的钩子(或 `.catch` 处理器)会被跳过并报告——参数情形指向[工具前输入改写提案](../../.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.zh.md)——因为已写入日志的调用按原样运行。`{ deny }`、`{ result }`、观察以及在 `next` 之后改写结果或 `isError` 均按描述工作 |
|
||||
| `command.run` | 为模组注册的命令运行 | 相同 |
|
||||
| `ui.render` | 为每个渲染位点触发(`AbovePrompt`、`Pane`、`ToolUse`、`AssistantMessage`、`Spinner`……) | 只为 `AbovePrompt` 触发,每次会话横幅重绘一次;`Pane` 钩子只经测试工具包的 `$.ui.mount({ component: 'Pane' })` 运行 |
|
||||
| `tool.check`、`tool.describe`、`tool.list`、`tool.register` | 塑造工具列表与权限 | 不触发;`$.tool.register` 与 `$.tool.list` 作为 `$` 调用服务 |
|
||||
| `turn.step` | 每个模型步骤 | 不触发 |
|
||||
| 参考中的其他所有事件:其他 `prompt.*` 事件、`skill.prompt`、`attribution.text`、`command.describe`、`config.*`、其他 `session.*` 事件、`agent.*`、`plugin.register`、`engine.create`、`telemetry.*`、`ui.resolve`、`ui.press`、`ui.input`、`ui.select`、`ui.focus`、`ui.scroll`、`ui.close`、`ui.message` | 由引擎触发 | 可注册,永不触发,在加载时以警告点名 |
|
||||
| `classic.*` | 经典设置钩子作为事件 | 在 `register` 时拒绝,与任何未知事件名相同 |
|
||||
| `<namespace>.<method>` 操作事件(`fs.read`、`store.set`……) | 更晚加载的模组的 `$` 调用到达在它之前加载的模组 | 相同;`tool.call` 则经由工具流水线到达 |
|
||||
|
||||
## 链
|
||||
|
||||
| Claude Code | 本桥接 | 原因 |
|
||||
|---|---|---|
|
||||
| 钩子可多次调用 `next`;每次调用都再次运行下层链 | `next` 只运行一次:第二次调用返回第一次运行的结果 | 每条写入日志的调用只执行一次工具 |
|
||||
| 钩子在其 `next` 仍在运行时作答 | 下层运行会在钩子答案落定前被等待;答案之后的下层失败会被报告 | 事件只在其启动的一切都结束后落定 |
|
||||
| 自身时间预算 10 秒,`.catch` 为 1 秒,`next` 与 `$` 调用内的时间不计,`$.clock.sleep` 除外 | 相同,可配置(`hookTimeoutMs`、`catchTimeoutMs`) | — |
|
||||
| 不返回结果对象视为失败 | 相同;`null` 被接受为答案(绘制为空的表面) | 无可绘制内容的 `ui.render` |
|
||||
| 结果中的未知字段被丢弃,类型错误使钩子失败 | 类型错误的 `prompt.submit` 结果保留原文本,并只取字符串形式的 context 行 | 对这一常见部分结果的事件从宽处理 |
|
||||
|
||||
## `$` 成员
|
||||
|
||||
| 命名空间 | Claude Code | 本桥接 |
|
||||
|---|---|---|
|
||||
| `$.plugin` | `name`、`root`、`tier` | 来自 `defineMod` 规格的 `name`、`root` |
|
||||
| `$.ui` | `resolve`、`invalidate`、`open`、`close`、`panes`、`focus`、`log`、`toast`、`status`、`notice`、`ask`、`blit`、`copy`、`press`、`input`、`select` | `resolve`、`invalidate`、`open`、`close`、`panes`、`log`、`toast`、`status`、`ask`;`open` 带原因回答 `{ isPlaced: false }` 并重绘横幅;`log`、`toast`、`status` 到达宿主日志;其余拒绝 |
|
||||
| `$.command` | `register`、`run`、`list` | 相同;`run` 一个无人回答的命令时拒绝,并点名注册它的模组 |
|
||||
| `$.tool` | `register`、`call`、`list` | 相同;注册的工具为 `mcp__<plugin>__<tool>`;`call` 运行 harness 流水线,并把工具推迟的上下文注入会话 |
|
||||
| `$.prompt` | `submit`、`compose` | `submit`;消息为 `user` 来源,除非 `asUser` 否则套上 `Message from the "<plugin>" mod:` 框架,它触发的 `prompt.submit` 以该模组为来源 |
|
||||
| `$.session` | `id`、`cwd`、`root`、`repo`、`model`、`turns`、`messages`、`usage`、`version`、`send`、`append`、`authorize`、`compact`、`surfaces` | `id`、`cwd`、`root`、`model`、`turns`、`messages`、`usage`、`version`;`cwd` 与 `root` 都报告会话工作区;在 token 计量器得知路由窗口前 `usage.context.window` 为 `0` 且无 `percent`;`rateLimits` 为空;`version` 指明本桥接 |
|
||||
| `$.state` | 按会话,热重载后保留 | 按会话,内存中;`ui.render` 期间的读取让横幅订阅该槽位 |
|
||||
| `$.store` | 按插件,持久 | 相同,在 `claude_code_mods` 存储域中,每插件 4 MiB |
|
||||
| `$.clock` | `now`、`sleep`、`after`、`every`;定时器随插件消亡 | 相同;定时器还随安排它的事件所属会话消亡,事件取消时 `sleep` 拒绝 |
|
||||
| `$.fs` | `read`、`write`、`list`、`exists`、`stat`、`ancestors` | 除 `ancestors` 外全部;每文件 4 MiB;`stat.mtimeMs` 为 `0` |
|
||||
| `$.process` | `run`、`spawn` | 仅 `run`,不经 shell 的 argv,默认 `processTimeoutMs` |
|
||||
| `$.http` | `fetch` | 相同,正文最多 4 MiB |
|
||||
| `$.env` | `get`、`set` | 相同;进程环境由所有会话与插件共享 |
|
||||
| `$.model`、`$.agent`、`$.config`、`$.settings`、`$.mcp`、`$.audio`、`$.telemetry`、`$.turn` | 已服务 | 以 `no implementation` 拒绝;`$.model.complete` 等待一个写入日志的副请求事件 |
|
||||
|
||||
## 绘制
|
||||
|
||||
| Claude Code | 本桥接 |
|
||||
|---|---|
|
||||
| `Box`、`Text`、`Button`、`Input`、`Spacer` 以及宿主自己的元素种类 | 来自 `$.ui.resolve(e)` 的 `Box`、`Text`、`Button`;树经校验,`Button` 的 `onPress` 留在宿主中,位于按绘制分配的动作 id 之后 |
|
||||
| `Box` 属性:`flexDirection`、`padding*`、`gap`、`border*`、`width`、`height`、`overflow`、`scroll` | `flexDirection`、`padding`、`paddingX`、`paddingY`、`gap`、`border`、`borderColor`;其余会序列化,Web 横幅忽略 |
|
||||
| `Text` 属性:`color`、`bold`、`dimColor`、`italic`、`underline`、`wrap`、`truncate` | `color`(终端调色板名)、`bold`、`dimColor`、`italic`、`underline`;Web 横幅会换行 |
|
||||
| `Button` 属性:`label`、`hotkey`、`disabled`、`onPress`;单个数字键触发热键 | `label`、`hotkey`(显示为提示)、`disabled`、`onPress`(点击) |
|
||||
| 横幅是一个实例;`hasSurvey`、`isWorking`、`maxRows`、`bodyColumns` 反映终端 | 每会话一个实例,按模组加载顺序咨询;`bodyColumns` 与 `viewport.columns` 取桥接的 `bandColumns`(默认 `120`),`maxRows` 取 `bandRows`(默认 `10`),`hasSurvey` 与 `isWorking` 为 `false`;Web 横幅换行而非滚动 |
|
||||
| 横幅随宿主帧重绘,并在渲染读取过的 `$.state` 值变化时重绘 | 在 `session.start` 之后、上次绘制读取过的 `$.state` 值被写入时、在 `$.ui.open`、`$.ui.close`、`$.ui.invalidate` 之后、在每条 `tool.call` 链与 `turn.complete` 结束后以及按下之后重绘;在其他时刻改变模块级状态的模组在下一次触发时绘出。按下运行 `onPress` 时没有时间限制 |
|
||||
| 带 `focus`、`scroll`、停靠在对话旁的 `Pane` | 没有面板:`$.ui.open` 回答 `{ isPlaced: false }`,模组回退到横幅;测试工具包直接挂载 `Pane` 钩子 |
|
||||
| 热键、`TextInput`、焦点、滚动、`ui.press`、`ui.input`、`ui.select` | 不服务;后续工作 |
|
||||
|
||||
## 测试
|
||||
|
||||
| `claude-code/testing` | 本桥接 |
|
||||
|---|---|
|
||||
| `describe`、`test(name, ($, on) => …)`、`expect`、`mock`、`tier` | 在本仓库内同一模块名基于 Vitest 提供它们;`test` 从测试文件位置推断模组(`<mod>/tests/*.test.ts` → `<mod>/index.ts`),`defineModTests` 可覆盖。仓库外则从 `@deepseek-ai/dsh-experimental-claude-code-mods/testing` 获取 `createModTestKit` 与 `mock` |
|
||||
| `on` 为引擎的答案打桩;工具包自行回答 `ui.*` | 相同;`ui.open` 回答 `{ isPlaced: false }`,`ui.invalidate` 与 `ui.close` 成功;`session.cwd`、`process.run`、`fs.*`、`store.*`、`env.*` 需要桩(`mock.store`、`mock.env`、`mock.clock` 回答整个命名空间) |
|
||||
| `$.ui.mount({ plugin, surface, component, props })` → `find`、`findAll`、`press`、`unmount` | 相同,另有 `tree()` 与 `text()`;每次读取都经所有已加载模组的钩子重新渲染;`plugin` 与 `surface` 为源码兼容而接受 |
|
||||
| 测试中钩子预算 5 秒 | 相同(`budgetMs`) |
|
||||
|
||||
## 示例模组
|
||||
|
||||
| 模组 | 来源 | 经本桥接 |
|
||||
|---|---|---|
|
||||
| Token Weather | 文中的模块、类型契约与测试,原样 | 按发布原样运行;测试中的三处 `as any` 转换因工具包带类型而去掉。通过 token 计量器从 `$.session.usage` 在横幅中绘制 |
|
||||
| Blast Radius | 文中的 `tool.call` 钩子原样;`classify`、`measure` 与树按文中描述补全 | 以真实的 `sleep` 轮询持有命令,在横幅中绘制报告(`isPlaced: false`),由 Proceed 或 Cancel 决定;请加载在 Token Weather 之前 |
|
||||
| Replay Theater | 文中的五个钩子原样;`stepsFor`、差异、`openReplay`、面板与提示补全 | 记录 Edit 与 Write 调用,轮结束后在横幅中提示,并因无面板放置而在横幅中逐步浏览差异 |
|
||||
| `diff`([anthropics/claude-code](https://github.com/anthropics/claude-code/tree/main/mods/diff),于 `52c7644` 阅读) | TypeScript,约 570 个文件,钩住 `command.run`、`prompt.submit`、`session.start`、`tool.call`、`ui.close`、`ui.focus`、`ui.render`、`ui.scroll` | 不可运行:需要 `$.settings.read`、`$.telemetry.*`、面板上的 `$.session.messages`、`ui.focus` 与 `ui.scroll` 事件以及已放置的 `Pane`。以阅读方式评估;该仓库的条款不允许复制夹具 |
|
||||
| `agents-md`(同一仓库) | 钩住 `agent.spawn`、`prompt.context`、`session.start`、`tool.call` | 不可运行:`prompt.context` 与 `agent.spawn` 不触发,`$.fs.ancestors`、`$.telemetry.*` 不服务 |
|
||||
| `sec-default`(同一仓库) | 钩住 `tool.check`、`prompt.compose`、`settings.read`、`classic.*`、`plugin.register`…… | 不可运行:基于本宿主不触发的事件的托管层策略 |
|
||||
| `telemetry`(同一仓库) | 钩住 `engine.create`、`telemetry.*`、`session.start`、`session.end`;调用 `$.session.repo`、`$.settings.read` | 不可运行:`telemetry.*` 与 `engine.create` 不触发,两个 `$` 成员不服务 |
|
||||
|
||||
<!-- BEGIN GENERATED cordis-surface (gen-cordis-catalog.ts) — do not edit between markers -->
|
||||
|
||||
<a id="cordis-surface"></a>
|
||||
|
||||
## Cordis API
|
||||
|
||||
Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — the language sides differ only in locale-specific paired document paths. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.zh.md#dispatch-modes), and the framework-inherited `ctx` API lives in [cordis-api/inherited.md](../cordis-api/inherited.md).
|
||||
|
||||
<a id="ctxclaudecodemods--claudecodemods"></a>
|
||||
|
||||
### `ctx.claudeCodeMods` — `ClaudeCodeMods`
|
||||
|
||||
The bridge service: loaded mods, their hooks, and the harness listeners that raise their events. Mods join through ClaudeCodeMods.add, which defineMod calls when a mod plugin mounts. The Remote methods let a Client draw each session's band above the prompt and press its buttons.
|
||||
|
||||
```ts cordis-catalog
|
||||
/**
|
||||
* Watch the band above the prompt of one session: the current drawing, then
|
||||
* every redraw, until the Client stops watching.
|
||||
* @param agent - the session's agent, resolved by the Gateway.
|
||||
* @param signal - carrier cancellation.
|
||||
* @returns the band's snapshots.
|
||||
*/
|
||||
@Remote({ mode: 'stream' }) watchBand(agent: Agent, signal: AbortSignal): AsyncIterable<SurfaceSnapshot>
|
||||
|
||||
/**
|
||||
* Press a button of the band's current drawing: runs the mod's `onPress` and redraws.
|
||||
* @param agent - the session's agent, resolved by the Gateway.
|
||||
* @param generation - the drawing the Client saw.
|
||||
* @param actionId - the button's action id in that drawing.
|
||||
* @returns the snapshot after the press.
|
||||
*/
|
||||
@Remote pressBand(agent: Agent, generation: number, actionId: string): Promise<SurfaceSnapshot>
|
||||
|
||||
/**
|
||||
* Load one mod beneath every mod loaded before it: run its `register`,
|
||||
* keep its hooks, and report which of its events this host never raises.
|
||||
* @param definition - the mod as its plugin defined it.
|
||||
* @returns the disposer that removes the mod's hooks, closes its timers, and releases its registrations.
|
||||
* @throws Error when the name is invalid or taken, or when `register` throws (Claude Code's `hooks module did not load` wording).
|
||||
*/
|
||||
async add(definition: ModDefinition): Promise<() => Promise<void>>
|
||||
```
|
||||
|
||||
Types: [Agent](core.zh.md)
|
||||
|
||||
Source: [`packages/experimental/claude-code-mods/src/index.ts`](../../packages/experimental/claude-code-mods/src/index.ts)
|
||||
<!-- END GENERATED cordis-surface -->
|
||||
+7
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-root",
|
||||
"version": "0.2.0-rc.2",
|
||||
"version": "0.2.1-alpha.1",
|
||||
"license": "MIT",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@@ -19,11 +19,11 @@
|
||||
"scripts": {
|
||||
"prepare:primary-runtime": "tsx scripts/primary-runtime/prepare.ts",
|
||||
"build": "tsx scripts/build.ts",
|
||||
"build:bench": "npm run build:native-system && npm run build:lib && tsdown --config benchmarks/tsdown.config.ts",
|
||||
"build:bench": "npm run build:native-system && npm run build:lib && tsdown --config-loader native --config benchmarks/tsdown.config.ts",
|
||||
"build:official": "tsx scripts/build.ts --profile official",
|
||||
"build:lib": "pnpm run build:lib:host && pnpm run build:lib:client",
|
||||
"build:lib:host": "node --max-old-space-size=4096 ./node_modules/typescript/bin/tsc -b tsconfig.host.json && tsdown --env.DSH_BUILD_FACE host && pnpm --filter @deepseek-ai/dsh-desktop run bundle",
|
||||
"build:lib:client": "tsc -b tsconfig.client.json && tsdown --env.DSH_BUILD_FACE client",
|
||||
"build:lib:host": "node --max-old-space-size=4096 ./node_modules/typescript/bin/tsc -b tsconfig.host.json && tsdown --config-loader native --env.DSH_BUILD_FACE host && pnpm --filter @deepseek-ai/dsh-desktop run bundle",
|
||||
"build:lib:client": "tsc -b tsconfig.client.json && tsdown --config-loader native --env.DSH_BUILD_FACE client",
|
||||
"build:web": "pnpm --filter @deepseek-ai/dsh-web-frontend run build",
|
||||
"build:desktop": "pnpm --filter @deepseek-ai/dsh-desktop run build",
|
||||
"dev:desktop": "pnpm --filter @deepseek-ai/dsh-desktop run dev",
|
||||
@@ -213,6 +213,7 @@
|
||||
"@deepseek-ai/dsh-computer-use": "workspace:*",
|
||||
"@deepseek-ai/dsh-experimental-computer-use-cua-driver-mcp": "workspace:*",
|
||||
"@deepseek-ai/dsh-experimental-computer-use-cua-driver-native": "workspace:*",
|
||||
"@deepseek-ai/dsh-experimental-claude-code-mods": "workspace:*",
|
||||
"@deepseek-ai/dsh-llm-replay": "workspace:*",
|
||||
"@deepseek-ai/dsh-mcp-client": "workspace:*",
|
||||
"@deepseek-ai/dsh-tool-session-query": "workspace:*",
|
||||
@@ -224,6 +225,7 @@
|
||||
"@types/jsdom": "^28.0.3",
|
||||
"@types/mdast": "^4.0.4",
|
||||
"@types/node": "^22.20.0",
|
||||
"@types/semver": "^7.8.0",
|
||||
"@types/spdx-expression-parse": "^4.0.0",
|
||||
"@vitest/coverage-v8": "^4.1.8",
|
||||
"@yao-pkg/pkg": "6.21.0",
|
||||
@@ -247,6 +249,7 @@
|
||||
"oxlint-tsgolint": "7.0.2001",
|
||||
"pnpm": "11.7.0",
|
||||
"publint": "^0.3.21",
|
||||
"semver": "^7.8.5",
|
||||
"smol-toml": "^1.7.1",
|
||||
"spdx-expression-parse": "^5.0.0",
|
||||
"tar": "^7.5.0",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-acp",
|
||||
"description": "Automation-only Agent Client Protocol server for driving DeepSeek Harness agents over JSON-RPC stdio",
|
||||
"version": "0.2.0-rc.2",
|
||||
"version": "0.2.1-alpha.1",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-api-account-controller",
|
||||
"description": "Expose safe account operations over authenticated Remote",
|
||||
"version": "0.2.0-rc.2",
|
||||
"version": "0.2.1-alpha.1",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-api-gateway",
|
||||
"description": "Typert Remote Host dispatcher and Client API endpoint",
|
||||
"version": "0.2.0-rc.2",
|
||||
"version": "0.2.1-alpha.1",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-api-job-controller",
|
||||
"description": "Job Remote observation stream and the reference-counted client job-output service",
|
||||
"version": "0.2.0-rc.2",
|
||||
"version": "0.2.1-alpha.1",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-api-remotes",
|
||||
"description": "Remote BFF assembly for application-selected Host capabilities",
|
||||
"version": "0.2.0-rc.2",
|
||||
"version": "0.2.1-alpha.1",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-api-session-controller",
|
||||
"description": "Session Remote commands, cold reads, and live control transport",
|
||||
"version": "0.2.0-rc.2",
|
||||
"version": "0.2.1-alpha.1",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-api-settings-controller",
|
||||
"description": "Remote owner for the configuration surfaces over the settings-domain seams",
|
||||
"version": "0.2.0-rc.2",
|
||||
"version": "0.2.1-alpha.1",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-api-terminal-controller",
|
||||
"description": "Session-owned interactive terminals with shell discovery, screen recovery and typed Remote control",
|
||||
"version": "0.2.0-rc.2",
|
||||
"version": "0.2.1-alpha.1",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-api-workspace-controller",
|
||||
"description": "Workspace Remote commands and reconnect-safe state transport",
|
||||
"version": "0.2.0-rc.2",
|
||||
"version": "0.2.1-alpha.1",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-api-workspace-files",
|
||||
"description": "Workspace file service and Client resource provider: bounded reads, directory listing, and live metadata over the workspaceFiles Remote namespace",
|
||||
"version": "0.2.0-rc.2",
|
||||
"version": "0.2.1-alpha.1",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-attachment-local",
|
||||
"description": "Private content-addressed DSH_HOME attachment storage",
|
||||
"version": "0.2.0-rc.2",
|
||||
"version": "0.2.1-alpha.1",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-attachment",
|
||||
"description": "Durable immutable attachment storage seam for the DeepSeek Harness",
|
||||
"version": "0.2.0-rc.2",
|
||||
"version": "0.2.1-alpha.1",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -45,8 +45,8 @@
|
||||
en: f4a2695cb4c8a570
|
||||
zh: 89837235d2f376cf
|
||||
/deepseek-ai-dsh-app-boot/understand-the-implementation/design-notes:
|
||||
en: 4ac0c33806b5a5b2
|
||||
zh: cdb56f3adfcb4364
|
||||
en: 25a0d1b46fd66d68
|
||||
zh: 5cd8b4ac382dccdc
|
||||
/deepseek-ai-dsh-app-boot/understand-the-implementation/helper-behavior:
|
||||
en: f7bc3a8dd4b00bdd
|
||||
zh: 30098428aa769ad0
|
||||
|
||||
@@ -135,7 +135,7 @@ This section explains how the outcomes above are realized and points at the code
|
||||
- **Package metadata.** `ctx.pluginPackages.packageOf` locates the owning package without loading code or requiring an exported `package.json`; a subpath selects its package without validating that file. The installed runtime resolution owns selection even for a miss. Low-level embedders that install the service without one retain native lookup. Display metadata uses the separate entry-aware reader described above.
|
||||
- **Two Loader builtins.** `mountRootInclude` registers `cordis:include` and `cordis:group` as Loader builtins: a group row gives one `isolate` realm to a provider and its consumers together, and an agent preset outside this workspace cannot resolve `@deepseek-ai/cordis-plugin-group` by name. Both load through the ambient module pipeline rather than the included tree's own specifier resolution.
|
||||
- **Consumer-owned strictness.** Ordinary Loader groups keep successful siblings. App-boot applies the global required-entry policy after initial settlement; agent presets and dynamic multi-entry compositions own and dispose their separate Loader subtree when they require all-or-nothing setup. App-boot reads failed fibers to report their recorded errors and coalesces duplicate Loader rejection notifications through one process checkpoint.
|
||||
- **One runtime resolution.** The installation-first and ordered-bundle breadth-first traversal produces the runtime table. Runtime resolution creates no links; runtime resolution entries occupy their package names at `$DSH_HOME/profiles/node_modules`, and every other name sees that directory as an ordinary ancestor. Profile load removes the `.dsh-module-fallback` projections that link-backend releases wrote into a profile; pnpm-installed packages stay. External bare targets selected by package `imports` use the same package order, while Node retains mapping, conditions, and exact target resolution. A complete successor may add package names and update linked-root membership atomically within the existing package-mapping and local-name constraints.
|
||||
- **One runtime resolution.** The installation-first and ordered-bundle breadth-first traversal produces the runtime table. Runtime resolution creates no links; runtime resolution entries occupy their package names at `$DSH_HOME/profiles/node_modules`, and every other name sees that directory as an ordinary ancestor. Profile load removes the `.dsh-module-fallback` projections that link-backend releases wrote into a profile; pnpm-installed packages stay. External bare targets selected by package `imports` use the same package order, while Node retains mapping, conditions, and exact target resolution. A complete successor may add package names, remove profile-scoped mappings and profile-local names, and update linked-root membership atomically; retained mappings keep their normalized directory, version, and scope. Installation anchors remain fixed; a profile mapping may change its declarer. [Plugin Manager](../plugin-manager/README.md) publishes successors after package operations ([decision](../../../.agents/notes/implemented/architecture/2026-09-30-profile-package-refresh-and-manifest-invalidation.md)).
|
||||
- **Removing linked interception.** A successor may remove a linked root without restart. Directories outside all remaining roots use native lookup for subsequent requests, which may find a development copy or report a missing package. Existing module references and Node caches stay unchanged. Re-adding the same link name and target is allowed; pointing a previously published name at a different target is rejected even after removal ([generation rules](../../../.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.md#immutable-generations)).
|
||||
- **Application-owned profiles.** Application-owned profiles use the same runtime resolution. Links within the active profile directory, including pnpm store links, are not external roots even when the profile is outside the shared profiles tree. Resolution does not modify their `node_modules`; pnpm owns installed packages.
|
||||
- **Owned Workers.** Worker build banners import `@deepseek-ai/dsh-app-boot/worker/profile-resolution-bootstrap` before bundled business code. Each Worker installs the structured-cloned runtime resolution in its own isolate. The bootstrap bundle has no static package imports. Source Worker entries retain their self-contained dependency closure, and third-party Workers receive no injection.
|
||||
|
||||
@@ -135,7 +135,7 @@ Loader 结算后,app-boot 在仅 optional 条目未激活时输出警告。如
|
||||
- **包元数据。** `ctx.pluginPackages.packageOf` 定位所属包,不加载代码,也不要求导出 `package.json`;子路径选择其所属包,不校验该文件。安装 runtime resolution 后,即使查询未命中也以其选包规则为准。仅安装服务而不提供 runtime resolution 的底层嵌入方保留原生查询。展示元数据使用上文另述的入口感知读取器。
|
||||
- **两个 Loader builtin。** `mountRootInclude` 把 `cordis:include` 与 `cordis:group` 注册为 Loader builtin:group 行能把一个提供方与它的消费方放进同一个 `isolate` realm,而位于本工作区之外的 agent preset 无法按名称解析 `@deepseek-ai/cordis-plugin-group`。两者都通过宿主的模块管线加载,而非被包含树自身的说明符解析。
|
||||
- **由 consumer 持有严格语义。** 普通 Loader group 保留成功 sibling。App-boot 在首次结算后应用全局 required-entry policy;agent preset 与动态多 entry 组合在需要 all-or-nothing setup 时,持有并拆卸各自的独立 Loader 子树。App-boot 读取 failed fiber 来报告已记录的错误,并在一个进程检查点内合并 Loader 重复的 rejection 通知。
|
||||
- **唯一 runtime resolution。** 安装优先、有序 bundle 逐根 breadth-first 遍历生成运行时表。runtime 解析不创建链接;runtime resolution 条目占据 `$DSH_HOME/profiles/node_modules` 上各自的包名位置,其余包名把该目录当作普通祖先。profile 加载时删除 Link 后端发布版写进 profile 的 `.dsh-module-fallback` 投影;pnpm 安装的包保留。package `imports` 选中的外部 bare target 使用相同的选包顺序,映射、conditions 和精确 target 解析仍由 Node 负责。完整后继 runtime resolution 可以在既有包映射和本地包名约束内原子增加 package name、更新 linked root 集合。
|
||||
- **唯一 runtime resolution。** 安装优先、有序 bundle 逐根 breadth-first 遍历生成运行时表。runtime 解析不创建链接;runtime resolution 条目占据 `$DSH_HOME/profiles/node_modules` 上各自的包名位置,其余包名把该目录当作普通祖先。profile 加载时删除 Link 后端发布版写进 profile 的 `.dsh-module-fallback` 投影;pnpm 安装的包保留。package `imports` 选中的外部 bare target 使用相同的选包顺序,映射、conditions 和精确 target 解析仍由 Node 负责。完整后继 runtime resolution 可以原子地增加 package name、移除 profile 范围的映射和 profile 本地包名、更新 linked root 集合;保留映射的规范化目录、版本和作用域不变。安装范围的锚点不变;profile 映射可以更换声明来源。[插件管理器](../plugin-manager/README.zh.md)在包操作之后发布后继代([决策](../../../.agents/notes/implemented/architecture/2026-09-30-profile-package-refresh-and-manifest-invalidation.zh.md))。
|
||||
- **移除链接拦截。** 后继 generation 可以移除 linked root,无需重启。目录不再被任何剩余 root 覆盖时,后续请求使用原生查询,可能找到开发副本,也可能报告缺包。已有模块引用和 Node 缓存保持不变。同名、同目标可以重新加入;曾发布的名称改指向不同目标时,即使中间移除过也会被拒绝([generation 规则](../../../.agents/notes/implemented/architecture/2026-09-09-profile-resolution-generations.zh.md#immutable-generations))。
|
||||
- **应用自有 profile。** 应用自有 profile 使用相同的 runtime resolution。目标位于当前 profile 目录内的链接(包括 pnpm store 链接)不算外部 root,即使 profile 位于共享 profiles 树外。解析过程不修改其 `node_modules`;已安装包由 pnpm 管理。
|
||||
- **自有 Worker。** Worker 构建 banner 会在业务 bundle 前导入 `@deepseek-ai/dsh-app-boot/worker/profile-resolution-bootstrap`。每个 Worker 在自己的 isolate 中安装结构化克隆的 runtime resolution。bootstrap bundle 不静态导入任何包。源码 Worker 入口保留自包含依赖,第三方 Worker 不接受注入。
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-app-boot",
|
||||
"description": "Shared boot glue for the app bins: .env loading, fail-loud Loader guards, snapshot-aware config resolution, and the Loader boot sequence",
|
||||
"version": "0.2.0-rc.2",
|
||||
"version": "0.2.1-alpha.1",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -56,6 +56,7 @@ declare module '@deepseek-ai/cordis' {
|
||||
export {
|
||||
composeEntries,
|
||||
createRuntimeResolution,
|
||||
ProfileRuntimeResolution,
|
||||
DEFAULT_PROFILE_BUNDLES,
|
||||
OPTIONAL_BUNDLES,
|
||||
bundlePatchFiles,
|
||||
|
||||
@@ -124,11 +124,14 @@ export interface RuntimeInterception {
|
||||
*/
|
||||
packageDir(specifier: string, parentURL: string): string | undefined
|
||||
/**
|
||||
* Atomically publish a complete successor and fresh caches. Linked roots may be added or removed.
|
||||
* Removed roots stop intercepting uncovered directories; existing modules and Node caches remain intact.
|
||||
* @param successor - fully constructed generation retaining existing package mappings and local names.
|
||||
* @throws when the profile scope or existing mappings change, local names are removed or override
|
||||
* existing mappings, or a previously published link name selects a different real directory.
|
||||
* Atomically publish a complete successor and fresh caches. Profile-scoped mappings, local names, and linked
|
||||
* roots may be removed; removed roots stop intercepting uncovered directories. Existing modules and Node caches
|
||||
* remain intact, so callers remove a package only after its plugins stop.
|
||||
* A retained profile mapping may change its declarer if its normalized directory, version, and scope stay unchanged.
|
||||
* @param successor - fully constructed generation retaining the installation mappings.
|
||||
* @throws when the profile scope, a retained directory, version, scope, or installation declarer changes;
|
||||
* an installation mapping is removed; a new local name overrides an existing mapping; or a previously published
|
||||
* link name selects a different real directory.
|
||||
*/
|
||||
replace(successor: RuntimeResolution): void
|
||||
/** Restore the native resolver methods. Interceptions dispose in reverse order. */
|
||||
@@ -378,20 +381,16 @@ class ResolutionRouter {
|
||||
}
|
||||
for (const [name, current] of this.current.entries) {
|
||||
const next = entries.get(name)
|
||||
if (next === undefined && current.scope === 'profile') continue
|
||||
if (next === undefined
|
||||
|| !sameResolution(current.packageDir, next.packageDir)
|
||||
|| !sameResolution(current.declarer, next.declarer)
|
||||
|| (current.scope === 'installation' && !sameResolution(current.declarer, next.declarer))
|
||||
|| current.version !== next.version
|
||||
|| current.scope !== next.scope) {
|
||||
throw new Error(`profile resolution: replacing ${JSON.stringify(name)} requires a process restart`)
|
||||
}
|
||||
}
|
||||
const localPackageNames = new Set(successor.localPackageNames)
|
||||
for (const name of this.current.localPackageNames) {
|
||||
if (!localPackageNames.has(name)) {
|
||||
throw new Error(`profile resolution: removing local package ${JSON.stringify(name)} requires a process restart`)
|
||||
}
|
||||
}
|
||||
for (const name of localPackageNames) {
|
||||
if (!this.current.localPackageNames.has(name) && this.current.entries.has(name)) {
|
||||
throw new Error(`profile resolution: overriding ${JSON.stringify(name)} locally requires a process restart`)
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
registerWorkerResolution,
|
||||
type RuntimeInterception,
|
||||
} from './resolver.ts'
|
||||
import type { RuntimeResolution } from '../profile.ts'
|
||||
import { ProfileRuntimeResolution, type RuntimeResolution } from '../profile.ts'
|
||||
import { readPluginMeta } from '../package-meta.ts'
|
||||
|
||||
declare module '@deepseek-ai/cordis' {
|
||||
@@ -61,10 +61,12 @@ export class PluginPackages extends Service {
|
||||
private packages = new Map<string, PluginPackage | undefined>()
|
||||
private readonly interception: RuntimeInterception | undefined
|
||||
private disposeWorkerResolution: (() => void) | undefined
|
||||
private current: RuntimeResolution | undefined
|
||||
|
||||
constructor(ctx: Context, config: PluginPackagesConfig = {}) {
|
||||
super(ctx, 'pluginPackages')
|
||||
if (config.resolution === undefined) return
|
||||
this.current = config.resolution
|
||||
const interception = installRuntimeInterception(config.resolution)
|
||||
this.disposeWorkerResolution = registerWorkerResolution(config.resolution)
|
||||
this.interception = interception
|
||||
@@ -76,17 +78,33 @@ export class PluginPackages extends Service {
|
||||
|
||||
/**
|
||||
* Publish a complete successor generation for this process and subsequently created Workers.
|
||||
* Linked roots may be removed without unloading modules or clearing Node caches.
|
||||
* Profile mappings and local package names may be removed after their plugins stop; linked roots may also be removed.
|
||||
* Retained profile mappings may change their declarer, but not their normalized directory, version, or scope;
|
||||
* installation mappings must remain unchanged. Publication does not unload modules or clear Node caches.
|
||||
* @param successor - fully constructed generation accepted by {@link RuntimeInterception.replace}.
|
||||
*/
|
||||
replace(successor: RuntimeResolution): void {
|
||||
if (this.interception === undefined) throw new Error('plugin-packages: runtime resolution is not installed')
|
||||
this.interception.replace(successor)
|
||||
this.current = successor
|
||||
this.packages = new Map()
|
||||
this.disposeWorkerResolution?.()
|
||||
this.disposeWorkerResolution = registerWorkerResolution(successor)
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish the latest generation computed by the installed resolution through {@link replace}. Package contents
|
||||
* and loaded modules are not reloaded.
|
||||
* @throws when no resolution is installed, it is plain data rather than a {@link ProfileRuntimeResolution},
|
||||
* reading the latest files fails, or the successor is rejected. A computed resolution without a profile can refresh.
|
||||
*/
|
||||
async refresh(): Promise<void> {
|
||||
if (!(this.current instanceof ProfileRuntimeResolution)) {
|
||||
throw new Error('plugin-packages: the installed runtime resolution cannot be recomputed')
|
||||
}
|
||||
this.replace(await this.current.computeLatestResolution())
|
||||
}
|
||||
|
||||
/**
|
||||
* Locate the package named by a specifier without requiring a package export.
|
||||
* @param specifier - module specifier whose package owns the requested module.
|
||||
|
||||
@@ -440,7 +440,7 @@ export interface RuntimeResolutionOptions {
|
||||
*/
|
||||
export async function createRuntimeResolution(
|
||||
options: RuntimeResolutionOptions,
|
||||
): Promise<RuntimeResolution> {
|
||||
): Promise<ProfileRuntimeResolution> {
|
||||
const { installAnchor, profile, home = resolveDshHome() } = options
|
||||
const profilesDir = join(home, PROFILES_DIR)
|
||||
const manifest = readOptionalProfileManifest(profile)
|
||||
@@ -455,7 +455,7 @@ export async function createRuntimeResolution(
|
||||
: collectProfileScopePackages(profile, packageNames, profileDeclarers, profileVersions)
|
||||
const linkedRoots = profile === undefined ? [] : linkedProfileRoots(profile, profilesDir)
|
||||
// The Promise return type is the pre-stable API; construction has no asynchronous step.
|
||||
return await Promise.resolve(Object.freeze({
|
||||
return await Promise.resolve(new ProfileRuntimeResolution({ installAnchor, profileDir: profile?.dir, home }, {
|
||||
profilesDir,
|
||||
profileDir: profile?.dir,
|
||||
localPackageNames: Object.freeze(localPackageNames),
|
||||
@@ -473,6 +473,54 @@ export async function createRuntimeResolution(
|
||||
}))
|
||||
}
|
||||
|
||||
/** Inputs a {@link ProfileRuntimeResolution} reuses to compute its successor. */
|
||||
interface ResolutionSource {
|
||||
installAnchor: string
|
||||
home: string
|
||||
profileDir: string | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* A runtime resolution that remembers the inputs it was computed from. Worker environment data carries only its
|
||||
* fields; the inputs stay private to the thread that computed it.
|
||||
*/
|
||||
export class ProfileRuntimeResolution implements RuntimeResolution {
|
||||
readonly profilesDir: string
|
||||
readonly profileDir: string | undefined
|
||||
readonly localPackageNames: readonly string[]
|
||||
readonly entries: readonly RuntimeResolutionEntry[]
|
||||
readonly linkedRoots: readonly LinkedRoot[]
|
||||
readonly #source: ResolutionSource
|
||||
|
||||
/**
|
||||
* @param source - inputs of {@link createRuntimeResolution}, reused by {@link computeLatestResolution}.
|
||||
* @param table - the computed package table.
|
||||
*/
|
||||
constructor(source: ResolutionSource, table: RuntimeResolution) {
|
||||
this.#source = source
|
||||
this.profilesDir = table.profilesDir
|
||||
this.profileDir = table.profileDir
|
||||
this.localPackageNames = table.localPackageNames
|
||||
this.entries = table.entries
|
||||
this.linkedRoots = table.linkedRoots
|
||||
Object.freeze(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the latest generation from the same installation, profile directory, and Harness home, rereading the
|
||||
* profile's manifest, bundle selection, and installed packages from disk, without retaining synthetic layers.
|
||||
* With no profile directory, only installation packages are recomputed. This instance is unchanged.
|
||||
* @returns a new resolution for the latest generation.
|
||||
*/
|
||||
computeLatestResolution(): Promise<ProfileRuntimeResolution> {
|
||||
const { installAnchor, home, profileDir } = this.#source
|
||||
return createRuntimeResolution({
|
||||
installAnchor, home,
|
||||
...profileDir === undefined ? {} : { profile: loadProfileDirectory('dsh', profileDir, installAnchor) },
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/** Synthetic profiles used by direct callers may have no on-disk manifest. */
|
||||
function readOptionalProfileManifest(profile: Profile | undefined): ProfileManifest | undefined {
|
||||
if (profile === undefined) return undefined
|
||||
|
||||
@@ -79,7 +79,8 @@ describe('generateConfigSchema', () => {
|
||||
]
|
||||
const original = structuredClone(layers)
|
||||
const installAnchor = join(dir, 'installation.json')
|
||||
const resolve = vi.spyOn(profileOperations, 'createRuntimeResolution').mockResolvedValue(resolution)
|
||||
const resolve = vi.spyOn(profileOperations, 'createRuntimeResolution')
|
||||
.mockResolvedValue(new profileOperations.ProfileRuntimeResolution({ installAnchor: '', home: '', profileDir: undefined }, resolution))
|
||||
const result = await generateConfigSchema(profile, layers, installAnchor)
|
||||
expect(resolve).toHaveBeenCalledExactlyOnceWith({ installAnchor, profile })
|
||||
expect(result['x-cordis'].entries.map(entry => [entry.path, entry.name])).toEqual([
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/** Package metadata queries share the active runtime resolution. */
|
||||
|
||||
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
@@ -9,7 +9,7 @@ import { getEnvironmentData } from 'node:worker_threads'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { PluginPackages } from '../src/profile-resolution/service.ts'
|
||||
import type { RuntimeResolution } from '../src/profile.ts'
|
||||
import { createRuntimeResolution, initProfile, loadProfileDirectory, type RuntimeResolution } from '../src/profile.ts'
|
||||
|
||||
const roots: string[] = []
|
||||
const contexts: Context[] = []
|
||||
@@ -53,6 +53,71 @@ function resolution(
|
||||
}
|
||||
|
||||
describe('profile package metadata service', () => {
|
||||
it('refreshes the installed resolution from its own profile and installation', async () => {
|
||||
const root = realpathSync.native(mkdtempSync(join(tmpdir(), 'dsh-package-service-refresh-')))
|
||||
roots.push(root)
|
||||
const installAnchor = join(root, 'install', 'package.json')
|
||||
file(installAnchor, JSON.stringify({ name: '@deepseek-ai/dsh', version: '0.0.0' }))
|
||||
const profileDir = join(root, 'profiles', 'test')
|
||||
initProfile(profileDir, ['extra'])
|
||||
const bundleDir = join(profileDir, 'node_modules', 'extra')
|
||||
const privateDir = join(bundleDir, 'node_modules', 'metadata-lib')
|
||||
file(join(bundleDir, 'package.json'), JSON.stringify({
|
||||
name: 'extra', version: '1.0.0', dependencies: { 'metadata-lib': '1.0.0' }, dsh: { bundle: { patch: './cordis.patch.yml' } },
|
||||
}))
|
||||
file(join(bundleDir, 'cordis.patch.yml'), '[]\n')
|
||||
const parentURL = pathToFileURL(join(profileDir, 'caller.mjs')).href
|
||||
const ctx = new Context()
|
||||
contexts.push(ctx)
|
||||
await ctx.plugin(PluginPackages, {
|
||||
resolution: await createRuntimeResolution({ installAnchor, home: root, profile: loadProfileDirectory('dsh', profileDir, installAnchor) }),
|
||||
})
|
||||
expect(ctx.pluginPackages.packageOf('metadata-lib', parentURL)).toBeUndefined()
|
||||
|
||||
pkg(privateDir, '1.0.0')
|
||||
const manifest = JSON.parse(readFileSync(join(profileDir, 'package.json'), 'utf8')) as Record<string, unknown>
|
||||
writeFileSync(join(profileDir, 'package.json'), JSON.stringify({ ...manifest, dependencies: { extra: '1.0.0' } }))
|
||||
await ctx.pluginPackages.refresh()
|
||||
|
||||
expect(ctx.pluginPackages.packageOf('metadata-lib', parentURL)).toMatchObject({ dir: privateDir, version: '1.0.0' })
|
||||
expect(createRequire(join(profileDir, 'caller.cjs')).resolve('metadata-lib')).toBe(join(privateDir, 'index.cjs'))
|
||||
})
|
||||
|
||||
it('refreshes a computed resolution without a profile', async () => {
|
||||
const root = realpathSync.native(mkdtempSync(join(tmpdir(), 'dsh-package-service-installation-')))
|
||||
roots.push(root)
|
||||
const installAnchor = join(root, 'install', 'package.json')
|
||||
file(installAnchor, JSON.stringify({ name: 'installation', version: '1.0.0', dependencies: { 'metadata-lib': '*' } }))
|
||||
const ctx = new Context()
|
||||
contexts.push(ctx)
|
||||
await ctx.plugin(PluginPackages, { resolution: await createRuntimeResolution({ installAnchor, home: root }) })
|
||||
const parentURL = pathToFileURL(join(root, 'profiles', 'test', 'caller.cjs')).href
|
||||
expect(ctx.pluginPackages.packageOf('metadata-lib', parentURL)).toBeUndefined()
|
||||
const packageDir = join(root, 'install', 'node_modules', 'metadata-lib')
|
||||
pkg(packageDir, '1.0.0')
|
||||
|
||||
await ctx.pluginPackages.refresh()
|
||||
|
||||
expect(ctx.pluginPackages.packageOf('metadata-lib', parentURL)).toMatchObject({ dir: packageDir, version: '1.0.0' })
|
||||
expect(createRequire(parentURL).resolve('metadata-lib')).toBe(join(packageDir, 'index.cjs'))
|
||||
})
|
||||
|
||||
it('rejects refreshing a plain-data or absent resolution', async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-package-service-plain-'))
|
||||
roots.push(root)
|
||||
const packageDir = join(root, 'lib')
|
||||
const ctx = new Context()
|
||||
contexts.push(ctx)
|
||||
await ctx.plugin(PluginPackages, {
|
||||
resolution: resolution(join(root, 'profiles'), join(root, 'profiles', 'test'), packageDir, pkg(packageDir, '1.0.0'), '1.0.0'),
|
||||
})
|
||||
await expect(ctx.pluginPackages.refresh()).rejects.toThrow('plugin-packages: the installed runtime resolution cannot be recomputed')
|
||||
const bare = new Context()
|
||||
contexts.push(bare)
|
||||
await bare.plugin(PluginPackages)
|
||||
await expect(bare.pluginPackages.refresh()).rejects.toThrow('plugin-packages: the installed runtime resolution cannot be recomputed')
|
||||
})
|
||||
|
||||
it('reads translated metadata from the selected local package without importing its entry', async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'dsh-localized-package-service-'))
|
||||
roots.push(root)
|
||||
|
||||
@@ -1618,6 +1618,14 @@ describe('runtime resolution', { concurrent: false }, () => {
|
||||
: entry),
|
||||
})
|
||||
}).toThrow(/requires a process restart/u)
|
||||
expect(() => {
|
||||
registration.replace({
|
||||
...first,
|
||||
entries: first.entries.map(entry => entry.name === '@deepseek-ai/dsh-core'
|
||||
? { ...entry, declarer: join(f.root, 'another-bundle', 'package.json') }
|
||||
: entry),
|
||||
})
|
||||
}).toThrow(/requires a process restart/u)
|
||||
expect(() => {
|
||||
registration.replace({ ...first, localPackageNames: ['@deepseek-ai/dsh-core'] })
|
||||
}).toThrow(/requires a process restart/u)
|
||||
@@ -1629,7 +1637,6 @@ describe('runtime resolution', { concurrent: false }, () => {
|
||||
}).toThrow(/cannot change its profile scope/u)
|
||||
registration.replace({ ...first, localPackageNames: ['new-local'] })
|
||||
registration.replace({ ...first, localPackageNames: ['new-local'] })
|
||||
expect(() => { registration.replace(first) }).toThrow(/removing local package/u)
|
||||
const linked = { name: 'linked-plugin', realPath: join(f.root, 'work', 'a') }
|
||||
const withLocal = { ...first, localPackageNames: ['new-local'] }
|
||||
registration.replace({ ...withLocal, linkedRoots: [linked] })
|
||||
@@ -1643,6 +1650,104 @@ describe('runtime resolution', { concurrent: false }, () => {
|
||||
)).toBe(f.installed)
|
||||
})
|
||||
|
||||
it('removes profile mappings and local names while retaining changed profile mappings as restarts', async () => {
|
||||
const f = fixture()
|
||||
const firstDir = join(f.root, 'bundle-a', 'node_modules', 'private-lib')
|
||||
const secondDir = join(f.root, 'bundle-b', 'node_modules', 'private-lib')
|
||||
pkg(firstDir, 'private-lib', 1)
|
||||
pkg(secondDir, 'private-lib', 2)
|
||||
const base = await resolutionOf(f)
|
||||
const entry = {
|
||||
name: 'private-lib', packageDir: firstDir, version: '1.0.0',
|
||||
declarer: join(firstDir, 'package.json'), scope: 'profile' as const,
|
||||
}
|
||||
const first = { ...base, entries: [...base.entries, entry], localPackageNames: ['local-lib'] }
|
||||
const registration = installRuntimeInterception(first)
|
||||
registrations.push(registration)
|
||||
const parent = pathToFileURL(join(f.profile.dir, 'entry.mjs')).href
|
||||
expect(registration.packageDir('private-lib', parent)).toBe(firstDir)
|
||||
expect(() => {
|
||||
registration.replace({ ...first, entries: [...base.entries, {
|
||||
...entry, packageDir: secondDir, version: '2.0.0', declarer: join(secondDir, 'package.json'),
|
||||
}] })
|
||||
}).toThrow(/replacing "private-lib" requires a process restart/u)
|
||||
expect(registration.packageDir('private-lib', parent)).toBe(firstDir)
|
||||
registration.replace(base)
|
||||
expect(registration.packageDir('private-lib', parent)).toBeUndefined()
|
||||
expect(() => resolveFrom('private-lib', parent)).toThrow(expect.objectContaining({ code: 'ERR_MODULE_NOT_FOUND' }))
|
||||
registration.replace(first)
|
||||
expect(registration.packageDir('private-lib', parent)).toBe(firstDir)
|
||||
})
|
||||
|
||||
it('accepts another profile declarer for the same normalized package directory and version', async () => {
|
||||
const f = fixture()
|
||||
const shared = join(f.root, 'shared', 'node_modules', 'private-lib')
|
||||
pkg(shared, 'private-lib', 1)
|
||||
const firstAnchor = pkg(join(f.root, 'shared', 'bundle-a'), 'bundle-a', 1, { 'private-lib': '*' })
|
||||
const nextAnchor = pkg(join(f.root, 'shared', 'bundle-b'), 'bundle-b', 1, { 'private-lib': '*' })
|
||||
const alias = join(f.root, 'private-lib-alias')
|
||||
symlinkSync(shared, alias, process.platform === 'win32' ? 'junction' : 'dir')
|
||||
try {
|
||||
const base = await resolutionOf(f)
|
||||
const entry = { name: 'private-lib', packageDir: shared, declarer: firstAnchor, version: '1.0.0', scope: 'profile' as const }
|
||||
const registration = installRuntimeInterception({ ...base, entries: [...base.entries, entry] })
|
||||
registrations.push(registration)
|
||||
const parent = pathToFileURL(join(f.profile.dir, 'entry.mjs')).href
|
||||
const require = createRequire(parent)
|
||||
expect(registration.packageDir('private-lib', parent)).toBe(shared)
|
||||
expect(require.resolve('private-lib')).toBe(join(shared, 'index.cjs'))
|
||||
expect(resolveFrom('private-lib', parent)).toBe(pathToFileURL(join(shared, 'index.js')).href)
|
||||
|
||||
registration.replace({ ...base, entries: [...base.entries, { ...entry, packageDir: alias, declarer: nextAnchor }] })
|
||||
|
||||
expect(registration.packageDir('private-lib', parent)).toBe(alias)
|
||||
expect(require.resolve('private-lib')).toBe(join(shared, 'index.cjs'))
|
||||
expect(resolveFrom('private-lib', parent)).toBe(pathToFileURL(join(shared, 'index.js')).href)
|
||||
} finally {
|
||||
unlinkSync(alias)
|
||||
}
|
||||
})
|
||||
|
||||
it.each(['directory', 'version', 'scope'] as const)('rejects a retained profile mapping with a different %s', async (change) => {
|
||||
const f = fixture()
|
||||
const packageDir = join(f.root, 'bundle', 'node_modules', 'private-lib')
|
||||
pkg(packageDir, 'private-lib', 1)
|
||||
const base = await resolutionOf(f)
|
||||
const entry = { name: 'private-lib', packageDir, declarer: join(packageDir, 'package.json'), version: '1.0.0', scope: 'profile' as const }
|
||||
const registration = installRuntimeInterception({ ...base, entries: [...base.entries, entry] })
|
||||
registrations.push(registration)
|
||||
const successor = {
|
||||
...entry,
|
||||
packageDir: change === 'directory' ? join(f.root, 'different') : entry.packageDir,
|
||||
version: change === 'version' ? '2.0.0' : entry.version,
|
||||
scope: change === 'scope' ? 'installation' as const : entry.scope,
|
||||
}
|
||||
|
||||
expect(() => { registration.replace({ ...base, entries: [...base.entries, successor] }) })
|
||||
.toThrow(/replacing "private-lib" requires a process restart/u)
|
||||
expect(registration.packageDir('private-lib', pathToFileURL(join(f.profile.dir, 'entry.mjs')).href)).toBe(packageDir)
|
||||
})
|
||||
|
||||
it('recomputes from the captured directory without retaining synthetic profile layers', async () => {
|
||||
const f = fixture()
|
||||
const profileDir = f.profile.dir
|
||||
const bundleDir = join(f.root, 'synthetic-bundle')
|
||||
pkg(bundleDir, 'synthetic-bundle', 1, { 'private-lib': '*' })
|
||||
pkg(join(bundleDir, 'node_modules', 'private-lib'), 'private-lib', 1)
|
||||
f.profile.layers.push({ packageName: 'synthetic-bundle', packageDir: bundleDir, patchPaths: [], patches: [] })
|
||||
const first = await createRuntimeResolution({ installAnchor: f.installAnchor, home: f.root, profile: f.profile })
|
||||
expect(first.entries.some(entry => entry.name === 'private-lib')).toBe(true)
|
||||
f.profile.dir = join(f.root, 'another-profile')
|
||||
file(join(f.profile.dir, 'package.json'), JSON.stringify({ name: 'another-profile' }))
|
||||
|
||||
const latest = await first.computeLatestResolution()
|
||||
|
||||
expect(latest.profileDir).toBe(profileDir)
|
||||
expect(latest.entries.some(entry => entry.name === 'private-lib')).toBe(false)
|
||||
expect(first.profileDir).toBe(profileDir)
|
||||
expect(first.entries.some(entry => entry.name === 'private-lib')).toBe(true)
|
||||
})
|
||||
|
||||
it('leaves non-package and out-of-scope metadata lookups to native resolution', async () => {
|
||||
const f = fixture()
|
||||
const outside = join(f.root, 'outside')
|
||||
|
||||
@@ -514,6 +514,7 @@ describe('createRuntimeResolution', () => {
|
||||
expect(resolution.entries.find(entry => entry.name === 'dep-of-a')?.packageDir).toBe(join(modules, 'dep-of-a'))
|
||||
expect(existsSync(join(home, 'profiles', 'node_modules'))).toBe(false)
|
||||
await expect(createRuntimeResolution({ installAnchor: anchor, home })).resolves.toEqual(resolution)
|
||||
await expect(resolution.computeLatestResolution()).resolves.toEqual(resolution)
|
||||
})
|
||||
|
||||
it('keeps selected bundle closures profile-local without overriding installation packages', async () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-cmdline",
|
||||
"description": "Immutable command-line handoff from a dsh launcher to any app plugin that injects cmdlineArgs",
|
||||
"version": "0.2.0-rc.2",
|
||||
"version": "0.2.1-alpha.1",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-config-editor",
|
||||
"description": "Persist plugin configuration through profile patches and Loader reconciliation",
|
||||
"version": "0.2.0-rc.2",
|
||||
"version": "0.2.1-alpha.1",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
en: 45ee194ad781d603
|
||||
zh: b3d7fcd8ed73717c
|
||||
/deepseek-ai-dsh-hmr/understand-the-implementation:
|
||||
en: 247499831fdee81f
|
||||
zh: 690f1859c1f73bbe
|
||||
en: 227c3c99c3c4b597
|
||||
zh: 853b656340cb8bce
|
||||
/deepseek-ai-dsh-hmr/model-experience:
|
||||
en: 215e7ba838619b7b
|
||||
zh: a311da3843709f90
|
||||
@@ -39,8 +39,8 @@
|
||||
en: 89e0624fafd701fb
|
||||
zh: 19d36a33c1d9af9d
|
||||
/deepseek-ai-dsh-hmr/known-limitations-and-deferred-work:
|
||||
en: 379bc2c72fd9e53e
|
||||
zh: 1fff1e0e676bb161
|
||||
en: 0f46d4df1dab7f3b
|
||||
zh: d172835159a20cce
|
||||
/deepseek-ai-dsh-hmr/known-limitations-and-deferred-work/dev-note:
|
||||
en: 7972e4b2005ec9a4
|
||||
zh: 754c49ec538c164c
|
||||
@@ -56,7 +56,15 @@ Chokidar options, including polling, retain their existing meaning. Exact config
|
||||
|
||||
`watchConfig()` registers an awaited configuration handler. `runExclusive()` serializes configuration changes and Loader updates with automatic reloads and rejects nested transactions. Package installation and removal run outside this queue. HMR does not acquire the package writer lock; manifest notifications reload only when the ordered `dsh.profile.bundles` list changes. Profile and home patch changes also trigger recomposition. File events received during a configuration transaction are processed afterward. Include refreshes and profile reconciliation reach plugins through ordinary Loader entry updates; Loader commits volatile-only changes in place.
|
||||
|
||||
App-boot owns profile parsing and patch precedence. HMR reads the launcher’s data-only `profileContext`, registers the profile manifest and both user patch watches during initialization, and waits for application readiness before processing changes. Its disposal closes the watchers and cancels reloads waiting for startup. HMR also owns module-cache replacement and reload scheduling. Configuration watchers start outside the active transaction context so later notifications can enter the queue.
|
||||
App-boot owns profile parsing and patch precedence. HMR reads the launcher’s data-only `profileContext`, registers the profile manifest and both user patch watches during initialization, and waits for application readiness before processing changes. Its disposal closes the watchers and cancels reloads waiting for startup. Configuration watchers start outside the active transaction context so later notifications can enter the queue.
|
||||
|
||||
A changed `package.json` outside `node_modules` expires that package's cached configuration. A configuration-only manifest triggers no module reload; a manifest imported as a JSON module retains dependency-driven reloads, and one in the host dependency graph retains host reloads. Dedicated configuration watches keep their existing ownership. A new package entry takes effect when its Loader entry restarts.
|
||||
|
||||
Loader entries retain their raw import results. HMR retains every distinct imported namespace for each entry name and configuration-tree base URL; an uninitialized entry does not overwrite another entry's namespace. HMR matches those objects to cached ModuleJob namespaces instead of resolving entry names again. Names without an imported namespace and `cordis:` builtins keep name-based resolution. Source reloads therefore use the loaded modules after `exports` or `main` changes.
|
||||
|
||||
Entries sharing a runtime are replaced together, with each entry receiving the implementation for its own module. Duplicate entries for one module retain their separate plugin instances. On replacement failure, HMR removes partially activated replacements and restores the previous modules and plugin implementations. A successful reload updates every matching entry record, including disabled entries; a failed replacement leaves those records unchanged. Entries that have never loaded remain uninitialized.
|
||||
|
||||
[package-manifest.ts](src/package-manifest.ts) owns the Node internal interfaces for package reads, scope, type, and nearest-manifest lookups. Each HMR instance owns its configuration cache and restores its installed hooks on disposal. Manifest invalidation clears the ESM `ResolveCache` and CommonJS `_pathCache`: neither records every consulted manifest, and entries may point outside their package directory. CommonJS loads resolve requests with Node's default resolver before entering the native loader, bypassing old request aliases while preserving evaluated modules. Synchronous resolve hooks receive the resulting filenames. Ordinary module replacement remains HMR's separate operation.
|
||||
|
||||
Watched module paths use Node ESM resolution's `realpathSync()` spelling, including Windows short directory names, so file events match the module cache.
|
||||
|
||||
@@ -86,7 +94,11 @@ Reloading a contributing plugin can change later request prefixes; HMR does not
|
||||
<a id="known-limitations-and-deferred-work"></a>
|
||||
|
||||
- Module replacement requires Node loader internals. Framework dependency changes call the host-provided `loader.exit()` hook; HMR itself does not restart the process.
|
||||
- Replacing installed package versions still requires a restart through Plugin Manager. The browser Client module graph retains its separate browser-side loading mechanism.
|
||||
- Replacing installed package versions still requires a restart through Plugin Manager; manifests below `node_modules` keep Node's cached configuration. The browser Client module graph retains its separate browser-side loading mechanism.
|
||||
- TSX loaders and ordinary application-created Workers are outside the supported package-cache refresh scope.
|
||||
- CommonJS requests must be resolvable by Node's default resolver. Synchronous hooks may postprocess the resulting filenames, as Desktop's Office resolver does; hooks requiring the original request name or introducing virtual requests are unsupported. Builtins retain Node's original loading behavior.
|
||||
- Future work: retain package invalidation across HMR disposal and replacement. Restoring the native readers can expose their older cached configuration again.
|
||||
- If a shared runtime's replacement modules have different plugin callbacks, an instance without a Loader entry cannot be assigned to one of them. HMR reports an ambiguity error and rolls back the reload.
|
||||
- `watchConfig()` resolves when Chokidar reports readiness. On darwin, libuv starts the FSEvents stream afterwards on its own thread, so a write that lands within milliseconds of registration is not reported until the next event in that directory; edits made after startup are unaffected.
|
||||
|
||||
### Dev Note
|
||||
|
||||
@@ -56,7 +56,15 @@ Chokidar 选项(包括轮询)保持原有含义。精确配置监听同时
|
||||
|
||||
`watchConfig()` 注册会被等待的配置处理器。`runExclusive()` 将配置变更、Loader 更新与自动重载串行化,并拒绝嵌套事务。包安装和删除在该队列之外执行。HMR 不获取包操作写锁;manifest 通知仅在有序的 `dsh.profile.bundles` 列表变化时触发重载。profile 与 home patch 变化也会触发重新组合。配置事务期间收到的文件事件在事务结束后处理。 Include 刷新和 profile 重载都通过普通的 Loader 条目更新到达插件;仅 volatile 变化由 Loader 就地提交。
|
||||
|
||||
App-boot 负责 profile 解析和 patch 优先级规则。HMR 读取启动器提供的纯数据 `profileContext`,在初始化时注册 profile manifest 和两份用户 patch 的监听,并等待应用就绪后处理更改。销毁 HMR 时会关闭监听器并取消等待启动的重载。HMR 也负责模块缓存替换和重载调度。配置监听器在当前事务上下文之外启动,使后续通知可以进入队列。
|
||||
App-boot 负责 profile 解析和 patch 优先级规则。HMR 读取启动器提供的纯数据 `profileContext`,在初始化时注册 profile manifest 和两份用户 patch 的监听,并等待应用就绪后处理更改。销毁 HMR 时会关闭监听器并取消等待启动的重载。配置监听器在当前事务上下文之外启动,使后续通知可以进入队列。
|
||||
|
||||
`node_modules` 之外的 `package.json` 变化使该包的配置缓存失效。仅作为配置的 manifest 不触发模块重载;作为 JSON 模块导入的 manifest 保留依赖驱动重载,位于宿主依赖图中的 manifest 保留宿主重载。专属配置监听保留原有归属。新包入口在 Loader entry 重启时生效。
|
||||
|
||||
Loader entry 保留原始导入结果。HMR 为每个 entry 名称与配置树 base URL 保留所有不同的已导入命名空间;尚未初始化的 entry 不会覆盖其他 entry 的命名空间。HMR 将这些对象与缓存中 ModuleJob 的模块命名空间按对象身份匹配,不再重新解析 entry 名称。没有已导入命名空间的名称和 `cordis:` 内置入口仍按名称解析。因此 `exports` 或 `main` 改变后,源码重载仍使用已加载模块。
|
||||
|
||||
共享同一运行时的 entry 一起替换,每个 entry 使用其自身模块对应的实现。同一模块的重复 entry 仍保有各自的插件实例。替换失败时,HMR 清理已部分激活的替换实例,并恢复原来的模块和插件实现。重载成功后更新所有匹配的 entry 记录,包括已停用的 entry;替换失败时这些记录保持不变。从未加载过的 entry 仍保持未初始化状态。
|
||||
|
||||
[package-manifest.ts](src/package-manifest.ts) 封装 package 读取、scope、type 和最近 manifest 查询所需的 Node internal 接口。每个 HMR 实例拥有自己的配置缓存,销毁时恢复它安装的 hook。manifest 失效会清空 ESM `ResolveCache` 和 CommonJS `_pathCache`:它们都不记录查询过的全部 manifest,入口也可能指向包目录之外。CommonJS 加载先用 Node 默认 resolver 解析请求,再进入原生 loader,绕过旧请求别名并保留已求值模块。同步 resolve hook 收到的是解析后的文件名。普通模块替换仍由 HMR 独立处理。
|
||||
|
||||
被监听模块的路径沿用 Node ESM 解析所用的 `realpathSync()` 表示,包括 Windows 短目录名,使文件事件与模块缓存匹配。
|
||||
|
||||
@@ -86,7 +94,11 @@ App-boot 负责 profile 解析和 patch 优先级规则。HMR 读取启动器提
|
||||
<a id="known-limitations-and-deferred-work"></a>
|
||||
|
||||
- 模块替换需要 Node loader 内部接口。框架依赖变化调用宿主提供的 `loader.exit()` 钩子;HMR 本身不重启进程。
|
||||
- 通过插件管理器替换已安装包版本仍需要重启。浏览器 Client 模块图保留独立的浏览器侧加载机制。
|
||||
- 通过插件管理器替换已安装包版本仍需要重启;`node_modules` 之下的 manifest 保留 Node 缓存的配置。浏览器 Client 模块图保留独立的浏览器侧加载机制。
|
||||
- TSX loader 和普通应用自行创建的 Worker 不在包缓存刷新支持范围内。
|
||||
- CommonJS 请求必须能由 Node 默认 resolver 解析。同步 hook 可以像 Desktop 的 Office resolver 一样处理解析后的文件名;依赖原始请求名或引入虚拟请求的 hook 不受支持。内置模块保留 Node 原有的加载行为。
|
||||
- 后续工作:跨 HMR 销毁和替换保留包配置失效状态。恢复原生 reader 后,其旧配置缓存可能重新可见。
|
||||
- 如果共享运行时的替换模块具有不同的插件回调,没有 Loader entry 的实例就无法确定应使用哪一个。HMR 会报告歧义错误并回滚此次重载。
|
||||
- `watchConfig()` 在 Chokidar 报告就绪时 resolve。darwin 上 libuv 随后才在自己的线程启动 FSEvents 流,因此注册后数毫秒内落地的写入要等到该目录的下一个事件才会被报告;启动之后的编辑不受影响。
|
||||
|
||||
### 开发备注
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-hmr",
|
||||
"description": "Coordinated module and profile configuration hot reload",
|
||||
"version": "0.2.0-rc.2",
|
||||
"version": "0.2.1-alpha.1",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
@@ -38,6 +38,7 @@
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"chokidar": "^4.0.3",
|
||||
"node-addon-require-builtin": "^0.1.6",
|
||||
"picomatch": "^4.0.3",
|
||||
"@deepseek-ai/schemastery": "workspace:~"
|
||||
},
|
||||
|
||||
+188
-38
@@ -1,15 +1,17 @@
|
||||
/** Serialized module and profile-configuration reloads. */
|
||||
import assert from 'node:assert/strict'
|
||||
import { AsyncLocalStorage } from 'node:async_hooks'
|
||||
import { watchConfig as watchExactConfig } from './watch-config.ts'
|
||||
import { Context, Inject, Service, type Plugin } from '@deepseek-ai/cordis'
|
||||
import { ModuleLoader, type ModuleJob, type ResolveResult } from '@deepseek-ai/cordis-plugin-loader'
|
||||
import { Context, Inject, Service, type Fiber, type Plugin } from '@deepseek-ai/cordis'
|
||||
import type { ModuleLoader, ModuleJob, ResolveResult } from '@deepseek-ai/cordis-plugin-loader'
|
||||
import type { Include } from '@deepseek-ai/cordis-plugin-include'
|
||||
import { FSWatcher, watch, type ChokidarOptions } from 'chokidar'
|
||||
import { basename, dirname, join, relative, resolve } from 'node:path'
|
||||
import { basename, dirname, join, relative, resolve, sep } from 'node:path'
|
||||
import { readFileSync, realpathSync } from 'node:fs'
|
||||
import { readProfileManifest, readProfilePatches, reconcileProfilePatches, PROFILE_PATCH_FILENAME } from '@deepseek-ai/dsh-app-boot'
|
||||
import type {} from '@deepseek-ai/dsh-cmdline'
|
||||
import { handleError } from './error.ts'
|
||||
import { PackageManifests } from './package-manifest.ts'
|
||||
import type {} from '@deepseek-ai/cordis-plugin-timer'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { createRequire } from 'node:module'
|
||||
@@ -79,9 +81,129 @@ async function loadDependencies(job: ModuleJob, ignored = new Set<string>()) {
|
||||
/** Module location and runtime retained during a replacement. */
|
||||
export interface Reload {
|
||||
filename: string
|
||||
/** Original namespaces and URLs of all entry modules participating in this runtime replacement. */
|
||||
modules: ReloadModules
|
||||
runtime?: Plugin.Runtime | undefined
|
||||
}
|
||||
|
||||
/** Entry names with distinct namespaces; iteration falls back to undefined only for names without a loaded namespace. */
|
||||
class EntryNamespaces {
|
||||
private readonly names = new Map<string, Set<unknown>>()
|
||||
|
||||
constructor(private internal: ModuleLoader, private moduleUrls: ReadonlyMap<unknown, string>) {}
|
||||
|
||||
static getModuleUrls(internal: ModuleLoader): Map<unknown, string> {
|
||||
const moduleUrls = new Map<unknown, string>()
|
||||
for (const url of internal.loadCache.keys()) {
|
||||
const job = internal.loadCache.get(url)
|
||||
if (!job?.module) continue
|
||||
try {
|
||||
moduleUrls.set(job.module.getNamespace(), url)
|
||||
} catch (_error) {
|
||||
// Node also caches unfinished jobs whose namespace is not available yet.
|
||||
}
|
||||
}
|
||||
return moduleUrls
|
||||
}
|
||||
|
||||
add(name: string, moduleNamespace: unknown): this {
|
||||
const namespaces = this.names.get(name) ?? new Set<unknown>()
|
||||
namespaces.add(moduleNamespace)
|
||||
this.names.set(name, namespaces)
|
||||
return this
|
||||
}
|
||||
|
||||
*[Symbol.iterator](): Generator<[name: string, moduleNamespace: unknown]> {
|
||||
for (const [name, namespaces] of this.names) {
|
||||
for (const moduleNamespace of namespaces) {
|
||||
if (moduleNamespace === undefined && namespaces.size > 1) continue
|
||||
yield [name, moduleNamespace]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async resolve(name: string, baseUrl: string, moduleNamespace: unknown): Promise<Pick<ResolveResult, 'url'>> {
|
||||
if (moduleNamespace === undefined || name.startsWith('cordis:')) {
|
||||
switch (this.internal.version) {
|
||||
case 'v1': return await this.internal.resolve(name, baseUrl, {})
|
||||
case 'v2': return this.internal.resolveSync(baseUrl, { specifier: name, attributes: {} })
|
||||
}
|
||||
}
|
||||
const url = this.moduleUrls.get(moduleNamespace)
|
||||
if (url === undefined) throw new Error(`HMR cannot locate the loaded module for ${name} from ${baseUrl}`)
|
||||
return { url }
|
||||
}
|
||||
}
|
||||
|
||||
type ReloadFiber = {
|
||||
fiber: Fiber
|
||||
entry: Fiber['entry']
|
||||
config: unknown
|
||||
moduleNamespace: unknown
|
||||
}
|
||||
|
||||
type ReloadModule = { filename: string; moduleNamespace: unknown; plugin: Plugin }
|
||||
|
||||
/** Entry modules belonging to one runtime, keyed by their original namespaces across replacement. */
|
||||
class ReloadModules {
|
||||
private readonly modules = new Map<unknown, ReloadModule>()
|
||||
|
||||
static include(reloads: ReadonlyMap<Plugin, Reload>, plugin: Plugin, job: ModuleJob, runtime: Plugin.Runtime | undefined): boolean {
|
||||
let info = reloads.get(plugin)
|
||||
if (info === undefined && runtime) info = [...reloads.values()].find(info => info.runtime === runtime)
|
||||
if (info === undefined) return false
|
||||
info.modules.add(job, plugin)
|
||||
return true
|
||||
}
|
||||
|
||||
add(job: ModuleJob, plugin: Plugin): this {
|
||||
assert(job.module, `HMR pending module is missing: ${job.url}`)
|
||||
const moduleNamespace: unknown = job.module.getNamespace()
|
||||
this.modules.set(moduleNamespace, { filename: job.url, moduleNamespace, plugin })
|
||||
return this
|
||||
}
|
||||
|
||||
async importRemaining(loader: Context['loader'], getOuterStack: () => string[], primary: ReloadModule): Promise<ReloadModules> {
|
||||
const replacements = new ReloadModules()
|
||||
for (const [originalNamespace, { filename }] of this.modules) {
|
||||
if (filename === primary.filename) {
|
||||
replacements.modules.set(originalNamespace, primary)
|
||||
continue
|
||||
}
|
||||
const moduleNamespace: unknown = await loader.import(filename, getOuterStack)
|
||||
const plugin = loader.unwrapExports(moduleNamespace) as Plugin
|
||||
replacements.modules.set(originalNamespace, { filename, moduleNamespace, plugin })
|
||||
}
|
||||
return replacements
|
||||
}
|
||||
|
||||
*getActiveImplementations(ctx: Context, plugin: Plugin, fibers: readonly ReloadFiber[]): Generator<[ReloadFiber, Plugin]> {
|
||||
const callbacks = new Set([...this.modules.values()].map(value => ctx.registry.resolve(value.plugin)))
|
||||
const implementations = fibers.map((previousFiber) => {
|
||||
if (previousFiber.entry === undefined) return [previousFiber, plugin] as const
|
||||
const replacement = this.modules.get(previousFiber.moduleNamespace)
|
||||
const implementation = replacement ? replacement.plugin : ctx.loader.unwrapExports(previousFiber.moduleNamespace) as Plugin
|
||||
callbacks.add(ctx.registry.resolve(implementation))
|
||||
return [previousFiber, implementation] as const
|
||||
})
|
||||
for (const [previousFiber, implementation] of implementations) {
|
||||
if (previousFiber.fiber.parent.fiber.uid === null) continue
|
||||
if (previousFiber.entry === undefined && callbacks.size > 1) {
|
||||
throw new Error('HMR replacement is ambiguous for a plugin instance without a Loader entry')
|
||||
}
|
||||
yield [previousFiber, implementation]
|
||||
}
|
||||
}
|
||||
|
||||
updateEntries(loader: Context['loader']): void {
|
||||
for (const [originalNamespace, { moduleNamespace }] of this.modules) {
|
||||
for (const entry of loader.entries()) {
|
||||
if (entry.moduleNamespace === originalNamespace) entry.moduleNamespace = moduleNamespace
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Hot reload service with Cordis-compatible module configuration and events. */
|
||||
@Inject('loader')
|
||||
@Inject('timer')
|
||||
@@ -131,6 +253,7 @@ class Hmr extends Service {
|
||||
private applicationReady: Promise<boolean> = Promise.resolve(true)
|
||||
private closing = false
|
||||
private readonly configPaths = new Set<string>()
|
||||
private readonly manifests = new PackageManifests()
|
||||
|
||||
/** Serialize a caller-owned mutation with all automatic reload paths.
|
||||
* @param operation Work that must not overlap module or configuration replacement.
|
||||
@@ -185,22 +308,13 @@ class Hmr extends Service {
|
||||
this.baseDir = fileURLToPath(new URL(config.base || '.', ctx.baseUrl))
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a module specifier to a URL, compatible with Node 22-24.
|
||||
*/
|
||||
private async _resolve(specifier: string, parentURL: string, attrs: ImportAttributes): Promise<ResolveResult> {
|
||||
switch (this.internal.version) {
|
||||
case 'v1': return await this.internal.resolve(specifier, parentURL, attrs)
|
||||
case 'v2': return this.internal.resolveSync(parentURL, { specifier, attributes: attrs })
|
||||
}
|
||||
}
|
||||
|
||||
async* [Service.init](): AsyncGenerator<() => Promise<void>, void, unknown> {
|
||||
yield async () => {
|
||||
this.closing = true
|
||||
await this.watcher?.close()
|
||||
// A configuration reload may remove its own HMR entry.
|
||||
if (!this.executing.getStore()) await this.operations
|
||||
this.manifests.dispose()
|
||||
}
|
||||
|
||||
const profile = this.ownerContext.get('profileContext')
|
||||
@@ -276,15 +390,20 @@ class Hmr extends Service {
|
||||
const filename = canonicalPath(resolve(watchBaseDir, path))
|
||||
const configuredFilename = resolve(this.baseDir, path)
|
||||
if (this.configPaths.has(filename) || this.configPaths.has(configuredFilename)) continue
|
||||
const isManifest = basename(filename) === 'package.json' && !filename.includes(`${sep}node_modules${sep}`)
|
||||
if (isManifest) {
|
||||
this.manifests.invalidate(filename)
|
||||
}
|
||||
const url = pathToFileURL(filename).href
|
||||
if (this.externals.has(url)) {
|
||||
fullReload = true
|
||||
continue
|
||||
}
|
||||
if (this.internal.loadCache.has(url)) {
|
||||
if (this.internal.loadCache.has(url) || this.internal.loadCache.has(url, 'json')) {
|
||||
this.stashed.add(url)
|
||||
continue
|
||||
}
|
||||
if (isManifest) continue
|
||||
const include = [...loader.entries()].map(entry => entry.subtree as Include | undefined)
|
||||
.find(tree => tree?.filename === filename || tree?.filename === configuredFilename)
|
||||
if (include !== undefined) includes.add(include)
|
||||
@@ -408,23 +527,24 @@ class Hmr extends Service {
|
||||
|
||||
const pending = new Map<ModuleJob, Plugin>()
|
||||
const reloads = new Map<Plugin, Reload>()
|
||||
const moduleUrls = EntryNamespaces.getModuleUrls(this.internal)
|
||||
|
||||
// Build a map of plugin names per config tree URL.
|
||||
// Plugin entry files are treated as atomic reload units.
|
||||
const nameMap = new Map<string, Set<string>>()
|
||||
const nameMap = new Map<string, EntryNamespaces>()
|
||||
for (const entry of this.ctx.loader.entries()) {
|
||||
const baseUrl = entry.parent.tree.ctx.baseUrl
|
||||
if (baseUrl === undefined) throw new Error('HMR entry tree has no base URL')
|
||||
const names = nameMap.get(baseUrl) ?? new Set<string>()
|
||||
names.add(entry.options.name)
|
||||
const names = nameMap.get(baseUrl) ?? new EntryNamespaces(this.internal, moduleUrls)
|
||||
names.add(entry.options.name, entry.moduleNamespace)
|
||||
nameMap.set(baseUrl, names)
|
||||
}
|
||||
|
||||
// Resolve each plugin name to its file URL and check if it needs reload
|
||||
// Find each plugin's loaded URL and check if it needs reload.
|
||||
for (const [baseUrl, names] of nameMap) {
|
||||
for (const name of names) {
|
||||
for (const [name, moduleNamespace] of names) {
|
||||
try {
|
||||
const { url } = await this._resolve(name, baseUrl, {})
|
||||
const { url } = await names.resolve(name, baseUrl, moduleNamespace)
|
||||
if (this.declined.has(url)) continue
|
||||
const job = this.internal.loadCache.get(url)
|
||||
const plugin = this.ctx.loader.unwrapExports(job?.module?.getNamespace()) as Plugin | undefined
|
||||
@@ -443,15 +563,27 @@ class Hmr extends Service {
|
||||
const dependencies = [...await loadDependencies(job, this.declined)]
|
||||
this.declined.add(job.url)
|
||||
|
||||
if (dependencies.length === 0) {
|
||||
pending.delete(job)
|
||||
continue
|
||||
}
|
||||
if (!dependencies.some(dep => this.accepted.has(dep))) continue
|
||||
dependencies.forEach(dep => this.accepted.add(dep))
|
||||
|
||||
const runtime = this.ctx.registry.get(plugin)
|
||||
if (ReloadModules.include(reloads, plugin, job, runtime)) continue
|
||||
reloads.set(plugin, {
|
||||
filename: job.url,
|
||||
runtime: this.ctx.registry.get(plugin),
|
||||
runtime,
|
||||
modules: new ReloadModules().add(job, plugin),
|
||||
})
|
||||
}
|
||||
|
||||
// Re-export roots of a replaced runtime must not retain bindings to its old modules.
|
||||
for (const [job, plugin] of pending) {
|
||||
if (ReloadModules.include(reloads, plugin, job, this.ctx.registry.get(plugin))) this.accepted.add(job.url)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear module caches for all accepted files before re-importing.
|
||||
*
|
||||
@@ -502,14 +634,23 @@ class Hmr extends Service {
|
||||
fibers: [...info.runtime?.fibers ?? []].map((fiber) => {
|
||||
const entry = fiber.entry?.fiber?.uid === fiber.uid ? fiber.entry : undefined
|
||||
const config: unknown = entry === undefined ? fiber._config : entry.options.config
|
||||
return { fiber, entry, config }
|
||||
return { fiber, entry, config, moduleNamespace: entry?.moduleNamespace }
|
||||
}),
|
||||
}))
|
||||
const attempts: Array<(typeof generations)[number] & { replacement: Plugin }> = []
|
||||
type ReloadAttempt = (typeof generations)[number] & {
|
||||
replacement: Plugin
|
||||
replacements: ReloadModules
|
||||
activated: Fiber[]
|
||||
}
|
||||
const attempts: ReloadAttempt[] = []
|
||||
try {
|
||||
for (const generation of generations) {
|
||||
const replacement = this.ctx.loader.unwrapExports(await this.ctx.loader.import(generation.filename, this.getOuterStack)) as Plugin
|
||||
attempts.push({ ...generation, replacement })
|
||||
const moduleNamespace: unknown = await this.ctx.loader.import(generation.filename, this.getOuterStack)
|
||||
const replacement = this.ctx.loader.unwrapExports(moduleNamespace) as Plugin
|
||||
const replacements = await generation.modules.importRemaining(this.ctx.loader, this.getOuterStack, {
|
||||
filename: generation.filename, moduleNamespace, plugin: replacement,
|
||||
})
|
||||
attempts.push({ ...generation, replacement, replacements, activated: [] })
|
||||
}
|
||||
} catch (e) {
|
||||
handleError(this.ctx, e)
|
||||
@@ -517,11 +658,14 @@ class Hmr extends Service {
|
||||
throw e
|
||||
}
|
||||
|
||||
const reload = async (plugin: Plugin, fibers: (typeof generations)[number]['fibers']) => {
|
||||
const activated = []
|
||||
for (const previousFiber of fibers) {
|
||||
if (previousFiber.fiber.parent.fiber.uid === null) continue
|
||||
const fiber = previousFiber.fiber.parent.registry.plugin(plugin, previousFiber.config, this.getOuterStack).ctx.fiber
|
||||
const reload = async (
|
||||
plugin: Plugin,
|
||||
fibers: (typeof generations)[number]['fibers'],
|
||||
replacements = new ReloadModules(),
|
||||
activated: Fiber[] = [],
|
||||
) => {
|
||||
for (const [previousFiber, implementation] of replacements.getActiveImplementations(this.ctx, plugin, fibers)) {
|
||||
const fiber = previousFiber.fiber.parent.registry.plugin(implementation, previousFiber.config, this.getOuterStack).ctx.fiber
|
||||
if (previousFiber.entry !== undefined) {
|
||||
fiber.entry = previousFiber.entry
|
||||
previousFiber.entry.fiber = fiber
|
||||
@@ -533,7 +677,7 @@ class Hmr extends Service {
|
||||
|
||||
const removed = new Set<Plugin>()
|
||||
try {
|
||||
for (const { previous: plugin, replacement, filename, runtime, fibers } of attempts) {
|
||||
for (const { previous: plugin, replacement, filename, runtime, fibers, replacements, activated } of attempts) {
|
||||
if (!runtime) continue
|
||||
const path = relative(this.baseDir, fileURLToPath(filename))
|
||||
|
||||
@@ -547,7 +691,7 @@ class Hmr extends Service {
|
||||
}
|
||||
|
||||
try {
|
||||
await reload(replacement, fibers)
|
||||
await reload(replacement, fibers, replacements, activated)
|
||||
this.ctx.logger.info('reload plugin at %C', path)
|
||||
} catch (err) {
|
||||
this.ctx.logger.warn('failed to reload plugin at %C', path)
|
||||
@@ -558,14 +702,17 @@ class Hmr extends Service {
|
||||
} catch (error) {
|
||||
// Restore caches and re-register old plugins after a replacement failure.
|
||||
rollback()
|
||||
for (const { previous: plugin, replacement, fibers } of attempts) {
|
||||
for (const { previous: plugin, fibers, activated } of attempts) {
|
||||
if (!removed.has(plugin)) continue
|
||||
try {
|
||||
const replacementRuntime = this.ctx.registry.get(replacement)
|
||||
const replacementFibers = [...replacementRuntime?.fibers ?? []]
|
||||
this.ctx.registry.delete(replacement)
|
||||
// Failed startup errors remain on fibers after their teardown finishes.
|
||||
await Promise.allSettled(replacementFibers.map(fiber => fiber.await()))
|
||||
for (const { runtime } of activated) {
|
||||
if (runtime && this.ctx.registry.get(runtime.callback) === runtime) {
|
||||
const replacementFibers = [...runtime.fibers]
|
||||
this.ctx.registry.delete(runtime.callback)
|
||||
// Failed startup errors remain on fibers after their teardown finishes.
|
||||
await Promise.allSettled(replacementFibers.map(fiber => fiber.await()))
|
||||
}
|
||||
}
|
||||
await reload(plugin, fibers)
|
||||
} catch (err) {
|
||||
this.ctx.logger.warn(err)
|
||||
@@ -575,6 +722,9 @@ class Hmr extends Service {
|
||||
}
|
||||
|
||||
await this.ctx.loader.await()
|
||||
for (const { replacements } of attempts) {
|
||||
replacements.updateEntries(this.ctx.loader)
|
||||
}
|
||||
this.ctx.emit('hmr/reload', reloads)
|
||||
this.stashed = new Set()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
/** Expire Node's cached package configuration for package directories whose manifest changed. */
|
||||
import { isUtf8 } from 'node:buffer'
|
||||
import { readFileSync, realpathSync } from 'node:fs'
|
||||
import { createRequire, isBuiltin } from 'node:module'
|
||||
import { basename, dirname, join, sep, toNamespacedPath } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
|
||||
/** Package fields Node's resolver consumes; exports and imports hold JSON values. */
|
||||
interface PackageConfig {
|
||||
name?: string
|
||||
main?: string
|
||||
type: string
|
||||
exports?: unknown
|
||||
imports?: unknown
|
||||
}
|
||||
|
||||
/** Serialized fields returned by the native modules binding: name, main, type, imports, exports, manifest path. */
|
||||
type PackageTuple = [string | undefined, string | undefined, string, string | undefined, string | undefined, string]
|
||||
|
||||
/**
|
||||
* Methods of `internalBinding('modules')` that read package configuration through Node's native cache. Node's
|
||||
* package reader, CommonJS loader, and ESM resolver and format detection look them up at each call.
|
||||
*/
|
||||
interface PackageBinding {
|
||||
readPackageJSON(this: void, path: string, isEsm?: boolean, base?: string, specifier?: string): PackageTuple | undefined
|
||||
getPackageScopeConfig(this: void, url: string): PackageTuple | string
|
||||
getPackageType(this: void, url: string): string | undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* `internal/modules/package_json_reader` export that keeps its own nearest-manifest cache; the CommonJS loader calls it
|
||||
* through the module object, so it is the only caller of the binding's nearest-manifest lookup.
|
||||
*/
|
||||
interface PackageReader {
|
||||
getNearestParentPackageJSON(this: void, path: string): { data: PackageConfig; exists: boolean; path: string } | undefined
|
||||
}
|
||||
|
||||
/** Node's ESM resolution cache: a Map from parent URL to the serialized request keys resolved from it. */
|
||||
interface ResolveCacheClass {
|
||||
prototype: { get: (this: Map<string, unknown>, key: string, parent: string) => unknown }
|
||||
}
|
||||
|
||||
interface CommonJsModule {
|
||||
_pathCache: Record<string, string>
|
||||
_resolveFilename(request: string, parent: NodeJS.Module | null | undefined, isMain?: boolean): string
|
||||
_load(request: string, parent: NodeJS.Module | null | undefined, isMain?: boolean, ...options: unknown[]): unknown
|
||||
}
|
||||
|
||||
interface NativeAccess {
|
||||
binding: PackageBinding
|
||||
reader: PackageReader
|
||||
ResolveCache: ResolveCacheClass
|
||||
cjs: CommonJsModule
|
||||
esmLoader: { resolveSync(...args: unknown[]): unknown }
|
||||
}
|
||||
|
||||
/** Snapshot own methods; each disposer restores its method only while its replacement is still installed. */
|
||||
function createReplaceHelper<T extends { [K in keyof T]: (...args: never[]) => unknown }>(target: T) {
|
||||
const rawImpl = { ...target }
|
||||
return {
|
||||
rawImpl,
|
||||
replaceMethod<K extends keyof T>(key: K, replacement: T[K]): () => void {
|
||||
target[key] = replacement
|
||||
return () => { if (target[key] === replacement) target[key] = rawImpl[key] }
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** Same lookup as the vendored Loader's `requireInternal`: `--expose-internals` first, then the builtin addon. */
|
||||
function requireInternal(id: string): unknown {
|
||||
const require = createRequire(import.meta.url)
|
||||
if (process.execArgv.includes('--expose-internals')) {
|
||||
try {
|
||||
return require(id)
|
||||
} catch (_error) {
|
||||
// Some internals, such as internal/bootstrap/realm, stay private under --expose-internals.
|
||||
}
|
||||
}
|
||||
try {
|
||||
return (require('node-addon-require-builtin') as { requireBuiltin(id: string): unknown }).requireBuiltin(id)
|
||||
} catch (_error) {
|
||||
// Without the addon, the caller reports the missing interface.
|
||||
}
|
||||
}
|
||||
|
||||
function loadNodeInternals(): NativeAccess {
|
||||
const realm = requireInternal('internal/bootstrap/realm') as { internalBinding(id: 'modules'): PackageBinding }
|
||||
const esm = requireInternal('internal/modules/esm/loader') as {
|
||||
getOrInitializeCascadedLoader(): NativeAccess['esmLoader']
|
||||
}
|
||||
return {
|
||||
binding: realm.internalBinding('modules'),
|
||||
reader: requireInternal('internal/modules/package_json_reader') as PackageReader,
|
||||
ResolveCache: (requireInternal('internal/modules/esm/module_map') as { ResolveCache: ResolveCacheClass }).ResolveCache,
|
||||
cjs: (requireInternal('internal/modules/cjs/loader') as { Module: CommonJsModule }).Module,
|
||||
esmLoader: esm.getOrInitializeCascadedLoader(),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the ESM loader's resolution cache by observing one lookup through its prototype.
|
||||
* The prototype method is restored before this returns.
|
||||
*/
|
||||
function captureEsmResolveCache(native: NativeAccess, parentURL: string): Map<string, unknown> | undefined {
|
||||
const prototype = native.ResolveCache.prototype
|
||||
const original = prototype.get
|
||||
const seen = new Set<Map<string, unknown>>()
|
||||
prototype.get = function (this: Map<string, unknown>, key: string, parent: string): unknown {
|
||||
seen.add(this)
|
||||
return Reflect.apply(original, this, [key, parent])
|
||||
}
|
||||
try {
|
||||
const loader = native.esmLoader
|
||||
/* v8 ignore else -- CI coverage runs the Node 24 loader; the Node 22 matrix exercises the v1 signature */
|
||||
if ('getOrCreateModuleJob' in loader) loader.resolveSync(parentURL, { specifier: 'node:path', attributes: {} })
|
||||
else loader.resolveSync('node:path', parentURL, {})
|
||||
} finally {
|
||||
prototype.get = original
|
||||
}
|
||||
return [...seen][0]
|
||||
}
|
||||
|
||||
interface Configuration {
|
||||
data: PackageConfig
|
||||
serialized: PackageTuple
|
||||
}
|
||||
|
||||
interface ImportContext {
|
||||
base: string
|
||||
specifier: string | undefined
|
||||
}
|
||||
|
||||
/** Package directories whose manifests are read from disk instead of Node's native cache. */
|
||||
export class PackageManifests {
|
||||
private readonly directories = new Set<string>()
|
||||
private readonly configurations = new Map<string, Configuration | undefined>()
|
||||
/** Real directory of each directory a lookup reached; linked consumers name a package by its link path. */
|
||||
private readonly realDirectories = new Map<string, string>()
|
||||
private native: NativeAccess | undefined
|
||||
private rawBinding!: PackageBinding
|
||||
private rawReader!: PackageReader
|
||||
private rawCommonJs!: Pick<CommonJsModule, '_load'>
|
||||
private readonly restorers: Array<() => void> = []
|
||||
|
||||
/**
|
||||
* Expire cached configuration for one package directory. Loaded modules stay evaluated; later resolutions and
|
||||
* format checks read the manifest on disk.
|
||||
* @param manifest - absolute path of the changed `package.json`.
|
||||
*/
|
||||
invalidate(manifest: string): void {
|
||||
const native = this.installPackageHooks()
|
||||
const directory = dirname(manifest)
|
||||
this.directories.add(directory)
|
||||
this.configurations.clear()
|
||||
this.realDirectories.clear()
|
||||
// A package's entry may resolve outside its directory; cache keys do not record the manifests they consulted.
|
||||
for (const key of Object.keys(native.cjs._pathCache)) Reflect.deleteProperty(native.cjs._pathCache, key)
|
||||
const directoryURL = pathToFileURL(directory).href + '/'
|
||||
const cache = captureEsmResolveCache(native, directoryURL)
|
||||
/* v8 ignore else -- asynchronous loader hooks own a separate cache; loader-thread invalidation is deferred */
|
||||
if (cache !== undefined) Map.prototype.clear.call(cache)
|
||||
}
|
||||
|
||||
/** Restore every replaced Node method this instance installed. */
|
||||
dispose(): void {
|
||||
for (const restore of this.restorers.splice(0).reverse()) restore()
|
||||
this.native = undefined
|
||||
this.realDirectories.clear()
|
||||
}
|
||||
|
||||
private isPathWithinDirectory(path: string, directory: string): boolean {
|
||||
return path === directory || path.startsWith(join(directory, sep))
|
||||
}
|
||||
|
||||
private isPathInInvalidatedDirectory(path: string): boolean {
|
||||
const parent = dirname(path)
|
||||
let real = this.realDirectories.get(parent)
|
||||
if (real === undefined) {
|
||||
try { real = realpathSync(parent) } catch (_error) {
|
||||
// A missing directory keeps its configured spelling; it has no manifest to read through a link.
|
||||
real = parent
|
||||
}
|
||||
this.realDirectories.set(parent, real)
|
||||
}
|
||||
const target = join(real, basename(path))
|
||||
for (const directory of this.directories) if (this.isPathWithinDirectory(target, directory)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
private isUrlInInvalidatedDirectory(url: string): boolean {
|
||||
try { return this.isPathInInvalidatedDirectory(fileURLToPath(url)) } catch (_error) {
|
||||
// Only file URLs can name an invalidated package directory.
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private installPackageHooks(): NativeAccess {
|
||||
if (this.native !== undefined) return this.native
|
||||
const native = loadNodeInternals()
|
||||
this.native = native
|
||||
const binding = createReplaceHelper(native.binding)
|
||||
const reader = createReplaceHelper(native.reader)
|
||||
const commonJs = createReplaceHelper<Pick<CommonJsModule, '_load'>>(native.cjs)
|
||||
this.rawBinding = binding.rawImpl
|
||||
this.rawReader = reader.rawImpl
|
||||
this.rawCommonJs = commonJs.rawImpl
|
||||
this.restorers.push(
|
||||
binding.replaceMethod('readPackageJSON', this.hookBindingReadPackageJSON.bind(this)),
|
||||
binding.replaceMethod('getPackageScopeConfig', this.hookBindingGetPackageScopeConfig.bind(this)),
|
||||
binding.replaceMethod('getPackageType', this.hookBindingGetPackageType.bind(this)),
|
||||
reader.replaceMethod('getNearestParentPackageJSON', this.hookReaderGetNearestParentPackageJSON.bind(this)),
|
||||
commonJs.replaceMethod('_load', this.hookCommonJsLoad.bind(this)),
|
||||
)
|
||||
return native
|
||||
}
|
||||
|
||||
private hookCommonJsLoad(...[request, parent, isMain, ...options]: Parameters<CommonJsModule['_load']>) {
|
||||
const native = this.installPackageHooks()
|
||||
// Absolute filenames bypass Node's private request cache without evicting evaluated modules.
|
||||
const filename = request.startsWith('node:') || isBuiltin(request)
|
||||
? request : native.cjs._resolveFilename(request, parent, isMain)
|
||||
return this.rawCommonJs._load.call(native.cjs, filename, parent, isMain, ...options)
|
||||
}
|
||||
|
||||
private hookBindingReadPackageJSON(path: string, isEsm?: boolean, base?: string, specifier?: string) {
|
||||
return this.isPathInInvalidatedDirectory(path)
|
||||
? this.readCachedPackageConfig(
|
||||
path,
|
||||
isEsm && base !== undefined ? { base, specifier } : undefined,
|
||||
)?.serialized
|
||||
: this.rawBinding.readPackageJSON(path, isEsm, base, specifier)
|
||||
}
|
||||
|
||||
private hookBindingGetPackageScopeConfig(url: string) {
|
||||
if (!this.isUrlInInvalidatedDirectory(url)) return this.rawBinding.getPackageScopeConfig(url)
|
||||
const found = this.findNearestPackageConfig(fileURLToPath(url), true)
|
||||
if (typeof found === 'string') return this.rawBinding.getPackageScopeConfig(pathToFileURL(found).href)
|
||||
return found.configuration?.serialized ?? found.path
|
||||
}
|
||||
|
||||
private hookBindingGetPackageType(url: string) {
|
||||
if (!this.isUrlInInvalidatedDirectory(url)) return this.rawBinding.getPackageType(url)
|
||||
const found = this.findNearestPackageConfig(fileURLToPath(url), true)
|
||||
return typeof found === 'string'
|
||||
? this.rawBinding.getPackageType(pathToFileURL(found).href)
|
||||
: found.configuration?.data.type
|
||||
}
|
||||
|
||||
private hookReaderGetNearestParentPackageJSON(path: string) {
|
||||
if (!this.isPathInInvalidatedDirectory(path)) return this.rawReader.getNearestParentPackageJSON(path)
|
||||
const found = this.findNearestPackageConfig(path, false)
|
||||
if (typeof found === 'string') return this.rawReader.getNearestParentPackageJSON(found)
|
||||
// A lookup that stops without a manifest keeps the running Node version's own absent result.
|
||||
if (found.configuration === undefined) return this.rawReader.getNearestParentPackageJSON(path)
|
||||
return { data: found.configuration.data, exists: true, path: found.path }
|
||||
}
|
||||
|
||||
private readCachedPackageConfig(path: string, context?: ImportContext): Configuration | undefined {
|
||||
if (!this.configurations.has(path)) this.configurations.set(path, this.readPackageConfigFromDisk(path, context))
|
||||
return this.configurations.get(path)
|
||||
}
|
||||
|
||||
/** A string names the first manifest outside the invalidated directories; Node answers for it. */
|
||||
private findNearestPackageConfig(path: string, includeRoot: boolean): { path: string; configuration?: Configuration } | string {
|
||||
let directory = dirname(path)
|
||||
while (true) {
|
||||
const manifest = join(directory, 'package.json')
|
||||
const parent = dirname(directory)
|
||||
if (basename(directory) === 'node_modules' || (!includeRoot && parent === directory)) return { path: manifest }
|
||||
if (!this.isPathInInvalidatedDirectory(manifest)) return manifest
|
||||
const configuration = this.readCachedPackageConfig(manifest)
|
||||
if (configuration !== undefined) return { path: toNamespacedPath(manifest), configuration }
|
||||
/* v8 ignore next -- root-scope queries run in an isolated child to contain non-termination */
|
||||
if (parent === directory) return { path: manifest }
|
||||
directory = parent
|
||||
}
|
||||
}
|
||||
|
||||
/** @param context - optional ESM import details used only when reporting invalid package configuration. */
|
||||
private readPackageConfigFromDisk(path: string, context?: ImportContext): Configuration | undefined {
|
||||
let bytes: Buffer
|
||||
try { bytes = readFileSync(path) } catch (_error) {
|
||||
// Node reports a missing manifest as absent configuration.
|
||||
return undefined
|
||||
}
|
||||
const throwInvalidPackageConfig = (): never => {
|
||||
// The native reader throws a plain Error carrying only this code.
|
||||
const base = context?.base
|
||||
const importing = base === undefined ? ''
|
||||
: ` while importing "${context?.specifier}" from ${base.startsWith('file:') ? fileURLToPath(base) : base}`
|
||||
throw Object.assign(new Error(`Invalid package config ${toNamespacedPath(path)}${importing}.`), { code: 'ERR_INVALID_PACKAGE_CONFIG' })
|
||||
}
|
||||
if (!isUtf8(bytes)) return throwInvalidPackageConfig()
|
||||
const source = bytes.toString('utf8')
|
||||
let parsed: unknown
|
||||
try { parsed = JSON.parse(source.charCodeAt(0) === 0xfeff ? source.slice(1) : source) } catch (_error) {
|
||||
return throwInvalidPackageConfig()
|
||||
}
|
||||
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return throwInvalidPackageConfig()
|
||||
const fields = parsed as Record<string, unknown>
|
||||
if (fields.name !== undefined && typeof fields.name !== 'string') return throwInvalidPackageConfig()
|
||||
if (fields.type !== undefined && typeof fields.type !== 'string') return throwInvalidPackageConfig()
|
||||
for (const key of ['name', 'type', 'exports', 'imports']) {
|
||||
const value = fields[key]
|
||||
if (typeof value === 'string' && !value.isWellFormed()) return throwInvalidPackageConfig()
|
||||
}
|
||||
const normalizePackageJsonField = (value: unknown): unknown =>
|
||||
value !== null && (typeof value === 'string' || typeof value === 'object') ? value : undefined
|
||||
const data: PackageConfig = {
|
||||
...typeof fields.name === 'string' ? { name: fields.name } : {},
|
||||
...typeof fields.main === 'string' && fields.main.isWellFormed() ? { main: fields.main } : {},
|
||||
type: fields.type === 'module' || fields.type === 'commonjs' ? fields.type : 'none',
|
||||
exports: normalizePackageJsonField(fields.exports),
|
||||
imports: normalizePackageJsonField(fields.imports),
|
||||
}
|
||||
const serializePackageJsonField = (value: unknown): string | undefined =>
|
||||
value === undefined ? undefined : typeof value === 'string' ? value : JSON.stringify(value)
|
||||
return {
|
||||
data,
|
||||
serialized: [
|
||||
data.name,
|
||||
data.main,
|
||||
data.type,
|
||||
serializePackageJsonField(data.imports),
|
||||
serializePackageJsonField(data.exports),
|
||||
toNamespacedPath(path),
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/** Isolate root-scope traversal; the filesystem root is read but never modified. */
|
||||
import assert from 'node:assert/strict'
|
||||
import { createRequire } from 'node:module'
|
||||
import { join, parse } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { PackageManifests } from '../../src/package-manifest.ts'
|
||||
|
||||
const [root] = process.argv.slice(2)
|
||||
const reader = createRequire(import.meta.url)('internal/modules/package_json_reader')
|
||||
const source = join(root, 'missing', 'entry.js')
|
||||
const url = pathToFileURL(source).href
|
||||
const expectedScope = reader.getPackageScopeConfig(url)
|
||||
const expectedNearest = reader.getNearestParentPackageJSON(source)
|
||||
const manifests = new PackageManifests()
|
||||
try {
|
||||
await manifests.invalidate(join(parse(root).root, 'package.json'))
|
||||
assert.deepEqual(reader.getPackageScopeConfig(url), expectedScope)
|
||||
assert.deepEqual(reader.getNearestParentPackageJSON(source), expectedNearest)
|
||||
process.stdout.write('scope traversal completed\n')
|
||||
} finally {
|
||||
manifests.dispose()
|
||||
}
|
||||
@@ -58,6 +58,42 @@ async function fixture(version: 'v1' | 'v2' = 'v2') {
|
||||
return { ctx, cache, imports, imported, reload, module, url, resolve }
|
||||
}
|
||||
|
||||
it('does not reactivate a runtime removed during dependency traversal', async () => {
|
||||
const { ctx, module, imports, imported, reload } = await fixture()
|
||||
const dependency = module('dependency.mjs')
|
||||
const original = { apply() {} }
|
||||
const replacement = { apply: vi.fn() }
|
||||
const job = module('plugin.mjs', original, [dependency])
|
||||
const entry = ctx.loader.resolve(await ctx.loader.create({ name: job.url }))
|
||||
await ctx.loader.await()
|
||||
const fiber = entry.fiber!
|
||||
const reached = Promise.withResolvers<undefined>()
|
||||
const linked = Promise.withResolvers<ModuleJob[]>()
|
||||
Object.defineProperty(job, 'linked', { configurable: true, get() {
|
||||
reached.resolve(undefined)
|
||||
return linked.promise
|
||||
} })
|
||||
onTestFinished(() => { linked.resolve([dependency]) })
|
||||
imports.set(job.url, replacement)
|
||||
imported.mockClear()
|
||||
reload.stashed.add(dependency.url)
|
||||
const operation = reload.partialReload()
|
||||
try {
|
||||
await reached.promise
|
||||
ctx.registry.delete(original)
|
||||
await fiber.await()
|
||||
expect(ctx.registry.get(original)).toBeUndefined()
|
||||
linked.resolve([dependency])
|
||||
await operation
|
||||
expect(imported).toHaveBeenCalledWith(job.url, expect.any(Function))
|
||||
expect(replacement.apply).not.toHaveBeenCalled()
|
||||
expect(ctx.registry.get(replacement)).toBeUndefined()
|
||||
} finally {
|
||||
linked.resolve([dependency])
|
||||
await operation
|
||||
}
|
||||
})
|
||||
|
||||
it.each(['v1', 'v2'] as const)('replaces a %s module and retains the latest Loader entry configuration', async (version) => {
|
||||
const { ctx, module, imports, reload } = await fixture(version)
|
||||
const mounted: string[] = []
|
||||
@@ -166,6 +202,34 @@ it('does not replace a plugin whose dependencies have not changed', async () =>
|
||||
expect(imported).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps a node_modules entry cached when it shares a runtime with a replaced source entry', async () => {
|
||||
const { ctx, module, imports, imported, cache, reload } = await fixture()
|
||||
const original = { apply: vi.fn() }
|
||||
const replacement = { apply: vi.fn() }
|
||||
const source = module('source.mjs', original)
|
||||
const installedNamespace = { apply: original.apply }
|
||||
const installed = module('node_modules/addon/index.mjs', installedNamespace)
|
||||
await ctx.loader.root.update([
|
||||
{ id: 'source', name: source.url },
|
||||
{ id: 'installed', name: installed.url },
|
||||
])
|
||||
await ctx.loader.await()
|
||||
expect(ctx.loader.resolve('source').fiber?.runtime).toBe(ctx.loader.resolve('installed').fiber?.runtime)
|
||||
imports.set(source.url, replacement)
|
||||
imported.mockClear()
|
||||
reload.stashed.add(source.url)
|
||||
|
||||
await reload.partialReload()
|
||||
|
||||
expect(imported).toHaveBeenCalledExactlyOnceWith(source.url, expect.any(Function))
|
||||
expect(replacement.apply).toHaveBeenCalledOnce()
|
||||
expect(cache.get(installed.url)).toBe(installed)
|
||||
expect(ctx.loader.resolve('installed').moduleNamespace).toBe(installedNamespace)
|
||||
expect(ctx.loader.resolve('installed').fiber?.runtime?.callback).toBe(original.apply)
|
||||
expect(ctx.loader.resolve('source').fiber?.runtime?.callback).toBe(replacement.apply)
|
||||
expect(reload.accepted).not.toContain(installed.url)
|
||||
})
|
||||
|
||||
it('replaces a plugin when a linked dependency changes, excluding framework modules', async () => {
|
||||
const { ctx, module, imports, reload } = await fixture()
|
||||
const dependency = module('dependency.mjs')
|
||||
@@ -237,14 +301,15 @@ it('leaves disposed child instances to their replacing parent', async () => {
|
||||
expect(entry.fiber?._config).toEqual({ value: 'entry' })
|
||||
})
|
||||
|
||||
it('invalidates a disabled module without activating it', async () => {
|
||||
const { ctx, module, imports, reload } = await fixture()
|
||||
it.each(['v1', 'v2'] as const)('invalidates a disabled %s module without activating it', async (version) => {
|
||||
const { ctx, module, imports, resolve, reload } = await fixture(version)
|
||||
const job = module('disabled.mjs', { apply() {} })
|
||||
const id = await ctx.loader.create({ name: job.url, disabled: true })
|
||||
const apply = vi.fn()
|
||||
imports.set(job.url, { apply })
|
||||
reload.stashed.add(job.url)
|
||||
await reload.partialReload()
|
||||
expect(resolve.mock.calls).toEqual(version === 'v1' ? [[job.url, ctx.baseUrl, {}]] : [[job.url]])
|
||||
expect(ctx.loader.resolve(id).fiber).toBeUndefined()
|
||||
expect(apply).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -263,6 +328,45 @@ it('does not disturb an unchanged runtime after an earlier replacement fails', a
|
||||
expect(untouched).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('cleans up a shared replacement runtime once when a later plugin activation fails', async () => {
|
||||
const { ctx, module, imports, cache, reload } = await fixture()
|
||||
const mounted: string[] = []
|
||||
const disposed: string[] = []
|
||||
const original = { apply(_ctx: Context, config: { label: string }) { mounted.push(config.label) } }
|
||||
const replacement = { apply(ctx: Context, config: { label: string }) {
|
||||
ctx.effect(() => () => { disposed.push(config.label) })
|
||||
} }
|
||||
const first = module('shared.mjs', original)
|
||||
const second = module('failing.mjs', { apply() {} })
|
||||
await ctx.loader.root.update([
|
||||
{ id: 'one', name: first.url, config: { label: 'one' } },
|
||||
{ id: 'two', name: first.url, config: { label: 'two' } },
|
||||
{ id: 'failing', name: second.url },
|
||||
])
|
||||
await ctx.loader.await()
|
||||
imports.set(first.url, replacement)
|
||||
imports.set(second.url, { apply() { throw new Error('later activation failed') } })
|
||||
const remove = vi.spyOn(ctx.registry, 'delete')
|
||||
const event = vi.fn()
|
||||
ctx.on('hmr/reload', event)
|
||||
reload.stashed.add(first.url)
|
||||
reload.stashed.add(second.url)
|
||||
|
||||
await expect(reload.partialReload()).rejects.toThrow('later activation failed')
|
||||
|
||||
expect(disposed.sort()).toEqual(['one', 'two'])
|
||||
expect(mounted).toEqual(['one', 'two', 'one', 'two'])
|
||||
expect(remove.mock.calls.filter(([plugin]) => plugin === replacement.apply)).toHaveLength(1)
|
||||
expect(ctx.registry.get(replacement)).toBeUndefined()
|
||||
const restored = ctx.registry.get(original)
|
||||
expect([...restored!.fibers]).toHaveLength(2)
|
||||
expect(ctx.loader.resolve('one').fiber?.runtime).toBe(restored)
|
||||
expect(ctx.loader.resolve('two').fiber?.runtime).toBe(restored)
|
||||
expect(cache.get(first.url)).toBe(first)
|
||||
expect(cache.get(second.url)).toBe(second)
|
||||
expect(event).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('restores the prior plugin when the replacement does not export a plugin', async () => {
|
||||
const { ctx, module, imported, reload } = await fixture()
|
||||
const original = { apply: vi.fn() }
|
||||
@@ -292,7 +396,7 @@ it('ignores framework entries and reports unresolved entry modules', async () =>
|
||||
const framework = module('framework.mjs', { apply() {} })
|
||||
const uncached = module('uncached.mjs', { apply() {} })
|
||||
await ctx.loader.create({ name: framework.url })
|
||||
await ctx.loader.create({ name: uncached.url })
|
||||
await ctx.loader.create({ name: uncached.url, disabled: true })
|
||||
await ctx.loader.await()
|
||||
reload.externals.add(framework.url)
|
||||
resolve.mockImplementation((name) => {
|
||||
|
||||
@@ -0,0 +1,669 @@
|
||||
/** HMR dispatch of package manifest changes through a real Loader and a controlled watcher. */
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { Context, type Plugin } from '@deepseek-ai/cordis'
|
||||
import Loader from '@deepseek-ai/cordis-plugin-loader'
|
||||
import Timer from '@deepseek-ai/cordis-plugin-timer'
|
||||
import { FSWatcher, type ChokidarOptions } from 'chokidar'
|
||||
import { expect, it, onTestFinished, vi } from 'vitest'
|
||||
import Hmr from '../src/index.ts'
|
||||
|
||||
const watchers = vi.hoisted(() => [] as FSWatcher[])
|
||||
// File notifications are delivered only when a test emits them.
|
||||
vi.mock('chokidar', async (original) => {
|
||||
const actual = await original<typeof import('chokidar')>()
|
||||
return { ...actual, watch: (_paths: string | string[], options: ChokidarOptions = {}) => {
|
||||
const watcher = new actual.FSWatcher(options)
|
||||
watchers.push(watcher)
|
||||
queueMicrotask(() => watcher.emit('ready'))
|
||||
return watcher
|
||||
} }
|
||||
})
|
||||
|
||||
function file(path: string, source: string): void {
|
||||
mkdirSync(dirname(path), { recursive: true })
|
||||
writeFileSync(path, source)
|
||||
}
|
||||
|
||||
function plugin(marker: string): string {
|
||||
return `import { value } from '#dep';
|
||||
export function apply(ctx) {
|
||||
ctx.get('manifestTrace').push('start:${marker}:' + value);
|
||||
ctx.effect(() => () => { ctx.get('manifestTrace').push('stop:${marker}'); });
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
function manifest(entry: string, dep: string): string {
|
||||
return JSON.stringify({ name: 'addon', type: 'module', exports: `./${entry}.mjs`, imports: { '#dep': `./${dep}.mjs` } })
|
||||
}
|
||||
|
||||
async function fixture(source = plugin('a'), options: { lateHmr?: boolean; packageJson?: string } = {}) {
|
||||
const root = realpathSync.native(mkdtempSync(join(tmpdir(), 'dsh-hmr-manifest-dispatch-')))
|
||||
const ctx = new Context()
|
||||
onTestFinished(async () => {
|
||||
try { await ctx.fiber.dispose() } finally { rmSync(root, { recursive: true, force: true }) }
|
||||
})
|
||||
const pkg = join(root, 'plugins', 'addon')
|
||||
file(join(pkg, 'package.json'), options.packageJson ?? manifest('a', 'dep-1'))
|
||||
file(join(pkg, 'a.mjs'), source)
|
||||
file(join(pkg, 'b.mjs'), plugin('b'))
|
||||
file(join(pkg, 'dep-1.mjs'), 'export const value = 1')
|
||||
file(join(pkg, 'dep-2.mjs'), 'export const value = 2')
|
||||
file(join(root, 'app', 'package.json'), '{"type":"module"}')
|
||||
mkdirSync(join(root, 'app', 'node_modules'), { recursive: true })
|
||||
symlinkSync(pkg, join(root, 'app', 'node_modules', 'addon'), process.platform === 'win32' ? 'junction' : 'dir')
|
||||
const trace: string[] = []
|
||||
ctx.baseUrl = pathToFileURL(join(root, 'app')).href + '/'
|
||||
ctx.provide('manifestTrace', trace)
|
||||
await ctx.plugin(Loader)
|
||||
await ctx.plugin(Timer)
|
||||
const startHmr = async () => {
|
||||
const start = watchers.length
|
||||
const fiber = (await ctx.plugin(Hmr, { root: ['../plugins'], ignored: [], debounce: 0 })).ctx.fiber
|
||||
return { fiber, watcher: watchers.slice(start).at(-1)! }
|
||||
}
|
||||
const initialHmr = options.lateHmr ? undefined : await startHmr()
|
||||
const entry = ctx.loader.resolve(await ctx.loader.create({ name: 'addon' }))
|
||||
await ctx.loader.await()
|
||||
let hmr = initialHmr ?? await startHmr()
|
||||
const warn = vi.spyOn(ctx.logger, 'warn')
|
||||
onTestFinished(() => { warn.mockRestore() })
|
||||
const restartHmr = async () => {
|
||||
await hmr.fiber.dispose()
|
||||
hmr = await startHmr()
|
||||
}
|
||||
const emit = async (...paths: string[]) => {
|
||||
const dispatch = vi.spyOn(ctx.hmr, 'runExclusive')
|
||||
try {
|
||||
for (const path of paths) hmr.watcher.emit('change', path)
|
||||
await vi.waitFor(() => { expect(dispatch).toHaveBeenCalledOnce() })
|
||||
const result = dispatch.mock.results[0]!
|
||||
expect(result.type).toBe('return')
|
||||
await result.value
|
||||
} finally {
|
||||
dispatch.mockRestore()
|
||||
}
|
||||
}
|
||||
return { ctx, pkg, trace, emit, entry, warn, restartHmr }
|
||||
}
|
||||
|
||||
it('does not reload for a manifest alone and resolves the new entry when the Loader entry restarts', async () => {
|
||||
const f = await fixture()
|
||||
expect(f.trace).toEqual(['start:a:1'])
|
||||
file(join(f.pkg, 'package.json'), manifest('b', 'dep-2'))
|
||||
await f.emit(join(f.pkg, 'package.json'))
|
||||
expect(f.trace).toEqual(['start:a:1'])
|
||||
|
||||
await f.entry.update({ disabled: true })
|
||||
await f.entry.update({ disabled: false })
|
||||
await f.ctx.loader.await()
|
||||
expect(f.trace).toEqual(['start:a:1', 'stop:a', 'start:b:2'])
|
||||
})
|
||||
|
||||
const jsonPlugin = `import manifest from './package.json' with { type: 'json' };
|
||||
export function apply(ctx) {
|
||||
ctx.get('manifestTrace').push('start:json:' + manifest.imports['#dep']);
|
||||
ctx.effect(() => () => { ctx.get('manifestTrace').push('stop:json'); });
|
||||
}
|
||||
`
|
||||
|
||||
it('reloads a plugin that imports its changed manifest as a JSON module', async () => {
|
||||
const f = await fixture(jsonPlugin)
|
||||
expect(f.trace).toEqual(['start:json:./dep-1.mjs'])
|
||||
file(join(f.pkg, 'package.json'), manifest('a', 'dep-2'))
|
||||
await f.emit(join(f.pkg, 'package.json'))
|
||||
expect(f.trace).toEqual(['start:json:./dep-1.mjs', 'stop:json', 'start:json:./dep-2.mjs'])
|
||||
})
|
||||
|
||||
it('requests a host reload for a changed manifest in the host module graph', async () => {
|
||||
const f = await fixture(jsonPlugin)
|
||||
const filename = join(f.pkg, 'package.json')
|
||||
const externals = Reflect.get(f.ctx.hmr, 'externals') as Set<string>
|
||||
externals.add(pathToFileURL(filename).href)
|
||||
const exit = vi.spyOn(f.ctx.loader, 'exit').mockImplementation(() => {})
|
||||
onTestFinished(() => { exit.mockRestore() })
|
||||
file(filename, manifest('a', 'dep-2'))
|
||||
await f.emit(filename)
|
||||
expect(exit).toHaveBeenCalledOnce()
|
||||
expect(f.trace).toEqual(['start:json:./dep-1.mjs'])
|
||||
})
|
||||
|
||||
it('leaves a configuration-owned manifest to its dedicated watcher', async () => {
|
||||
const f = await fixture(jsonPlugin)
|
||||
const filename = join(f.pkg, 'package.json')
|
||||
const dispose = await f.ctx.hmr.watchConfig(filename, async () => {})
|
||||
try {
|
||||
file(filename, manifest('a', 'dep-2'))
|
||||
await f.emit(filename)
|
||||
expect(f.trace).toEqual(['start:json:./dep-1.mjs'])
|
||||
} finally {
|
||||
await dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('resolves package imports from the changed manifest when a source change reloads the plugin', async () => {
|
||||
const f = await fixture()
|
||||
file(join(f.pkg, 'package.json'), manifest('a', 'dep-2'))
|
||||
file(join(f.pkg, 'a.mjs'), `${plugin('a')}// edited\n`)
|
||||
await f.emit(join(f.pkg, 'a.mjs'), join(f.pkg, 'package.json'))
|
||||
await f.ctx.loader.await()
|
||||
expect(f.trace).toEqual(['start:a:1', 'stop:a', 'start:a:2'])
|
||||
})
|
||||
|
||||
it('keeps cached package imports when only source changes', async () => {
|
||||
const f = await fixture()
|
||||
file(join(f.pkg, 'package.json'), manifest('a', 'dep-2'))
|
||||
file(join(f.pkg, 'a.mjs'), `${plugin('a')}// edited\n`)
|
||||
await f.emit(join(f.pkg, 'a.mjs'))
|
||||
await f.ctx.loader.await()
|
||||
expect(f.trace).toEqual(['start:a:1', 'stop:a', 'start:a:1'])
|
||||
})
|
||||
|
||||
it('leaves manifests below node_modules to their package lifecycle', async () => {
|
||||
const f = await fixture()
|
||||
const installed = join(dirname(f.pkg), 'node_modules', 'dep', 'package.json')
|
||||
file(installed, '{"name":"dep"}')
|
||||
const invalidate = vi.spyOn(Reflect.get(f.ctx.hmr, 'manifests') as { invalidate(path: string): void }, 'invalidate')
|
||||
onTestFinished(() => { invalidate.mockRestore() })
|
||||
await f.emit(installed)
|
||||
expect(invalidate).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['exports', 'main'] as const)('keeps reloading the loaded entry after %s moves and selects the new entry on restart', async (field) => {
|
||||
const packageJson = (entry: string, dep: string) => JSON.stringify({
|
||||
name: 'addon', type: 'module', [field]: `./${entry}.mjs`, imports: { '#dep': `./${dep}.mjs` },
|
||||
})
|
||||
const f = await fixture(plugin('a'), { packageJson: packageJson('a', 'dep-1') })
|
||||
const originalNamespace = f.entry.moduleNamespace
|
||||
file(join(f.pkg, 'package.json'), packageJson('b', 'dep-2'))
|
||||
await f.emit(join(f.pkg, 'package.json'))
|
||||
expect(f.trace).toEqual(['start:a:1'])
|
||||
expect(f.entry.moduleNamespace).toBe(originalNamespace)
|
||||
file(join(f.pkg, 'a.mjs'), plugin('a-edited'))
|
||||
await f.emit(join(f.pkg, 'a.mjs'))
|
||||
expect(f.trace).toEqual(['start:a:1', 'stop:a', 'start:a-edited:2'])
|
||||
expect(f.entry.moduleNamespace).not.toBe(originalNamespace)
|
||||
expect(f.entry.moduleNamespace).toBe(await f.ctx.loader.import(pathToFileURL(join(f.pkg, 'a.mjs')).href))
|
||||
await f.entry.update({ disabled: true })
|
||||
await f.entry.update({ disabled: false })
|
||||
await f.ctx.loader.await()
|
||||
expect(f.trace).toEqual(['start:a:1', 'stop:a', 'start:a-edited:2', 'stop:a-edited', 'start:b:2'])
|
||||
expect(f.warn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('retains the loaded entry through repeated reloads when the new entry is already cached', async () => {
|
||||
const f = await fixture()
|
||||
file(join(f.pkg, 'package.json'), manifest('b', 'dep-2'))
|
||||
await f.emit(join(f.pkg, 'package.json'))
|
||||
await f.ctx.loader.import('addon')
|
||||
const originalNamespace = f.entry.moduleNamespace
|
||||
file(join(f.pkg, 'a.mjs'), plugin('a-first'))
|
||||
await f.emit(join(f.pkg, 'a.mjs'))
|
||||
const firstNamespace = f.entry.moduleNamespace
|
||||
expect(firstNamespace).not.toBe(originalNamespace)
|
||||
file(join(f.pkg, 'a.mjs'), plugin('a-second'))
|
||||
await f.emit(join(f.pkg, 'a.mjs'))
|
||||
expect(f.entry.moduleNamespace).not.toBe(firstNamespace)
|
||||
expect(f.trace).toEqual(['start:a:1', 'stop:a', 'start:a-first:2', 'stop:a-first', 'start:a-second:2'])
|
||||
expect(f.warn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['apply', 'apply as default'])('retains a re-export entry using its namespace when it exports %s', async (exported) => {
|
||||
const f = await fixture(`export { ${exported} } from './b.mjs';\n`)
|
||||
const implementation: unknown = await f.ctx.loader.import(pathToFileURL(join(f.pkg, 'b.mjs')).href)
|
||||
expect(implementation).toMatchObject({ apply: f.entry.fiber?.runtime?.callback })
|
||||
expect(f.entry.moduleNamespace).not.toBe(implementation)
|
||||
file(join(f.pkg, 'c.mjs'), plugin('c'))
|
||||
file(join(f.pkg, 'package.json'), manifest('c', 'dep-1'))
|
||||
await f.emit(join(f.pkg, 'package.json'))
|
||||
file(join(f.pkg, 'dep-1.mjs'), 'export const value = 2')
|
||||
await f.emit(join(f.pkg, 'dep-1.mjs'))
|
||||
expect(f.trace).toEqual(['start:b:1', 'stop:b', 'start:b:2'])
|
||||
expect(f.entry.moduleNamespace).toBe(await f.ctx.loader.import(pathToFileURL(join(f.pkg, 'a.mjs')).href))
|
||||
await f.entry.update({ disabled: true })
|
||||
await f.entry.update({ disabled: false })
|
||||
await f.ctx.loader.await()
|
||||
expect(f.trace).toEqual(['start:b:1', 'stop:b', 'start:b:2', 'stop:b', 'start:c:2'])
|
||||
expect(f.warn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('tracks an entry loaded before HMR starts', async () => {
|
||||
const f = await fixture(plugin('a'), { lateHmr: true })
|
||||
file(join(f.pkg, 'package.json'), manifest('b', 'dep-1'))
|
||||
await f.emit(join(f.pkg, 'package.json'))
|
||||
file(join(f.pkg, 'a.mjs'), plugin('a-late'))
|
||||
await f.emit(join(f.pkg, 'a.mjs'))
|
||||
expect(f.trace).toEqual(['start:a:1', 'stop:a', 'start:a-late:1'])
|
||||
expect(f.warn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps entry identity when HMR is recreated between source reloads', async () => {
|
||||
const f = await fixture()
|
||||
file(join(f.pkg, 'a.mjs'), plugin('a-first'))
|
||||
await f.emit(join(f.pkg, 'a.mjs'))
|
||||
const firstNamespace = f.entry.moduleNamespace
|
||||
await f.restartHmr()
|
||||
expect(f.entry.moduleNamespace).toBe(firstNamespace)
|
||||
file(join(f.pkg, 'a.mjs'), plugin('a-second'))
|
||||
await f.emit(join(f.pkg, 'a.mjs'))
|
||||
expect(f.entry.moduleNamespace).not.toBe(firstNamespace)
|
||||
expect(f.trace).toEqual(['start:a:1', 'stop:a', 'start:a-first:1', 'stop:a-first', 'start:a-second:1'])
|
||||
expect(f.warn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('leaves an unimported disabled entry without a Node cache entry alone', async () => {
|
||||
const f = await fixture()
|
||||
const disabled = f.ctx.loader.resolve(await f.ctx.loader.create({
|
||||
name: pathToFileURL(join(f.pkg, 'b.mjs')).href, disabled: true,
|
||||
}))
|
||||
expect(disabled.moduleNamespace).toBeUndefined()
|
||||
file(join(f.pkg, 'a.mjs'), plugin('a-edited'))
|
||||
await f.emit(join(f.pkg, 'a.mjs'))
|
||||
expect(disabled.moduleNamespace).toBeUndefined()
|
||||
expect(disabled.fiber).toBeUndefined()
|
||||
expect(f.trace).toEqual(['start:a:1', 'stop:a', 'start:a-edited:1'])
|
||||
expect(f.warn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('leaves a cordis builtin outside the Node namespace index without reporting an error', async () => {
|
||||
const f = await fixture()
|
||||
const builtin = { apply: vi.fn() }
|
||||
f.ctx.loader.builtins.fixture = builtin
|
||||
const entry = f.ctx.loader.resolve(await f.ctx.loader.create({ name: 'cordis:fixture' }))
|
||||
await f.ctx.loader.await()
|
||||
expect(entry.moduleNamespace).toBe(builtin)
|
||||
file(join(f.pkg, 'a.mjs'), plugin('a-edited'))
|
||||
await f.emit(join(f.pkg, 'a.mjs'))
|
||||
expect(builtin.apply).toHaveBeenCalledOnce()
|
||||
expect(f.trace).toEqual(['start:a:1', 'stop:a', 'start:a-edited:1'])
|
||||
expect(f.warn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('updates disabled entry namespaces through repeated reloads without activating them', async () => {
|
||||
const f = await fixture()
|
||||
const originalNamespace = f.entry.moduleNamespace
|
||||
await f.entry.update({ disabled: true })
|
||||
await f.ctx.loader.await()
|
||||
file(join(f.pkg, 'a.mjs'), plugin('disabled-first'))
|
||||
await f.emit(join(f.pkg, 'a.mjs'))
|
||||
const firstNamespace = f.entry.moduleNamespace
|
||||
expect(firstNamespace).not.toBe(originalNamespace)
|
||||
expect(firstNamespace).toBe(await f.ctx.loader.import('addon'))
|
||||
expect(f.trace).toEqual(['start:a:1', 'stop:a'])
|
||||
file(join(f.pkg, 'a.mjs'), plugin('disabled-second'))
|
||||
await f.emit(join(f.pkg, 'a.mjs'))
|
||||
expect(f.entry.moduleNamespace).not.toBe(firstNamespace)
|
||||
expect(f.trace).toEqual(['start:a:1', 'stop:a'])
|
||||
expect(f.warn).not.toHaveBeenCalled()
|
||||
await f.entry.update({ disabled: false })
|
||||
await f.ctx.loader.await()
|
||||
expect(f.trace).toEqual(['start:a:1', 'stop:a', 'start:disabled-second:1'])
|
||||
})
|
||||
|
||||
it('reports a resolution error for an unimported entry through the existing warning path', async () => {
|
||||
const f = await fixture()
|
||||
await f.ctx.loader.create({ name: './missing-plugin.mjs', disabled: true })
|
||||
file(join(f.pkg, 'a.mjs'), plugin('a-edited'))
|
||||
await f.emit(join(f.pkg, 'a.mjs'))
|
||||
expect(f.warn).toHaveBeenCalledWith(expect.objectContaining({ code: 'ERR_MODULE_NOT_FOUND' }))
|
||||
expect(f.trace).toEqual(['start:a:1', 'stop:a', 'start:a-edited:1'])
|
||||
})
|
||||
|
||||
it('reports a recorded namespace missing from the cache while reloading another entry', async () => {
|
||||
const f = await fixture()
|
||||
const originalFiber = f.entry.fiber
|
||||
const originalNamespace = f.entry.moduleNamespace
|
||||
await f.ctx.loader.create({ name: pathToFileURL(join(f.pkg, 'b.mjs')).href })
|
||||
await f.ctx.loader.await()
|
||||
expect(Map.prototype.delete.call(f.ctx.loader.internal!.loadCache, pathToFileURL(join(f.pkg, 'a.mjs')).href)).toBe(true)
|
||||
file(join(f.pkg, 'b.mjs'), plugin('b-edited'))
|
||||
await f.emit(join(f.pkg, 'b.mjs'))
|
||||
expect(f.warn).toHaveBeenCalledWith(expect.objectContaining({
|
||||
message: `HMR cannot locate the loaded module for addon from ${f.ctx.baseUrl}`,
|
||||
}))
|
||||
expect(f.entry.fiber).toBe(originalFiber)
|
||||
expect(f.entry.moduleNamespace).toBe(originalNamespace)
|
||||
expect(f.trace).toEqual(['start:a:1', 'start:b:1', 'stop:b', 'start:b-edited:1'])
|
||||
})
|
||||
|
||||
it.each(['import', 'activation'] as const)('retains the prior namespace after replacement %s fails and can reload again', async (phase) => {
|
||||
const f = await fixture()
|
||||
const originalNamespace = f.entry.moduleNamespace
|
||||
const originalCallback = f.entry.fiber?.runtime?.callback
|
||||
const message = `replacement ${phase} failed`
|
||||
file(join(f.pkg, 'a.mjs'), phase === 'import'
|
||||
? `throw new Error(${JSON.stringify(message)});\n${plugin('failed')}`
|
||||
: `export function apply() { throw new Error(${JSON.stringify(message)}); }`)
|
||||
await expect(f.emit(join(f.pkg, 'a.mjs'))).rejects.toThrow(message)
|
||||
expect(f.entry.moduleNamespace).toBe(originalNamespace)
|
||||
expect(await f.ctx.loader.import('addon')).toBe(originalNamespace)
|
||||
expect(f.entry.fiber?.runtime?.callback).toBe(originalCallback)
|
||||
expect(f.trace).toEqual(phase === 'import' ? ['start:a:1'] : ['start:a:1', 'stop:a', 'start:a:1'])
|
||||
file(join(f.pkg, 'a.mjs'), plugin('recovered'))
|
||||
await f.emit(join(f.pkg, 'a.mjs'))
|
||||
expect(f.entry.moduleNamespace).not.toBe(originalNamespace)
|
||||
expect(f.trace.at(-1)).toBe('start:recovered:1')
|
||||
})
|
||||
|
||||
it('reloads a native CommonJS entry and retains its new import namespace', async () => {
|
||||
const f = await fixture()
|
||||
const filename = join(f.pkg, 'common.cjs')
|
||||
const source = (version: number) => `exports.apply = ctx => {
|
||||
ctx.get('manifestTrace').push('start:cjs:${version}');
|
||||
ctx.effect(() => () => { ctx.get('manifestTrace').push('stop:cjs:${version}'); });
|
||||
};`
|
||||
file(filename, source(1))
|
||||
const entry = f.ctx.loader.resolve(await f.ctx.loader.create({ name: pathToFileURL(filename).href }))
|
||||
await f.ctx.loader.await()
|
||||
const originalNamespace = entry.moduleNamespace
|
||||
file(filename, source(2))
|
||||
await f.emit(filename)
|
||||
expect(entry.moduleNamespace).not.toBe(originalNamespace)
|
||||
expect(entry.moduleNamespace).toBe(await f.ctx.loader.import(pathToFileURL(filename).href))
|
||||
expect(f.trace).toEqual(['start:a:1', 'start:cjs:1', 'stop:cjs:1', 'start:cjs:2'])
|
||||
expect(f.warn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handles an entry still evaluating while another entry reloads', async () => {
|
||||
const f = await fixture()
|
||||
const entered = Promise.withResolvers<undefined>()
|
||||
const release = Promise.withResolvers<undefined>()
|
||||
const key = 'dsh-hmr-pending-entry-' + randomUUID()
|
||||
Reflect.set(globalThis, key, { entered: () => { entered.resolve(undefined) }, wait: release.promise })
|
||||
f.ctx.provide('releasePendingEntry', () => { release.resolve(undefined) })
|
||||
const filename = join(f.pkg, 'pending.mjs')
|
||||
file(filename, `const gate = globalThis[${JSON.stringify(key)}]; gate.entered(); await gate.wait;\n${plugin('pending')}`)
|
||||
const loading = f.ctx.loader.create({ name: pathToFileURL(filename).href })
|
||||
onTestFinished(async () => {
|
||||
release.resolve(undefined)
|
||||
try { await loading } finally { Reflect.deleteProperty(globalThis, key) }
|
||||
})
|
||||
try {
|
||||
await entered.promise
|
||||
file(join(f.pkg, 'a.mjs'), plugin('a-edited').replace('export function apply(ctx) {',
|
||||
"export function apply(ctx) { ctx.get('releasePendingEntry')();"))
|
||||
await f.emit(join(f.pkg, 'a.mjs'))
|
||||
const entry = f.ctx.loader.resolve(await loading)
|
||||
await f.ctx.loader.await()
|
||||
expect(entry.moduleNamespace).toBe(await f.ctx.loader.import(pathToFileURL(filename).href))
|
||||
expect(f.trace).toEqual(['start:a:1', 'stop:a', 'start:a-edited:1', 'start:pending:1'])
|
||||
expect(f.warn).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
release.resolve(undefined)
|
||||
await loading
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps an active entry namespace when a later same-name entry has never been enabled', async () => {
|
||||
const f = await fixture()
|
||||
await f.ctx.loader.root.update([
|
||||
...f.ctx.loader.root.data,
|
||||
{ id: 'disabled-shadow', name: 'addon', disabled: true },
|
||||
])
|
||||
await f.ctx.loader.await()
|
||||
const disabled = f.ctx.loader.resolve('disabled-shadow')
|
||||
expect(disabled.moduleNamespace).toBeUndefined()
|
||||
expect(disabled.fiber).toBeUndefined()
|
||||
const before = [...f.trace]
|
||||
file(join(f.pkg, 'package.json'), manifest('b', 'dep-2'))
|
||||
await f.emit(join(f.pkg, 'package.json'))
|
||||
expect(f.trace).toEqual(before)
|
||||
file(join(f.pkg, 'a.mjs'), plugin('a-edited'))
|
||||
await f.emit(join(f.pkg, 'a.mjs'))
|
||||
expect(f.trace).toEqual([...before, 'stop:a', 'start:a-edited:2'])
|
||||
expect(disabled.fiber).toBeUndefined()
|
||||
expect(f.warn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
type DispatchFixture = Awaited<ReturnType<typeof fixture>>
|
||||
|
||||
async function addConfiguredEntry(f: DispatchFixture, id: string, name: string, disabled = false) {
|
||||
const options = { id, name, disabled }
|
||||
const entry = f.ctx.loader.resolve(await f.ctx.loader.create(options))
|
||||
await f.ctx.loader.await()
|
||||
return entry
|
||||
}
|
||||
|
||||
function cachedNamespace(f: DispatchFixture, filename: string): unknown {
|
||||
const url = pathToFileURL(join(f.pkg, filename)).href
|
||||
const job = f.ctx.loader.internal!.loadCache.get(url)
|
||||
expect(job?.module).toBeDefined()
|
||||
return job!.module!.getNamespace()
|
||||
}
|
||||
|
||||
function liveFibers(runtime: Plugin.Runtime | null | undefined) {
|
||||
expect(runtime).toBeDefined()
|
||||
expect(runtime).not.toBeNull()
|
||||
return [...runtime!.fibers].filter(fiber => fiber.uid !== null)
|
||||
}
|
||||
|
||||
async function sharedEntryFixture(kind: 'default' | 'named') {
|
||||
const source = kind === 'default'
|
||||
? "export { apply as default } from './b.mjs';\n"
|
||||
: "export { apply } from './b.mjs';\n"
|
||||
const f = await fixture(source)
|
||||
const secondFile = kind === 'default' ? 'c.mjs' : 'b.mjs'
|
||||
if (kind === 'default') file(join(f.pkg, secondFile), source)
|
||||
const second = await addConfiguredEntry(f, 'shared-second', pathToFileURL(join(f.pkg, secondFile)).href)
|
||||
const firstPlugin = f.ctx.loader.unwrapExports(f.entry.moduleNamespace) as Plugin
|
||||
const secondPlugin = f.ctx.loader.unwrapExports(second.moduleNamespace) as Plugin
|
||||
expect(f.entry.moduleNamespace).not.toBe(second.moduleNamespace)
|
||||
expect(f.entry.moduleNamespace).toBe(cachedNamespace(f, 'a.mjs'))
|
||||
expect(second.moduleNamespace).toBe(cachedNamespace(f, secondFile))
|
||||
if (kind === 'default') expect(firstPlugin).toBe(secondPlugin)
|
||||
else expect(firstPlugin).not.toBe(secondPlugin)
|
||||
expect(f.entry.fiber?.runtime).toBe(second.fiber?.runtime)
|
||||
expect(liveFibers(f.entry.fiber?.runtime)).toHaveLength(2)
|
||||
expect(f.trace).toEqual(['start:b:1', 'start:b:1'])
|
||||
return { ...f, second, secondFile, firstPlugin }
|
||||
}
|
||||
|
||||
it('reloads both active namespaces for one package name independently after exports move', async () => {
|
||||
const f = await fixture()
|
||||
const firstNamespace = f.entry.moduleNamespace
|
||||
const firstRuntime = f.entry.fiber?.runtime
|
||||
file(join(f.pkg, 'package.json'), manifest('b', 'dep-2'))
|
||||
await f.emit(join(f.pkg, 'package.json'))
|
||||
const second = await addConfiguredEntry(f, 'active-after-move', 'addon')
|
||||
const secondNamespace = second.moduleNamespace
|
||||
const secondRuntime = second.fiber?.runtime
|
||||
expect(firstNamespace).not.toBe(secondNamespace)
|
||||
expect(firstRuntime).not.toBe(secondRuntime)
|
||||
expect(liveFibers(firstRuntime)).toHaveLength(1)
|
||||
expect(liveFibers(secondRuntime)).toHaveLength(1)
|
||||
expect(f.trace).toEqual(['start:a:1', 'start:b:2'])
|
||||
|
||||
file(join(f.pkg, 'a.mjs'), plugin('a-independent'))
|
||||
await f.emit(join(f.pkg, 'a.mjs'))
|
||||
const reloadedFirst = f.entry.moduleNamespace
|
||||
expect(reloadedFirst).not.toBe(firstNamespace)
|
||||
expect(reloadedFirst).toBe(cachedNamespace(f, 'a.mjs'))
|
||||
expect(second.moduleNamespace).toBe(secondNamespace)
|
||||
expect(second.fiber?.runtime).toBe(secondRuntime)
|
||||
expect(f.trace).toEqual(['start:a:1', 'start:b:2', 'stop:a', 'start:a-independent:2'])
|
||||
|
||||
file(join(f.pkg, 'b.mjs'), plugin('b-independent'))
|
||||
await f.emit(join(f.pkg, 'b.mjs'))
|
||||
expect(f.entry.moduleNamespace).toBe(reloadedFirst)
|
||||
expect(second.moduleNamespace).not.toBe(secondNamespace)
|
||||
expect(second.moduleNamespace).toBe(cachedNamespace(f, 'b.mjs'))
|
||||
expect(liveFibers(f.entry.fiber?.runtime)).toHaveLength(1)
|
||||
expect(liveFibers(second.fiber?.runtime)).toHaveLength(1)
|
||||
expect(f.trace).toEqual([
|
||||
'start:a:1', 'start:b:2', 'stop:a', 'start:a-independent:2', 'stop:b', 'start:b-independent:2',
|
||||
])
|
||||
expect(f.warn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refreshes both default re-export namespaces sharing one plugin without multiplying instances', async () => {
|
||||
const f = await sharedEntryFixture('default')
|
||||
const firstNamespace = f.entry.moduleNamespace
|
||||
const secondNamespace = f.second.moduleNamespace
|
||||
const oldFibers = liveFibers(f.entry.fiber?.runtime)
|
||||
const before = f.trace.length
|
||||
file(join(f.pkg, 'dep-1.mjs'), 'export const value = 2')
|
||||
await f.emit(join(f.pkg, 'dep-1.mjs'))
|
||||
|
||||
expect(f.entry.moduleNamespace).not.toBe(firstNamespace)
|
||||
expect(f.second.moduleNamespace).not.toBe(secondNamespace)
|
||||
expect(f.entry.moduleNamespace).not.toBe(f.second.moduleNamespace)
|
||||
expect(f.entry.moduleNamespace).toBe(cachedNamespace(f, 'a.mjs'))
|
||||
expect(f.second.moduleNamespace).toBe(cachedNamespace(f, 'c.mjs'))
|
||||
expect(f.ctx.loader.unwrapExports(f.entry.moduleNamespace)).toBe(f.ctx.loader.unwrapExports(f.second.moduleNamespace))
|
||||
expect(f.entry.fiber?.runtime).toBe(f.second.fiber?.runtime)
|
||||
expect(liveFibers(f.entry.fiber?.runtime)).toHaveLength(2)
|
||||
expect(oldFibers.every(fiber => fiber.uid === null)).toBe(true)
|
||||
expect(f.trace.slice(before).sort()).toEqual(['start:b:2', 'start:b:2', 'stop:b', 'stop:b'])
|
||||
expect(f.warn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refreshes named re-export and implementation namespaces sharing a callback without multiplying instances', async () => {
|
||||
const f = await sharedEntryFixture('named')
|
||||
const firstNamespace = f.entry.moduleNamespace
|
||||
const secondNamespace = f.second.moduleNamespace
|
||||
const oldFibers = liveFibers(f.entry.fiber?.runtime)
|
||||
const before = f.trace.length
|
||||
file(join(f.pkg, 'b.mjs'), plugin('implementation-updated'))
|
||||
await f.emit(join(f.pkg, 'b.mjs'))
|
||||
|
||||
expect(f.entry.moduleNamespace).not.toBe(firstNamespace)
|
||||
expect(f.second.moduleNamespace).not.toBe(secondNamespace)
|
||||
expect(f.entry.moduleNamespace).toBe(cachedNamespace(f, 'a.mjs'))
|
||||
expect(f.second.moduleNamespace).toBe(cachedNamespace(f, 'b.mjs'))
|
||||
expect(f.ctx.loader.unwrapExports(f.entry.moduleNamespace)).not.toBe(f.ctx.loader.unwrapExports(f.second.moduleNamespace))
|
||||
expect(f.entry.fiber?.runtime).toBe(f.second.fiber?.runtime)
|
||||
expect(liveFibers(f.entry.fiber?.runtime)).toHaveLength(2)
|
||||
expect(oldFibers.every(fiber => fiber.uid === null)).toBe(true)
|
||||
expect(f.trace.slice(before).sort()).toEqual([
|
||||
'start:implementation-updated:1', 'start:implementation-updated:1', 'stop:b', 'stop:b',
|
||||
])
|
||||
expect(f.warn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
function activeTraceCount(trace: string[], marker: string): number {
|
||||
return trace.filter(event => event.startsWith(`start:${marker}:`)).length
|
||||
- trace.filter(event => event === `stop:${marker}`).length
|
||||
}
|
||||
|
||||
it('assigns each entry its own replacement when a shared runtime splits into distinct callbacks', async () => {
|
||||
const f = await sharedEntryFixture('default')
|
||||
const firstNamespace = f.entry.moduleNamespace
|
||||
const secondNamespace = f.second.moduleNamespace
|
||||
const oldFibers = liveFibers(f.entry.fiber?.runtime)
|
||||
const before = f.trace.length
|
||||
file(join(f.pkg, 'a.mjs'), plugin('a-split'))
|
||||
file(join(f.pkg, 'c.mjs'), plugin('c-split'))
|
||||
await f.emit(join(f.pkg, 'a.mjs'), join(f.pkg, 'c.mjs'))
|
||||
|
||||
expect(f.entry.moduleNamespace).not.toBe(firstNamespace)
|
||||
expect(f.second.moduleNamespace).not.toBe(secondNamespace)
|
||||
expect(f.entry.moduleNamespace).toBe(cachedNamespace(f, 'a.mjs'))
|
||||
expect(f.second.moduleNamespace).toBe(cachedNamespace(f, 'c.mjs'))
|
||||
expect(f.entry.moduleNamespace).toMatchObject({ apply: f.entry.fiber?.runtime?.callback })
|
||||
expect(f.second.moduleNamespace).toMatchObject({ apply: f.second.fiber?.runtime?.callback })
|
||||
expect(f.entry.fiber?.runtime).not.toBe(f.second.fiber?.runtime)
|
||||
expect(liveFibers(f.entry.fiber?.runtime)).toHaveLength(1)
|
||||
expect(liveFibers(f.second.fiber?.runtime)).toHaveLength(1)
|
||||
expect(oldFibers.every(fiber => fiber.uid === null)).toBe(true)
|
||||
expect(f.trace.slice(before).sort()).toEqual(['start:a-split:1', 'start:c-split:1', 'stop:b', 'stop:b'])
|
||||
expect(f.warn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('restores shared entries and a programmatic instance when split replacements are ambiguous', async () => {
|
||||
const f = await sharedEntryFixture('default')
|
||||
const firstNamespace = f.entry.moduleNamespace
|
||||
const secondNamespace = f.second.moduleNamespace
|
||||
const programmatic = (await f.ctx.plugin(f.firstPlugin)).ctx.fiber
|
||||
expect(programmatic.entry).toBeUndefined()
|
||||
expect(liveFibers(f.ctx.registry.get(f.firstPlugin))).toHaveLength(3)
|
||||
file(join(f.pkg, 'a.mjs'), plugin('a-split'))
|
||||
file(join(f.pkg, 'c.mjs'), plugin('c-split'))
|
||||
await expect(f.emit(join(f.pkg, 'a.mjs'), join(f.pkg, 'c.mjs'))).rejects.toThrow(
|
||||
'HMR replacement is ambiguous for a plugin instance without a Loader entry',
|
||||
)
|
||||
|
||||
expect(f.entry.moduleNamespace).toBe(firstNamespace)
|
||||
expect(f.second.moduleNamespace).toBe(secondNamespace)
|
||||
expect(cachedNamespace(f, 'a.mjs')).toBe(firstNamespace)
|
||||
expect(cachedNamespace(f, 'c.mjs')).toBe(secondNamespace)
|
||||
const restored = f.ctx.registry.get(f.firstPlugin)
|
||||
const fibers = liveFibers(restored)
|
||||
expect(fibers).toHaveLength(3)
|
||||
expect(fibers.filter(fiber => fiber.entry === undefined)).toHaveLength(1)
|
||||
expect(f.entry.fiber?.runtime).toBe(restored)
|
||||
expect(f.second.fiber?.runtime).toBe(restored)
|
||||
expect(activeTraceCount(f.trace, 'b')).toBe(3)
|
||||
expect(activeTraceCount(f.trace, 'a-split')).toBe(0)
|
||||
expect(activeTraceCount(f.trace, 'c-split')).toBe(0)
|
||||
})
|
||||
|
||||
it.each(['import', 'activation'] as const)('restores every shared entry when one replacement %s fails and can reload again', async (phase) => {
|
||||
const f = await sharedEntryFixture('default')
|
||||
const firstNamespace = f.entry.moduleNamespace
|
||||
const secondNamespace = f.second.moduleNamespace
|
||||
const message = `shared replacement ${phase} failed`
|
||||
file(join(f.pkg, 'a.mjs'), plugin('a-replacement'))
|
||||
file(join(f.pkg, 'c.mjs'), phase === 'import'
|
||||
? `throw new Error(${JSON.stringify(message)});\n${plugin('c-failed')}`
|
||||
: `export function apply() { throw new Error(${JSON.stringify(message)}); }`)
|
||||
await expect(f.emit(join(f.pkg, 'a.mjs'), join(f.pkg, 'c.mjs'))).rejects.toThrow(message)
|
||||
|
||||
expect(f.entry.moduleNamespace).toBe(firstNamespace)
|
||||
expect(f.second.moduleNamespace).toBe(secondNamespace)
|
||||
expect(cachedNamespace(f, 'a.mjs')).toBe(firstNamespace)
|
||||
expect(cachedNamespace(f, 'c.mjs')).toBe(secondNamespace)
|
||||
const restored = f.ctx.registry.get(f.firstPlugin)
|
||||
expect(f.entry.fiber?.runtime).toBe(restored)
|
||||
expect(f.second.fiber?.runtime).toBe(restored)
|
||||
expect(liveFibers(restored)).toHaveLength(2)
|
||||
expect(activeTraceCount(f.trace, 'b')).toBe(2)
|
||||
expect(activeTraceCount(f.trace, 'a-replacement')).toBe(0)
|
||||
expect(activeTraceCount(f.trace, 'c-failed')).toBe(0)
|
||||
|
||||
file(join(f.pkg, 'c.mjs'), plugin('c-recovered'))
|
||||
await f.emit(join(f.pkg, 'a.mjs'), join(f.pkg, 'c.mjs'))
|
||||
expect(f.entry.moduleNamespace).not.toBe(firstNamespace)
|
||||
expect(f.second.moduleNamespace).not.toBe(secondNamespace)
|
||||
expect(f.entry.moduleNamespace).toBe(cachedNamespace(f, 'a.mjs'))
|
||||
expect(f.second.moduleNamespace).toBe(cachedNamespace(f, 'c.mjs'))
|
||||
expect(f.entry.moduleNamespace).toMatchObject({ apply: f.entry.fiber?.runtime?.callback })
|
||||
expect(f.second.moduleNamespace).toMatchObject({ apply: f.second.fiber?.runtime?.callback })
|
||||
expect(f.entry.fiber?.runtime).not.toBe(f.second.fiber?.runtime)
|
||||
expect(liveFibers(f.entry.fiber?.runtime)).toHaveLength(1)
|
||||
expect(liveFibers(f.second.fiber?.runtime)).toHaveLength(1)
|
||||
expect(activeTraceCount(f.trace, 'b')).toBe(0)
|
||||
expect(activeTraceCount(f.trace, 'a-replacement')).toBe(1)
|
||||
expect(activeTraceCount(f.trace, 'c-recovered')).toBe(1)
|
||||
})
|
||||
|
||||
it('deduplicates a repeated namespace without activating a same-name uninitialized entry', async () => {
|
||||
const f = await fixture()
|
||||
const second = await addConfiguredEntry(f, 'duplicate-active', 'addon')
|
||||
const disabled = await addConfiguredEntry(f, 'duplicate-disabled', 'addon', true)
|
||||
const originalNamespace = f.entry.moduleNamespace
|
||||
expect(second.moduleNamespace).toBe(originalNamespace)
|
||||
expect(disabled.moduleNamespace).toBeUndefined()
|
||||
expect(disabled.fiber).toBeUndefined()
|
||||
expect(liveFibers(f.entry.fiber?.runtime)).toHaveLength(2)
|
||||
file(join(f.pkg, 'package.json'), manifest('b', 'dep-2'))
|
||||
await f.emit(join(f.pkg, 'package.json'))
|
||||
const before = f.trace.length
|
||||
file(join(f.pkg, 'a.mjs'), plugin('deduplicated'))
|
||||
await f.emit(join(f.pkg, 'a.mjs'))
|
||||
|
||||
expect(f.entry.moduleNamespace).not.toBe(originalNamespace)
|
||||
expect(f.entry.moduleNamespace).toBe(cachedNamespace(f, 'a.mjs'))
|
||||
expect(second.moduleNamespace).toBe(f.entry.moduleNamespace)
|
||||
expect(f.entry.fiber?.runtime).toBe(second.fiber?.runtime)
|
||||
expect(liveFibers(f.entry.fiber?.runtime)).toHaveLength(2)
|
||||
expect(disabled.moduleNamespace).toBeUndefined()
|
||||
expect(disabled.fiber).toBeUndefined()
|
||||
expect(f.trace.slice(before).sort()).toEqual([
|
||||
'start:deduplicated:2', 'start:deduplicated:2', 'stop:a', 'stop:a',
|
||||
])
|
||||
expect(f.warn).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -0,0 +1,457 @@
|
||||
/** Package configuration read by Node's resolver follows an invalidated manifest on disk. */
|
||||
import { spawn } from 'node:child_process'
|
||||
import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs'
|
||||
import NodeModule, { createRequire, registerHooks } from 'node:module'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join, toNamespacedPath } from 'node:path'
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url'
|
||||
import { ModuleLoader } from '@deepseek-ai/cordis-plugin-loader'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { PackageManifests } from '../src/package-manifest.ts'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const addon = require('node-addon-require-builtin') as { requireBuiltin(id: string): unknown }
|
||||
const reader = addon.requireBuiltin('internal/modules/package_json_reader') as {
|
||||
read: (path: string, options?: { isESM: boolean; base: string; specifier: string }) => { exists: boolean; type: string }
|
||||
getPackageScopeConfig: (url: string) => { exists: boolean; type: string; pjsonPath: string }
|
||||
getNearestParentPackageJSON: (path: string) => { data: { type: string }; path: string } | undefined
|
||||
}
|
||||
const formats = addon.requireBuiltin('internal/modules/esm/get_format') as {
|
||||
defaultGetFormatWithoutErrors(url: URL, context?: object): string | null
|
||||
}
|
||||
const binding = (addon.requireBuiltin('internal/bootstrap/realm') as {
|
||||
internalBinding(id: 'modules'): Record<string, unknown>
|
||||
}).internalBinding('modules')
|
||||
|
||||
const cleanup: Array<() => void | Promise<void>> = []
|
||||
afterEach(async () => { for (const dispose of cleanup.splice(0).reverse()) await dispose() })
|
||||
|
||||
function file(path: string, source: string): void {
|
||||
mkdirSync(dirname(path), { recursive: true })
|
||||
writeFileSync(path, source)
|
||||
}
|
||||
|
||||
function fixture() {
|
||||
const root = realpathSync.native(mkdtempSync(join(tmpdir(), 'dsh-hmr-manifest-')))
|
||||
cleanup.push(() => { rmSync(root, { recursive: true, force: true }) })
|
||||
const dir = join(root, 'packages', 'pkg')
|
||||
const manifest = join(dir, 'package.json')
|
||||
const importer = join(root, 'importer', 'entry.mjs')
|
||||
file(join(root, 'importer', 'package.json'), '{"type":"module","dependencies":{"pkg":"*"}}')
|
||||
file(importer, 'export {}')
|
||||
// A workspace-style link: the importer resolves `pkg` from its own node_modules to the source directory.
|
||||
mkdirSync(join(root, 'importer', 'node_modules'), { recursive: true })
|
||||
const link = join(root, 'importer', 'node_modules', 'pkg')
|
||||
symlinkSync(dir, link, process.platform === 'win32' ? 'junction' : 'dir')
|
||||
for (const name of ['a', 'b']) {
|
||||
file(join(dir, `${name}.mjs`), `export const marker = '${name}'`)
|
||||
file(join(dir, `${name}.cjs`), `module.exports = { marker: '${name}' }`)
|
||||
}
|
||||
const manifests = new PackageManifests()
|
||||
cleanup.push(() => { manifests.dispose() })
|
||||
const invalidate = (path: string) => Promise.resolve().then(manifests.invalidate.bind(manifests, path))
|
||||
return { root, dir, manifest, importer, importerURL: pathToFileURL(importer).href, manifests, invalidate }
|
||||
}
|
||||
|
||||
function resolveEsm(specifier: string, parentURL: string): string {
|
||||
const loader = ModuleLoader.fromInternal()!
|
||||
return (loader.version === 'v2'
|
||||
? loader.resolveSync(parentURL, { specifier, attributes: {} })
|
||||
: loader.resolveSync(specifier, parentURL, {})).url
|
||||
}
|
||||
|
||||
describe('package manifest invalidation', { concurrent: false }, () => {
|
||||
it('preserves filename-based resolve hooks and node-prefixed builtins after invalidation', async () => {
|
||||
const f = fixture()
|
||||
file(f.manifest, '{}')
|
||||
const importerRequire = createRequire(f.importer)
|
||||
const nativeFs = importerRequire('node:fs') as typeof import('node:fs')
|
||||
const missingBuiltin = 'node:dsh-hmr-missing'
|
||||
const missingFailure = outcome(() => importerRequire(missingBuiltin))
|
||||
expect(missingFailure).toHaveProperty('code', 'ERR_UNKNOWN_BUILTIN_MODULE')
|
||||
const target = join(f.dir, 'a.cjs')
|
||||
const specifier = join(f.dir, 'alias.cjs')
|
||||
file(specifier, 'module.exports = { marker: "alias" }')
|
||||
cleanup.push(() => { Reflect.deleteProperty(importerRequire.cache, target) })
|
||||
let hooks: ReturnType<typeof registerHooks> | undefined = registerHooks({
|
||||
resolve(request, context, nextResolve) {
|
||||
const resolved = nextResolve(request, context)
|
||||
return resolved.url === pathToFileURL(specifier).href ? { ...resolved, url: pathToFileURL(target).href } : resolved
|
||||
},
|
||||
})
|
||||
cleanup.push(() => { hooks?.deregister() })
|
||||
try {
|
||||
const original = importerRequire(specifier) as { marker: string }
|
||||
expect(original).toEqual({ marker: 'a' })
|
||||
await f.invalidate(f.manifest)
|
||||
expect(importerRequire(specifier)).toBe(original)
|
||||
} finally {
|
||||
hooks.deregister()
|
||||
hooks = undefined
|
||||
}
|
||||
|
||||
const descriptor = Object.getOwnPropertyDescriptor(importerRequire.cache, 'fs')
|
||||
const restore = () => {
|
||||
if (descriptor) Object.defineProperty(importerRequire.cache, 'fs', descriptor)
|
||||
else Reflect.deleteProperty(importerRequire.cache, 'fs')
|
||||
}
|
||||
cleanup.push(restore)
|
||||
const fake = new NodeModule('fs')
|
||||
const fakeExports = { marker: 'fake fs' }
|
||||
fake.exports = fakeExports
|
||||
try {
|
||||
importerRequire.cache.fs = fake
|
||||
expect(importerRequire('fs')).toBe(fakeExports)
|
||||
expect(importerRequire('node:fs')).toBe(nativeFs)
|
||||
expect(outcome(() => importerRequire(missingBuiltin))).toEqual(missingFailure)
|
||||
} finally {
|
||||
restore()
|
||||
}
|
||||
})
|
||||
|
||||
it('loads changed CommonJS exports without evicting previously loaded modules', async () => {
|
||||
const f = fixture()
|
||||
const a = join(f.dir, 'a.cjs')
|
||||
const b = join(f.dir, 'b.cjs')
|
||||
const unrelated = join(f.root, 'unrelated.cjs')
|
||||
file(unrelated, 'module.exports = { marker: "unrelated" }')
|
||||
file(f.manifest, JSON.stringify({ name: 'pkg', exports: './a.cjs' }))
|
||||
const importerRequire = createRequire(f.importer)
|
||||
cleanup.push(() => {
|
||||
for (const path of [a, b, unrelated]) Reflect.deleteProperty(importerRequire.cache, path)
|
||||
})
|
||||
const original = importerRequire('pkg') as { marker: string }
|
||||
const other = importerRequire(unrelated) as { marker: string }
|
||||
const originalModule = importerRequire.cache[a]
|
||||
const otherModule = importerRequire.cache[unrelated]
|
||||
expect(original).toEqual({ marker: 'a' })
|
||||
expect(originalModule).toBeDefined()
|
||||
expect(otherModule).toBeDefined()
|
||||
|
||||
file(f.manifest, JSON.stringify({ name: 'pkg', exports: './b.cjs' }))
|
||||
await f.invalidate(f.manifest)
|
||||
expect(importerRequire.resolve('pkg')).toBe(b)
|
||||
const current = importerRequire('pkg') as { marker: string }
|
||||
expect(importerRequire.cache[a]).toBe(originalModule)
|
||||
expect(importerRequire.cache[unrelated]).toBe(otherModule)
|
||||
expect(original).toEqual({ marker: 'a' })
|
||||
expect(importerRequire(a)).toBe(original)
|
||||
expect(importerRequire(unrelated)).toBe(other)
|
||||
expect(current).toEqual({ marker: 'b' })
|
||||
})
|
||||
|
||||
it.each(['symlink', 'main'] as const)('refreshes a cached external %s entry after its manifest changes', async (entry) => {
|
||||
const f = fixture()
|
||||
const external = join(f.root, 'packages', 'outside', 'entry.mjs')
|
||||
file(external, 'export {}')
|
||||
if (entry === 'symlink') {
|
||||
symlinkSync(dirname(external), join(f.dir, 'linked'), process.platform === 'win32' ? 'junction' : 'dir')
|
||||
file(f.manifest, JSON.stringify({ name: 'pkg', exports: './linked/entry.mjs' }))
|
||||
} else {
|
||||
file(f.manifest, JSON.stringify({ name: 'pkg', main: external }))
|
||||
}
|
||||
const importerRequire = createRequire(f.importer)
|
||||
expect(importerRequire.resolve('pkg')).toBe(external)
|
||||
expect(resolveEsm('pkg', f.importerURL)).toBe(pathToFileURL(external).href)
|
||||
|
||||
file(f.manifest, JSON.stringify({ name: 'pkg', main: './b.mjs', exports: './b.mjs' }))
|
||||
await f.invalidate(f.manifest)
|
||||
expect.soft(importerRequire.resolve('pkg')).toBe(join(f.dir, 'b.mjs'))
|
||||
expect(resolveEsm('pkg', f.importerURL)).toBe(pathToFileURL(join(f.dir, 'b.mjs')).href)
|
||||
})
|
||||
|
||||
it('resolves changed exports and main only after the manifest is invalidated', () => {
|
||||
const f = fixture()
|
||||
file(f.manifest, JSON.stringify({ name: 'pkg', exports: { import: './a.mjs', require: './a.cjs' } }))
|
||||
const importerRequire = createRequire(f.importer)
|
||||
expect(resolveEsm('pkg', f.importerURL)).toBe(pathToFileURL(join(f.dir, 'a.mjs')).href)
|
||||
expect(importerRequire.resolve('pkg')).toBe(join(f.dir, 'a.cjs'))
|
||||
|
||||
file(f.manifest, JSON.stringify({ name: 'pkg', exports: { import: './b.mjs', require: './b.cjs' } }))
|
||||
expect(resolveEsm('pkg', f.importerURL)).toBe(pathToFileURL(join(f.dir, 'a.mjs')).href)
|
||||
f.manifests.invalidate(f.manifest)
|
||||
expect(resolveEsm('pkg', f.importerURL)).toBe(pathToFileURL(join(f.dir, 'b.mjs')).href)
|
||||
expect(importerRequire.resolve('pkg')).toBe(join(f.dir, 'b.cjs'))
|
||||
|
||||
file(f.manifest, JSON.stringify({ name: 'pkg', main: './a.cjs' }))
|
||||
f.manifests.invalidate(f.manifest)
|
||||
expect(importerRequire.resolve('pkg')).toBe(join(f.dir, 'a.cjs'))
|
||||
})
|
||||
|
||||
it('updates package type, scope, and nearest-package reads inside the directory', () => {
|
||||
const f = fixture()
|
||||
file(f.manifest, JSON.stringify({ name: 'pkg', type: 'commonjs' }))
|
||||
const source = join(f.dir, 'lib', 'entry.js')
|
||||
file(source, 'export {}')
|
||||
expect(formats.defaultGetFormatWithoutErrors(pathToFileURL(source))).toBe('commonjs')
|
||||
expect(reader.getNearestParentPackageJSON(source)?.data.type).toBe('commonjs')
|
||||
|
||||
file(f.manifest, JSON.stringify({ name: 'pkg', type: 'module' }))
|
||||
f.manifests.invalidate(f.manifest)
|
||||
expect(formats.defaultGetFormatWithoutErrors(pathToFileURL(source))).toBe('module')
|
||||
expect(reader.getNearestParentPackageJSON(source)?.data.type).toBe('module')
|
||||
expect(reader.getPackageScopeConfig(pathToFileURL(source).href)).toMatchObject({
|
||||
exists: true, type: 'module', pjsonPath: toNamespacedPath(f.manifest),
|
||||
})
|
||||
})
|
||||
|
||||
it('resolves changed package imports from inside the directory', () => {
|
||||
const f = fixture()
|
||||
file(f.manifest, JSON.stringify({ name: 'pkg', type: 'module', imports: { '#target': './a.mjs' } }))
|
||||
const parent = pathToFileURL(join(f.dir, 'inside.mjs')).href
|
||||
file(join(f.dir, 'inside.mjs'), 'export {}')
|
||||
expect(resolveEsm('#target', parent)).toBe(pathToFileURL(join(f.dir, 'a.mjs')).href)
|
||||
file(f.manifest, JSON.stringify({ name: 'pkg', type: 'module', imports: { '#target': './b.mjs' } }))
|
||||
f.manifests.invalidate(f.manifest)
|
||||
expect(resolveEsm('#target', parent)).toBe(pathToFileURL(join(f.dir, 'b.mjs')).href)
|
||||
})
|
||||
|
||||
it('reports an invalid manifest with the native error and recovers after it is fixed', () => {
|
||||
const f = fixture()
|
||||
file(f.manifest, JSON.stringify({ name: 'pkg', exports: './a.mjs' }))
|
||||
expect(resolveEsm('pkg', f.importerURL)).toBe(pathToFileURL(join(f.dir, 'a.mjs')).href)
|
||||
file(f.manifest, '{')
|
||||
f.manifests.invalidate(f.manifest)
|
||||
expect(() => reader.read(f.manifest)).toThrow(expect.objectContaining({ code: 'ERR_INVALID_PACKAGE_CONFIG' }))
|
||||
file(f.manifest, JSON.stringify({ name: 'pkg', exports: './b.mjs' }))
|
||||
f.manifests.invalidate(f.manifest)
|
||||
expect(resolveEsm('pkg', f.importerURL)).toBe(pathToFileURL(join(f.dir, 'b.mjs')).href)
|
||||
})
|
||||
|
||||
it('leaves other package directories to Node and restores native readers on disposal', () => {
|
||||
const f = fixture()
|
||||
const other = join(f.root, 'packages', 'other', 'package.json')
|
||||
file(other, JSON.stringify({ name: 'other', type: 'commonjs' }))
|
||||
file(f.manifest, JSON.stringify({ name: 'pkg', type: 'commonjs' }))
|
||||
expect(reader.read(other).type).toBe('commonjs')
|
||||
const original = binding.readPackageJSON
|
||||
f.manifests.invalidate(f.manifest)
|
||||
file(other, JSON.stringify({ name: 'other', type: 'module' }))
|
||||
expect(reader.read(other).type).toBe('commonjs')
|
||||
expect(binding.readPackageJSON).not.toBe(original)
|
||||
f.manifests.dispose()
|
||||
expect(binding.readPackageJSON).toBe(original)
|
||||
})
|
||||
|
||||
it('reports invalid JSON with its non-file ESM base after invalidation', async () => {
|
||||
const f = fixture()
|
||||
file(f.manifest, '{')
|
||||
await f.invalidate(f.manifest)
|
||||
const base = 'data:text/javascript,export{}'
|
||||
const failure = outcome(() => reader.read(f.manifest, { isESM: true, base, specifier: 'pkg' }))
|
||||
expect(failure).toHaveProperty('code', 'ERR_INVALID_PACKAGE_CONFIG')
|
||||
expect(failure).toHaveProperty('message', expect.stringContaining(base))
|
||||
})
|
||||
})
|
||||
|
||||
function outcome(run: () => unknown): unknown {
|
||||
try { return run() } catch (error) {
|
||||
const failure = error as Error & { code?: string }
|
||||
return { code: failure.code, message: failure.message }
|
||||
}
|
||||
}
|
||||
|
||||
describe('package manifest reader parity', { concurrent: false }, () => {
|
||||
it('terminates scope lookup after invalidating the filesystem root without modifying it', async () => {
|
||||
const f = fixture()
|
||||
const child = spawn(process.execPath, [
|
||||
'--expose-internals',
|
||||
fileURLToPath(new URL('./fixtures/package-root-scope.mjs', import.meta.url)),
|
||||
f.root,
|
||||
], { stdio: ['ignore', 'pipe', 'pipe'] })
|
||||
const completed = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve, reject) => {
|
||||
child.once('error', reject)
|
||||
child.once('close', (code, signal) => { resolve({ code, signal }) })
|
||||
})
|
||||
cleanup.push(async () => {
|
||||
if (child.exitCode === null && child.signalCode === null) child.kill()
|
||||
await completed
|
||||
})
|
||||
let stdout = '', stderr = ''
|
||||
child.stdout.setEncoding('utf8').on('data', (chunk: string) => { stdout += chunk })
|
||||
child.stderr.setEncoding('utf8').on('data', (chunk: string) => { stderr += chunk })
|
||||
const result = await completed
|
||||
expect(result.signal, stderr).toBeNull()
|
||||
expect(result.code, stderr).toBe(0)
|
||||
expect(stdout).toBe('scope traversal completed\n')
|
||||
})
|
||||
|
||||
it('reads valid JSON with a non-file ESM base like Node', async () => {
|
||||
const f = fixture()
|
||||
file(f.manifest, '{}')
|
||||
const options = { isESM: true, base: 'data:text/javascript,export{}', specifier: 'pkg' }
|
||||
const expected = outcome(() => reader.read(f.manifest, options))
|
||||
await f.invalidate(f.manifest)
|
||||
expect(outcome(() => reader.read(f.manifest, options))).toEqual(expected)
|
||||
})
|
||||
|
||||
it('refreshes a cached real directory after a package link moves', async () => {
|
||||
const f = fixture()
|
||||
const other = join(f.root, 'packages', 'other')
|
||||
const link = join(f.root, 'importer', 'node_modules', 'pkg')
|
||||
const linkedManifest = join(link, 'package.json')
|
||||
file(f.manifest, '{"type":"commonjs"}')
|
||||
file(join(other, 'package.json'), '{"type":"commonjs"}')
|
||||
unlinkSync(link)
|
||||
symlinkSync(other, link, process.platform === 'win32' ? 'junction' : 'dir')
|
||||
await f.invalidate(f.manifest)
|
||||
expect(reader.read(linkedManifest).type).toBe('commonjs')
|
||||
|
||||
unlinkSync(link)
|
||||
symlinkSync(f.dir, link, process.platform === 'win32' ? 'junction' : 'dir')
|
||||
file(f.manifest, '{"type":"module"}')
|
||||
await f.invalidate(f.manifest)
|
||||
expect(reader.read(linkedManifest).type).toBe('module')
|
||||
})
|
||||
|
||||
it.each([
|
||||
'{}',
|
||||
'{"name":"pkg","main":"./a.cjs","type":"commonjs"}',
|
||||
'{"name":"pkg","exports":{"import":"./a.mjs","require":"./a.cjs"}}',
|
||||
'{"name":"pkg","exports":false,"main":"./a.cjs"}',
|
||||
'{"name":"pkg","imports":{"#item":"./a.mjs"},"type":"module"}',
|
||||
'{"name":"pkg","exports":"./a.mjs"}',
|
||||
'{', 'null', '[]', '1', '{"name":1}', '{"type":1}', '{"type":"other"}',
|
||||
'{"name":"\\ud800"}', '{"main":"\\ud800"}', '{"type":"\\ud800"}', '{"exports":"\\ud800"}',
|
||||
])('reads %s like the native reader', (source) => {
|
||||
const f = fixture()
|
||||
file(f.manifest, source)
|
||||
const expected = outcome(() => reader.read(f.manifest))
|
||||
f.manifests.invalidate(f.manifest)
|
||||
expect(outcome(() => reader.read(f.manifest))).toEqual(expected)
|
||||
})
|
||||
|
||||
it('reports an invalid manifest found while importing like the native resolver', () => {
|
||||
const f = fixture()
|
||||
const errorOf = (run: () => unknown): Error => {
|
||||
try { run() } catch (error) { return error as Error }
|
||||
throw new Error('expected the lookup to fail')
|
||||
}
|
||||
file(f.manifest, '{')
|
||||
const expected = errorOf(() => resolveEsm('pkg', f.importerURL))
|
||||
f.manifests.invalidate(f.manifest)
|
||||
const actual = errorOf(() => resolveEsm('pkg', f.importerURL))
|
||||
expect([String(actual), Object.keys(actual)]).toEqual([String(expected), Object.keys(expected)])
|
||||
expect(String(errorOf(() => reader.read(f.manifest)))).toBe(String(expected).replace(/ while importing .*(?=\.$)/u, '').replace(join(f.root, 'importer', 'node_modules', 'pkg'), f.dir))
|
||||
})
|
||||
|
||||
it('reads invalid UTF-8 like the native reader', () => {
|
||||
const f = fixture()
|
||||
mkdirSync(f.dir, { recursive: true })
|
||||
writeFileSync(f.manifest, Buffer.concat([Buffer.from('{"name":"'), Buffer.from([0xff]), Buffer.from('"}')]))
|
||||
const expected = outcome(() => reader.read(f.manifest))
|
||||
f.manifests.invalidate(f.manifest)
|
||||
expect(outcome(() => reader.read(f.manifest))).toEqual(expected)
|
||||
})
|
||||
|
||||
it('refreshes a changed CommonJS main after a cached lookup', () => {
|
||||
const f = fixture()
|
||||
file(f.manifest, JSON.stringify({ name: 'pkg', main: './a.cjs' }))
|
||||
const importerRequire = createRequire(f.importer)
|
||||
expect(importerRequire.resolve('pkg')).toBe(join(f.dir, 'a.cjs'))
|
||||
file(f.manifest, JSON.stringify({ name: 'pkg', main: './b.cjs' }))
|
||||
expect(importerRequire.resolve('pkg')).toBe(join(f.dir, 'a.cjs'))
|
||||
f.manifests.invalidate(f.manifest)
|
||||
expect(importerRequire.resolve('pkg')).toBe(join(f.dir, 'b.cjs'))
|
||||
})
|
||||
|
||||
it('updates the package type of extensionless files', () => {
|
||||
const f = fixture()
|
||||
file(f.manifest, JSON.stringify({ name: 'pkg', type: 'commonjs' }))
|
||||
const extensionless = pathToFileURL(join(f.dir, 'bin', 'tool'))
|
||||
file(join(f.dir, 'bin', 'tool'), '')
|
||||
const before = formats.defaultGetFormatWithoutErrors(extensionless)
|
||||
file(f.manifest, JSON.stringify({ name: 'pkg', type: 'module' }))
|
||||
f.manifests.invalidate(f.manifest)
|
||||
expect(formats.defaultGetFormatWithoutErrors(extensionless)).not.toBe(before)
|
||||
expect(formats.defaultGetFormatWithoutErrors(extensionless)).toBe('module')
|
||||
})
|
||||
|
||||
it('continues to the enclosing scope after the manifest is deleted', () => {
|
||||
const f = fixture()
|
||||
const enclosing = join(f.root, 'packages', 'package.json')
|
||||
file(enclosing, JSON.stringify({ name: 'packages', type: 'commonjs' }))
|
||||
file(f.manifest, JSON.stringify({ name: 'pkg', type: 'module' }))
|
||||
const source = join(f.dir, 'lib', 'entry.js')
|
||||
file(source, '')
|
||||
expect(reader.getPackageScopeConfig(pathToFileURL(source).href).type).toBe('module')
|
||||
rmSync(f.manifest)
|
||||
f.manifests.invalidate(f.manifest)
|
||||
expect(reader.getPackageScopeConfig(pathToFileURL(source).href)).toMatchObject({
|
||||
exists: true, type: 'commonjs', pjsonPath: toNamespacedPath(enclosing),
|
||||
})
|
||||
expect(formats.defaultGetFormatWithoutErrors(pathToFileURL(join(f.dir, 'bin', 'tool')))).toBe(
|
||||
formats.defaultGetFormatWithoutErrors(pathToFileURL(join(f.root, 'packages', 'tool'))),
|
||||
)
|
||||
expect(reader.getNearestParentPackageJSON(source)?.path).toBe(toNamespacedPath(enclosing))
|
||||
})
|
||||
|
||||
it('stops a scope lookup at a node_modules boundary like Node', () => {
|
||||
const f = fixture()
|
||||
file(f.manifest, JSON.stringify({ name: 'pkg', type: 'module' }))
|
||||
const nested = join(f.dir, 'node_modules', 'dep', 'entry.js')
|
||||
file(nested, '')
|
||||
const expected = [
|
||||
outcome(() => reader.getPackageScopeConfig(pathToFileURL(nested).href)),
|
||||
outcome(() => reader.getNearestParentPackageJSON(nested)),
|
||||
]
|
||||
f.manifests.invalidate(f.manifest)
|
||||
expect([
|
||||
outcome(() => reader.getPackageScopeConfig(pathToFileURL(nested).href)),
|
||||
outcome(() => reader.getNearestParentPackageJSON(nested)),
|
||||
]).toEqual(expected)
|
||||
})
|
||||
|
||||
it('answers missing directories and non-file URLs natively', () => {
|
||||
const f = fixture()
|
||||
file(f.manifest, '{}')
|
||||
const missing = join(f.root, 'missing', 'package.json')
|
||||
const nativeScope = binding.getPackageScopeConfig as (url: string) => unknown
|
||||
const expected = [outcome(() => reader.read(missing)), outcome(() => nativeScope('node:fs'))]
|
||||
f.manifests.invalidate(f.manifest)
|
||||
expect([outcome(() => reader.read(missing)), outcome(() => (binding.getPackageScopeConfig as (url: string) => unknown)('node:fs'))])
|
||||
.toEqual(expected)
|
||||
})
|
||||
|
||||
it('falls back to the builtin addon for internals that --expose-internals does not expose', () => {
|
||||
const f = fixture()
|
||||
file(f.manifest, JSON.stringify({ name: 'pkg', main: './a.cjs' }))
|
||||
const importerRequire = createRequire(f.importer)
|
||||
expect(importerRequire.resolve('pkg')).toBe(join(f.dir, 'a.cjs'))
|
||||
const argv = [...process.execArgv]
|
||||
process.execArgv.push('--expose-internals')
|
||||
try {
|
||||
file(f.manifest, JSON.stringify({ name: 'pkg', main: './b.cjs' }))
|
||||
f.manifests.invalidate(f.manifest)
|
||||
} finally {
|
||||
process.execArgv.splice(0, process.execArgv.length, ...argv)
|
||||
}
|
||||
expect(importerRequire.resolve('pkg')).toBe(join(f.dir, 'b.cjs'))
|
||||
})
|
||||
|
||||
it('keeps readers replaced after installation when disposed', async () => {
|
||||
const f = fixture()
|
||||
file(f.manifest, '{}')
|
||||
const previousRead = binding.readPackageJSON
|
||||
const previousScope = binding.getPackageScopeConfig
|
||||
const previousType = binding.getPackageType
|
||||
const previousNearest = reader.getNearestParentPackageJSON
|
||||
try {
|
||||
await f.invalidate(f.manifest)
|
||||
const later = () => undefined
|
||||
binding.readPackageJSON = later
|
||||
reader.getNearestParentPackageJSON = later
|
||||
f.manifests.dispose()
|
||||
expect(binding.readPackageJSON).toBe(later)
|
||||
expect(reader.getNearestParentPackageJSON).toBe(later)
|
||||
expect(binding.getPackageScopeConfig).toBe(previousScope)
|
||||
expect(binding.getPackageType).toBe(previousType)
|
||||
} finally {
|
||||
f.manifests.dispose()
|
||||
binding.readPackageJSON = previousRead
|
||||
binding.getPackageScopeConfig = previousScope
|
||||
binding.getPackageType = previousType
|
||||
reader.getNearestParentPackageJSON = previousNearest
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -24,8 +24,8 @@
|
||||
en: d5643a06f300c30a
|
||||
zh: 9348b6a7676a2849
|
||||
/deepseek-ai-dsh-plugin-manager/understand-the-implementation:
|
||||
en: 0f3d0c6c622577c1
|
||||
zh: 5a85f66e33977da3
|
||||
en: 7b54d30e231bb325
|
||||
zh: 08df9441fed9927f
|
||||
/deepseek-ai-dsh-plugin-manager/further-exploration:
|
||||
en: c6538243d8702a0d
|
||||
zh: 04a82d923353c75f
|
||||
@@ -48,8 +48,8 @@
|
||||
en: fde2d9491fe5dd4f
|
||||
zh: ef6e6db15c4f19d2
|
||||
/deepseek-ai-dsh-plugin-manager/known-limitations-and-deferred-work/failure-behavior:
|
||||
en: 4b31f3638e4a6628
|
||||
zh: 23f6599a963bcf89
|
||||
en: 4447e4df8d62b274
|
||||
zh: c76443e2edb97b1d
|
||||
/deepseek-ai-dsh-plugin-manager/known-limitations-and-deferred-work/dev-note:
|
||||
en: e67f633dc6fe7773
|
||||
zh: 3bb0c20c93f16f98
|
||||
@@ -89,9 +89,13 @@ The CLI exposes `dsh plugin --profile <profile> version-exemptions`, `allow-vers
|
||||
<details>
|
||||
<summary>Implementation internals — click to expand</summary>
|
||||
|
||||
The service and `dsh plugin` share the package operations in [operations.ts](src/operations.ts). The launcher supplies the current profile; [DSH HMR](../hmr/README.md) serializes module reloads, file watching and management writes. Each refresh re-reads bundle selection and patch layers, updates the original root Include, and awaits removed plugin resources as well as the remaining Loader tree. CLI and service operations share the profile manifest writer lock to prevent concurrent package and manifest writes. HMR does not acquire that lock. Pnpm runs outside the HMR queue; installation selects the bundle after pnpm succeeds, while removal deselects and unloads the bundle before pnpm runs. A service run whose captured output stays silent for `idleTimeoutMs` is terminated, reports `timedOut` alongside its exit status, is classified `timeout` whatever status the signal left behind, and is not retried on the next registry, which bounds how long one operation can hold the profile lock; the CLI inherits the terminal, captures no output, and stays unbounded for its operator to interrupt. A run completes when its process exits, and the pipes then drain under a bounded grace period, so a descendant that inherited them cannot hold the operation open. A terminated run stops its whole process tree and waits for it, because a lifecycle script outlives the pnpm process that started it (issue #4981). Each operation records the pnpm run it starts in `.plugin-manager/run.json` and removes the record when the run ends. A lock whose holder process exited is taken over by the next writer, but that process's pnpm tree can still be running, so an operation that finds a record waits up to five seconds for the recorded run to stop and otherwise fails with a diagnostic that names the process and the record, without running pnpm. Dependency-only changes do not trigger configuration reloads.
|
||||
The service and `dsh plugin` share the package operations in [operations.ts](src/operations.ts). The launcher supplies the current profile; [DSH HMR](../hmr/README.md) serializes module reloads, file watching and management writes. Each configuration refresh re-reads bundle selection and patch layers, updates the original root Include, and awaits removed plugin resources as well as the remaining Loader tree. CLI and service operations share the profile manifest writer lock to prevent concurrent package and manifest writes. HMR does not acquire that lock. Pnpm runs outside the HMR queue; installation selects the bundle after pnpm succeeds, while removal deselects and unloads the bundle before pnpm runs.
|
||||
|
||||
Results contain the last attempted stage, target, saved-state change, application status and error codes. Web dictionaries render management text; pnpm and Loader diagnostics remain unmodified. Unrelated pre-existing inactive entries return warnings; new or changed failures and inactive explicit enablement targets fail the operation. A failed or cancelled installation restores the manifest and lockfile it snapshotted before pnpm ran ([rationale](../../../.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.md)); a failed removal retains its partial changes and diagnostics. Installations are tracked by request id until their call settles, so a cancellation names one run and joins its settlement without taking the profile lock. The CLI inherits authentication variables and terminal descriptors; service operations use a scrubbed environment and captured output.
|
||||
Installing a new package, enabling a bundle, disabling it with HMR after its plugins stop, and a successful removal publish the profile's runtime resolution. Shared dependencies may pass to another bundle's declaration when their normalized directory, version, and scope stay unchanged. Without HMR, a deselected startup bundle still runs: while any such bundle is absent from the saved selection, subsequent package operations retain the entire current table and report `restart-required`. Other new installations and removals of non-running bundles can still publish without HMR; activation waits for restart. Overwriting an installed package publishes nothing and reports `restart-required` ([decision](../../../.agents/notes/implemented/architecture/2026-09-30-profile-package-refresh-and-manifest-invalidation.md)).
|
||||
|
||||
A service run whose captured output stays silent for `idleTimeoutMs` is terminated, reports `timedOut` alongside its exit status, is classified `timeout` whatever status the signal left behind, and is not retried on the next registry, which bounds how long one operation can hold the profile lock; the CLI inherits the terminal, captures no output, and stays unbounded for its operator to interrupt. A run completes when its process exits, and the pipes then drain under a bounded grace period, so a descendant that inherited them cannot hold the operation open. A terminated run stops its whole process tree and waits for it, because a lifecycle script outlives the pnpm process that started it (issue #4981). Each operation records the pnpm run it starts in `.plugin-manager/run.json` and removes the record when the run ends. A lock whose holder process exited is taken over by the next writer, but that process's pnpm tree can still be running, so an operation that finds a record waits up to five seconds for the recorded run to stop and otherwise fails with a diagnostic that names the process and the record, without running pnpm. Dependency-only changes do not trigger configuration reloads.
|
||||
|
||||
Results contain the last attempted stage, target, saved-state change, application status and error codes. Web dictionaries render management text; pnpm and Loader diagnostics remain unmodified. Unrelated pre-existing inactive entries return warnings; new or changed failures and inactive explicit enablement targets fail the operation. A failed or cancelled package run, or failed bundle validation, restores the manifest and lockfile snapshotted before pnpm ran ([rationale](../../../.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.md)); later failures retain saved changes as described in [Failure behavior](#failure-behavior). Installations are tracked by request id until their call settles, so a cancellation names one run and joins its settlement without taking the profile lock. The CLI inherits authentication variables and terminal descriptors; service operations use a scrubbed environment and captured output.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -143,9 +147,10 @@ Failures preserve completed steps and report the actual remaining state. Profile
|
||||
|---|---|
|
||||
| Install: pnpm or bundle validation fails | Restore `package.json` and `pnpm-lock.yaml` as snapshotted before pnpm ran; files pnpm downloaded may remain. Report installation failure. |
|
||||
| Enable: saving selection or loading fails | Keep the installed dependency and any saved selection. Report enablement failure; allow repair, disablement or removal. |
|
||||
| Resolution publication after a successful package operation fails | Keep completed disk changes and the previous runtime table. Report `application: 'failed'` with the publication diagnostic; pnpm's zero exit code does not mean publication succeeded. |
|
||||
| Remove: any step fails | Stop at the failed step. Preserve completed changes, retain remaining dependencies for retry, and report removal failure. Do not re-enable the bundle. |
|
||||
|
||||
Installation finishes after pnpm and bundle validation succeed; subsequent enablement failure does not undo installation. Removal proceeds in order: remove the bundle from `dsh.profile.bundles`, unload its runtime contributions, then run `pnpm remove`. A failed step prevents subsequent steps. A selected name that neither the profile's dependencies nor the installation holds, such as a retired bundle, is also removable; its removal stops after deselection and runs no pnpm.
|
||||
Installation finishes after pnpm and bundle validation succeed; subsequent publication or enablement failure does not undo installation. Removal proceeds in order: remove the bundle from `dsh.profile.bundles`, unload its runtime contributions, run `pnpm remove`, then publish runtime resolution unless a deselected startup bundle still runs without HMR. A failed step prevents subsequent steps; publication failure after removal does not restore deleted packages. A selected name that neither the profile's dependencies nor the installation holds, such as a retired bundle, is also removable; its removal stops after deselection and runs no pnpm.
|
||||
|
||||
Restoration rewrites only the two snapshotted files; user-authored patch configuration, application data, diagnostic logs and files pnpm downloaded remain untouched, and the next package operation prunes packages no manifest references.
|
||||
|
||||
|
||||
@@ -89,9 +89,13 @@ CLI 提供 `dsh plugin --profile <profile> version-exemptions`、`allow-version
|
||||
<details>
|
||||
<summary>实现细节——点击展开</summary>
|
||||
|
||||
服务与 `dsh plugin` 共用 [operations.ts](src/operations.ts) 中的包管理操作。启动器提供当前 profile;[DSH HMR](../hmr/README.zh.md) 串行执行模块重载、文件监听和管理写入。每次刷新重新读取组合包选择与 patch 层,更新原有根 Include,并等待已移除插件释放资源及剩余 Loader 树稳定。CLI 与 service 操作共用 profile manifest 写锁,防止并发包操作和 manifest 写入。HMR 不获取该锁。pnpm 在 HMR 队列之外执行;安装在 pnpm 成功后选入组合包,删除则在执行 pnpm 前取消选入并完成卸载。service 运行若在 `idleTimeoutMs` 内没有任何捕获输出即被终止,与退出状态一并报告 `timedOut`,不论信号留下什么退出状态都归类为 `timeout`,且不再转问下一个注册表,因此单次操作占用 profile 锁的时长有上界;CLI 继承终端、不捕获输出,因此不受此上界约束,由操作者中断。运行以进程退出为完成点,随后只在一个有界的宽限窗口内排空管道,因此继承管道的孙进程无法让操作挂起。被终止的运行会停止整棵进程树并等待其消失,因为生命周期脚本的存活时间超过启动它的 pnpm 进程(issue #4981)。每个操作把它启动的 pnpm 运行记录在 `.plugin-manager/run.json` 中,并在运行结束时删除该记录。持有者进程已退出的锁会被下一个写入方接管,但该进程的 pnpm 进程树可能仍在运行,因此发现记录的操作最多等待五秒让记录中的运行停止,否则不运行 pnpm,并以指明该进程与记录文件的诊断失败。仅依赖字段变化不会触发配置重载。
|
||||
服务与 `dsh plugin` 共用 [operations.ts](src/operations.ts) 中的包管理操作。启动器提供当前 profile;[DSH HMR](../hmr/README.zh.md) 串行执行模块重载、文件监听和管理写入。每次配置刷新重新读取组合包选择与 patch 层,更新原有根 Include,并等待已移除插件释放资源及剩余 Loader 树稳定。CLI 与 service 操作共用 profile manifest 写锁,防止并发包操作和 manifest 写入。HMR 不获取该锁。pnpm 在 HMR 队列之外执行;安装在 pnpm 成功后选入组合包,删除则在执行 pnpm 前取消选入并完成卸载。
|
||||
|
||||
结果包含最后尝试的阶段、目标、磁盘变化、应用状态和错误码。Web 词典呈现管理文案;pnpm 与 Loader 的诊断保持原样。无关的已有故障作为警告返回;新出现、配置变化后的故障,以及显式启用目标未激活,都会使操作失败。失败或被取消的安装会恢复 pnpm 运行前快照的 manifest 与 lockfile([理由](../../../.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.zh.md));失败的删除保留部分改动和诊断。安装按 request id 跟踪到调用结束,因此取消只针对一次运行,并且不取 profile 锁就能等待它结束。CLI 继承认证环境和终端描述符;service 使用清理后的环境并捕获输出。
|
||||
安装新包、启用组合包、有 HMR 时停用组合包并等插件退出后、以及删除成功后,都会发布 profile 的 runtime resolution。共享依赖的规范化目录、版本和作用域不变时,可以改由另一个组合包声明。没有 HMR 时,已取消选入的启动组合包仍在运行:只要保存的选择列表中缺少任何这样的组合包,后续包操作就保留整张当前表,并报告 `restart-required`。其他新安装和未运行组合包的删除仍可在没有 HMR 时发布;激活等待重启。覆盖已安装的包不发布,并报告 `restart-required`([决策](../../../.agents/notes/implemented/architecture/2026-09-30-profile-package-refresh-and-manifest-invalidation.zh.md))。
|
||||
|
||||
service 运行若在 `idleTimeoutMs` 内没有任何捕获输出即被终止,与退出状态一并报告 `timedOut`,不论信号留下什么退出状态都归类为 `timeout`,且不再转问下一个注册表,因此单次操作占用 profile 锁的时长有上界;CLI 继承终端、不捕获输出,因此不受此上界约束,由操作者中断。运行以进程退出为完成点,随后只在一个有界的宽限窗口内排空管道,因此继承管道的孙进程无法让操作挂起。被终止的运行会停止整棵进程树并等待其消失,因为生命周期脚本的存活时间超过启动它的 pnpm 进程(issue #4981)。每个操作把它启动的 pnpm 运行记录在 `.plugin-manager/run.json` 中,并在运行结束时删除该记录。持有者进程已退出的锁会被下一个写入方接管,但该进程的 pnpm 进程树可能仍在运行,因此发现记录的操作最多等待五秒让记录中的运行停止,否则不运行 pnpm,并以指明该进程与记录文件的诊断失败。仅依赖字段变化不会触发配置重载。
|
||||
|
||||
结果包含最后尝试的阶段、目标、磁盘变化、应用状态和错误码。Web 词典呈现管理文案;pnpm 与 Loader 的诊断保持原样。无关的已有故障作为警告返回;新出现、配置变化后的故障,以及显式启用目标未激活,都会使操作失败。包管理运行失败或被取消,或组合包校验失败时,会恢复 pnpm 运行前快照的 manifest 与 lockfile([理由](../../../.agents/notes/implemented/architecture/2026-09-15-guided-plugin-installation.zh.md));后续失败按[失败行为](#failure-behavior)所述保留已保存的改动。安装按 request id 跟踪到调用结束,因此取消只针对一次运行,并且不取 profile 锁就能等待它结束。CLI 继承认证环境和终端描述符;service 使用清理后的环境并捕获输出。管理器直接读取文件和 Loader 状态,不维护第二份目标状态注册表,因此不发布单独的运行时不变式伴生入口。
|
||||
|
||||
</details>
|
||||
|
||||
@@ -143,9 +147,10 @@ CLI 提供 `dsh plugin --profile <profile> version-exemptions`、`allow-version
|
||||
|---|---|
|
||||
| 安装:pnpm 执行或组合包校验失败 | 恢复 pnpm 运行前快照的 `package.json` 与 `pnpm-lock.yaml`;pnpm 已下载的文件可能保留。报告安装失败。 |
|
||||
| 启用:保存选择项或加载失败 | 保留已安装的依赖和已保存的选择项。报告启用失败,允许修正、停用或卸载。 |
|
||||
| 包操作成功后解析表发布失败 | 保留已完成的磁盘改动和上一张运行时表。报告 `application: 'failed'` 及发布诊断;pnpm 的零退出码不代表发布成功。 |
|
||||
| 卸载:任一步失败 | 停在失败步骤,保留已完成的改动和待重试删除的依赖,报告卸载失败。不重新启用组合包。 |
|
||||
|
||||
pnpm 执行和组合包校验成功即完成安装,后续启用失败不撤销安装。卸载依次执行:从 `dsh.profile.bundles` 移除组合包、卸载运行时贡献、执行 `pnpm remove`。任一步失败都不继续执行后续步骤。profile 依赖和安装都不持有的已选名称(例如已下线的组合包)同样可卸载;其卸载在取消选择后结束,不运行 pnpm。
|
||||
pnpm 执行和组合包校验成功即完成安装,后续发布或启用失败不撤销安装。卸载依次执行:从 `dsh.profile.bundles` 移除组合包、卸载运行时贡献、执行 `pnpm remove`,然后发布 runtime resolution,除非没有 HMR 且已取消选入的启动组合包仍在运行。任一步失败都不继续执行后续步骤;删除后的发布失败不会恢复已删除的包。 profile 依赖和安装都不持有的已选名称(例如已下线的组合包)同样可卸载;其卸载在取消选择后结束,不运行 pnpm。
|
||||
|
||||
恢复只重写这两份快照文件;用户编写的 patch 配置、应用数据、诊断日志以及 pnpm 已下载的文件保持原样,没有 manifest 引用的包由下一次包操作清理。
|
||||
|
||||
|
||||
Loaded 100 of 558 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user